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 <cursoragent@cursor.com>
This commit is contained in:
2026-10-21 21:47:00 +03:30
co-authored by Cursor
parent 23ae273232
commit cb5944e767
15 changed files with 907 additions and 476 deletions
@@ -1,66 +1,68 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { Segmented as ASegmented } from 'ant-design-vue';
import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const canPayroll = computed(() =>
auth.user?.role === 'owner'
|| auth.user?.role === 'admin'
|| Boolean(auth.user?.permissions?.includes('treasury_payroll')),
const canPayroll = computed(
() =>
auth.user?.role === 'owner' ||
auth.user?.role === 'admin' ||
Boolean(auth.user?.permissions?.includes('treasury_payroll')),
);
const links = computed(() => [
{ to: '/finance', label: 'داشبورد', exact: true },
{ to: '/finance/incomes', label: 'دریافت‌ها و درآمدها' },
{ to: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها' },
{ to: '/finance/receivables', label: 'اقساط و مطالبات' },
{ to: '/finance/accounts', label: 'صندوق و حساب‌ها' },
...(canPayroll.value ? [{ to: '/finance/payroll', label: 'حقوق' }] : []),
{ to: '/finance/recurring', label: 'تکرارشونده' },
{ to: '/finance/reports', label: 'گزارش' },
{ to: '/finance/audit', label: 'سوابق' },
{ to: '/finance/categories', label: 'دسته‌بندی‌ها' },
{ value: '/finance', label: 'داشبورد', exact: true },
{ value: '/finance/incomes', label: 'دریافت‌ها و درآمدها' },
{ value: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها' },
{ value: '/finance/receivables', label: 'اقساط و مطالبات' },
{ value: '/finance/accounts', label: 'صندوق و حساب‌ها' },
...(canPayroll.value ? [{ value: '/finance/payroll', label: 'حقوق' }] : []),
{ value: '/finance/recurring', label: 'تکرارشونده' },
{ value: '/finance/reports', label: 'گزارش' },
{ value: '/finance/audit', label: 'سوابق' },
{ value: '/finance/categories', label: 'دسته‌بندی‌ها' },
]);
function on(path: string, exact?: boolean) {
if (exact) return route.path === path;
return route.path === path || route.path.startsWith(`${path}/`);
const options = computed(() => links.value.map((l) => ({ value: l.value, label: l.label })));
const active = computed(() => {
const path = route.path;
for (const link of links.value) {
if (link.exact) {
if (path === link.value) return link.value;
} else if (path === link.value || path.startsWith(`${link.value}/`)) {
return link.value;
}
}
return '/finance';
});
function onChange(value: string | number) {
const next = String(value);
if (next && next !== route.path) void router.push(next);
}
</script>
<template>
<nav class="treasury-nav" aria-label="مدیریت مالی">
<RouterLink
v-for="link in links"
:key="link.to"
:to="link.to"
:class="{ on: on(link.to, link.exact) }"
>
{{ link.label }}
</RouterLink>
</nav>
<div
:style="{
width: '100%',
overflowX: 'auto',
flexShrink: 0,
minHeight: '36px',
}"
>
<a-segmented
:value="active"
:options="options"
dir="rtl"
:style="{ minWidth: 'max-content' }"
@change="onChange"
/>
</div>
</template>
<style scoped>
.treasury-nav {
display: flex;
gap: 0.35rem;
overflow: auto;
padding-bottom: 0.25rem;
}
.treasury-nav a {
flex: 0 0 auto;
border-radius: 999px;
padding: 0.4rem 0.8rem;
color: #475569;
text-decoration: none;
font-weight: 700;
font-size: 0.86rem;
background: #f8fafc;
}
.treasury-nav a.on {
background: #0f766e;
color: #fff;
}
</style>
+117 -62
View File
@@ -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',
@@ -1,121 +1,203 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
Row as ARow,
Space as ASpace,
Statistic as AStatistic,
Table as ATable,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialMovementDto } from '@mento/shared';
import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali';
import { parseMoneyNumber } from '@/lib/money';
import { useTreasuryPageChrome } from './treasuryPageChrome';
const route = useRoute();
const router = useRouter();
const pageChrome = useTreasuryPageChrome();
const account = ref<FinancialAccountDto | null>(null);
const items = ref<FinancialMovementDto[]>([]);
const error = ref('');
const stated = ref('');
const stated = ref<string | number | null>('');
const reason = ref('');
const note = ref('');
const busy = ref(false);
const columns: TableColumnsType<FinancialMovementDto> = [
{
title: 'تاریخ',
key: 'occurredAt',
customRender: ({ record }) => formatJalaliNumeric(new Date(record.occurredAt)),
},
{
title: 'شرح',
key: 'memo',
customRender: ({ record }) => record.memo || '—',
},
{ title: 'نوع', dataIndex: 'kindLabel', key: 'kindLabel' },
{
title: 'ورودی',
key: 'in',
customRender: ({ record }) => (record.direction === 'in' ? toman(record.amount) : '—'),
},
{
title: 'خروجی',
key: 'out',
customRender: ({ record }) => (record.direction === 'out' ? toman(record.amount) : '—'),
},
{
title: 'مانده',
key: 'balanceAfter',
customRender: ({ record }) => (record.balanceAfter ? toman(record.balanceAfter) : '—'),
},
];
async function load() {
const { data } = await api.get<{ account: FinancialAccountDto; items: FinancialMovementDto[] }>(
`/treasury/accounts/${route.params.id}/ledger`,
);
account.value = data.account;
items.value = data.items;
pageChrome?.set({
title: data.account.name || 'جزئیات حساب',
description: 'گردش قطعی این حساب',
});
}
async function reconcile() {
error.value = '';
note.value = '';
busy.value = true;
try {
const { data } = await api.post<{ difference: string; systemBalance: string }>('/treasury/reconciliations', {
accountId: route.params.id,
statedBalance: stated.value,
});
const amount = parseMoneyNumber(String(stated.value ?? ''), 0);
const { data } = await api.post<{ difference: string; systemBalance: string }>(
'/treasury/reconciliations',
{
accountId: route.params.id,
statedBalance: amount ?? 0,
},
);
note.value = `اختلاف ${toman(data.difference)} نسبت به موجودی سیستم ${toman(data.systemBalance)}. موجودی خودکار عوض نشد.`;
} catch {
error.value = 'ثبت مغایرت ناموفق بود.';
} finally {
busy.value = false;
}
}
async function adjust(direction: 'in' | 'out') {
error.value = '';
busy.value = true;
try {
const amount = parseMoneyNumber(String(stated.value ?? ''), 0);
await api.post('/treasury/adjustments', {
accountId: route.params.id,
amount: stated.value,
amount: amount ?? 0,
direction,
reason: reason.value,
});
window.location.reload();
await load();
note.value = 'اصلاح موجودی ثبت شد.';
} catch {
error.value = 'اصلاح موجودی ناموفق بود.';
} finally {
busy.value = false;
}
}
onMounted(async () => {
try {
const { data } = await api.get<{ account: FinancialAccountDto; items: FinancialMovementDto[] }>(
`/treasury/accounts/${route.params.id}/ledger`,
);
account.value = data.account;
items.value = data.items;
} catch {
onMounted(() => {
void load().catch(() => {
error.value = 'گردش حساب بارگذاری نشد.';
}
});
});
onUnmounted(() => {
pageChrome?.clear();
});
</script>
<template>
<section class="page">
<UiPageHeader :title="account?.name || 'حساب'" description="گردش قطعی این حساب" />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<div v-if="account" class="stats">
<article><span>موجودی فعلی</span><strong>{{ toman(account.balance) }}</strong></article>
<article><span>مانده افتتاحیه</span><strong>{{ toman(account.openingBalance) }}</strong></article>
<article><span>ورودی</span><strong>{{ toman(account.periodIn) }}</strong></article>
<article><span>خروجی</span><strong>{{ toman(account.periodOut) }}</strong></article>
</div>
<div v-if="account" class="reconcile">
<UiField label="موجودی واقعی"><MoneyInput v-model="stated" :decimals="0" /></UiField>
<UiField label="علت اصلاح"><UiInput v-model="reason" /></UiField>
<UiButton variant="secondary" @click="reconcile">ثبت مغایرت</UiButton>
<UiButton variant="secondary" @click="adjust('in')">اصلاح ورود</UiButton>
<UiButton variant="secondary" @click="adjust('out')">اصلاح خروج</UiButton>
<p v-if="note" class="note">{{ note }}</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>تاریخ</th>
<th>شرح</th>
<th>نوع</th>
<th>ورودی</th>
<th>خروجی</th>
<th>مانده</th>
</tr>
</thead>
<tbody>
<tr v-for="row in items" :key="row.id">
<td>{{ formatJalaliNumeric(new Date(row.occurredAt)) }}</td>
<td>{{ row.memo || '—' }}</td>
<td>{{ row.kindLabel }}</td>
<td>{{ row.direction === 'in' ? toman(row.amount) : '—' }}</td>
<td>{{ row.direction === 'out' ? toman(row.amount) : '—' }}</td>
<td>{{ row.balanceAfter ? toman(row.balanceAfter) : '—' }}</td>
</tr>
<tr v-if="!items.length"><td colspan="6">گردشی ثبت نشده است.</td></tr>
</tbody>
</table>
</div>
</section>
</template>
<a-flex vertical :gap="16">
<a-space>
<a-button type="link" @click="router.push('/finance/accounts')">بازگشت به حساب‌ها</a-button>
</a-space>
<style scoped>
.page { display: grid; gap: 1rem; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.7rem; }
.stats article { background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem; }
.stats span { color: #64748b; display: block; }
.table-wrap { overflow: auto; background: #fff; border: 1px solid #e7eef2; border-radius: 16px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.7rem; text-align: right; border-bottom: 1px solid #f1f5f9; white-space: nowrap; }
.reconcile { display: grid; grid-template-columns: 1fr 1fr auto auto auto; gap: 0.6rem; align-items: end; }
.note { grid-column: 1 / -1; color: #0f766e; font-weight: 700; }
.err { color: #b91c1c; font-weight: 700; }
@media (max-width: 800px) { .reconcile { grid-template-columns: 1fr; } }
</style>
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="note" type="success" show-icon :message="note" />
<a-row v-if="account" :gutter="[16, 16]">
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic title="موجودی فعلی" :value="toman(account.balance)" />
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic title="مانده افتتاحیه" :value="toman(account.openingBalance)" />
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic title="ورودی" :value="toman(account.periodIn)" />
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic title="خروجی" :value="toman(account.periodOut)" />
</a-card>
</a-col>
</a-row>
<a-card v-if="account" :bordered="false">
<a-divider orientation="right">مغایرت و اصلاح</a-divider>
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8">
<a-form-item label="موجودی واقعی">
<MoneyInput v-model="stated" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="علت اصلاح">
<a-input v-model:value="reason" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label=" ">
<a-space wrap>
<a-button :loading="busy" @click="reconcile">ثبت مغایرت</a-button>
<a-button :loading="busy" @click="adjust('in')">اصلاح ورود</a-button>
<a-button :loading="busy" @click="adjust('out')">اصلاح خروج</a-button>
</a-space>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-divider orientation="right">گردش حساب</a-divider>
<a-empty v-if="!items.length" description="گردشی ثبت نشده است." />
<a-table
v-else
:columns="columns"
:data-source="items"
:pagination="false"
row-key="id"
:scroll="{ x: true }"
/>
</a-card>
</a-flex>
</template>
@@ -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(() => {
</script>
<template>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">صندوق و حساب‌ها</a-typography-title>
<a-typography-text type="secondary">
موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه می‌شود.
</a-typography-text>
</div>
<TreasuryNav />
<a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
@@ -192,15 +199,24 @@ onMounted(() => {
<template v-if="openForm">
<a-divider orientation="right">حساب جدید</a-divider>
<a-form layout="vertical" @finish="createAccount">
<a-form
layout="vertical"
:model="form"
@finish="createAccount"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام حساب" name="name">
<a-form-item
label="نام حساب"
name="name"
:rules="[{ required: true, message: 'نام حساب الزامی است' }]"
>
<a-input v-model:value="form.name" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="type">
<a-form-item label="نوع" name="type" :rules="[{ required: true }]">
<a-select v-model:value="form.type" style="width: 100%">
<a-select-option value="cash">صندوق نقدی</a-select-option>
<a-select-option value="bank">حساب بانکی</a-select-option>
@@ -225,12 +241,12 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="موجودی اولیه" name="openingBalance">
<a-input v-model:value="form.openingBalance" />
<MoneyInput v-model="form.openingBalance" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="تاریخ شروع" name="openingDate">
<a-input v-model:value="form.openingDate" type="date" style="width: 100%" />
<JalaliDateInput v-model="form.openingDate" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
@@ -245,12 +261,25 @@ onMounted(() => {
<template v-if="transferOpen">
<a-divider orientation="right">انتقال بین حساب‌ها</a-divider>
<a-form layout="vertical" @finish="sendTransfer">
<a-form
layout="vertical"
:model="transfer"
@finish="sendTransfer"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="از حساب" name="fromAccountId">
<a-select v-model:value="transfer.fromAccountId" style="width: 100%" placeholder="انتخاب">
<a-select-option value="">انتخاب</a-select-option>
<a-form-item
label="از حساب"
name="fromAccountId"
:rules="[{ required: true, message: 'حساب مبدأ الزامی است' }]"
>
<a-select
v-model:value="transfer.fromAccountId"
allow-clear
style="width: 100%"
placeholder="انتخاب"
>
<a-select-option v-for="account in active" :key="account.id" :value="account.id">
{{ account.name }}
</a-select-option>
@@ -258,18 +287,34 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="به حساب" name="toAccountId">
<a-select v-model:value="transfer.toAccountId" style="width: 100%" placeholder="انتخاب">
<a-select-option value="">انتخاب</a-select-option>
<a-select-option v-for="account in active" :key="`to-${account.id}`" :value="account.id">
<a-form-item
label="به حساب"
name="toAccountId"
:rules="[{ required: true, message: 'حساب مقصد الزامی است' }]"
>
<a-select
v-model:value="transfer.toAccountId"
allow-clear
style="width: 100%"
placeholder="انتخاب"
>
<a-select-option
v-for="account in active"
:key="`to-${account.id}`"
:value="account.id"
>
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ" name="amount">
<a-input v-model:value="transfer.amount" />
<a-form-item
label="مبلغ"
name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
>
<MoneyInput v-model="transfer.amount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
@@ -309,6 +354,5 @@ onMounted(() => {
</template>
</a-list>
</a-card>
</a-flex>
</div>
</a-flex>
</template>
@@ -1,10 +1,20 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import {
Alert as AAlert,
Card as ACard,
Empty as AEmpty,
Flex as AFlex,
List as AList,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import { UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { formatJalaliNumeric } from '@/lib/jalali';
const { Paragraph: ATypographyParagraph } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
type Row = {
id: string;
action: string;
@@ -32,26 +42,31 @@ onMounted(async () => {
</script>
<template>
<section class="page">
<UiPageHeader title="سوابق تغییرات مالی" description="تسویه سند و نشستن وجه در صندوق، با مقدار ثبت‌شده." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="!error && !rows.length" class="muted">سابقه‌ای نیست.</p>
<article v-for="row in rows" :key="row.id">
<header>
<strong>{{ actionLabel[row.action] ?? row.action }}</strong>
<time>{{ formatJalaliNumeric(new Date(row.createdAt)) }}</time>
</header>
<pre>{{ JSON.stringify(row.after ?? row.before, null, 2) }}</pre>
</article>
</section>
</template>
<a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" />
<style scoped>
.page { display: grid; gap: 0.8rem; }
article { background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
header { display: flex; justify-content: space-between; gap: 0.75rem; }
time, .muted { color: #64748b; }
pre { margin: 0.45rem 0 0; white-space: pre-wrap; font: inherit; color: #334155; }
.err { color: #b91c1c; font-weight: 700; }
</style>
<a-card :bordered="false">
<a-empty v-if="!error && !rows.length" description="سابقه‌ای نیست." />
<a-list v-else item-layout="vertical" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>
{{ actionLabel[item.action] ?? item.action }}
</template>
<template #description>
{{ formatJalaliNumeric(new Date(item.createdAt)) }}
</template>
</a-list-item-meta>
<a-typography-paragraph
:copyable="true"
:style="{ marginBottom: 0, whiteSpace: 'pre-wrap', fontFamily: 'inherit' }"
>
{{ JSON.stringify(item.after ?? item.before, null, 2) }}
</a-typography-paragraph>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</template>
@@ -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<FinancialCategoryDto[]>([]);
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(() => {
</script>
<template>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">دسته‌بندی‌های مالی</a-typography-title>
<a-typography-text type="secondary">
دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند.
</a-typography-text>
</div>
<TreasuryNav />
<a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-card :bordered="false">
<a-divider orientation="right">افزودن دسته</a-divider>
<a-form layout="vertical" @finish="add">
<a-form layout="vertical" :model="form" @finish="add">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام" name="name">
<a-input v-model:value="name" />
<a-form-item
label="نام"
name="name"
:rules="[{ required: true, message: 'نام الزامی است' }]"
>
<a-input v-model:value="form.name" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="kind">
<a-select v-model:value="kind" style="width: 100%">
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
<a-select v-model:value="form.kind" style="width: 100%">
<a-select-option value="income">درآمد</a-select-option>
<a-select-option value="expense">هزینه</a-select-option>
</a-select>
@@ -111,6 +104,5 @@ onMounted(() => {
</template>
</a-list>
</a-card>
</a-flex>
</div>
</a-flex>
</template>
@@ -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<string, string> = {
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(() => {
</script>
<template>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">{{ title }}</a-typography-title>
<a-typography-text type="secondary">
ثبت سند، موجودی را عوض نمی‌کند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.
</a-typography-text>
</div>
<TreasuryNav />
<a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false">
<a-divider orientation="right">سند جدید</a-divider>
<a-form layout="vertical" @finish="createDoc">
<a-form
layout="vertical"
:model="form"
@finish="createDoc"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<a-form-item
label="عنوان"
name="title"
:rules="[{ required: true, message: 'عنوان الزامی است' }]"
>
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ کل" name="amount">
<a-input v-model:value="form.amount" />
<a-form-item
label="مبلغ کل"
name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
>
<MoneyInput v-model="form.amount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount">
<a-input v-model:value="form.settleAmount" />
<MoneyInput v-model="form.settleAmount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دسته‌بندی" name="categoryId">
<a-select v-model:value="form.categoryId" allow-clear style="width: 100%" placeholder="بدون دسته">
<a-select-option value="">بدون دسته</a-select-option>
<a-select
v-model:value="form.categoryId"
allow-clear
style="width: 100%"
placeholder="بدون دسته"
>
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
{{ cat.name }}
</a-select-option>
@@ -186,8 +204,12 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-select v-model:value="form.accountId" allow-clear style="width: 100%" placeholder="انتخاب حساب">
<a-select-option value="">انتخاب حساب</a-select-option>
<a-select
v-model:value="form.accountId"
allow-clear
style="width: 100%"
placeholder="انتخاب حساب"
>
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
{{ account.name }}
</a-select-option>
@@ -256,6 +278,5 @@ onMounted(() => {
</template>
</a-list>
</a-card>
</a-flex>
</div>
</a-flex>
</template>
@@ -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 () => {
</script>
<template>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">مدیریت مالی</a-typography-title>
<a-typography-text type="secondary">
دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست.
</a-typography-text>
</div>
<TreasuryNav />
<a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" />
<template v-if="data">
@@ -157,6 +146,5 @@ onMounted(async () => {
</a-list>
</a-card>
</template>
</a-flex>
</div>
</a-flex>
</template>
@@ -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<string, string> = {
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(() => {
<!-- ─── Mobile app experience ─── -->
<template v-if="isMobile">
<a-flex vertical :gap="token.margin">
<div>
<a-flex align="center" :gap="token.marginXS">
<a-typography-title :level="3" :style="{ margin: 0, flex: 1 }">
ثبت دریافت جدید
</a-typography-title>
<ArrowLeftOutlined :style="{ color: token.colorTextSecondary, fontSize: '18px' }" />
</a-flex>
<a-typography-text type="secondary">اطلاعات دریافت را وارد کنید</a-typography-text>
</div>
<TreasuryNav />
<a-row :gutter="[12, 12]">
<a-col :span="12">
<a-card
@@ -418,14 +416,7 @@ onMounted(() => {
name="amount"
:rules="[{ required: true, message: 'مبلغ کل را وارد کنید' }]"
>
<a-input
v-model:value="form.amount"
inputmode="decimal"
placeholder="۰"
dir="ltr"
>
<template #prefix><NumberOutlined /></template>
</a-input>
<MoneyInput v-model="form.amount" :decimals="0" placeholder="۰" />
</a-form-item>
</a-col>
<a-col :span="12">
@@ -434,14 +425,7 @@ onMounted(() => {
name="settleAmount"
:rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]"
>
<a-input
v-model:value="form.settleAmount"
inputmode="decimal"
placeholder="۰"
dir="ltr"
>
<template #prefix><NumberOutlined /></template>
</a-input>
<MoneyInput v-model="form.settleAmount" :decimals="0" placeholder="۰" />
</a-form-item>
</a-col>
</a-row>
@@ -596,35 +580,39 @@ onMounted(() => {
<!-- ─── Desktop (unchanged interaction model) ─── -->
<template v-else>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">{{ pageTitle }}</a-typography-title>
<a-typography-text type="secondary">
ثبت سند، موجودی را عوض نمی‌کند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.
</a-typography-text>
</div>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false">
<a-divider orientation="right">سند جدید</a-divider>
<a-form layout="vertical" @finish="createDoc">
<a-form
layout="vertical"
:model="form"
@finish="createDoc"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<a-form-item
label="عنوان"
name="title"
:rules="[{ required: true, message: 'عنوان الزامی است' }]"
>
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ کل" name="amount">
<a-input v-model:value="form.amount" />
<a-form-item
label="مبلغ کل"
name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
>
<MoneyInput v-model="form.amount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount">
<a-input v-model:value="form.settleAmount" />
<MoneyInput v-model="form.settleAmount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { RouterView, useRoute } from 'vue-router';
import { Flex as AFlex, Typography, theme } from 'ant-design-vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { provideTreasuryPageChrome } from './treasuryPageChrome';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const route = useRoute();
const chrome = provideTreasuryPageChrome();
watch(
() => route.fullPath,
() => {
chrome.clear();
const scroller = document.querySelector('main.content');
if (scroller instanceof HTMLElement) scroller.scrollTop = 0;
},
);
const title = computed(
() =>
chrome.override.value.title ??
(typeof route.meta.treasuryTitle === 'string' ? route.meta.treasuryTitle : 'مدیریت مالی'),
);
const description = computed(
() =>
chrome.override.value.description ??
(typeof route.meta.treasuryDescription === 'string' ? route.meta.treasuryDescription : ''),
);
/** Keep title+subtitle block height stable across tabs (one-line subtitle). */
const headerStyle = computed(() => ({
minHeight: `${token.value.controlHeightLG + token.value.fontSizeLG + token.value.marginXXS}px`,
}));
</script>
<template>
<a-flex vertical :gap="token.margin" :style="{ width: '100%' }">
<div :style="headerStyle">
<a-typography-title :level="3" :style="{ margin: 0 }">{{ title }}</a-typography-title>
<a-typography-text
type="secondary"
:ellipsis="description ? { tooltip: description } : false"
:style="{ display: 'block', margin: 0 }"
>
{{ description || '\u00a0' }}
</a-typography-text>
</div>
<TreasuryNav />
<RouterView />
</a-flex>
</template>
@@ -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, OrgUserDto } 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;
@@ -41,8 +43,16 @@ const users = ref<OrgUserDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
const ok = ref('');
const form = ref({ userId: '', period: '', baseAmount: '', benefits: '', bonus: '', deductions: '' });
const pay = ref({ id: '', amount: '', accountId: '' });
const saving = ref(false);
const form = ref({
userId: undefined as string | undefined,
period: '',
baseAmount: '',
benefits: '',
bonus: '',
deductions: '',
});
const pay = ref({ id: '', amount: '', accountId: undefined as string | undefined });
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
@@ -50,6 +60,10 @@ function apiMessage(e: unknown) {
return Array.isArray(msg) ? msg.join('، ') : msg || 'عملیات ناموفق بود.';
}
function onFinishFailed() {
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
}
async function load() {
const [payroll, people, acc] = await Promise.all([
api.get<PayrollRow[]>('/treasury/payroll'),
@@ -63,33 +77,66 @@ async function load() {
async function createRow() {
error.value = '';
ok.value = '';
saving.value = true;
try {
await api.post('/treasury/payroll', form.value);
const baseAmount = parseMoneyNumber(String(form.value.baseAmount ?? ''), 0) ?? 0;
await api.post('/treasury/payroll', {
userId: form.value.userId,
period: form.value.period,
baseAmount,
benefits: parseMoneyNumber(String(form.value.benefits ?? ''), 0) ?? 0,
bonus: parseMoneyNumber(String(form.value.bonus ?? ''), 0) ?? 0,
deductions: parseMoneyNumber(String(form.value.deductions ?? ''), 0) ?? 0,
});
form.value = {
userId: undefined,
period: '',
baseAmount: '',
benefits: '',
bonus: '',
deductions: '',
};
ok.value = 'حقوق ثبت شد و تا پرداخت، موجودی حساب تغییر نمی‌کند.';
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
async function payRow() {
error.value = '';
ok.value = '';
saving.value = true;
try {
const amount = parseMoneyNumber(String(pay.value.amount ?? ''), 0);
if (amount == null || amount <= 0) {
error.value = 'مبلغ پرداخت معتبر نیست.';
return;
}
await api.post(`/treasury/payroll/${pay.value.id}/pay`, {
amount: pay.value.amount,
amount,
accountId: pay.value.accountId,
clientKey: crypto.randomUUID(),
});
ok.value = 'پرداخت حقوق از حساب ثبت شد.';
pay.value = { id: '', amount: '', accountId: '' };
pay.value = { id: '', amount: '', accountId: undefined };
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
function startPay(row: PayrollRow) {
pay.value = { id: row.id, amount: row.remaining, accountId: accounts.value[0]?.id || '' };
pay.value = {
id: row.id,
amount: row.remaining,
accountId: accounts.value[0]?.id,
};
}
onMounted(() => {
@@ -100,26 +147,31 @@ onMounted(() => {
</script>
<template>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">حقوق و دستمزد</a-typography-title>
<a-typography-text type="secondary">فقط مالک و مدیر این صفحه را می‌بینند.</a-typography-text>
</div>
<TreasuryNav />
<a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false">
<a-divider orientation="right">ثبت دوره</a-divider>
<a-form layout="vertical" @finish="createRow">
<a-form
layout="vertical"
:model="form"
@finish="createRow"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="پرسنل" name="userId">
<a-select v-model:value="form.userId" style="width: 100%" placeholder="انتخاب">
<a-select-option value="">انتخاب</a-select-option>
<a-form-item
label="پرسنل"
name="userId"
:rules="[{ required: true, message: 'پرسنل الزامی است' }]"
>
<a-select
v-model:value="form.userId"
allow-clear
style="width: 100%"
placeholder="انتخاب"
>
<a-select-option v-for="user in users" :key="user.id" :value="user.id">
{{ user.fullName }}
</a-select-option>
@@ -127,54 +179,84 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره" name="period">
<a-form-item
label="دوره"
name="period"
:rules="[{ required: true, message: 'دوره الزامی است' }]"
>
<a-input v-model:value="form.period" placeholder="۱۴۰۵-۰۷" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پایه" name="baseAmount">
<a-input v-model:value="form.baseAmount" />
<a-form-item
label="پایه"
name="baseAmount"
:rules="[{ required: true, message: 'مبلغ پایه الزامی است' }]"
>
<MoneyInput v-model="form.baseAmount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مزایا" name="benefits">
<a-input v-model:value="form.benefits" />
<MoneyInput v-model="form.benefits" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پاداش" name="bonus">
<a-input v-model:value="form.bonus" />
<MoneyInput v-model="form.bonus" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="کسورات" name="deductions">
<a-input v-model:value="form.deductions" />
<MoneyInput v-model="form.deductions" :decimals="0" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit">ثبت حقوق</a-button>
<a-button type="primary" html-type="submit" :loading="saving">ثبت حقوق</a-button>
</a-form>
<template v-if="pay.id">
<a-divider orientation="right">پرداخت حقوق</a-divider>
<a-form layout="vertical" @finish="payRow">
<a-form
layout="vertical"
:model="pay"
@finish="payRow"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ" name="amount">
<a-input v-model:value="pay.amount" />
<a-form-item
label="مبلغ"
name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
>
<MoneyInput v-model="pay.amount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-select v-model:value="pay.accountId" style="width: 100%">
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
<a-form-item
label="حساب"
name="accountId"
:rules="[{ required: true, message: 'حساب الزامی است' }]"
>
<a-select
v-model:value="pay.accountId"
allow-clear
style="width: 100%"
placeholder="انتخاب حساب"
>
<a-select-option
v-for="account in accounts"
:key="account.id"
:value="account.id"
>
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit">ثبت پرداخت</a-button>
<a-button type="primary" html-type="submit" :loading="saving">ثبت پرداخت</a-button>
</a-form>
</template>
@@ -203,6 +285,5 @@ onMounted(() => {
</template>
</a-list>
</a-card>
</a-flex>
</div>
</a-flex>
</template>
@@ -19,15 +19,18 @@ import {
Table as ATable,
Tag as ATag,
Typography,
message,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialReceivableDto } 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 { formatJalaliNumeric } from '@/lib/jalali';
import { parseMoneyNumber } from '@/lib/money';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { Text: ATypographyText } = Typography;
const ASelectOption = ASelect.Option;
type InstallmentRow = FinancialReceivableDto['installments'][number];
@@ -109,19 +112,35 @@ async function downloadExcel() {
URL.revokeObjectURL(url);
}
function onFinishFailed() {
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
}
async function createContract() {
saving.value = true;
error.value = '';
ok.value = '';
try {
const totalAmount = parseMoneyNumber(String(form.value.totalAmount ?? ''), 0);
if (totalAmount == null || totalAmount <= 0) {
error.value = 'مبلغ کل معتبر نیست.';
return;
}
const prepaidAmount = parseMoneyNumber(String(form.value.prepaidAmount ?? ''), 0) ?? 0;
const installments = custom.value
? customLines.value.map((line) => ({
amount: parseMoneyNumber(String(line.amount ?? ''), 0) ?? 0,
dueDate: line.dueDate,
}))
: undefined;
await api.post('/treasury/receivables', {
title: form.value.title,
partyName: form.value.partyName || null,
totalAmount: form.value.totalAmount,
prepaidAmount: form.value.prepaidAmount || 0,
totalAmount,
prepaidAmount,
accountId: form.value.accountId || null,
...(custom.value
? { installments: customLines.value }
? { installments }
: {
count: Number(form.value.count),
interval: form.value.interval,
@@ -145,8 +164,13 @@ async function collectInstallment() {
saving.value = true;
error.value = '';
try {
const amount = parseMoneyNumber(String(collect.value.amount ?? ''), 0);
if (amount == null || amount <= 0) {
error.value = 'مبلغ دریافت معتبر نیست.';
return;
}
await api.post(`/treasury/installments/${collect.value.installmentId}/collect`, {
amount: collect.value.amount,
amount,
accountId: collect.value.accountId,
clientKey: crypto.randomUUID(),
});
@@ -177,26 +201,25 @@ onMounted(() => {
</script>
<template>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">اقساط و مطالبات</a-typography-title>
<a-typography-text type="secondary">
دریافت هر قسط فقط یک بار وارد حساب می‌شود و مانده همان قسط را کم می‌کند.
</a-typography-text>
</div>
<TreasuryNav />
<a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false">
<a-divider orientation="right">قرارداد جدید</a-divider>
<a-form layout="vertical" @finish="createContract">
<a-form
layout="vertical"
:model="form"
@finish="createContract"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<a-form-item
label="عنوان"
name="title"
:rules="[{ required: true, message: 'عنوان الزامی است' }]"
>
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
@@ -206,19 +229,27 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ کل" name="totalAmount">
<a-input v-model:value="form.totalAmount" />
<a-form-item
label="مبلغ کل"
name="totalAmount"
:rules="[{ required: true, message: 'مبلغ کل الزامی است' }]"
>
<MoneyInput v-model="form.totalAmount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پیش‌پرداخت" name="prepaidAmount">
<a-input v-model:value="form.prepaidAmount" />
<MoneyInput v-model="form.prepaidAmount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب پیش‌پرداخت" name="accountId">
<a-select v-model:value="form.accountId" allow-clear style="width: 100%" placeholder="انتخاب">
<a-select-option value="">انتخاب</a-select-option>
<a-select
v-model:value="form.accountId"
allow-clear
style="width: 100%"
placeholder="انتخاب"
>
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
{{ account.name }}
</a-select-option>
@@ -240,7 +271,7 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="شروع" name="startDate">
<a-input v-model:value="form.startDate" type="date" :disabled="custom" style="width: 100%" />
<JalaliDateInput v-model="form.startDate" :disabled="custom" />
</a-form-item>
</a-col>
<a-col :xs="24">
@@ -254,12 +285,12 @@ onMounted(() => {
<a-row v-for="(line, index) in customLines" :key="index" :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item :label="`مبلغ قسط ${index + 1}`">
<a-input v-model:value="line.amount" />
<MoneyInput v-model="line.amount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="سررسید">
<a-input v-model:value="line.dueDate" type="date" style="width: 100%" />
<JalaliDateInput v-model="line.dueDate" />
</a-form-item>
</a-col>
</a-row>
@@ -276,17 +307,39 @@ onMounted(() => {
<template v-if="collect.installmentId">
<a-divider orientation="right">دریافت قسط</a-divider>
<a-form layout="vertical" @finish="collectInstallment">
<a-form
layout="vertical"
:model="collect"
@finish="collectInstallment"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ" name="amount">
<a-input v-model:value="collect.amount" />
<a-form-item
label="مبلغ"
name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
>
<MoneyInput v-model="collect.amount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب مقصد" name="accountId">
<a-select v-model:value="collect.accountId" style="width: 100%">
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
<a-form-item
label="حساب مقصد"
name="accountId"
:rules="[{ required: true, message: 'حساب الزامی است' }]"
>
<a-select
v-model:value="collect.accountId"
allow-clear
style="width: 100%"
placeholder="انتخاب حساب"
>
<a-select-option
v-for="account in accounts"
:key="account.id"
:value="account.id"
>
{{ account.name }}
</a-select-option>
</a-select>
@@ -337,6 +390,5 @@ onMounted(() => {
</div>
</template>
</a-card>
</a-flex>
</div>
</a-flex>
</template>
@@ -16,21 +16,33 @@ import {
Row as ARow,
Select as ASelect,
Typography,
message,
} from 'ant-design-vue';
import { api } from '@/lib/api';
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 { formatJalaliNumeric } from '@/lib/jalali';
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;
type Row = { id: string; title: string; kind: string; amount: string; interval: string; nextDue: string; isActive: boolean };
type Row = {
id: string;
title: string;
kind: string;
amount: string;
interval: string;
nextDue: string;
isActive: boolean;
};
const rows = ref<Row[]>([]);
const error = ref('');
const ok = ref('');
const saving = ref(false);
const form = ref({ title: '', kind: 'expense', amount: '', interval: 'monthly', startDate: '' });
async function load() {
@@ -38,16 +50,34 @@ async function load() {
rows.value = data;
}
function onFinishFailed() {
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
}
async function createItem() {
error.value = '';
ok.value = '';
saving.value = true;
try {
await api.post('/treasury/recurring', form.value);
const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0);
if (amount == null || amount <= 0) {
error.value = 'مبلغ معتبر نیست.';
return;
}
await api.post('/treasury/recurring', {
...form.value,
amount,
startDate: form.value.startDate || undefined,
});
form.value = { title: '', kind: 'expense', amount: '', interval: 'monthly', startDate: '' };
ok.value = 'الگو ثبت شد. در سررسید فقط سند باز ساخته می‌شود.';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق بود.')
: 'ثبت ناموفق بود.';
} finally {
saving.value = false;
}
}
@@ -57,31 +87,30 @@ onMounted(() => {
</script>
<template>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">درآمد و هزینه تکرارشونده</a-typography-title>
<a-typography-text type="secondary">
تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمی‌شود.
</a-typography-text>
</div>
<TreasuryNav />
<a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false">
<a-divider orientation="right">الگوی جدید</a-divider>
<a-form layout="vertical" @finish="createItem">
<a-form
layout="vertical"
:model="form"
@finish="createItem"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<a-form-item
label="عنوان"
name="title"
:rules="[{ required: true, message: 'عنوان الزامی است' }]"
>
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="kind">
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
<a-select v-model:value="form.kind" style="width: 100%">
<a-select-option value="expense">هزینه</a-select-option>
<a-select-option value="income">درآمد</a-select-option>
@@ -89,12 +118,16 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ" name="amount">
<a-input v-model:value="form.amount" />
<a-form-item
label="مبلغ"
name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
>
<MoneyInput v-model="form.amount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره" name="interval">
<a-form-item label="دوره" name="interval" :rules="[{ required: true }]">
<a-select v-model:value="form.interval" style="width: 100%">
<a-select-option value="monthly">ماهانه</a-select-option>
<a-select-option value="weekly">هفتگی</a-select-option>
@@ -104,11 +137,11 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="شروع" name="startDate">
<a-input v-model:value="form.startDate" type="date" style="width: 100%" />
<JalaliDateInput v-model="form.startDate" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit">ثبت الگو</a-button>
<a-button type="primary" html-type="submit" :loading="saving">ثبت الگو</a-button>
</a-form>
<a-divider orientation="right">فهرست الگوها</a-divider>
@@ -131,6 +164,5 @@ onMounted(() => {
</template>
</a-list>
</a-card>
</a-flex>
</div>
</a-flex>
</template>
@@ -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(() => {
</script>
<template>
<div>
<a-flex vertical :gap="16">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">گزارش مالی</a-typography-title>
<a-typography-text type="secondary">
سود عملیاتی از درآمد و هزینه ثبت‌شده است، نه از خالص وجه.
</a-typography-text>
</div>
<a-flex vertical :gap="16">
<a-flex justify="flex-end">
<a-button @click="download">
<template #icon><DownloadOutlined /></template>
خروجی اکسل
</a-button>
</a-flex>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-card :bordered="false">
@@ -179,6 +169,5 @@ onMounted(() => {
</a-list>
</template>
</a-card>
</a-flex>
</div>
</a-flex>
</template>
@@ -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<TreasuryChromeOverride>;
set: (next: TreasuryChromeOverride) => void;
clear: () => void;
};
const KEY: InjectionKey<TreasuryChromeApi> = Symbol('treasuryPageChrome');
export function provideTreasuryPageChrome(): TreasuryChromeApi {
const override = ref<TreasuryChromeOverride>({ 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);
}