Files
CRM_Montzar/apps/web/src/views/TahesabSalesReportView.vue
T
MahdiandCursor cb3a488b21 feat(web): continue Ant settings migration and tighten page chrome
Migrate remaining settings screens to Ant Design Vue, densify teams/roles UI, drop page-header subtitles project-wide, and polish notes cards plus contacts row actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:05:28 +03:30

1073 lines
36 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Collapse as ACollapse,
CollapsePanel as ACollapsePanel,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
List as AList,
Modal as AModal,
Row as ARow,
Segmented as ASegmented,
Select as ASelect,
Space as ASpace,
Statistic as AStatistic,
Table as ATable,
Tag as ATag,
Typography,
message,
theme,
} from 'ant-design-vue';
import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue';
import {
ArrowRightOutlined,
FilterOutlined,
SearchOutlined,
SyncOutlined,
} from '@ant-design/icons-vue';
import axios from 'axios';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
import {
TAHESAB_ASNAD_KIND_LABELS,
TAHESAB_ASNAD_KINDS,
TAHESAB_JENS_FELEZ_LABELS,
type CustomerGroupDto,
type TahesabAsnadKind,
type TahesabAsnadLineDto,
type TahesabAsnadReportPageDto,
type TahesabAsnadSummaryDto,
type TahesabAsnadSyncJobDto,
type TahesabConnectionDto,
type TahesabSalesOverviewDto,
} from '@mento/shared';
import { api } from '@/lib/api';
import {
dateToJalali,
formatLocalDateInput,
jalaliMonthLength,
jalaliMonthName,
jalaliToDate,
parseLocalInput,
toFaDigits,
} from '@/lib/jalali';
import { formatTahesabBedBes } from '@/lib/tahesab';
import { useCustomerGroups } from '@/lib/customer-groups';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import SalesReportBoard from '@/components/tahesab/SalesReportBoard.vue';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
const router = useRouter();
const { token } = theme.useToken();
const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups();
const connections = ref<TahesabConnectionDto[]>([]);
const connectionId = ref<string | undefined>();
const fromDate = ref('');
const toDate = ref('');
const groupIds = ref<string[]>([]);
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));
const showInternet = computed(() => {
const mods = auth.user?.enabledModules;
return Boolean(mods?.includes('montify'));
});
function normDocTitle(raw: string) {
return raw.replace(/\u064A/g, '\u06CC').replace(/\u0643/g, '\u06A9');
}
const otherDocGroups = computed(() => {
const buckets: Array<{ title: string; hint: string; test: (title: string) => boolean; names: string[] }> = [
{
title: 'درصد اجرت',
hint: 'نرخ اجرت روی سند است. وزن فروش نیست و در چهار بخش کالا جمع نمی‌شود.',
test: (title) => title.includes('درصد'),
names: [],
},
{
title: 'بانک، نقد و چک',
hint: 'جابه‌جایی پول است، فروش کالا نیست.',
test: (title) => /بانک|وجه نقد|چک|برداشت|واریز/.test(title),
names: [],
},
{
title: 'طلب، بدهی، تخفیف و تبدیل',
hint: 'حساب مشتری یا تبدیل پول و وزن است، فروش کالا نیست.',
test: (title) => /طلب|بدهی|تخفیف|پولی کردن/.test(title),
names: [],
},
{
title: 'فروش و خرید بدون نام کالا',
hint: 'این سند فروش یا خرید است، اما نوع کالا در عنوان آن نیامده. وزن آن در ردیف «سایر عنوان‌ها» محاسبه شده است.',
test: (title) => /فروش|خرید|کالا|آبشده|متفرقه/.test(title),
names: [],
},
];
const rest: string[] = [];
for (const name of overview.value?.otherNos ?? []) {
const title = normDocTitle(name);
const bucket = buckets.find((item) => item.test(title));
if (bucket) bucket.names.push(name);
else rest.push(name);
}
if (rest.length) {
buckets.push({
title: 'عنوان‌های دیگر',
hint: 'در چهار بخش کالا جا نگرفته‌اند.',
test: () => true,
names: rest,
});
}
return buckets.filter((item) => item.names.length);
});
const childGroupId = ref<string | undefined>();
const parentGroupId = ref<string | undefined>();
const overviewError = ref('');
let applyingPeriod = false;
const jensFelez = ref<number | undefined>();
const loading = ref(false);
const syncing = ref(false);
const error = ref('');
const syncHint = ref('');
const page = ref(1);
const pageSize = ref(50);
const report = ref<TahesabAsnadReportPageDto | null>(null);
const summary = ref<TahesabAsnadSummaryDto | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const kindOptions = TAHESAB_ASNAD_KINDS.map((k) => ({
value: k,
label: TAHESAB_ASNAD_KIND_LABELS[k],
}));
const metalOptions = [
{ value: 0, label: TAHESAB_JENS_FELEZ_LABELS[0] },
{ value: 1, label: TAHESAB_JENS_FELEZ_LABELS[1] },
{ value: 2, label: TAHESAB_JENS_FELEZ_LABELS[2] },
];
function toShamsiYmd(localIso: string): string | null {
const d = parseLocalInput(localIso);
if (!d) return null;
const { jy, jm, jd } = dateToJalali(d);
return `${jy}-${String(jm).padStart(2, '0')}-${String(jd).padStart(2, '0')}`;
}
function defaultRange() {
applyNamedPeriod('month', false);
}
function applyNamedPeriod(key: 'week' | 'month' | 'season' | 'custom', reload: boolean) {
applyingPeriod = true;
period.value = key;
if (key !== 'custom') {
const now = new Date();
const j = dateToJalali(now);
let start = now;
if (key === 'week') {
const delta = (now.getDay() + 1) % 7;
start = new Date(now);
start.setDate(now.getDate() - delta);
} else if (key === 'month') {
start = jalaliToDate(j.jy, j.jm, 1);
} else {
const sm = j.jm <= 3 ? 1 : j.jm <= 6 ? 4 : j.jm <= 9 ? 7 : 10;
start = jalaliToDate(j.jy, sm, 1);
}
fromDate.value = formatLocalDateInput(start);
toDate.value = formatLocalDateInput(now);
}
void nextTick(() => {
applyingPeriod = false;
});
if (reload) applyFilters();
}
function markCustomPeriod() {
if (applyingPeriod) return;
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;
}
function parentChoices(groupId: string) {
return (customerGroups.value as CustomerGroupDto[])
.filter((g) => g.id !== groupId && !g.parentId)
.map((g) => ({ value: g.id, label: g.name }));
}
function groupHasChildren(groupId: string) {
return (customerGroups.value as CustomerGroupDto[]).some((g) => g.parentId === groupId);
}
const childGroupOptions = computed(() =>
(customerGroups.value as CustomerGroupDto[])
.filter((g) => !groupHasChildren(g.id))
.map((g) => ({ value: g.id, label: groupOptionLabel(g) })),
);
const linkedGroups = computed(() =>
(customerGroups.value as CustomerGroupDto[]).filter((g) => g.parentId && g.parentName),
);
function onChildGroup(value: unknown) {
const id = typeof value === 'string' ? value : undefined;
childGroupId.value = id;
const current = (customerGroups.value as CustomerGroupDto[]).find((g) => g.id === id);
parentGroupId.value = current?.parentId ?? undefined;
}
async function saveGroupParent() {
const groupId = childGroupId.value;
if (!groupId) return;
const current = (customerGroups.value as CustomerGroupDto[]).find((g) => g.id === groupId);
const next = parentGroupId.value || null;
if ((current?.parentId ?? null) === next) return;
try {
await api.patch(`/customer-groups/${groupId}`, { parentId: next });
await loadCustomerGroups(true);
message.success('گروه اصلی ذخیره شد.');
await loadReport();
} catch (e: unknown) {
message.error(apiErrMessage(e, 'ذخیره گروه اصلی ناموفق بود.'));
}
}
async function clearGroupParent(groupId: string) {
try {
await api.patch(`/customer-groups/${groupId}`, { parentId: null });
if (childGroupId.value === groupId) parentGroupId.value = undefined;
await loadCustomerGroups(true);
message.success('گروه از زیرمجموعه خارج شد.');
await loadReport();
} catch (e: unknown) {
message.error(apiErrMessage(e, 'برداشتن گروه اصلی ناموفق بود.'));
}
}
function kindLabel(k: string) {
return TAHESAB_ASNAD_KIND_LABELS[k as TahesabAsnadKind] ?? k;
}
function metalLabel(v: number | null) {
if (v == null) return '—';
return TAHESAB_JENS_FELEZ_LABELS[v] ?? String(v);
}
function formatNum(n: number | null | undefined) {
if (n == null) return '—';
return toFaDigits(
new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 3 }).format(n),
);
}
function formatMali(n: number | null | undefined) {
return formatTahesabBedBes(n, 0);
}
const columns: TableColumnsType<TahesabAsnadLineDto> = [
{
title: 'تاریخ سند',
dataIndex: 'tarikh',
key: 'tarikh',
width: 110,
customRender: ({ text }) => toFaDigits(String(text ?? '')),
},
{
title: 'شخص',
key: 'person',
ellipsis: true,
customRender: ({ record }) =>
record.personName
? `${record.personName} (${toFaDigits(String(record.moshtariCode))})`
: toFaDigits(String(record.moshtariCode)),
},
{
title: 'گروه',
key: 'groups',
ellipsis: true,
customRender: ({ record }) =>
record.groups?.map((g) => g.name).join('، ') || '—',
},
{ title: 'نوع سند (NO)', dataIndex: 'noRaw', key: 'noRaw', ellipsis: true },
{
title: 'طبقه‌بندی',
key: 'kind',
width: 100,
customRender: ({ record }) => kindLabel(record.kind),
},
{
title: 'فلز',
key: 'jensFelez',
width: 90,
customRender: ({ record }) => metalLabel(record.jensFelez),
},
{
title: 'وزن',
key: 'vazn',
width: 90,
customRender: ({ record }) => formatNum(record.vazn),
},
{
title: 'مبلغ',
key: 'mali',
width: 140,
customRender: ({ record }) => formatMali(record.mali),
},
{
title: 'ش. فاکتور',
key: 'shFactor',
width: 100,
customRender: ({ record }) =>
record.shFactor != null ? toFaDigits(String(record.shFactor)) : '—',
},
];
const filterParams = computed(() => {
const from = toShamsiYmd(fromDate.value);
const to = toShamsiYmd(toDate.value);
if (!from || !to) return null;
return {
connectionId: connectionId.value,
from,
to,
groupIds: groupIds.value.length ? groupIds.value.join(',') : undefined,
personId: personId.value,
kinds: kinds.value.length ? kinds.value.join(',') : undefined,
jensFelez: jensFelez.value,
};
});
const lastSyncedLabel = computed(() => {
const raw = summary.value?.lastSyncedAt;
if (!raw) return null;
try {
return toFaDigits(new Date(raw).toLocaleString('fa-IR'));
} catch {
return null;
}
});
async function loadConnections() {
const { data } = await api.get<TahesabConnectionDto[]>(
'/integrations/tahesab/connections/public',
);
connections.value = data.filter((c) => c.enabled);
const def =
connections.value.find((c) => c.isDefault) ?? connections.value[0];
if (!connectionId.value && def) connectionId.value = def.id;
}
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: 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 : [];
const next = items.map((item) => ({
value: item.personId,
label: `${item.name || 'بدون نام'} (${toFaDigits(String(item.moshtariCode))})`,
}));
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;
const msg = raw?.message;
if (Array.isArray(msg)) return msg.filter(Boolean).join(' · ') || fallback;
if (typeof msg === 'string' && msg.trim()) return msg;
return fallback;
}
async function loadReport(opts?: { notifyEmpty?: boolean }) {
const params = filterParams.value;
if (!params) {
error.value = 'تاریخ از و تا را انتخاب کنید.';
message.warning(error.value);
return;
}
loading.value = true;
error.value = '';
try {
const [pageRes, sumRes] = await Promise.all([
api.get<TahesabAsnadReportPageDto>('/integrations/tahesab/reports/asnad', {
params: { ...params, page: page.value, pageSize: pageSize.value },
}),
api.get<TahesabAsnadSummaryDto>(
'/integrations/tahesab/reports/asnad/summary',
{ params },
),
]);
report.value = pageRes.data;
summary.value = sumRes.data;
try {
const overRes = await api.get<TahesabSalesOverviewDto>(
'/integrations/tahesab/reports/asnad/overview',
{ params },
);
overview.value = overRes.data;
overviewError.value = '';
} catch (e: unknown) {
overview.value = null;
const status = axios.isAxiosError(e) ? e.response?.status : undefined;
overviewError.value =
status === 404
? 'خلاصه فروش، گروه و پورسانت بعد از به‌روزرسانی سرور اینجا می‌آید. جدول سندها با دادهٔ فعلی کار می‌کند.'
: apiErrMessage(e, 'خلاصه فروش، گروه و پورسانت بارگذاری نشد.');
}
if (opts?.notifyEmpty && (pageRes.data.total ?? 0) === 0) {
message.info(
'ردیفی نیست. اول «به‌روزرسانی از ته‌حساب» را برای همین بازه بزنید.',
);
}
} catch (e: unknown) {
error.value = apiErrMessage(e, 'بارگذاری گزارش ناموفق بود.');
message.error(error.value);
report.value = null;
summary.value = null;
overview.value = null;
overviewError.value = '';
} finally {
loading.value = false;
}
}
function stopPoll() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function pollJob(jobId: string) {
stopPoll();
pollTimer = setInterval(async () => {
try {
const { data } = await api.get<TahesabAsnadSyncJobDto>(
`/integrations/tahesab/reports/asnad/sync/${jobId}`,
);
syncHint.value = `همگام‌سازی: ${toFaDigits(String(data.processed))} از ${toFaDigits(String(data.total))} · ${toFaDigits(String(data.linesUpserted))} ردیف`;
if (data.status === 'done' || data.status === 'failed') {
stopPoll();
syncing.value = false;
if (data.status === 'failed') {
error.value = data.errorMessage || 'همگام‌سازی ناموفق بود.';
} else {
if (data.truncatedPersonIds?.length) {
syncHint.value += ` · هشدار: برای ${toFaDigits(String(data.truncatedPersonIds.length))} مشتری سندهای بازه کامل نیامده است.`;
} else {
syncHint.value = `همگام‌سازی تمام شد · ${toFaDigits(String(data.linesUpserted))} ردیف`;
}
await loadReport();
}
}
} catch {
stopPoll();
syncing.value = false;
error.value = 'وضعیت همگام‌سازی دریافت نشد.';
}
}, 1500);
}
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 = 'ابتدا بازه تاریخ را مشخص کنید، بعد به‌روزرسانی را بزنید.';
message.warning(error.value);
return;
}
syncing.value = true;
error.value = '';
syncHint.value = scoped
? 'شروع به‌روزرسانی مشتریان همین فیلتر…'
: 'شروع به‌روزرسانی همه مشتریان متصل…';
syncModalOpen.value = false;
try {
const { data } = await api.post<TahesabAsnadSyncJobDto>(
'/integrations/tahesab/reports/asnad/sync',
{
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 },
);
message.success('همگام‌سازی شروع شد.');
await pollJob(data.id);
} catch (e: unknown) {
syncing.value = false;
syncHint.value = '';
error.value = apiErrMessage(e, 'شروع همگام‌سازی ناموفق بود.');
message.error(error.value);
}
}
function applyFilters() {
page.value = 1;
void loadReport({ notifyEmpty: true });
}
onMounted(async () => {
defaultRange();
try {
await Promise.all([loadConnections(), loadCustomerGroups()]);
await loadReport();
} catch (e: unknown) {
error.value = apiErrMessage(e, 'بارگذاری اولیه ناموفق بود.');
message.error(error.value);
}
});
function onTableChange(pag: TablePaginationConfig) {
page.value = pag.current ?? 1;
pageSize.value = pag.pageSize ?? 50;
void loadReport();
}
watch([connectionId, groupIds, kinds, jensFelez, personId], () => {
page.value = 1;
});
onUnmounted(() => {
stopPoll();
if (personSearchTimer) clearTimeout(personSearchTimer);
});
</script>
<template>
<a-row :gutter="[24, 24]">
<a-col :span="24">
<a-flex
justify="space-between"
align="center"
wrap="wrap"
:gap="token.marginSM"
>
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">
گزارش فروش ته‌حساب
</a-typography-title>
</div>
<a-space :size="8" wrap>
<a-button @click="router.push({ name: 'reports' })">
<template #icon><ArrowRightOutlined /></template>
بازگشت
</a-button>
<a-button type="primary" :loading="syncing" @click="openSyncChoice">
<template #icon><SyncOutlined /></template>
به‌روزرسانی از ته‌حساب
</a-button>
</a-space>
</a-flex>
</a-col>
<a-col v-if="error" :span="24">
<a-alert type="error" show-icon :message="error" closable @close="error = ''" />
</a-col>
<a-col v-if="syncHint" :span="24">
<a-alert type="info" show-icon :message="syncHint" />
</a-col>
<a-col :span="24">
<a-card :bordered="false">
<template #title>
<a-space :size="8">
<FilterOutlined />
<span>فیلترها</span>
</a-space>
</template>
<a-form layout="vertical" @submit.prevent>
<a-row :gutter="[16, 8]">
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="اتصال">
<a-select
v-model:value="connectionId"
:options="
connections.map((c) => ({ value: c.id, label: c.label }))
"
placeholder="اتصال ته‌حساب"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :xs="24" :lg="12">
<a-form-item label="بازه">
<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">
<a-form-item label="از تاریخ">
<JalaliDateInput v-model="fromDate" placeholder="از تاریخ" @update:model-value="markCustomPeriod" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="تا تاریخ">
<JalaliDateInput v-model="toDate" placeholder="تا تاریخ" @update:model-value="markCustomPeriod" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="گروه مشتری">
<a-select
v-model:value="groupIds"
mode="multiple"
allow-clear
show-search
option-filter-prop="label"
placeholder="همه گروه‌ها"
:options="
(customerGroups as CustomerGroupDto[]).map((g) => ({
value: g.id,
label: groupOptionLabel(g),
}))
"
:max-tag-count="2"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="نوع سند">
<a-select
v-model:value="kinds"
mode="multiple"
show-search
option-filter-prop="label"
:options="kindOptions"
placeholder="همه انواع"
:max-tag-count="2"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="فلز">
<a-select
v-model:value="jensFelez"
allow-clear
show-search
option-filter-prop="label"
placeholder="همه"
:options="metalOptions"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8" :lg="6">
<a-form-item label="شخص">
<a-select
v-model:value="personId"
show-search
allow-clear
placeholder="جستجوی مشتری ته‌حساب"
:filter-option="false"
:options="personOptions"
@search="searchTahesabCustomers"
@dropdown-visible-change="onPersonDropdown"
>
<template #suffixIcon><SearchOutlined /></template>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-text v-if="lastSyncedLabel" type="secondary">
آخرین همگام‌سازی: {{ lastSyncedLabel }}
</a-typography-text>
<a-typography-text v-else type="secondary">
بعد از انتخاب بازه، فیلتر را اعمال کنید یا از ته‌حساب به‌روز کنید.
</a-typography-text>
<a-button type="primary" :loading="loading" @click="applyFilters">
اعمال فیلتر
</a-button>
</a-flex>
</a-form>
</a-card>
</a-col>
<a-col v-if="canGroups" :span="24">
<a-collapse :bordered="false">
<a-collapse-panel key="parents" header="چیدمان گروه اصلی و فرعی">
<a-typography-paragraph type="secondary">
گروه فرعی را به یک گروه اصلی وصل کنید تا فیلتر فروش، زیرگروه‌ها را هم بیاورد.
</a-typography-paragraph>
<a-row :gutter="[12, 12]" align="bottom">
<a-col :xs="24" :md="9">
<a-form-item label="گروه فرعی">
<a-select
:value="childGroupId"
show-search
allow-clear
placeholder="جستجوی گروه"
option-filter-prop="label"
:options="childGroupOptions"
@change="onChildGroup"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="9">
<a-form-item label="گروه اصلی">
<a-select
v-model:value="parentGroupId"
show-search
allow-clear
placeholder="بدون گروه اصلی"
option-filter-prop="label"
:disabled="!childGroupId"
:options="childGroupId ? parentChoices(childGroupId) : []"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="6">
<a-form-item label=" ">
<a-button type="primary" block :disabled="!childGroupId" @click="saveGroupParent">
ذخیره ارتباط
</a-button>
</a-form-item>
</a-col>
</a-row>
<a-list
v-if="linkedGroups.length"
:data-source="linkedGroups"
:locale="{ emptyText: 'هنوز گروهی زیرمجموعه نشده.' }"
>
<template #renderItem="{ item }">
<a-list-item>
<a-typography-text>{{ item.parentName }} / {{ item.name }}</a-typography-text>
<template #actions>
<a-button type="link" @click="clearGroupParent(item.id)">جدا کردن</a-button>
</template>
</a-list-item>
</template>
</a-list>
</a-collapse-panel>
</a-collapse>
</a-col>
<a-col v-if="overviewError" :span="24">
<a-alert type="warning" show-icon :message="overviewError" />
</a-col>
<a-col :span="24">
<SalesReportBoard :overview="overview" :show-internet="showInternet" />
</a-col>
<a-col v-if="summary" :span="24">
<a-typography-paragraph type="secondary" :style="{ marginBottom: 0 }">
سه عدد زیر جمع همهٔ سندهای بازه است، از جمله بانک و درصد. نتیجهٔ فروش کالا جدول «نتیجهٔ این بازه» است.
</a-typography-paragraph>
</a-col>
<a-col v-if="summary" :xs="24" :sm="8">
<a-card :bordered="false">
<a-statistic
title="تعداد ردیف سند"
:value="toFaDigits(String(summary.totalLines))"
/>
</a-card>
</a-col>
<a-col v-if="summary" :xs="24" :sm="8">
<a-card :bordered="false">
<a-statistic title="جمع مبلغ همهٔ سندها" :value="formatMali(summary.maliSum)" />
</a-card>
</a-col>
<a-col v-if="summary" :xs="24" :sm="8">
<a-card :bordered="false">
<a-statistic title="جمع وزن همهٔ سندها" :value="formatNum(summary.vaznSum)" />
</a-card>
</a-col>
<a-col v-if="summary?.byKind?.length" :xs="24" :lg="12">
<a-card :bordered="false" title="به تفکیک نوع">
<a-list :data-source="summary.byKind" item-layout="horizontal">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.label">
<template #description>
{{ toFaDigits(String(item.lineCount)) }} ردیف ·
{{ formatMali(item.maliSum) }} · وزن {{ formatNum(item.vaznSum) }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
<a-col v-if="summary?.byGroup?.length" :xs="24" :lg="12">
<a-card :bordered="false" title="به تفکیک گروه">
<a-list
:data-source="summary.byGroup.slice(0, 8)"
item-layout="horizontal"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.label">
<template #description>
{{ toFaDigits(String(item.lineCount)) }} ردیف ·
{{ formatMali(item.maliSum) }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
<a-col v-if="(overview?.otherNos?.length ?? 0) > 0" :span="24">
<a-collapse :bordered="false">
<a-collapse-panel
key="other"
:header="`سندهایی که نوع کالا مشخص نیست (${toFaDigits(String(overview?.otherNos.length ?? 0))} عنوان)`"
>
<a-typography-paragraph type="secondary">
جدول بالا فقط کارساخته، اتیکت، شمش و سکه است. بقیهٔ عنوان‌ها این‌جا دسته‌بندی شده‌اند.
</a-typography-paragraph>
<a-list :data-source="otherDocGroups" item-layout="vertical">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta
:title="`${item.title} (${toFaDigits(String(item.names.length))})`"
:description="item.hint"
/>
<a-space wrap>
<a-tag v-for="name in item.names" :key="name">{{ name }}</a-tag>
</a-space>
</a-list-item>
</template>
</a-list>
</a-collapse-panel>
</a-collapse>
</a-col>
<a-col :span="24">
<a-card :bordered="false" title="ردیف‌های گزارش">
<a-table
:columns="columns"
:data-source="report?.items ?? []"
:loading="loading"
:pagination="{
current: page,
pageSize,
total: report?.total ?? 0,
showSizeChanger: true,
showTotal: (total) => `${toFaDigits(String(total))} ردیف`,
pageSizeOptions: ['20', '50', '100'],
}"
row-key="id"
:scroll="{ x: 1100 }"
@change="onTableChange"
>
<template #emptyText>
<a-empty description="برای این بازه ردیفی نیست. بازه را اعمال کنید یا از ته‌حساب به‌روز کنید." />
</template>
</a-table>
</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>