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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user