feat(finance): show invoice balances in treasury and split payroll

Issued invoices feed debtors, creditors, and period profit without a second income document. Payroll is split into setup, staged payment, and a filtered report.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-08 14:20:00 +03:30
co-authored by Cursor
parent 33f33f9521
commit 6ebd45f2cb
7 changed files with 647 additions and 167 deletions
+4 -2
View File
@@ -330,7 +330,8 @@ export const router = createRouter({
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'حقوق و دستمزد',
treasuryDescription: 'پرداخت حقوق فقط با دسترسی جداگانه حقوق و دستمزد باز است.',
treasuryDescription:
'تعیین حقوق، پرداخت یکجا یا چندمرحله‌ای، و گزارش دوره‌ها. پرداخت فقط با دسترسی حقوق و دستمزد باز است.',
},
},
{
@@ -350,7 +351,8 @@ export const router = createRouter({
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'گزارش مالی',
treasuryDescription: 'سود عملیاتی از درآمد و هزینه ثبت‌شده است، نه از خالص وجه.',
treasuryDescription:
'سود از درآمد، فروش فاکتور و هزینه است. بدهکاران و بستانکاران مانده فاکتور و اسناد باز را یک‌جا نشان می‌دهند.',
},
},
{
@@ -28,9 +28,9 @@ const cards = [
{ key: 'actualReceipts', label: 'دریافت واقعی', hint: 'ورود به حساب' },
{ key: 'bookedExpense', label: 'هزینه ثبت‌شده', hint: 'تعهدی' },
{ key: 'actualPayments', label: 'پرداخت واقعی', hint: 'خروج از حساب' },
{ key: 'operatingProfit', label: 'سود یا زیان عملیاتی', hint: 'درآمد ثبت‌شده − هزینه ثبت‌شده' },
{ key: 'receivables', label: 'مطالبات وصول‌نشده', hint: 'درآمد بدون دریافت کامل' },
{ key: 'payables', label: 'تعهدات پرداخت', hint: 'هزینه بدون پرداخت کامل' },
{ key: 'operatingProfit', label: 'سود یا زیان عملیاتی', hint: 'درآمد و فروش فاکتور − هزینه' },
{ key: 'receivables', label: 'مطالبات وصول‌نشده', hint: 'اسناد باز و مانده فاکتور' },
{ key: 'payables', label: 'تعهدات پرداخت', hint: 'هزینه باز، حقوق و برگشتی' },
{ key: 'totalBalance', label: 'موجودی حساب‌ها', hint: 'صندوق و بانک' },
] as const;
@@ -1,41 +1,60 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
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,
List as AList,
Row as ARow,
Select as ASelect,
Table as ATable,
Typography,
message,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
import MoneyInput from '@/components/MoneyInput.vue';
import { formatJalaliNumeric } from '@/lib/jalali';
import { toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money';
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
type PayrollPayment = {
id: string;
amount: string;
occurredAt: string;
accountName: string;
};
type PayrollRow = {
id: string;
userId: string;
fullName: string;
period: string;
baseAmount: string;
benefits: string;
bonus: string;
deductions: string;
payable: string;
paidAmount: string;
remaining: string;
status: string;
payments: PayrollPayment[];
};
const statusLabel: Record<string, string> = {
open: 'پرداخت‌نشده',
partial: 'پرداخت جزئی',
settled: 'تسویه‌شده',
void: 'باطل',
};
const rows = ref<PayrollRow[]>([]);
@@ -53,6 +72,85 @@ const form = ref({
deductions: '',
});
const pay = ref({ id: '', amount: '', accountId: undefined as string | undefined });
const filterUserId = ref<string | undefined>(undefined);
const filterPeriod = ref('');
const filterStatus = ref('all');
const filterMin = ref('');
const filterMax = ref('');
const statusOptions = [
{ value: 'all', label: 'همه وضعیت‌ها' },
{ value: 'open', label: 'پرداخت‌نشده' },
{ value: 'partial', label: 'پرداخت جزئی' },
{ value: 'settled', label: 'تسویه‌شده' },
{ value: 'void', label: 'باطل' },
];
const userOptions = computed(() =>
users.value.map((user) => ({ value: user.id, label: user.fullName })),
);
const payablePreview = computed(() => {
const base = parseMoneyNumber(String(form.value.baseAmount ?? ''), 0) ?? 0;
const benefits = parseMoneyNumber(String(form.value.benefits ?? ''), 0) ?? 0;
const bonus = parseMoneyNumber(String(form.value.bonus ?? ''), 0) ?? 0;
const deductions = parseMoneyNumber(String(form.value.deductions ?? ''), 0) ?? 0;
return base + benefits + bonus - deductions;
});
const openRows = computed(() =>
rows.value.filter((row) => row.status === 'open' || row.status === 'partial'),
);
const payOptions = computed(() =>
openRows.value.map((row) => ({
value: row.id,
label: `${row.fullName} · ${row.period} · مانده ${toman(row.remaining)}`,
})),
);
const selectedPay = computed(() => rows.value.find((row) => row.id === pay.value.id) ?? null);
const filtered = computed(() => {
const period = filterPeriod.value.trim();
const min = parseMoneyNumber(String(filterMin.value ?? ''), 0);
const max = parseMoneyNumber(String(filterMax.value ?? ''), 0);
return rows.value.filter((row) => {
if (filterUserId.value && row.userId !== filterUserId.value) return false;
if (period && !row.period.includes(period)) return false;
if (filterStatus.value !== 'all' && row.status !== filterStatus.value) return false;
const payable = Number(row.payable);
if (min != null && filterMin.value !== '' && payable < min) return false;
if (max != null && filterMax.value !== '' && payable > max) return false;
return true;
});
});
const moneyKeys = ['baseAmount', 'benefits', 'bonus', 'deductions', 'payable', 'paidAmount', 'remaining'] as const;
const reportColumns: TableColumnsType<PayrollRow> = [
{ title: 'پرسنل', dataIndex: 'fullName', key: 'fullName' },
{ title: 'دوره', dataIndex: 'period', key: 'period' },
{ title: 'پایه', key: 'baseAmount' },
{ title: 'مزایا', key: 'benefits' },
{ title: 'پاداش', key: 'bonus' },
{ title: 'کسورات', key: 'deductions' },
{ title: 'قابل پرداخت', key: 'payable' },
{ title: 'پرداخت‌شده', key: 'paidAmount' },
{ title: 'مانده', key: 'remaining' },
{ title: 'وضعیت', key: 'status' },
{ title: 'عملیات', key: 'action' },
];
const paymentColumns: TableColumnsType<PayrollPayment> = [
{
title: 'تاریخ',
key: 'occurredAt',
customRender: ({ record }) => formatJalaliNumeric(new Date(record.occurredAt)),
},
{ title: 'حساب', dataIndex: 'accountName', key: 'accountName' },
{ title: 'مبلغ', key: 'amount', customRender: ({ record }) => toman(record.amount) },
];
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
@@ -70,7 +168,7 @@ async function load() {
api.get<OrgUserDto[]>('/users'),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
]);
rows.value = payroll.data;
rows.value = payroll.data.map((row) => ({ ...row, payments: row.payments ?? [] }));
users.value = people.data.filter((row) => row.isActive !== false);
accounts.value = acc.data.filter((row) => row.isActive);
}
@@ -97,7 +195,7 @@ async function createRow() {
bonus: '',
deductions: '',
};
ok.value = 'حقوق ثبت شد و تا پرداخت، موجودی حساب تغییر نمی‌کند.';
ok.value = 'حقوق ثبت شد. تا وقتی پرداخت نکنید موجودی حساب عوض نمی‌شود.';
await load();
} catch (e) {
error.value = apiMessage(e);
@@ -106,24 +204,44 @@ async function createRow() {
}
}
function selectPay(id: string) {
const row = rows.value.find((item) => item.id === id);
if (!row) {
pay.value = { id: '', amount: '', accountId: pay.value.accountId };
return;
}
pay.value = {
id: row.id,
amount: row.remaining,
accountId: pay.value.accountId ?? accounts.value[0]?.id,
};
}
async function payRow() {
error.value = '';
ok.value = '';
saving.value = true;
const accountId = pay.value.accountId;
const keepId = pay.value.id;
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`, {
await api.post(`/treasury/payroll/${keepId}/pay`, {
amount,
accountId: pay.value.accountId,
accountId,
clientKey: crypto.randomUUID(),
});
ok.value = 'پرداخت حقوق از حساب ثبت شد.';
pay.value = { id: '', amount: '', accountId: undefined };
ok.value = 'پرداخت حقوق از حساب ثبت شد. اگر مانده مانده باشد، مرحلهٔ بعد را همین‌جا بزنید.';
await load();
const still = rows.value.find(
(row) => row.id === keepId && row.status !== 'settled' && row.status !== 'void',
);
pay.value = still
? { id: still.id, amount: still.remaining, accountId }
: { id: '', amount: '', accountId };
} catch (e) {
error.value = apiMessage(e);
} finally {
@@ -131,14 +249,6 @@ async function payRow() {
}
}
function startPay(row: PayrollRow) {
pay.value = {
id: row.id,
amount: row.remaining,
accountId: accounts.value[0]?.id,
};
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری حقوق ناموفق بود.';
@@ -148,143 +258,216 @@ onMounted(() => {
<template>
<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-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"
:model="form"
@finish="createRow"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item
label="پرسنل"
name="userId"
:rules="[{ required: true, message: 'پرسنل الزامی است' }]"
>
<a-select show-search option-filter-prop="label"
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" :label="user.fullName">
{{ user.fullName }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<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"
: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">
<MoneyInput v-model="form.benefits" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پاداش" name="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">
<MoneyInput v-model="form.deductions" :decimals="0" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت حقوق</a-button>
</a-form>
<a-card title="تعیین حقوق" :bordered="false">
<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"
:rules="[{ required: true, message: 'پرسنل الزامی است' }]"
>
<a-select
v-model:value="form.userId"
show-search
option-filter-prop="label"
allow-clear
style="width: 100%"
placeholder="انتخاب"
:options="userOptions"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<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"
: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">
<MoneyInput v-model="form.benefits" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پاداش" name="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">
<MoneyInput v-model="form.deductions" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="16">
<a-form-item label="قابل پرداخت">
<a-typography-text strong>{{ toman(payablePreview) }}</a-typography-text>
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت حقوق</a-button>
</a-form>
</a-card>
<template v-if="pay.id">
<a-divider orientation="right">پرداخت حقوق</a-divider>
<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"
: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"
:rules="[{ required: true, message: 'حساب الزامی است' }]"
>
<a-select show-search option-filter-prop="label"
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"
:label="account.name"
>
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت پرداخت</a-button>
</a-form>
</template>
<a-card title="پرداخت" :bordered="false">
<a-flex vertical :gap="16">
<a-typography-text type="secondary">
می‌توانید کل مانده را یکجا بدهید، یا هر بار بخشی را تا تمام شدن مانده ثبت کنید.
</a-typography-text>
<a-empty v-if="!openRows.length" description="حقوق باز برای پرداخت نیست." />
<a-form
v-else
layout="vertical"
:model="pay"
@finish="payRow"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item
label="دورهٔ حقوق"
name="id"
:rules="[{ required: true, message: 'دوره الزامی است' }]"
>
<a-select
:value="pay.id || undefined"
show-search
option-filter-prop="label"
style="width: 100%"
placeholder="انتخاب پرسنل و دوره"
:options="payOptions"
@update:value="selectPay(String($event ?? ''))"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="6">
<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="6">
<a-form-item
label="حساب"
name="accountId"
:rules="[{ required: true, message: 'حساب الزامی است' }]"
>
<a-select
v-model:value="pay.accountId"
show-search
option-filter-prop="label"
allow-clear
style="width: 100%"
placeholder="انتخاب حساب"
:options="accounts.map((account) => ({ value: account.id, label: account.name }))"
/>
</a-form-item>
</a-col>
</a-row>
<a-flex v-if="selectedPay" :gap="16" wrap="wrap">
<a-typography-text>قابل پرداخت {{ toman(selectedPay.payable) }}</a-typography-text>
<a-typography-text>پرداخت‌شده {{ toman(selectedPay.paidAmount) }}</a-typography-text>
<a-typography-text strong>مانده {{ toman(selectedPay.remaining) }}</a-typography-text>
</a-flex>
<a-button type="primary" html-type="submit" :loading="saving" :disabled="!pay.id">
ثبت پرداخت
</a-button>
</a-form>
<a-divider orientation="right">فهرست</a-divider>
<a-empty v-if="!rows.length" description="رکورد حقوقی ثبت نشده است." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.payable) }}</a-typography-text>
<a-button
v-if="item.status !== 'settled' && item.status !== 'void'"
type="link"
@click="startPay(item)"
>
پرداخت
</a-button>
</template>
<a-list-item-meta>
<template #title>{{ item.fullName }}</template>
<template #description>
{{ item.period }} · مانده {{ toman(item.remaining) }} · {{ item.status }}
</template>
</a-list-item-meta>
</a-list-item>
<a-table
v-if="selectedPay?.payments.length"
:columns="paymentColumns"
:data-source="selectedPay.payments"
:pagination="false"
row-key="id"
/>
</a-flex>
</a-card>
<a-card title="گزارش حقوق" :bordered="false">
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8">
<a-form-item label="پرسنل">
<a-select
v-model:value="filterUserId"
allow-clear
show-search
option-filter-prop="label"
style="width: 100%"
placeholder="همه"
:options="userOptions"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره">
<a-input v-model:value="filterPeriod" placeholder="مثلاً ۱۴۰۵-۰۷" allow-clear />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="وضعیت">
<a-select v-model:value="filterStatus" style="width: 100%" :options="statusOptions" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حداقل قابل پرداخت">
<MoneyInput v-model="filterMin" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حداکثر قابل پرداخت">
<MoneyInput v-model="filterMax" :decimals="0" />
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-table
:columns="reportColumns"
:data-source="filtered"
row-key="id"
:scroll="{ x: 'max-content' }"
:locale="{ emptyText: 'رکوردی با این فیلتر نیست.' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
{{ statusLabel[record.status] ?? record.status }}
</template>
</a-list>
</a-card>
<template v-else-if="column.key === 'action'">
<a-button
v-if="record.status === 'open' || record.status === 'partial'"
type="link"
@click="selectPay(record.id)"
>
پرداخت
</a-button>
</template>
<template v-else-if="moneyKeys.includes(column.key as (typeof moneyKeys)[number])">
{{ toman(record[column.key as (typeof moneyKeys)[number]]) }}
</template>
<template v-else>
{{ column.dataIndex ? record[column.dataIndex as 'fullName' | 'period'] : '' }}
</template>
</template>
</a-table>
</a-card>
</a-flex>
</template>
@@ -28,6 +28,8 @@ const ATabPane = ATabs.TabPane;
type Period = {
bookedIncome: string;
invoiceSales: string;
invoiceCredits: string;
bookedExpense: string;
operatingProfit: string;
cashIn: string;
@@ -61,6 +63,8 @@ const kindLabel: Record<string, string> = {
const lines = [
['درآمد ثبت‌شده', 'bookedIncome'],
['فروش فاکتور', 'invoiceSales'],
['فاکتور برگشتی', 'invoiceCredits'],
['هزینه ثبت‌شده', 'bookedExpense'],
['سود عملیاتی', 'operatingProfit'],
['ورود وجه', 'cashIn'],
@@ -79,8 +83,8 @@ const metricRows = computed<MetricRow[]>(() => {
return lines.map(([label, key]) => ({
key,
label,
current: toman(current.value![key]),
previous: toman(previous.value![key]),
current: toman(current.value![key] || '0'),
previous: toman(previous.value![key] || '0'),
}));
});
@@ -244,6 +248,9 @@ onMounted(() => {
<a-tab-pane key="debtors" tab="بدهکاران">
<a-flex vertical :gap="16">
<a-typography-text type="secondary">
ماندهٔ فاکتور صادرشده و اسناد دریافتِ باز. صدور فاکتور سند درآمد جدا نمی‌سازد.
</a-typography-text>
<a-flex justify="space-between" align="center">
<a-typography-text type="secondary">
{{ debtors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(debtors)) }}
@@ -259,6 +266,9 @@ onMounted(() => {
<a-tab-pane key="creditors" tab="بستانکاران">
<a-flex vertical :gap="16">
<a-typography-text type="secondary">
ماندهٔ فاکتور برگشتی و اسناد هزینهٔ باز. برگشتی سند هزینهٔ جدا نمی‌سازد.
</a-typography-text>
<a-flex justify="space-between" align="center">
<a-typography-text type="secondary">
{{ creditors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(creditors)) }}