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>
1073 lines
36 KiB
Vue
1073 lines
36 KiB
Vue
<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>
|