merge: bring latest origin/main into dev (design local, logic main)

Keep the local Ant screens and icon pack. Take treasury parties, routine self-service, Tahesab auto-sync, public lead registration, and contact merge from main.
This commit is contained in:
2026-11-05 15:10:48 +03:30
55 changed files with 2719 additions and 358 deletions
@@ -15,7 +15,6 @@ import {
Grid,
Input as AInput,
List as AList,
Modal as AModal,
Row as ARow,
Segmented as ASegmented,
Select as ASelect,
@@ -34,10 +33,7 @@ import {
CreditCardOutlined,
FileTextOutlined,
InfoCircleOutlined,
NumberOutlined,
PlusOutlined,
TagOutlined,
UserOutlined,
} from '@/icons/ant-bridge';
import { api } from '@/lib/api';
import { formatLocalDateInput } from '@/lib/jalali';
@@ -45,10 +41,15 @@ import type {
FinancialAccountDto,
FinancialCategoryDto,
FinancialDocumentDto,
FinancialPartyKind,
TreasuryDashboardDto,
TreasuryPartyBalanceDto,
} from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue';
import { useSettleDefault } from '@/components/treasury/useSettleDefault';
import { toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money';
@@ -62,6 +63,7 @@ const isMobile = computed(() => !screens.value.md);
const kind = 'income' as const;
const rows = ref<FinancialDocumentDto[]>([]);
const balances = ref<TreasuryPartyBalanceDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const dashboard = ref<TreasuryDashboardDto | null>(null);
@@ -70,9 +72,6 @@ const ok = ref('');
const saving = ref(false);
const q = ref('');
const formTab = ref<'main' | 'extra'>('main');
const categoryModalOpen = ref(false);
const newCategoryName = ref('');
const categorySaving = ref(false);
const mobileFormRef = ref<FormInstance>();
function emptyForm() {
@@ -80,17 +79,18 @@ function emptyForm() {
title: '',
amount: '',
categoryId: undefined as string | undefined,
partyName: '',
partyKind: undefined as FinancialPartyKind | undefined,
partyId: undefined as string | undefined,
accountId: undefined as string | undefined,
settleAmount: '',
method: 'transfer',
reference: '',
notes: '',
bookedAt: formatLocalDateInput(new Date()),
};
}
const form = ref(emptyForm());
const settleMirror = useSettleDefault(form);
const settleLabel = 'مبلغ دریافت‌شده';
const statusLabel: Record<string, string> = {
@@ -137,20 +137,37 @@ function apiMessage(e: unknown) {
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
}
function docLine(item: FinancialDocumentDto) {
const parts = [
item.reference ? `پیگیری ${item.reference}` : '',
item.partyName || 'بدون طرف حساب',
statusLabel[item.status] ?? item.status,
`مانده ${toman(item.remaining)}`,
].filter(Boolean);
if (item.categoryName) parts.push(item.categoryName);
return parts.join(' · ');
}
async function load() {
const [docs, cats, acc, dash] = await Promise.all([
const [docs, cats, acc, dash, bals] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
api.get<TreasuryDashboardDto>('/treasury/dashboard').catch(() => null),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e;
}),
]);
rows.value = docs.data;
balances.value = bals.data;
categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive);
if (dash) dashboard.value = dash.data;
}
function resetForm() {
settleMirror.reset();
form.value = emptyForm();
formTab.value = 'main';
error.value = '';
@@ -167,22 +184,34 @@ async function createDoc() {
error.value = 'مبلغ کل معتبر نیست.';
return;
}
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
error.value = 'طرف حساب را انتخاب کنید.';
return;
}
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
await api.post('/treasury/documents', {
if (form.value.partyKind === 'other' && settleAmount !== amount) {
error.value = 'برای سایر، مبلغ دریافت‌شده باید با مبلغ کل یکی باشد.';
return;
}
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
kind,
title: form.value.title,
amount,
categoryId: form.value.categoryId || null,
partyName: form.value.partyName || null,
partyKind: form.value.partyKind,
personId: form.value.partyKind === 'person' ? form.value.partyId : null,
companyId: form.value.partyKind === 'company' ? form.value.partyId : null,
userId: form.value.partyKind === 'user' ? form.value.partyId : null,
accountId: form.value.accountId || null,
settleAmount,
method: form.value.method,
reference: form.value.reference || null,
notes: form.value.notes || null,
bookedAt: form.value.bookedAt || undefined,
});
resetForm();
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.';
ok.value = data.reference
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
: 'سند ثبت شد.';
await load();
} catch (e) {
error.value = apiMessage(e);
@@ -211,30 +240,6 @@ async function voidDoc(id: string) {
}
}
async function createCategoryQuick() {
const name = newCategoryName.value.trim();
if (name.length < 2) {
error.value = 'نام دسته‌بندی کوتاه است.';
return;
}
categorySaving.value = true;
error.value = '';
try {
const { data } = await api.post<FinancialCategoryDto>('/treasury/categories', {
name,
kind,
});
await load();
form.value.categoryId = data.id;
newCategoryName.value = '';
categoryModalOpen.value = false;
} catch (e) {
error.value = apiMessage(e);
} finally {
categorySaving.value = false;
}
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری ناموفق بود.';
@@ -358,10 +363,8 @@ onMounted(() => {
<a-row :gutter="[12, 0]">
<a-col :span="12">
<a-form-item label="مشتری / طرف حساب" name="partyName">
<a-input v-model:value="form.partyName" placeholder="نام طرف حساب">
<template #prefix><UserOutlined /></template>
</a-input>
<a-form-item label="طرف حساب" name="partyKind">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item>
</a-col>
<a-col :span="12">
@@ -393,21 +396,16 @@ onMounted(() => {
<span>دسته‌بندی درآمد</span>
</a-space>
</template>
<a-flex :gap="8">
<a-select show-search option-filter-prop="label"
v-model:value="form.categoryId"
allow-clear
placeholder="انتخاب دسته"
style="flex: 1; min-width: 0"
>
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
{{ cat.name }}
</a-select-option>
</a-select>
<a-button type="primary" ghost @click="categoryModalOpen = true">
<template #icon><PlusOutlined /></template>
</a-button>
</a-flex>
<a-select show-search option-filter-prop="label"
v-model:value="form.categoryId"
allow-clear
placeholder="انتخاب دسته"
style="width: 100%"
>
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
{{ cat.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-row :gutter="[12, 0]">
@@ -426,7 +424,12 @@ onMounted(() => {
name="settleAmount"
:rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]"
>
<MoneyInput v-model="form.settleAmount" :decimals="0" placeholder="۰" />
<MoneyInput
v-model="form.settleAmount"
:decimals="0"
:disabled="form.partyKind === 'other'"
placeholder="۰"
/>
</a-form-item>
</a-col>
</a-row>
@@ -493,12 +496,6 @@ onMounted(() => {
</div>
<div v-show="formTab === 'extra'">
<a-form-item label="شماره پیگیری" name="reference">
<a-input v-model:value="form.reference" placeholder="شماره پیگیری بانکی یا مرجع">
<template #prefix><NumberOutlined /></template>
</a-input>
</a-form-item>
<a-form-item label="توضیحات" name="notes">
<a-textarea
v-model:value="form.notes"
@@ -511,13 +508,15 @@ onMounted(() => {
</a-card>
<a-card :bordered="false">
<a-divider orientation="right">مانده دریافتنی</a-divider>
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافت‌شده" />
<a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical">
<a-form-item label="جستجو">
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو…"
placeholder="عنوان یا شماره پیگیری"
@press-enter="load"
@change="load"
/>
@@ -540,11 +539,7 @@ onMounted(() => {
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
<template #description>{{ docLine(item) }}</template>
</a-list-item-meta>
</a-list-item>
</template>
@@ -614,7 +609,7 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount">
<MoneyInput v-model="form.settleAmount" :decimals="0" />
<MoneyInput v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
@@ -632,8 +627,8 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="طرف حساب" name="partyName">
<a-input v-model:value="form.partyName" />
<a-form-item label="طرف حساب" name="partyKind">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
@@ -674,11 +669,6 @@ onMounted(() => {
<JalaliDateInput v-model="form.bookedAt" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پیگیری" name="reference">
<a-input v-model:value="form.reference" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="16">
<a-form-item label="توضیحات" name="notes">
<a-input v-model:value="form.notes" />
@@ -688,6 +678,8 @@ onMounted(() => {
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
</a-form>
<a-divider orientation="right">مانده دریافتنی</a-divider>
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافت‌شده" />
<a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
@@ -696,7 +688,7 @@ onMounted(() => {
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو…"
placeholder="عنوان یا شماره پیگیری"
@press-enter="load"
@change="load"
/>
@@ -722,11 +714,7 @@ onMounted(() => {
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
<template #description>{{ docLine(item) }}</template>
</a-list-item-meta>
</a-list-item>
</template>
@@ -735,23 +723,5 @@ onMounted(() => {
</a-flex>
</template>
<a-modal
v-model:open="categoryModalOpen"
title="دسته جدید"
ok-text="افزودن"
cancel-text="انصراف"
:confirm-loading="categorySaving"
@ok="createCategoryQuick"
>
<a-form layout="vertical">
<a-form-item label="نام دسته" required>
<a-input
v-model:value="newCategoryName"
placeholder="مثلاً: فروش خدمات"
@press-enter="createCategoryQuick"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>