From cb5944e767c83f95b3eb2fef0e76665bd856c1c0 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 21 Oct 2026 21:47:00 +0330 Subject: [PATCH] fix(finance): shared treasury layout so tab switches stop jumping Keep title and nav mounted in TreasuryLayout with stable header height, and finish Ant form chrome (MoneyInput, Jalali) on finance pages. Co-authored-by: Cursor --- .../src/components/treasury/TreasuryNav.vue | 104 ++++---- apps/web/src/router/index.ts | 179 ++++++++----- .../views/treasury/TreasuryAccountView.vue | 238 ++++++++++++------ .../views/treasury/TreasuryAccountsView.vue | 108 +++++--- .../src/views/treasury/TreasuryAuditView.vue | 63 +++-- .../views/treasury/TreasuryCategoriesView.vue | 42 ++-- .../views/treasury/TreasuryExpensesView.vue | 75 ++++-- .../src/views/treasury/TreasuryHomeView.vue | 18 +- .../views/treasury/TreasuryIncomesView.vue | 88 +++---- .../web/src/views/treasury/TreasuryLayout.vue | 57 +++++ .../views/treasury/TreasuryPayrollView.vue | 155 +++++++++--- .../treasury/TreasuryReceivablesView.vue | 122 ++++++--- .../views/treasury/TreasuryRecurringView.vue | 82 ++++-- .../views/treasury/TreasuryReportsView.vue | 19 +- .../src/views/treasury/treasuryPageChrome.ts | 33 +++ 15 files changed, 907 insertions(+), 476 deletions(-) create mode 100644 apps/web/src/views/treasury/TreasuryLayout.vue create mode 100644 apps/web/src/views/treasury/treasuryPageChrome.ts diff --git a/apps/web/src/components/treasury/TreasuryNav.vue b/apps/web/src/components/treasury/TreasuryNav.vue index e91cb69..fd28d15 100644 --- a/apps/web/src/components/treasury/TreasuryNav.vue +++ b/apps/web/src/components/treasury/TreasuryNav.vue @@ -1,66 +1,68 @@ - - diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index cd1f4e4..8479516 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -241,69 +241,124 @@ export const router = createRouter({ }, { path: 'finance', - name: 'finance', - component: () => import('@/views/treasury/TreasuryHomeView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/accounts', - name: 'finance-accounts', - component: () => import('@/views/treasury/TreasuryAccountsView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/accounts/:id', - name: 'finance-account', - component: () => import('@/views/treasury/TreasuryAccountView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/incomes', - name: 'finance-incomes', - component: () => import('@/views/treasury/TreasuryIncomesView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/expenses', - name: 'finance-expenses', - component: () => import('@/views/treasury/TreasuryExpensesView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/receivables', - name: 'finance-receivables', - component: () => import('@/views/treasury/TreasuryReceivablesView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/payroll', - name: 'finance-payroll', - component: () => import('@/views/treasury/TreasuryPayrollView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/recurring', - name: 'finance-recurring', - component: () => import('@/views/treasury/TreasuryRecurringView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/reports', - name: 'finance-reports', - component: () => import('@/views/treasury/TreasuryReportsView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/audit', - name: 'finance-audit', - component: () => import('@/views/treasury/TreasuryAuditView.vue'), - meta: { roles: ['owner', 'admin'] as UserRole[] }, - }, - { - path: 'finance/categories', - name: 'finance-categories', - component: () => import('@/views/treasury/TreasuryCategoriesView.vue'), + component: () => import('@/views/treasury/TreasuryLayout.vue'), meta: { roles: ['owner', 'admin'] as UserRole[] }, + children: [ + { + path: '', + name: 'finance', + component: () => import('@/views/treasury/TreasuryHomeView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'مدیریت مالی', + treasuryDescription: + 'دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست.', + }, + }, + { + path: 'accounts', + name: 'finance-accounts', + component: () => import('@/views/treasury/TreasuryAccountsView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'صندوق و حساب‌ها', + treasuryDescription: 'موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه می‌شود.', + }, + }, + { + path: 'accounts/:id', + name: 'finance-account', + component: () => import('@/views/treasury/TreasuryAccountView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'جزئیات حساب', + treasuryDescription: 'گردش قطعی این حساب', + }, + }, + { + path: 'incomes', + name: 'finance-incomes', + component: () => import('@/views/treasury/TreasuryIncomesView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'دریافت‌ها و درآمدها', + treasuryDescription: + 'ثبت سند، موجودی را عوض نمی‌کند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.', + }, + }, + { + path: 'expenses', + name: 'finance-expenses', + component: () => import('@/views/treasury/TreasuryExpensesView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'پرداخت‌ها و هزینه‌ها', + treasuryDescription: + 'ثبت سند، موجودی را عوض نمی‌کند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.', + }, + }, + { + path: 'receivables', + name: 'finance-receivables', + component: () => import('@/views/treasury/TreasuryReceivablesView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'اقساط و مطالبات', + treasuryDescription: + 'دریافت هر قسط فقط یک بار وارد حساب می‌شود و مانده همان قسط را کم می‌کند.', + }, + }, + { + path: 'payroll', + name: 'finance-payroll', + component: () => import('@/views/treasury/TreasuryPayrollView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'حقوق و دستمزد', + treasuryDescription: 'فقط مالک و مدیر این صفحه را می‌بینند.', + }, + }, + { + path: 'recurring', + name: 'finance-recurring', + component: () => import('@/views/treasury/TreasuryRecurringView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'درآمد و هزینه تکرارشونده', + treasuryDescription: 'تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمی‌شود.', + }, + }, + { + path: 'reports', + name: 'finance-reports', + component: () => import('@/views/treasury/TreasuryReportsView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'گزارش مالی', + treasuryDescription: 'سود عملیاتی از درآمد و هزینه ثبت‌شده است، نه از خالص وجه.', + }, + }, + { + path: 'audit', + name: 'finance-audit', + component: () => import('@/views/treasury/TreasuryAuditView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'سوابق تغییرات مالی', + treasuryDescription: 'تسویه سند و نشستن وجه در صندوق، با مقدار ثبت‌شده.', + }, + }, + { + path: 'categories', + name: 'finance-categories', + component: () => import('@/views/treasury/TreasuryCategoriesView.vue'), + meta: { + roles: ['owner', 'admin'] as UserRole[], + treasuryTitle: 'دسته‌بندی‌های مالی', + treasuryDescription: 'دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند.', + }, + }, + ], }, { path: 'reservations', diff --git a/apps/web/src/views/treasury/TreasuryAccountView.vue b/apps/web/src/views/treasury/TreasuryAccountView.vue index efcdccd..7b102ed 100644 --- a/apps/web/src/views/treasury/TreasuryAccountView.vue +++ b/apps/web/src/views/treasury/TreasuryAccountView.vue @@ -1,121 +1,203 @@ + + + بازگشت به حساب‌ها + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + مغایرت و اصلاح + + + + + + + + + + + + + + + + ثبت مغایرت + اصلاح ورود + اصلاح خروج + + + + + + + گردش حساب + + + + + diff --git a/apps/web/src/views/treasury/TreasuryAccountsView.vue b/apps/web/src/views/treasury/TreasuryAccountsView.vue index f5de95e..ba17e20 100644 --- a/apps/web/src/views/treasury/TreasuryAccountsView.vue +++ b/apps/web/src/views/treasury/TreasuryAccountsView.vue @@ -20,13 +20,16 @@ import { Statistic as AStatistic, Tag as ATag, Typography, + message, } from 'ant-design-vue'; import { api } from '@/lib/api'; import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared'; -import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; +import MoneyInput from '@/components/MoneyInput.vue'; +import JalaliDateInput from '@/components/JalaliDateInput.vue'; import { toman } from './treasury-format'; +import { parseMoneyNumber } from '@/lib/money'; -const { Title: ATypographyTitle, Text: ATypographyText } = Typography; +const { Text: ATypographyText } = Typography; const AListItem = AList.Item; const AListItemMeta = AList.Item.Meta; const ASelectOption = ASelect.Option; @@ -82,14 +85,19 @@ async function load() { accounts.value = data; } +function onFinishFailed() { + message.warning('لطفاً فیلدهای الزامی را کامل کنید.'); +} + async function createAccount() { saving.value = true; error.value = ''; ok.value = ''; try { + const openingBalance = parseMoneyNumber(String(form.value.openingBalance ?? ''), 0) ?? 0; await api.post('/treasury/accounts', { ...form.value, - openingBalance: form.value.openingBalance || 0, + openingBalance, openingDate: form.value.openingDate || undefined, }); openForm.value = false; @@ -117,11 +125,20 @@ async function sendTransfer() { error.value = ''; ok.value = ''; try { + const amount = parseMoneyNumber(String(transfer.value.amount ?? ''), 0); + if (amount == null || amount <= 0) { + error.value = 'مبلغ انتقال معتبر نیست.'; + return; + } await api.post('/treasury/transfers', { - ...transfer.value, + fromAccountId: transfer.value.fromAccountId, + toAccountId: transfer.value.toAccountId, + amount, + memo: transfer.value.memo || undefined, clientKey: crypto.randomUUID(), }); transferOpen.value = false; + transfer.value = { fromAccountId: '', toAccountId: '', amount: '', memo: '' }; ok.value = 'انتقال ثبت شد و در درآمد یا هزینه نیامده است.'; await load(); } catch (e) { @@ -139,17 +156,7 @@ onMounted(() => { diff --git a/apps/web/src/views/treasury/TreasuryCategoriesView.vue b/apps/web/src/views/treasury/TreasuryCategoriesView.vue index 66f8c09..05b3b2b 100644 --- a/apps/web/src/views/treasury/TreasuryCategoriesView.vue +++ b/apps/web/src/views/treasury/TreasuryCategoriesView.vue @@ -16,20 +16,16 @@ import { Row as ARow, Select as ASelect, Tag as ATag, - Typography, } from 'ant-design-vue'; import { api } from '@/lib/api'; import type { FinancialCategoryDto } from '@mento/shared'; -import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; -const { Title: ATypographyTitle, Text: ATypographyText } = Typography; const AListItem = AList.Item; const AListItemMeta = AList.Item.Meta; const ASelectOption = ASelect.Option; const rows = ref([]); -const name = ref(''); -const kind = ref<'income' | 'expense'>('expense'); +const form = ref({ name: '', kind: 'expense' as 'income' | 'expense' }); const error = ref(''); async function load() { @@ -40,8 +36,11 @@ async function load() { async function add() { error.value = ''; try { - await api.post('/treasury/categories', { name: name.value, kind: kind.value }); - name.value = ''; + await api.post('/treasury/categories', { + name: form.value.name, + kind: form.value.kind, + }); + form.value.name = ''; await load(); } catch (e) { error.value = axios.isAxiosError(e) @@ -56,31 +55,25 @@ onMounted(() => { - - + diff --git a/apps/web/src/views/treasury/TreasuryExpensesView.vue b/apps/web/src/views/treasury/TreasuryExpensesView.vue index 21a0678..338c75e 100644 --- a/apps/web/src/views/treasury/TreasuryExpensesView.vue +++ b/apps/web/src/views/treasury/TreasuryExpensesView.vue @@ -16,13 +16,15 @@ import { Row as ARow, Select as ASelect, Typography, + message, } from 'ant-design-vue'; import { api } from '@/lib/api'; import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared'; -import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; +import MoneyInput from '@/components/MoneyInput.vue'; import { toman } from './treasury-format'; +import { parseMoneyNumber } from '@/lib/money'; -const { Title: ATypographyTitle, Text: ATypographyText } = Typography; +const { Text: ATypographyText } = Typography; const AListItem = AList.Item; const AListItemMeta = AList.Item.Meta; const ASelectOption = ASelect.Option; @@ -47,7 +49,6 @@ const form = ref({ notes: '', }); -const title = 'پرداخت‌ها و هزینه‌ها'; const settleLabel = 'مبلغ پرداخت‌شده'; const statusLabel: Record = { open: 'پرداخت‌نشده', @@ -81,19 +82,29 @@ async function load() { accounts.value = acc.data.filter((row) => row.isActive); } +function onFinishFailed() { + message.warning('لطفاً فیلدهای الزامی را کامل کنید.'); +} + async function createDoc() { saving.value = true; error.value = ''; ok.value = ''; try { + const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0); + if (amount == null || amount <= 0) { + error.value = 'مبلغ کل معتبر نیست.'; + return; + } + const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; await api.post('/treasury/documents', { kind, title: form.value.title, - amount: form.value.amount, + amount, categoryId: form.value.categoryId || null, partyName: form.value.partyName || null, accountId: form.value.accountId || null, - settleAmount: form.value.settleAmount || 0, + settleAmount, method: form.value.method, reference: form.value.reference || null, notes: form.value.notes || null, @@ -136,43 +147,50 @@ onMounted(() => { - - + diff --git a/apps/web/src/views/treasury/TreasuryHomeView.vue b/apps/web/src/views/treasury/TreasuryHomeView.vue index 088d07e..2995869 100644 --- a/apps/web/src/views/treasury/TreasuryHomeView.vue +++ b/apps/web/src/views/treasury/TreasuryHomeView.vue @@ -14,10 +14,9 @@ import { } from 'ant-design-vue'; import { api } from '@/lib/api'; import type { TreasuryDashboardDto } from '@mento/shared'; -import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; import { toman } from './treasury-format'; -const { Title: ATypographyTitle, Text: ATypographyText } = Typography; +const { Text: ATypographyText } = Typography; const AListItem = AList.Item; const AListItemMeta = AList.Item.Meta; @@ -46,17 +45,7 @@ onMounted(async () => { diff --git a/apps/web/src/views/treasury/TreasuryIncomesView.vue b/apps/web/src/views/treasury/TreasuryIncomesView.vue index e8c78d0..06d3480 100644 --- a/apps/web/src/views/treasury/TreasuryIncomesView.vue +++ b/apps/web/src/views/treasury/TreasuryIncomesView.vue @@ -22,12 +22,12 @@ import { Space as ASpace, Textarea as ATextarea, Typography, + message, theme, } from 'ant-design-vue'; import type { FormInstance } from 'ant-design-vue'; import { ArrowDownOutlined, - ArrowLeftOutlined, BankOutlined, CheckOutlined, ClockCircleOutlined, @@ -48,10 +48,11 @@ import type { TreasuryDashboardDto, } from '@mento/shared'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; -import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; +import MoneyInput from '@/components/MoneyInput.vue'; import { toman } from './treasury-format'; +import { parseMoneyNumber } from '@/lib/money'; -const { Title: ATypographyTitle, Text: ATypographyText } = Typography; +const { Text: ATypographyText } = Typography; const AListItem = AList.Item; const AListItemMeta = AList.Item.Meta; const ASelectOption = ASelect.Option; @@ -91,7 +92,6 @@ function emptyForm() { const form = ref(emptyForm()); -const pageTitle = 'دریافت‌ها و درآمدها'; const settleLabel = 'مبلغ دریافت‌شده'; const statusLabel: Record = { open: 'دریافت‌نشده', @@ -122,11 +122,15 @@ const formTabOptions = [ ]; const remainingAmount = computed(() => { - const total = Number(form.value.amount) || 0; - const settled = Number(form.value.settleAmount) || 0; + const total = parseMoneyNumber(String(form.value.amount ?? ''), 0) ?? 0; + const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; return Math.max(0, total - settled); }); +function onFinishFailed() { + message.warning('لطفاً فیلدهای الزامی را کامل کنید.'); +} + function apiMessage(e: unknown) { if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.'; const msg = (e.response?.data as { message?: string | string[] })?.message; @@ -158,14 +162,20 @@ async function createDoc() { error.value = ''; ok.value = ''; try { + const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0); + if (amount == null || amount <= 0) { + error.value = 'مبلغ کل معتبر نیست.'; + return; + } + const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; await api.post('/treasury/documents', { kind, title: form.value.title, - amount: form.value.amount, + amount, categoryId: form.value.categoryId || null, partyName: form.value.partyName || null, accountId: form.value.accountId || null, - settleAmount: form.value.settleAmount || 0, + settleAmount, method: form.value.method, reference: form.value.reference || null, notes: form.value.notes || null, @@ -243,18 +253,6 @@ onMounted(() => { diff --git a/apps/web/src/views/treasury/TreasuryReportsView.vue b/apps/web/src/views/treasury/TreasuryReportsView.vue index 01a56e8..8d3ac8c 100644 --- a/apps/web/src/views/treasury/TreasuryReportsView.vue +++ b/apps/web/src/views/treasury/TreasuryReportsView.vue @@ -14,10 +14,9 @@ import { import type { TableColumnsType } from 'ant-design-vue'; import { DownloadOutlined } from '@ant-design/icons-vue'; import { api } from '@/lib/api'; -import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; import { toman } from './treasury-format'; -const { Title: ATypographyTitle, Text: ATypographyText } = Typography; +const { Text: ATypographyText } = Typography; const AListItem = AList.Item; const AListItemMeta = AList.Item.Meta; @@ -100,23 +99,14 @@ onMounted(() => { - - + diff --git a/apps/web/src/views/treasury/treasuryPageChrome.ts b/apps/web/src/views/treasury/treasuryPageChrome.ts new file mode 100644 index 0000000..8ba653d --- /dev/null +++ b/apps/web/src/views/treasury/treasuryPageChrome.ts @@ -0,0 +1,33 @@ +import { inject, provide, ref, type InjectionKey, type Ref } from 'vue'; + +export type TreasuryChromeOverride = { + title: string | null; + description: string | null; +}; + +type TreasuryChromeApi = { + override: Ref; + set: (next: TreasuryChromeOverride) => void; + clear: () => void; +}; + +const KEY: InjectionKey = Symbol('treasuryPageChrome'); + +export function provideTreasuryPageChrome(): TreasuryChromeApi { + const override = ref({ title: null, description: null }); + const api: TreasuryChromeApi = { + override, + set(next) { + override.value = next; + }, + clear() { + override.value = { title: null, description: null }; + }, + }; + provide(KEY, api); + return api; +} + +export function useTreasuryPageChrome(): TreasuryChromeApi | null { + return inject(KEY, null); +}