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
+212
View File
@@ -0,0 +1,212 @@
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const OPEN_INVOICE_STATUSES = ['issued', 'partially_paid', 'paid'] as const;
export type InvoicePartyPosition = {
key: string;
partyKind: 'person' | 'company';
partyName: string;
/** فاکتورهای عادی به‌علاوهٔ ماندهٔ اول دورهٔ مثبت */
billed: Prisma.Decimal;
/** برگشتی‌ها و اضافه‌دریافت، برای ستون تسویه‌شدهٔ بدهکار */
offset: Prisma.Decimal;
/** مثبت = طلب ما، منفی = بدهی ما */
net: Prisma.Decimal;
};
type Bucket = {
key: string;
partyKind: 'person' | 'company';
partyName: string;
sales: Prisma.Decimal;
credits: Prisma.Decimal;
paid: Prisma.Decimal;
opening: Prisma.Decimal;
};
function zero() {
return new Prisma.Decimal(0);
}
function round2(value: Prisma.Decimal) {
return value.toDecimalPlaces(2);
}
/** همان فرمول ماندهٔ فاکتور: جمع ردیف‌ها − تخفیف + مالیات، برگشتی منفی. */
export function signedInvoicePayable(invoice: {
kind: string;
discountAmount: Prisma.Decimal;
taxPercent: Prisma.Decimal;
lineItems: Array<{ quantity: Prisma.Decimal; unitPrice: Prisma.Decimal }>;
}) {
const subtotal = invoice.lineItems.reduce(
(sum, line) => sum.plus(line.quantity.mul(line.unitPrice)),
zero(),
);
const discount = invoice.discountAmount ?? zero();
const taxable = Prisma.Decimal.max(subtotal.minus(discount), 0);
const tax = taxable.mul(invoice.taxPercent ?? 0).div(100);
const signed = round2(subtotal.minus(discount).plus(tax));
return invoice.kind === 'credit' ? signed.neg() : signed;
}
export async function internalBooksEnabled(prisma: PrismaService, organizationId: string) {
const connections = await prisma.accountingConnection.count({
where: { organizationId, enabled: true },
});
return connections === 0;
}
/**
* ماندهٔ طرف‌حساب از فاکتور صادرشده و ماندهٔ اول دوره.
* سند درآمد جدا ساخته نمی‌شود تا همان فروش دو بار طلب نشود.
* اگر حسابداری بیرونی وصل باشد، خالی برمی‌گردد.
*/
export async function loadInvoicePartyPositions(
prisma: PrismaService,
organizationId: string,
): Promise<InvoicePartyPosition[]> {
if (!(await internalBooksEnabled(prisma, organizationId))) return [];
const [invoices, openings] = await Promise.all([
prisma.invoice.findMany({
where: { organizationId, status: { in: [...OPEN_INVOICE_STATUSES] } },
select: {
kind: true,
discountAmount: true,
taxPercent: true,
personId: true,
companyId: true,
person: { select: { firstName: true, lastName: true } },
company: { select: { name: true } },
lineItems: { select: { quantity: true, unitPrice: true } },
payments: { select: { amount: true } },
},
}),
prisma.partyOpeningBalance.findMany({
where: { organizationId },
select: {
personId: true,
amount: true,
person: { select: { firstName: true, lastName: true } },
},
}),
]);
const buckets = new Map<string, Bucket>();
const ensure = (
key: string,
partyKind: 'person' | 'company',
partyName: string,
) => {
const current = buckets.get(key);
if (current) return current;
const created: Bucket = {
key,
partyKind,
partyName,
sales: zero(),
credits: zero(),
paid: zero(),
opening: zero(),
};
buckets.set(key, created);
return created;
};
for (const invoice of invoices) {
const personId = invoice.personId;
const companyId = invoice.companyId;
const party = personId
? {
key: `person:${personId}`,
partyKind: 'person' as const,
partyName:
`${invoice.person?.firstName ?? ''} ${invoice.person?.lastName ?? ''}`.trim() ||
'مشتری',
}
: companyId
? {
key: `company:${companyId}`,
partyKind: 'company' as const,
partyName: invoice.company?.name?.trim() || 'شرکت',
}
: null;
if (!party) continue;
const bucket = ensure(party.key, party.partyKind, party.partyName);
const payable = signedInvoicePayable(invoice);
if (payable.greaterThan(0)) bucket.sales = bucket.sales.plus(payable);
else if (payable.lessThan(0)) bucket.credits = bucket.credits.plus(payable.abs());
const paid = invoice.payments.reduce((sum, row) => sum.plus(row.amount), zero());
bucket.paid = bucket.paid.plus(paid);
}
for (const opening of openings) {
if (opening.amount.equals(0)) continue;
const bucket = ensure(
`person:${opening.personId}`,
'person',
`${opening.person.firstName} ${opening.person.lastName}`.trim() || 'مشتری',
);
bucket.opening = bucket.opening.plus(opening.amount);
}
const positions: InvoicePartyPosition[] = [];
for (const bucket of buckets.values()) {
const net = round2(
bucket.opening.plus(bucket.sales).minus(bucket.credits).minus(bucket.paid),
);
if (net.equals(0)) continue;
const billed = round2(bucket.sales.plus(Prisma.Decimal.max(bucket.opening, 0)));
const offset = round2(billed.minus(net));
positions.push({
key: bucket.key,
partyKind: bucket.partyKind,
partyName: bucket.partyName,
billed,
offset: Prisma.Decimal.max(offset, 0),
net,
});
}
return positions;
}
/** فروش و برگشتیِ فاکتورهای صادرشده در بازه. وجه نقد را دوباره جمع نمی‌کند. */
export async function invoiceSalesInPeriod(
prisma: PrismaService,
organizationId: string,
from: Date | null,
to: Date | null,
) {
const empty = { sales: '0.00', credits: '0.00', net: '0.00' };
if (!(await internalBooksEnabled(prisma, organizationId))) return empty;
const invoices = await prisma.invoice.findMany({
where: { organizationId, status: { in: [...OPEN_INVOICE_STATUSES] } },
select: {
kind: true,
discountAmount: true,
taxPercent: true,
issuedAt: true,
createdAt: true,
lineItems: { select: { quantity: true, unitPrice: true } },
},
});
let sales = zero();
let credits = zero();
for (const invoice of invoices) {
const when = invoice.issuedAt ?? invoice.createdAt;
if (from && when < from) continue;
if (to && when > to) continue;
const payable = signedInvoicePayable(invoice);
if (payable.greaterThan(0)) sales = sales.plus(payable);
else if (payable.lessThan(0)) credits = credits.plus(payable.abs());
}
return {
sales: round2(sales).toFixed(2),
credits: round2(credits).toFixed(2),
net: round2(sales.minus(credits)).toFixed(2),
};
}
+39 -5
View File
@@ -5,6 +5,7 @@ import ExcelJS from 'exceljs';
import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { invoiceSalesInPeriod } from './invoice-ledger';
function dec(value: Prisma.Decimal | number | string | null | undefined) {
return new Prisma.Decimal(value ?? 0);
@@ -36,9 +37,15 @@ export class TreasuryOpsService {
async listPayroll(actor: Actor) {
const rows = await this.prisma.financialPayroll.findMany({
where: { organizationId: actor.organizationId },
include: { movements: true },
include: {
movements: {
where: { voidedAt: null },
include: { account: { select: { name: true } } },
orderBy: { occurredAt: 'desc' },
},
},
orderBy: { dueDate: 'desc' },
take: 100,
take: 500,
});
const users = await this.prisma.user.findMany({
where: { id: { in: rows.map((row) => row.userId) } },
@@ -88,7 +95,13 @@ export class TreasuryOpsService {
notes: input.notes?.trim() || null,
createdById: actor.userId,
},
include: { movements: true },
include: {
movements: {
where: { voidedAt: null },
include: { account: { select: { name: true } } },
orderBy: { occurredAt: 'desc' },
},
},
});
const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } });
return this.payrollDto(row, user?.fullName ?? '');
@@ -327,6 +340,8 @@ export class TreasuryOpsService {
sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']);
const rows: Array<[string, string, string]> = [
['درآمد ثبت‌شده', data.current.bookedIncome, data.previous.bookedIncome],
['فروش فاکتور', data.current.invoiceSales, data.previous.invoiceSales],
['فاکتور برگشتی', data.current.invoiceCredits, data.previous.invoiceCredits],
['هزینه ثبت‌شده', data.current.bookedExpense, data.previous.bookedExpense],
['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit],
['ورود وجه', data.current.cashIn, data.previous.cashIn],
@@ -411,10 +426,15 @@ export class TreasuryOpsService {
amount: money(amount),
share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1),
}));
const invoiceSales = await invoiceSalesInPeriod(this.prisma, organizationId, from, to);
return {
bookedIncome: money(bookedIncome),
invoiceSales: invoiceSales.sales,
invoiceCredits: invoiceSales.credits,
bookedExpense: money(bookedExpense),
operatingProfit: money(bookedIncome.minus(bookedExpense)),
operatingProfit: money(
bookedIncome.plus(invoiceSales.net).minus(bookedExpense),
),
cashIn: money(cashIn),
cashOut: money(cashOut),
cashNet: money(cashIn.minus(cashOut)),
@@ -480,7 +500,13 @@ export class TreasuryOpsService {
payable: Prisma.Decimal;
dueDate: Date;
status: string;
movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>;
movements: Array<{
id: string;
amount: Prisma.Decimal;
voidedAt: Date | null;
occurredAt: Date;
account?: { name: string } | null;
}>;
},
fullName: string,
) {
@@ -500,6 +526,14 @@ export class TreasuryOpsService {
remaining: money(left.isNegative() ? 0 : left),
dueDate: row.dueDate.toISOString(),
status: row.status,
payments: row.movements
.filter((item) => !item.voidedAt)
.map((item) => ({
id: item.id,
amount: money(item.amount),
occurredAt: item.occurredAt.toISOString(),
accountName: item.account?.name ?? '',
})),
};
}
+40 -1
View File
@@ -25,6 +25,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
import type { Actor } from '../common/record-scope';
import { loadInvoicePartyPositions, invoiceSalesInPeriod } from './invoice-ledger';
import { TreasuryReceivablesService } from './treasury-receivables.service';
const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها'];
@@ -753,7 +754,34 @@ export class TreasuryService {
current.remaining = current.remaining.plus(rawRemaining);
buckets.set(key, current);
}
const invoiceParties = await loadInvoicePartyPositions(this.prisma, actor.organizationId);
for (const party of invoiceParties) {
const onDebtorSide = party.net.greaterThan(0);
if (kind === 'income' && !onDebtorSide) continue;
if (kind === 'expense' && onDebtorSide) continue;
const remaining = party.net.abs();
const amount = onDebtorSide ? party.billed : Prisma.Decimal.max(party.billed, remaining);
const settled = Prisma.Decimal.max(amount.minus(remaining), 0);
const current = buckets.get(party.key) ?? {
key: party.key,
partyKind: party.partyKind,
partyName: party.partyName,
amount: new Prisma.Decimal(0),
settled: new Prisma.Decimal(0),
remaining: new Prisma.Decimal(0),
};
current.amount = current.amount.plus(amount);
current.settled = current.settled.plus(settled);
current.remaining = current.remaining.plus(remaining);
if (!current.partyName || current.partyName === 'بدون طرف حساب') {
current.partyName = party.partyName;
}
buckets.set(party.key, current);
}
return [...buckets.values()]
.filter((row) => row.remaining.greaterThan(0))
.sort((a, b) => b.remaining.comparedTo(a.remaining))
.map((row) => ({
key: row.key,
@@ -1092,6 +1120,17 @@ export class TreasuryService {
}
const bookedIncome = dec(income._sum.amount);
const bookedExpense = dec(expense._sum.amount);
const invoiceSales = await invoiceSalesInPeriod(
this.prisma,
actor.organizationId,
from,
to,
);
const invoiceParties = await loadInvoicePartyPositions(this.prisma, actor.organizationId);
for (const party of invoiceParties) {
if (party.net.greaterThan(0)) receivables = receivables.plus(party.net);
else payables = payables.plus(party.net.abs());
}
const totalBalance = accounts.reduce(
(sum, account) => sum.plus(account.balance),
new Prisma.Decimal(0),
@@ -1101,7 +1140,7 @@ export class TreasuryService {
actualReceipts: money(dec(receipts._sum.amount)),
bookedExpense: money(bookedExpense),
actualPayments: money(dec(payments._sum.amount)),
operatingProfit: money(bookedIncome.minus(bookedExpense)),
operatingProfit: money(bookedIncome.plus(invoiceSales.net).minus(bookedExpense)),
receivables: money(receivables),
payables: money(payables),
totalBalance: money(totalBalance),
+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)) }}