Filtering tasks and assigning a person in charge during Excel import
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -98,6 +98,22 @@ function moodLabel(mood: PersonMood) {
|
||||
return PERSON_MOOD_LABELS[mood];
|
||||
}
|
||||
|
||||
/** کد شخص در نرمافزار مبدأ (دیدار و مشابه) */
|
||||
function externalCode(person: PersonDto): string {
|
||||
const fields = person.customFields ?? {};
|
||||
for (const key of ['didarId', 'customerCode'] as const) {
|
||||
const raw = fields[key];
|
||||
if (typeof raw === 'string' && raw.trim()) return raw.trim();
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function externalCodeLabel(person: PersonDto): string {
|
||||
const code = externalCode(person);
|
||||
return code ? toFaDigits(code) : '—';
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
try {
|
||||
return formatJalaliNumeric(new Date(value));
|
||||
@@ -430,6 +446,9 @@ onMounted(load);
|
||||
<p class="role">
|
||||
{{ [p.position, p.company?.name].filter(Boolean).join(' • ') || 'بدون سمت' }}
|
||||
</p>
|
||||
<p v-if="externalCode(p)" class="line">
|
||||
<span>کد مبدأ: {{ externalCodeLabel(p) }}</span>
|
||||
</p>
|
||||
<p v-if="p.mobile || p.phone" class="line" dir="ltr">
|
||||
<UiIcon name="phone" :size="13" />
|
||||
<span>{{ p.mobile || p.phone }}</span>
|
||||
@@ -449,6 +468,7 @@ onMounted(load);
|
||||
<tr>
|
||||
<th>وضعیت</th>
|
||||
<th>نام شخص</th>
|
||||
<th>کد مبدأ</th>
|
||||
<th>سمت</th>
|
||||
<th>شرکت</th>
|
||||
<th>تلفن</th>
|
||||
@@ -471,6 +491,7 @@ onMounted(load);
|
||||
{{ p.fullName }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td dir="ltr">{{ externalCodeLabel(p) }}</td>
|
||||
<td>{{ p.position || '—' }}</td>
|
||||
<td>
|
||||
<RouterLink
|
||||
|
||||
@@ -137,6 +137,15 @@ const mailHref = computed(() =>
|
||||
const registeredAt = computed(() =>
|
||||
person.value?.createdAt ? formatJalaliNumeric(new Date(person.value.createdAt)) : '—',
|
||||
);
|
||||
const externalCode = computed(() => {
|
||||
const fields = person.value?.customFields ?? {};
|
||||
for (const key of ['didarId', 'customerCode'] as const) {
|
||||
const raw = fields[key];
|
||||
if (typeof raw === 'string' && raw.trim()) return raw.trim();
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const lastCallAt = computed(() => {
|
||||
const calls = activities.value
|
||||
@@ -334,6 +343,7 @@ const infoEmpty = computed(() => {
|
||||
!p.website &&
|
||||
!p.referrer &&
|
||||
!p.company &&
|
||||
!externalCode.value &&
|
||||
!p.notes?.trim() &&
|
||||
!tickets.value.length
|
||||
);
|
||||
@@ -618,6 +628,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
</div>
|
||||
<dl class="facts">
|
||||
<div><dt>سمت</dt><dd>{{ person.position || '—' }}</dd></div>
|
||||
<div><dt>کد مبدأ</dt><dd dir="ltr">{{ externalCode ? toFaDigits(externalCode) : '—' }}</dd></div>
|
||||
<div><dt>وبسایت</dt><dd>{{ person.website || '—' }}</dd></div>
|
||||
<div><dt>معرف</dt><dd>{{ person.referrer || '—' }}</dd></div>
|
||||
<div><dt>شرکت</dt><dd>{{ person.company?.name || '—' }}</dd></div>
|
||||
@@ -893,6 +904,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
<div><dt>تلفن</dt><dd>{{ person.phone ? toFaDigits(person.phone) : '—' }}</dd></div>
|
||||
<div><dt>ایمیل</dt><dd>{{ person.email || '—' }}</dd></div>
|
||||
<div><dt>سمت</dt><dd>{{ person.position || '—' }}</dd></div>
|
||||
<div><dt>کد مبدأ</dt><dd dir="ltr">{{ externalCode ? toFaDigits(externalCode) : '—' }}</dd></div>
|
||||
<div><dt>وبسایت</dt><dd>{{ person.website || '—' }}</dd></div>
|
||||
<div><dt>معرف</dt><dd>{{ person.referrer || '—' }}</dd></div>
|
||||
<div><dt>شرکت</dt><dd>{{ person.company?.name || '—' }}</dd></div>
|
||||
|
||||
Reference in New Issue
Block a user