feat(tahesab): sync filtered customers and pick Jalali periods

Document refresh can follow the selected group or person instead of every linked customer, and the sales report picks a named month or season.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-03 10:09:40 +03:30
co-authored by Cursor
parent fdabdc54a3
commit 79733e6c96
5 changed files with 366 additions and 32 deletions
+216 -27
View File
@@ -13,6 +13,7 @@ import {
Form as AForm,
FormItem as AFormItem,
List as AList,
Modal as AModal,
Row as ARow,
Segmented as ASegmented,
Select as ASelect,
@@ -41,7 +42,6 @@ import {
TAHESAB_ASNAD_KINDS,
TAHESAB_JENS_FELEZ_LABELS,
type CustomerGroupDto,
type PersonDto,
type TahesabAsnadKind,
type TahesabAsnadLineDto,
type TahesabAsnadReportPageDto,
@@ -54,6 +54,8 @@ import { api } from '@/lib/api';
import {
dateToJalali,
formatLocalDateInput,
jalaliMonthLength,
jalaliMonthName,
jalaliToDate,
parseLocalInput,
toFaDigits,
@@ -78,6 +80,20 @@ const personId = ref<string | undefined>();
const personOptions = ref<Array<{ value: string; label: string }>>([]);
const kinds = ref<TahesabAsnadKind[]>([]);
const period = ref<'custom' | 'week' | 'month' | 'season'>('month');
const periodModalOpen = ref(false);
const periodModalKind = ref<'month' | 'season'>('month');
const pickerYear = ref(dateToJalali(new Date()).jy);
const syncModalOpen = ref(false);
const SEASONS = [
{ key: 'spring', label: 'بهار', start: 1, end: 3 },
{ key: 'summer', label: 'تابستان', start: 4, end: 6 },
{ key: 'autumn', label: 'پاییز', start: 7, end: 9 },
{ key: 'winter', label: 'زمستان', start: 10, end: 12 },
] as const;
const pickerYears = computed(() => {
const current = dateToJalali(new Date()).jy;
return Array.from({ length: 8 }, (_, index) => current - index);
});
const overview = ref<TahesabSalesOverviewDto | null>(null);
const auth = useAuthStore();
const canGroups = computed(() => canManageSettings(auth.user?.role));
@@ -203,6 +219,66 @@ function markCustomPeriod() {
period.value = 'custom';
}
function openPeriodModal(kind: 'month' | 'season') {
const parsed = parseLocalInput(fromDate.value);
pickerYear.value = parsed ? dateToJalali(parsed).jy : dateToJalali(new Date()).jy;
periodModalKind.value = kind;
periodModalOpen.value = true;
}
function onPeriodPick(value: string | number) {
const key = String(value) as 'week' | 'month' | 'season' | 'custom';
if (key === 'month' || key === 'season') {
openPeriodModal(key);
return;
}
applyNamedPeriod(key, true);
}
function onPeriodReclick(event: MouseEvent) {
const item = (event.target as HTMLElement | null)?.closest('.ant-segmented-item');
const text = item?.querySelector('.ant-segmented-item-label')?.textContent?.replace(/\s/g, '') ?? '';
if (text === 'ماه') openPeriodModal('month');
if (text === 'فصل') openPeriodModal('season');
}
function applyJalaliSpan(
year: number,
startMonth: number,
endMonth: number,
key: 'month' | 'season',
) {
const now = new Date();
const today = dateToJalali(now);
const start = jalaliToDate(year, startMonth, 1);
let end = jalaliToDate(year, endMonth, jalaliMonthLength(year, endMonth));
if (
today.jy === year &&
today.jm >= startMonth &&
today.jm <= endMonth &&
end.getTime() > now.getTime()
) {
end = now;
}
applyingPeriod = true;
period.value = key;
fromDate.value = formatLocalDateInput(start);
toDate.value = formatLocalDateInput(end);
periodModalOpen.value = false;
void nextTick(() => {
applyingPeriod = false;
});
applyFilters();
}
function pickMonth(month: number) {
applyJalaliSpan(pickerYear.value, month, month, 'month');
}
function pickSeason(start: number, end: number) {
applyJalaliSpan(pickerYear.value, start, end, 'season');
}
function groupOptionLabel(g: CustomerGroupDto) {
return g.parentName ? `${g.parentName} / ${g.name}` : g.name;
}
@@ -375,25 +451,51 @@ async function loadConnections() {
if (!connectionId.value && def) connectionId.value = def.id;
}
async function searchPeople(q: string) {
if (!q || q.trim().length < 2) {
let personSearchTimer: ReturnType<typeof setTimeout> | null = null;
let customersEndpointMissing = false;
async function loadTahesabCustomers(q: string) {
if (!connectionId.value) {
personOptions.value = [];
return;
}
try {
const { data } = await api.get<{ items: PersonDto[] }>('/people', {
params: { q: q.trim(), limit: 20 },
const { data } = await api.get<{
items: Array<{ personId: string; name: string; moshtariCode: number }>;
}>('/integrations/tahesab/reports/asnad/customers', {
params: { connectionId: connectionId.value, q: q.trim() },
});
const items = Array.isArray(data?.items) ? data.items : [];
personOptions.value = items.map((p) => ({
value: p.id,
label: [p.firstName, p.lastName].filter(Boolean).join(' ') || p.id,
const next = items.map((item) => ({
value: item.personId,
label: `${item.name || 'بدون نام'} (${toFaDigits(String(item.moshtariCode))})`,
}));
} catch {
personOptions.value = [];
const selected = personOptions.value.find((item) => item.value === personId.value);
if (selected && !next.some((item) => item.value === selected.value)) next.unshift(selected);
personOptions.value = next;
} catch (e: unknown) {
const status = axios.isAxiosError(e) ? e.response?.status : undefined;
if (status === 404 && !customersEndpointMissing) {
customersEndpointMissing = true;
message.warning('جستجوی مشتری ته‌حساب بعد از به‌روزرسانی سرور فعال می‌شود.');
}
personOptions.value = personId.value
? personOptions.value.filter((item) => item.value === personId.value)
: [];
}
}
function searchTahesabCustomers(q: string) {
if (personSearchTimer) clearTimeout(personSearchTimer);
personSearchTimer = setTimeout(() => {
void loadTahesabCustomers(q);
}, 250);
}
function onPersonDropdown(open: boolean) {
if (open) void loadTahesabCustomers('');
}
function apiErrMessage(e: unknown, fallback: string): string {
if (!axios.isAxiosError(e)) return fallback;
const raw = e.response?.data as { message?: string | string[] } | undefined;
@@ -493,7 +595,20 @@ async function pollJob(jobId: string) {
}, 1500);
}
async function startSync() {
const hasSyncScope = computed(
() => Boolean(personId.value) || groupIds.value.length > 0,
);
function openSyncChoice() {
if (!filterParams.value) {
error.value = 'ابتدا بازه تاریخ را مشخص کنید، بعد به‌روزرسانی را بزنید.';
message.warning(error.value);
return;
}
syncModalOpen.value = true;
}
async function startSync(scoped: boolean) {
const params = filterParams.value;
if (!params) {
error.value = 'ابتدا بازه تاریخ را مشخص کنید، بعد به‌روزرسانی را بزنید.';
@@ -502,7 +617,10 @@ async function startSync() {
}
syncing.value = true;
error.value = '';
syncHint.value = 'شروع همگام‌سازی…';
syncHint.value = scoped
? 'شروع به‌روزرسانی مشتریان همین فیلتر…'
: 'شروع به‌روزرسانی همه مشتریان متصل…';
syncModalOpen.value = false;
try {
const { data } = await api.post<TahesabAsnadSyncJobDto>(
'/integrations/tahesab/reports/asnad/sync',
@@ -510,6 +628,8 @@ async function startSync() {
connectionId: params.connectionId,
from: params.from,
to: params.to,
...(scoped && personId.value ? { personId: personId.value } : {}),
...(scoped && groupIds.value.length ? { groupIds: [...groupIds.value] } : {}),
},
{ timeout: 60_000 },
);
@@ -549,7 +669,10 @@ watch([connectionId, groupIds, kinds, jensFelez, personId], () => {
page.value = 1;
});
onUnmounted(() => stopPoll());
onUnmounted(() => {
stopPoll();
if (personSearchTimer) clearTimeout(personSearchTimer);
});
</script>
<template>
@@ -574,7 +697,7 @@ onUnmounted(() => stopPoll());
<template #icon><ArrowRightOutlined /></template>
بازگشت
</a-button>
<a-button type="primary" :loading="syncing" @click="startSync">
<a-button type="primary" :loading="syncing" @click="openSyncChoice">
<template #icon><SyncOutlined /></template>
به‌روزرسانی از ته‌حساب
</a-button>
@@ -613,17 +736,19 @@ onUnmounted(() => stopPoll());
</a-col>
<a-col :xs="24" :lg="12">
<a-form-item label="بازه">
<a-segmented
v-model:value="period"
block
:options="[
{ label: 'هفته', value: 'week' },
{ label: 'ماه', value: 'month' },
{ label: 'فصل', value: 'season' },
{ label: 'تاریخ دلخواه', value: 'custom' },
]"
@change="(value: string | number) => applyNamedPeriod(value as 'week' | 'month' | 'season' | 'custom', true)"
/>
<div @click="onPeriodReclick">
<a-segmented
:value="period"
block
:options="[
{ label: 'هفته', value: 'week' },
{ label: 'ماه', value: 'month' },
{ label: 'فصل', value: 'season' },
{ label: 'تاریخ دلخواه', value: 'custom' },
]"
@change="onPeriodPick"
/>
</div>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
@@ -642,6 +767,8 @@ onUnmounted(() => stopPoll());
v-model:value="groupIds"
mode="multiple"
allow-clear
show-search
option-filter-prop="label"
placeholder="همه گروه‌ها"
:options="
(customerGroups as CustomerGroupDto[]).map((g) => ({
@@ -658,6 +785,8 @@ onUnmounted(() => stopPoll());
<a-select
v-model:value="kinds"
mode="multiple"
show-search
option-filter-prop="label"
:options="kindOptions"
placeholder="همه انواع"
:max-tag-count="2"
@@ -669,6 +798,8 @@ onUnmounted(() => stopPoll());
<a-select
v-model:value="jensFelez"
allow-clear
show-search
option-filter-prop="label"
placeholder="همه"
:options="metalOptions"
/>
@@ -680,10 +811,11 @@ onUnmounted(() => stopPoll());
v-model:value="personId"
show-search
allow-clear
placeholder="جستجوی نام…"
placeholder="جستجوی مشتری ته‌حساب"
:filter-option="false"
:options="personOptions"
@search="searchPeople"
@search="searchTahesabCustomers"
@dropdown-visible-change="onPersonDropdown"
>
<template #suffixIcon><SearchOutlined /></template>
</a-select>
@@ -883,4 +1015,61 @@ onUnmounted(() => stopPoll());
</a-card>
</a-col>
</a-row>
<a-modal
v-model:open="periodModalOpen"
:title="periodModalKind === 'month' ? 'انتخاب ماه' : 'انتخاب فصل'"
:footer="null"
>
<a-form layout="vertical">
<a-form-item label="سال">
<a-select
v-model:value="pickerYear"
:options="pickerYears.map((year) => ({ value: year, label: toFaDigits(year) }))"
/>
</a-form-item>
</a-form>
<a-row v-if="periodModalKind === 'month'" :gutter="[8, 8]">
<a-col v-for="month in 12" :key="month" :span="8">
<a-button block @click="pickMonth(month)">{{ jalaliMonthName(month) }}</a-button>
</a-col>
</a-row>
<a-row v-else :gutter="[8, 8]">
<a-col v-for="season in SEASONS" :key="season.key" :span="12">
<a-button block @click="pickSeason(season.start, season.end)">{{ season.label }}</a-button>
</a-col>
</a-row>
</a-modal>
<a-modal
v-model:open="syncModalOpen"
title="به‌روزرسانی از ته‌حساب"
:footer="null"
>
<a-typography-paragraph>
ته‌حساب سند را مشتری‌به‌مشتری می‌دهد. اگر گروه یا شخص را انتخاب کنید، فقط همان‌ها گرفته می‌شود و سریع‌تر تمام می‌شود. نوع سند و فلز بعد از دریافت، روی همین داده‌ها فیلتر می‌شوند.
</a-typography-paragraph>
<a-typography-paragraph v-if="!hasSyncScope" type="secondary">
الان گروه یا شخصی انتخاب نشده، پس به‌روزرسانی همهٔ مشتریان متصل این بازه را از ته‌حساب می‌گیرد.
</a-typography-paragraph>
<a-flex vertical :gap="8">
<a-button
v-if="hasSyncScope"
type="primary"
block
:loading="syncing"
@click="startSync(true)"
>
فقط مشتریان همین فیلتر
</a-button>
<a-button
block
:type="hasSyncScope ? 'default' : 'primary'"
:loading="syncing"
@click="startSync(false)"
>
همه مشتریان این اتصال
</a-button>
</a-flex>
</a-modal>
</template>