Filtering tasks and assigning a person in charge during Excel import

This commit is contained in:
2026-10-03 12:00:46 +03:30
parent 5784b09232
commit 989c03b381
7 changed files with 315 additions and 18 deletions
+188 -13
View File
@@ -23,8 +23,10 @@ import ActivityFormView from '@/views/ActivityFormView.vue';
import type {
ActivityDto,
ActivityFilter,
ActivityStatus,
ActivityType,
ImportContactsResult,
OrgUserDto,
PaginatedResponse,
} from '@mento/shared';
import { brand } from '@mento/shared';
@@ -32,6 +34,7 @@ import { useAuthStore } from '@/stores/auth';
import { canViewAllRecords } from '@/lib/roles';
type TypeFilter = 'all' | ActivityType;
type StatusFilter = 'all' | ActivityStatus;
type UiStatus = 'done' | 'progress' | 'planned' | 'overdue' | 'canceled';
type DeskScope = 'mine' | 'team';
@@ -56,6 +59,13 @@ const TYPE_FILTERS: Array<{ key: TypeFilter; label: string }> = [
{ key: 'note', label: 'پیام' },
];
const STATUS_FILTERS: Array<{ key: StatusFilter; label: string }> = [
{ key: 'all', label: 'همه وضعیت‌ها' },
{ key: 'open', label: 'باز / انجام‌نشده' },
{ key: 'done', label: 'انجام‌شده' },
{ key: 'canceled', label: 'لغو شده' },
];
const PAGE_SIZE = 6;
const route = useRoute();
@@ -85,9 +95,28 @@ const loading = ref(false);
const error = ref('');
const data = ref<PaginatedResponse<ActivityDto> | null>(null);
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const typeFilter = ref<TypeFilter>('all');
const users = ref<OrgUserDto[]>([]);
const filterOpen = ref(false);
const ideasOpen = ref(false);
const ACTIVITY_TYPES: ActivityType[] = ['call', 'meeting', 'task', 'note'];
const ACTIVITY_STATUSES: ActivityStatus[] = ['open', 'done', 'canceled'];
const typeFilter = computed<TypeFilter>(() => {
const value = typeof route.query.type === 'string' ? route.query.type : 'all';
return ACTIVITY_TYPES.includes(value as ActivityType) ? (value as ActivityType) : 'all';
});
const statusFilter = computed<StatusFilter>(() => {
const value = typeof route.query.status === 'string' ? route.query.status : 'all';
return ACTIVITY_STATUSES.includes(value as ActivityStatus)
? (value as ActivityStatus)
: 'all';
});
const assigneeFilter = computed(() =>
typeof route.query.assigneeId === 'string' ? route.query.assigneeId : '',
);
const activityModal = ref<{
type?: ActivityType;
title?: string;
@@ -133,15 +162,21 @@ const statusIcon: Record<UiStatus, 'check' | 'clock' | 'calendar' | 'warning'> =
canceled: 'warning',
};
const items = computed(() => {
const rows = data.value?.items ?? [];
if (typeFilter.value === 'all') return rows;
return rows.filter((row) => row.type === typeFilter.value);
});
const items = computed(() => data.value?.items ?? []);
const total = computed(() => data.value?.total ?? 0);
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
const filterLabel = computed(() => TABS.find((t) => t.key === filter.value)?.label ?? 'امروز');
const activeFacetCount = computed(() => {
let n = 0;
if (typeFilter.value !== 'all') n += 1;
if (statusFilter.value !== 'all') n += 1;
if (assigneeFilter.value) n += 1;
return n;
});
const filterBtnLabel = computed(() =>
activeFacetCount.value ? `فیلتر (${toFaDigits(activeFacetCount.value)})` : 'فیلتر',
);
const trulyEmpty = computed(() => !loading.value && counts.value.all === 0 && !q.value.trim());
const listEmpty = computed(() => !loading.value && !items.value.length);
@@ -208,6 +243,19 @@ async function loadCounts() {
counts.value = next;
}
async function loadUsers() {
if (!showDeskTabs.value) {
users.value = [];
return;
}
try {
const { data: rows } = await api.get<OrgUserDto[]>('/users');
users.value = rows.filter((u) => u.isActive !== false);
} catch {
users.value = [];
}
}
async function load() {
loading.value = true;
error.value = '';
@@ -220,6 +268,9 @@ async function load() {
page: page.value,
limit: PAGE_SIZE,
desk: showDeskTabs.value ? desk.value : undefined,
type: typeFilter.value === 'all' ? undefined : typeFilter.value,
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
assigneeId: assigneeFilter.value || undefined,
},
});
data.value = res;
@@ -279,9 +330,48 @@ function onSearch() {
});
}
function patchFacetQuery(patch: Record<string, string | undefined>) {
void router.replace({
name: 'activities',
query: {
...route.query,
...patch,
page: undefined,
},
});
}
function setTypeFilter(next: TypeFilter) {
typeFilter.value = next;
filterOpen.value = false;
patchFacetQuery({ type: next === 'all' ? undefined : next });
}
function setStatusFilter(next: StatusFilter) {
// Avoid empty results when a time-tab forces a different status.
const nextFilter =
next === 'all'
? route.query.filter
: next === 'done'
? 'done'
: 'all';
patchFacetQuery({
status: next === 'all' ? undefined : next,
filter: nextFilter === 'today' || !nextFilter ? undefined : String(nextFilter),
});
}
function setAssigneeFilter(next: string) {
patchFacetQuery({
assigneeId: next || undefined,
desk: next && showDeskTabs.value ? 'team' : route.query.desk === 'team' ? 'team' : undefined,
});
}
function clearFacets() {
patchFacetQuery({
type: undefined,
status: undefined,
assigneeId: undefined,
});
}
async function complete(id: string) {
@@ -346,7 +436,15 @@ function onImportSettled() {
}
watch(
() => [route.query.filter, route.query.q, route.query.page, route.query.desk],
() => [
route.query.filter,
route.query.q,
route.query.page,
route.query.desk,
route.query.type,
route.query.status,
route.query.assigneeId,
],
() => {
q.value = typeof route.query.q === 'string' ? route.query.q : '';
void load();
@@ -363,6 +461,7 @@ onMounted(() => {
mobileQuery = window.matchMedia('(max-width: 899px)');
syncMobile();
mobileQuery.addEventListener('change', syncMobile);
void loadUsers();
void load();
void loadCounts();
});
@@ -455,21 +554,67 @@ onUnmounted(() => {
/>
</form>
<div class="filter-wrap" @click.stop>
<button class="btn ghost filter-btn" type="button" @click="filterOpen = !filterOpen">
<button
class="btn ghost filter-btn"
type="button"
:class="{ on: activeFacetCount > 0 }"
@click="filterOpen = !filterOpen"
>
<UiIcon name="filter" :size="16" />
فیلتر
{{ filterBtnLabel }}
<UiIcon name="chevron" :size="14" class="chev" />
</button>
<div v-if="filterOpen" class="popover">
<div v-if="filterOpen" class="popover filter-popover">
<p class="facet-title">نوع کار</p>
<button
v-for="opt in TYPE_FILTERS"
:key="opt.key"
:key="`type-${opt.key}`"
type="button"
:class="{ on: typeFilter === opt.key }"
@click="setTypeFilter(opt.key)"
>
{{ opt.label }}
</button>
<p class="facet-title">وضعیت</p>
<button
v-for="opt in STATUS_FILTERS"
:key="`status-${opt.key}`"
type="button"
:class="{ on: statusFilter === opt.key }"
@click="setStatusFilter(opt.key)"
>
{{ opt.label }}
</button>
<template v-if="showDeskTabs">
<p class="facet-title">کارشناس</p>
<button
type="button"
:class="{ on: !assigneeFilter }"
@click="setAssigneeFilter('')"
>
همه کارشناسان
</button>
<button
v-for="user in users"
:key="user.id"
type="button"
:class="{ on: assigneeFilter === user.id }"
@click="setAssigneeFilter(user.id)"
>
{{ user.fullName }}
</button>
</template>
<button
v-if="activeFacetCount"
type="button"
class="clear-facets"
@click="clearFacets"
>
پاک کردن فیلترها
</button>
</div>
</div>
</div>
@@ -916,6 +1061,12 @@ h1 {
transform: rotate(90deg);
}
.filter-btn.on {
border-color: #99f6e4;
background: #f0fdfa;
color: #0f766e;
}
.popover {
position: absolute;
top: calc(100% + 6px);
@@ -929,6 +1080,30 @@ h1 {
z-index: 20;
}
.filter-popover {
min-width: 14rem;
max-height: min(70vh, 28rem);
overflow: auto;
}
.facet-title {
margin: 0.45rem 0.35rem 0.15rem;
font-size: 0.72rem;
font-weight: 700;
color: #64748b;
}
.facet-title:first-child {
margin-top: 0.15rem;
}
.clear-facets {
margin-top: 0.25rem;
border-top: 1px solid #e2e8f0 !important;
border-radius: 0 0 var(--radius) var(--radius) !important;
color: #b91c1c !important;
}
.popover a,
.popover button {
display: block;