feat(finance): edit payroll with month ranges, reasons, and toman

Let salary and payments be corrected after they are saved, pick a Jalali period as a month range, and show toman on finance amounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-08 16:53:45 +03:30
co-authored by Cursor
parent 6ebd45f2cb
commit 5ef693990c
14 changed files with 594 additions and 116 deletions
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "FinancialPayroll" ADD COLUMN "benefitsReason" TEXT;
ALTER TABLE "FinancialPayroll" ADD COLUMN "bonusReason" TEXT;
ALTER TABLE "FinancialPayroll" ADD COLUMN "deductionsReason" TEXT;
+3
View File
@@ -2447,8 +2447,11 @@ model FinancialPayroll {
period String period String
baseAmount Decimal @db.Decimal(18, 2) baseAmount Decimal @db.Decimal(18, 2)
benefits Decimal @default(0) @db.Decimal(18, 2) benefits Decimal @default(0) @db.Decimal(18, 2)
benefitsReason String?
bonus Decimal @default(0) @db.Decimal(18, 2) bonus Decimal @default(0) @db.Decimal(18, 2)
bonusReason String?
deductions Decimal @default(0) @db.Decimal(18, 2) deductions Decimal @default(0) @db.Decimal(18, 2)
deductionsReason String?
payable Decimal @db.Decimal(18, 2) payable Decimal @db.Decimal(18, 2)
dueDate DateTime dueDate DateTime
notes String? notes String?
+141 -1
View File
@@ -13,11 +13,20 @@ function dec(value: Prisma.Decimal | number | string | null | undefined) {
function money(value: Prisma.Decimal | number | string) { function money(value: Prisma.Decimal | number | string) {
return new Prisma.Decimal(value).toFixed(2); return new Prisma.Decimal(value).toFixed(2);
} }
function reasonText(value?: string | null) {
const text = value?.trim() ?? '';
return text ? text.slice(0, 500) : null;
}
function parseDate(iso?: string | null) { function parseDate(iso?: string | null) {
if (!iso) return null; if (!iso) return null;
const d = new Date(iso); const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d; return Number.isNaN(d.getTime()) ? null : d;
} }
function payrollStatus(payable: Prisma.Decimal, paid: Prisma.Decimal) {
if (paid.lessThanOrEqualTo(0)) return 'open';
if (paid.greaterThanOrEqualTo(payable)) return 'settled';
return 'partial';
}
function periodKey(date: Date) { function periodKey(date: Date) {
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
@@ -62,8 +71,11 @@ export class TreasuryOpsService {
period: string; period: string;
baseAmount: string | number; baseAmount: string | number;
benefits?: string | number; benefits?: string | number;
benefitsReason?: string | null;
bonus?: string | number; bonus?: string | number;
bonusReason?: string | null;
deductions?: string | number; deductions?: string | number;
deductionsReason?: string | null;
dueDate?: string; dueDate?: string;
notes?: string | null; notes?: string | null;
}, },
@@ -88,8 +100,11 @@ export class TreasuryOpsService {
period: input.period.trim(), period: input.period.trim(),
baseAmount: base, baseAmount: base,
benefits, benefits,
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
bonus, bonus,
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
deductions, deductions,
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
payable, payable,
dueDate: parseDate(input.dueDate) ?? new Date(), dueDate: parseDate(input.dueDate) ?? new Date(),
notes: input.notes?.trim() || null, notes: input.notes?.trim() || null,
@@ -137,7 +152,7 @@ export class TreasuryOpsService {
if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.'); if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.');
const balance = await this.balance(account.id); const balance = await this.balance(account.id);
if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.'); if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
const status = paid.plus(amount).equals(row.payable) ? 'settled' : 'partial'; const status = payrollStatus(row.payable, paid.plus(amount));
await this.prisma.$transaction([ await this.prisma.$transaction([
this.prisma.financialMovement.create({ this.prisma.financialMovement.create({
data: { data: {
@@ -158,6 +173,123 @@ export class TreasuryOpsService {
return this.listPayroll(actor); return this.listPayroll(actor);
} }
async updatePayroll(
actor: Actor,
id: string,
input: {
userId: string;
period: string;
baseAmount: string | number;
benefits?: string | number;
benefitsReason?: string | null;
bonus?: string | number;
bonusReason?: string | null;
deductions?: string | number;
deductionsReason?: string | null;
},
) {
const row = await this.prisma.financialPayroll.findFirst({
where: { id, organizationId: actor.organizationId },
include: { movements: true },
});
if (!row || row.voidedAt || row.status === 'void') {
throw new NotFoundException('رکورد حقوق برای ویرایش پیدا نشد.');
}
const base = dec(input.baseAmount);
const benefits = dec(input.benefits ?? 0);
const bonus = dec(input.bonus ?? 0);
const deductions = dec(input.deductions ?? 0);
const payable = base.plus(benefits).plus(bonus).minus(deductions);
if (!input.userId || !input.period?.trim()) {
throw new BadRequestException('پرسنل و دوره حقوق لازم است.');
}
if (!payable.greaterThan(0)) throw new BadRequestException('مبلغ قابل پرداخت باید بزرگ‌تر از صفر باشد.');
const paid = this.paidOf(row.movements);
if (payable.lessThan(paid)) {
throw new BadRequestException('مبلغ قابل پرداخت از پرداخت‌های ثبت‌شده کمتر است.');
}
const member = await this.prisma.membership.findFirst({
where: { organizationId: actor.organizationId, userId: input.userId, isActive: true },
});
if (!member) throw new BadRequestException('این کاربر عضو محیط نیست.');
try {
await this.prisma.financialPayroll.update({
where: { id: row.id },
data: {
userId: input.userId,
period: input.period.trim(),
baseAmount: base,
benefits,
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
bonus,
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
deductions,
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
payable,
status: payrollStatus(payable, paid),
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new BadRequestException('برای این پرسنل در این دوره حقوق ثبت شده است.');
}
throw error;
}
return this.listPayroll(actor);
}
async updatePayrollPayment(
actor: Actor,
payrollId: string,
movementId: string,
input: { amount: string | number; accountId: string; occurredAt?: string },
) {
const movement = await this.prisma.financialMovement.findFirst({
where: {
id: movementId,
payrollId,
organizationId: actor.organizationId,
voidedAt: null,
},
});
const payroll = await this.prisma.financialPayroll.findFirst({
where: { id: payrollId, organizationId: actor.organizationId },
include: { movements: true },
});
if (!movement || !payroll || payroll.voidedAt || payroll.status === 'void') {
throw new NotFoundException('پرداخت حقوق برای ویرایش پیدا نشد.');
}
const amount = dec(input.amount);
const otherPaid = this.paidOf(payroll.movements.filter((item) => item.id !== movement.id));
if (!amount.greaterThan(0) || otherPaid.plus(amount).greaterThan(payroll.payable)) {
throw new BadRequestException('مبلغ پرداخت حقوق نامعتبر است.');
}
const account = await this.prisma.financialAccount.findFirst({
where: { id: input.accountId, organizationId: actor.organizationId },
});
if (!account || (!account.isActive && account.id !== movement.accountId)) {
throw new BadRequestException('حساب پرداخت پیدا نشد.');
}
const available = await this.balance(account.id);
const room = account.id === movement.accountId ? available.plus(movement.amount) : available;
if (room.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
await this.prisma.$transaction([
this.prisma.financialMovement.update({
where: { id: movement.id },
data: {
accountId: account.id,
amount,
occurredAt: parseDate(input.occurredAt) ?? movement.occurredAt,
},
}),
this.prisma.financialPayroll.update({
where: { id: payroll.id },
data: { status: payrollStatus(payroll.payable, otherPaid.plus(amount)) },
}),
]);
return this.listPayroll(actor);
}
async listRecurring(actor: Actor) { async listRecurring(actor: Actor) {
const rows = await this.prisma.financialRecurringItem.findMany({ const rows = await this.prisma.financialRecurringItem.findMany({
where: { organizationId: actor.organizationId }, where: { organizationId: actor.organizationId },
@@ -495,8 +627,11 @@ export class TreasuryOpsService {
period: string; period: string;
baseAmount: Prisma.Decimal; baseAmount: Prisma.Decimal;
benefits: Prisma.Decimal; benefits: Prisma.Decimal;
benefitsReason?: string | null;
bonus: Prisma.Decimal; bonus: Prisma.Decimal;
bonusReason?: string | null;
deductions: Prisma.Decimal; deductions: Prisma.Decimal;
deductionsReason?: string | null;
payable: Prisma.Decimal; payable: Prisma.Decimal;
dueDate: Date; dueDate: Date;
status: string; status: string;
@@ -504,6 +639,7 @@ export class TreasuryOpsService {
id: string; id: string;
amount: Prisma.Decimal; amount: Prisma.Decimal;
voidedAt: Date | null; voidedAt: Date | null;
accountId: string;
occurredAt: Date; occurredAt: Date;
account?: { name: string } | null; account?: { name: string } | null;
}>; }>;
@@ -519,8 +655,11 @@ export class TreasuryOpsService {
period: row.period, period: row.period,
baseAmount: money(row.baseAmount), baseAmount: money(row.baseAmount),
benefits: money(row.benefits), benefits: money(row.benefits),
benefitsReason: row.benefitsReason?.trim() || null,
bonus: money(row.bonus), bonus: money(row.bonus),
bonusReason: row.bonusReason?.trim() || null,
deductions: money(row.deductions), deductions: money(row.deductions),
deductionsReason: row.deductionsReason?.trim() || null,
payable: money(row.payable), payable: money(row.payable),
paidAmount: money(paid), paidAmount: money(paid),
remaining: money(left.isNegative() ? 0 : left), remaining: money(left.isNegative() ? 0 : left),
@@ -532,6 +671,7 @@ export class TreasuryOpsService {
id: item.id, id: item.id,
amount: money(item.amount), amount: money(item.amount),
occurredAt: item.occurredAt.toISOString(), occurredAt: item.occurredAt.toISOString(),
accountId: item.accountId,
accountName: item.account?.name ?? '', accountName: item.account?.name ?? '',
})), })),
}; };
@@ -232,6 +232,25 @@ export class TreasuryController {
return this.ops.createPayroll(user, body); return this.ops.createPayroll(user, body);
} }
@Patch('payroll/:id')
updatePayroll(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() body: Parameters<TreasuryOpsService['updatePayroll']>[2],
) {
return this.ops.updatePayroll(user, id, body);
}
@Patch('payroll/:id/payments/:movementId')
updatePayrollPayment(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Param('movementId') movementId: string,
@Body() body: Parameters<TreasuryOpsService['updatePayrollPayment']>[3],
) {
return this.ops.updatePayrollPayment(user, id, movementId, body);
}
@Post('payroll/:id/pay') @Post('payroll/:id/pay')
payPayroll( payPayroll(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
+8 -1
View File
@@ -30,6 +30,10 @@ const props = withDefaults(
placeholder?: string; placeholder?: string;
clearable?: boolean; clearable?: boolean;
icon?: 'calendar' | 'clock' | 'bell'; icon?: 'calendar' | 'clock' | 'bell';
/** اگر داده شود، متن باکس به‌جای تاریخ خام همین مقدار است. */
displayText?: string;
/** نمایی که با باز شدن پنل دیده می‌شود. */
startView?: 'days' | 'months' | 'years';
}>(), }>(),
{ {
mode: 'date', mode: 'date',
@@ -37,6 +41,8 @@ const props = withDefaults(
disabled: false, disabled: false,
clearable: true, clearable: true,
icon: undefined, icon: undefined,
displayText: '',
startView: 'days',
}, },
); );
@@ -87,6 +93,7 @@ const triggerIcon = computed(() => {
}); });
const display = computed(() => { const display = computed(() => {
if (props.displayText) return props.displayText;
if (props.mode === 'time') { if (props.mode === 'time') {
const match = /^(\d{1,2}):(\d{2})$/.exec(model.value); const match = /^(\d{1,2}):(\d{2})$/.exec(model.value);
if (!match) return ''; if (!match) return '';
@@ -368,7 +375,7 @@ function onWindowChange() {
async function show() { async function show() {
if (props.disabled) return; if (props.disabled) return;
syncDraftFrom(parsed.value); syncDraftFrom(parsed.value);
calView.value = 'days'; calView.value = props.startView;
clockFace.value = 'hour'; clockFace.value = 'hour';
syncViewport(); syncViewport();
liftAboveOverlays(); liftAboveOverlays();
+3
View File
@@ -22,6 +22,7 @@ const props = withDefaults(
/** اگر true باشد مقدار عددی emit می‌شود (نه رشته) */ /** اگر true باشد مقدار عددی emit می‌شود (نه رشته) */
asNumber?: boolean; asNumber?: boolean;
dir?: 'ltr' | 'rtl'; dir?: 'ltr' | 'rtl';
addonAfter?: string;
}>(), }>(),
{ {
required: false, required: false,
@@ -30,6 +31,7 @@ const props = withDefaults(
decimals: 2, decimals: 2,
asNumber: false, asNumber: false,
dir: 'ltr', dir: 'ltr',
addonAfter: '',
}, },
); );
@@ -129,6 +131,7 @@ function normalizeSep(raw: string): string {
:disabled="disabled" :disabled="disabled"
:readonly="readonly" :readonly="readonly"
:value="text" :value="text"
:addon-after="addonAfter || undefined"
style="width: 100%; font-variant-numeric: tabular-nums" style="width: 100%; font-variant-numeric: tabular-nums"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
@@ -168,7 +168,7 @@ onUnmounted(() => {
<a-row :gutter="[16, 0]"> <a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="موجودی واقعی"> <a-form-item label="موجودی واقعی">
<MoneyInput v-model="stated" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="stated" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
@@ -315,7 +315,7 @@ onMounted(() => {
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="موجودی اولیه" name="openingBalance"> <a-form-item label="موجودی اولیه" name="openingBalance">
<MoneyInput v-model="form.openingBalance" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="form.openingBalance" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
@@ -400,7 +400,7 @@ onMounted(() => {
name="amount" name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]" :rules="[{ required: true, message: 'مبلغ الزامی است' }]"
> >
<MoneyInput v-model="transfer.amount" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="transfer.amount" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="12"> <a-col :xs="24" :md="12">
@@ -158,7 +158,7 @@ onMounted(() => {
<UiCard title="سند جدید"> <UiCard title="سند جدید">
<div class="grid"> <div class="grid">
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField> <UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
<UiField label="مبلغ کل"><MoneyInput v-model="form.amount" :decimals="0" /></UiField> <UiField label="مبلغ کل"><MoneyInput addon-after="تومان" v-model="form.amount" :decimals="0" /></UiField>
<UiField label="دسته‌بندی"> <UiField label="دسته‌بندی">
<select v-model="form.categoryId" class="select"> <select v-model="form.categoryId" class="select">
<option value="">بدون دسته</option> <option value="">بدون دسته</option>
@@ -166,7 +166,7 @@ onMounted(() => {
</select> </select>
</UiField> </UiField>
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField> <UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
<UiField :label="settleLabel"><MoneyInput v-model="form.settleAmount" :decimals="0" /></UiField> <UiField :label="settleLabel"><MoneyInput addon-after="تومان" v-model="form.settleAmount" :decimals="0" /></UiField>
<UiField label="حساب"> <UiField label="حساب">
<select v-model="form.accountId" class="select"> <select v-model="form.accountId" class="select">
<option value="">انتخاب حساب</option> <option value="">انتخاب حساب</option>
@@ -215,12 +215,12 @@ onMounted(() => {
name="amount" name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]" :rules="[{ required: true, message: 'مبلغ الزامی است' }]"
> >
<MoneyInput v-model="form.amount" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="form.amount" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount"> <a-form-item :label="settleLabel" name="settleAmount">
<MoneyInput v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" /> <MoneyInput addon-after="تومان" v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
@@ -411,20 +411,20 @@ onMounted(() => {
<a-row :gutter="[12, 0]"> <a-row :gutter="[12, 0]">
<a-col :span="12"> <a-col :span="12">
<a-form-item <a-form-item
label="مبلغ کل (تومان)" label="مبلغ کل"
name="amount" name="amount"
:rules="[{ required: true, message: 'مبلغ کل را وارد کنید' }]" :rules="[{ required: true, message: 'مبلغ کل را وارد کنید' }]"
> >
<MoneyInput v-model="form.amount" :decimals="0" placeholder="۰" /> <MoneyInput addon-after="تومان" v-model="form.amount" :decimals="0" placeholder="۰" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
<a-form-item <a-form-item
:label="`${settleLabel} (تومان)`" :label="settleLabel"
name="settleAmount" name="settleAmount"
:rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]" :rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]"
> >
<MoneyInput <MoneyInput addon-after="تومان"
v-model="form.settleAmount" v-model="form.settleAmount"
:decimals="0" :decimals="0"
:disabled="form.partyKind === 'other'" :disabled="form.partyKind === 'other'"
@@ -604,12 +604,12 @@ onMounted(() => {
name="amount" name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]" :rules="[{ required: true, message: 'مبلغ الزامی است' }]"
> >
<MoneyInput v-model="form.amount" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="form.amount" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount"> <a-form-item :label="settleLabel" name="settleAmount">
<MoneyInput v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" /> <MoneyInput addon-after="تومان" v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
@@ -13,24 +13,42 @@ import {
Input as AInput, Input as AInput,
Row as ARow, Row as ARow,
Select as ASelect, Select as ASelect,
Space as ASpace,
Table as ATable, Table as ATable,
Tooltip as ATooltip,
Typography, Typography,
message, message,
theme,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { EditOutlined, WalletOutlined } from '@ant-design/icons-vue';
import type { TableColumnsType } from 'ant-design-vue'; import type { TableColumnsType } from 'ant-design-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared'; import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
import MoneyInput from '@/components/MoneyInput.vue'; import MoneyInput from '@/components/MoneyInput.vue';
import { formatJalaliNumeric } from '@/lib/jalali'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import {
addJalaliMonths,
dateToJalali,
formatLocalDateInput,
jalaliMonthLength,
jalaliMonthName,
jalaliToDate,
parseLocalInput,
toFaDigits,
formatJalaliNumeric,
} from '@/lib/jalali';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money'; import { parseMoneyNumber } from '@/lib/money';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const { useToken } = theme;
const { token } = useToken();
type PayrollPayment = { type PayrollPayment = {
id: string; id: string;
amount: string; amount: string;
occurredAt: string; occurredAt: string;
accountId?: string;
accountName: string; accountName: string;
}; };
@@ -41,8 +59,11 @@ type PayrollRow = {
period: string; period: string;
baseAmount: string; baseAmount: string;
benefits: string; benefits: string;
benefitsReason?: string | null;
bonus: string; bonus: string;
bonusReason?: string | null;
deductions: string; deductions: string;
deductionsReason?: string | null;
payable: string; payable: string;
paidAmount: string; paidAmount: string;
remaining: string; remaining: string;
@@ -63,17 +84,24 @@ const accounts = ref<FinancialAccountDto[]>([]);
const error = ref(''); const error = ref('');
const ok = ref(''); const ok = ref('');
const saving = ref(false); const saving = ref(false);
const todayJalali = dateToJalali(new Date());
const periodDate = ref(formatLocalDateInput(jalaliToDate(todayJalali.jy, todayJalali.jm, 1)));
const form = ref({ const form = ref({
userId: undefined as string | undefined, userId: undefined as string | undefined,
period: '',
baseAmount: '', baseAmount: '',
benefits: '', benefits: '',
benefitsReason: '',
bonus: '', bonus: '',
bonusReason: '',
deductions: '', deductions: '',
deductionsReason: '',
}); });
const pay = ref({ id: '', amount: '', accountId: undefined as string | undefined }); const pay = ref({ id: '', amount: '', accountId: undefined as string | undefined });
const editingId = ref('');
const editingPaymentId = ref('');
const filterUserId = ref<string | undefined>(undefined); const filterUserId = ref<string | undefined>(undefined);
const filterPeriod = ref(''); const filterYear = ref<number | undefined>(undefined);
const filterMonth = ref<number | undefined>(undefined);
const filterStatus = ref('all'); const filterStatus = ref('all');
const filterMin = ref(''); const filterMin = ref('');
const filterMax = ref(''); const filterMax = ref('');
@@ -90,6 +118,30 @@ const userOptions = computed(() =>
users.value.map((user) => ({ value: user.id, label: user.fullName })), users.value.map((user) => ({ value: user.id, label: user.fullName })),
); );
const monthOptions = computed(() =>
Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
label: jalaliMonthName(index + 1),
})),
);
const yearOptions = computed(() => {
const year = todayJalali.jy;
return [year - 1, year, year + 1].map((value) => ({
value,
label: toFaDigits(value),
}));
});
const periodParts = computed(() => {
const date = parseLocalInput(periodDate.value);
return date ? dateToJalali(date) : { jy: todayJalali.jy, jm: todayJalali.jm, jd: 1 };
});
const builtPeriod = computed(() =>
buildPeriod(periodParts.value.jy, periodParts.value.jm, periodParts.value.jd),
);
const payablePreview = computed(() => { const payablePreview = computed(() => {
const base = parseMoneyNumber(String(form.value.baseAmount ?? ''), 0) ?? 0; const base = parseMoneyNumber(String(form.value.baseAmount ?? ''), 0) ?? 0;
const benefits = parseMoneyNumber(String(form.value.benefits ?? ''), 0) ?? 0; const benefits = parseMoneyNumber(String(form.value.benefits ?? ''), 0) ?? 0;
@@ -103,21 +155,23 @@ const openRows = computed(() =>
); );
const payOptions = computed(() => const payOptions = computed(() =>
openRows.value.map((row) => ({ rows.value
value: row.id, .filter((row) => row.status === 'open' || row.status === 'partial' || row.id === pay.value.id)
label: `${row.fullName} · ${row.period} · مانده ${toman(row.remaining)}`, .filter((row) => row.status !== 'void')
})), .map((row) => ({
value: row.id,
label: `${row.fullName} · ${formatPeriod(row.period)} · مانده ${toman(row.remaining)}`,
})),
); );
const selectedPay = computed(() => rows.value.find((row) => row.id === pay.value.id) ?? null); const selectedPay = computed(() => rows.value.find((row) => row.id === pay.value.id) ?? null);
const filtered = computed(() => { const filtered = computed(() => {
const period = filterPeriod.value.trim();
const min = parseMoneyNumber(String(filterMin.value ?? ''), 0); const min = parseMoneyNumber(String(filterMin.value ?? ''), 0);
const max = parseMoneyNumber(String(filterMax.value ?? ''), 0); const max = parseMoneyNumber(String(filterMax.value ?? ''), 0);
return rows.value.filter((row) => { return rows.value.filter((row) => {
if (filterUserId.value && row.userId !== filterUserId.value) return false; if (filterUserId.value && row.userId !== filterUserId.value) return false;
if (period && !row.period.includes(period)) return false; if (!periodMatches(row.period, filterYear.value, filterMonth.value)) return false;
if (filterStatus.value !== 'all' && row.status !== filterStatus.value) return false; if (filterStatus.value !== 'all' && row.status !== filterStatus.value) return false;
const payable = Number(row.payable); const payable = Number(row.payable);
if (min != null && filterMin.value !== '' && payable < min) return false; if (min != null && filterMin.value !== '' && payable < min) return false;
@@ -143,15 +197,78 @@ const reportColumns: TableColumnsType<PayrollRow> = [
]; ];
const paymentColumns: TableColumnsType<PayrollPayment> = [ const paymentColumns: TableColumnsType<PayrollPayment> = [
{ { title: 'تاریخ', key: 'occurredAt' },
title: 'تاریخ',
key: 'occurredAt',
customRender: ({ record }) => formatJalaliNumeric(new Date(record.occurredAt)),
},
{ title: 'حساب', dataIndex: 'accountName', key: 'accountName' }, { title: 'حساب', dataIndex: 'accountName', key: 'accountName' },
{ title: 'مبلغ', key: 'amount', customRender: ({ record }) => toman(record.amount) }, { title: 'مبلغ', key: 'amount' },
{ title: 'عملیات', key: 'action' },
]; ];
function moneyFilled(value: string) {
return (parseMoneyNumber(String(value ?? ''), 0) ?? 0) > 0;
}
function reasonFor(amount: string, reason: string) {
if (!moneyFilled(amount)) return null;
const text = reason.trim();
return text || null;
}
function amountReason(
row: {
benefits?: string;
benefitsReason?: string | null;
bonus?: string;
bonusReason?: string | null;
deductions?: string;
deductionsReason?: string | null;
},
key: 'benefits' | 'bonus' | 'deductions',
) {
if (!Number(row[key])) return '';
if (key === 'benefits') return row.benefitsReason?.trim() || '';
if (key === 'bonus') return row.bonusReason?.trim() || '';
return row.deductionsReason?.trim() || '';
}
function buildPeriod(year: number, month: number, day: number | null) {
const startDay = Math.min(Math.max(1, Math.trunc(Number(day)) || 1), jalaliMonthLength(year, month));
const next = addJalaliMonths(year, month, 1);
const endDay = Math.min(startDay, jalaliMonthLength(next.jy, next.jm));
const pad = (value: number) => String(value).padStart(2, '0');
return {
key: `${year}/${pad(month)}/${pad(startDay)}-${next.jy}/${pad(next.jm)}/${pad(endDay)}`,
};
}
function formatPeriod(period: string) {
const range = period.match(/^(\d{4})\/(\d{2})\/(\d{2})-(\d{4})\/(\d{2})\/(\d{2})$/);
if (range) {
const startYear = Number(range[1]);
const startMonth = Number(range[2]);
const startDay = Number(range[3]);
const endYear = Number(range[4]);
const endMonth = Number(range[5]);
const endDay = Number(range[6]);
const start = `${toFaDigits(startDay)} ${jalaliMonthName(startMonth)}${startYear === endYear ? '' : ` ${toFaDigits(startYear)}`}`;
return `${start} تا ${toFaDigits(endDay)} ${jalaliMonthName(endMonth)} ${toFaDigits(endYear)}`;
}
const legacy = period.match(/^(\d{4})-(\d{2})$/);
if (legacy) return formatPeriod(buildPeriod(Number(legacy[1]), Number(legacy[2]), 1).key);
return period;
}
function periodMatches(period: string, year?: number, month?: number) {
if (year == null && month == null) return true;
const range = period.match(/^(\d{4})\/(\d{2})\//);
const legacy = period.match(/^(\d{4})-(\d{2})$/);
const periodYearValue = Number(range?.[1] ?? legacy?.[1]);
const periodMonthValue = Number(range?.[2] ?? legacy?.[2]);
if (!periodYearValue || !periodMonthValue) return false;
if (year != null && periodYearValue !== year) return false;
if (month != null && periodMonthValue !== month) return false;
return true;
}
function apiMessage(e: unknown) { function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.'; if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message; const msg = (e.response?.data as { message?: string | string[] })?.message;
@@ -173,30 +290,91 @@ async function load() {
accounts.value = acc.data.filter((row) => row.isActive); accounts.value = acc.data.filter((row) => row.isActive);
} }
function moneyField(value: string) {
const amount = Number(value);
return amount ? String(amount) : '';
}
function periodStartDate(period: string) {
const range = period.match(/^(\d{4})\/(\d{2})\/(\d{2})/);
if (range) {
return formatLocalDateInput(jalaliToDate(Number(range[1]), Number(range[2]), Number(range[3])));
}
const legacy = period.match(/^(\d{4})-(\d{2})$/);
if (legacy) return formatLocalDateInput(jalaliToDate(Number(legacy[1]), Number(legacy[2]), 1));
return periodDate.value;
}
function blankForm() {
form.value = {
userId: undefined,
baseAmount: '',
benefits: '',
benefitsReason: '',
bonus: '',
bonusReason: '',
deductions: '',
deductionsReason: '',
};
periodDate.value = formatLocalDateInput(jalaliToDate(todayJalali.jy, todayJalali.jm, 1));
}
function payrollPayload() {
return {
userId: form.value.userId,
period: builtPeriod.value.key,
baseAmount: parseMoneyNumber(String(form.value.baseAmount ?? ''), 0) ?? 0,
benefits: parseMoneyNumber(String(form.value.benefits ?? ''), 0) ?? 0,
benefitsReason: reasonFor(form.value.benefits, form.value.benefitsReason),
bonus: parseMoneyNumber(String(form.value.bonus ?? ''), 0) ?? 0,
bonusReason: reasonFor(form.value.bonus, form.value.bonusReason),
deductions: parseMoneyNumber(String(form.value.deductions ?? ''), 0) ?? 0,
deductionsReason: reasonFor(form.value.deductions, form.value.deductionsReason),
};
}
function scrollToPayroll(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function startEdit(row: PayrollRow) {
editingId.value = row.id;
form.value = {
userId: row.userId,
baseAmount: moneyField(row.baseAmount),
benefits: moneyField(row.benefits),
benefitsReason: row.benefitsReason ?? '',
bonus: moneyField(row.bonus),
bonusReason: row.bonusReason ?? '',
deductions: moneyField(row.deductions),
deductionsReason: row.deductionsReason ?? '',
};
periodDate.value = periodStartDate(row.period);
editingPaymentId.value = '';
pay.value = { id: row.id, amount: '', accountId: undefined };
scrollToPayroll('payroll-setup');
}
function cancelEdit() {
editingId.value = '';
blankForm();
}
async function createRow() { async function createRow() {
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
saving.value = true; saving.value = true;
const id = editingId.value;
try { try {
const baseAmount = parseMoneyNumber(String(form.value.baseAmount ?? ''), 0) ?? 0; if (id) await api.patch(`/treasury/payroll/${id}`, payrollPayload());
await api.post('/treasury/payroll', { else await api.post('/treasury/payroll', payrollPayload());
userId: form.value.userId, editingId.value = '';
period: form.value.period, blankForm();
baseAmount, ok.value = id
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(); await load();
if (!id) pay.value = { id: '', amount: '', accountId: undefined };
} catch (e) { } catch (e) {
error.value = apiMessage(e); error.value = apiMessage(e);
} finally { } finally {
@@ -204,17 +382,38 @@ async function createRow() {
} }
} }
function selectPay(id: string) { function onPayPick(value: unknown) {
const row = rows.value.find((item) => item.id === id); editingPaymentId.value = '';
if (!row) { if (value == null || value === '') {
pay.value = { id: '', amount: '', accountId: pay.value.accountId }; pay.value = { id: '', amount: '', accountId: undefined };
return; return;
} }
const row = rows.value.find((item) => item.id === String(value));
if (!row) {
pay.value = { id: '', amount: '', accountId: undefined };
return;
}
pay.value = {
id: row.id,
amount: row.remaining,
accountId: accounts.value[0]?.id,
};
}
function startEditPayment(payment: PayrollPayment) {
if (!pay.value.id) return;
editingPaymentId.value = payment.id;
pay.value = { pay.value = {
id: row.id, id: pay.value.id,
amount: row.remaining, amount: moneyField(payment.amount),
accountId: pay.value.accountId ?? accounts.value[0]?.id, accountId: payment.accountId,
}; };
scrollToPayroll('payroll-pay');
}
function cancelPaymentEdit() {
editingPaymentId.value = '';
pay.value = { id: pay.value.id, amount: '', accountId: undefined };
} }
async function payRow() { async function payRow() {
@@ -229,19 +428,25 @@ async function payRow() {
error.value = 'مبلغ پرداخت معتبر نیست.'; error.value = 'مبلغ پرداخت معتبر نیست.';
return; return;
} }
await api.post(`/treasury/payroll/${keepId}/pay`, { const paymentId = editingPaymentId.value;
amount, if (paymentId) {
accountId, await api.patch(`/treasury/payroll/${keepId}/payments/${paymentId}`, { amount, accountId });
clientKey: crypto.randomUUID(), } else {
}); await api.post(`/treasury/payroll/${keepId}/pay`, {
ok.value = 'پرداخت حقوق از حساب ثبت شد. اگر مانده مانده باشد، مرحلهٔ بعد را همین‌جا بزنید.'; amount,
accountId,
clientKey: crypto.randomUUID(),
});
}
editingPaymentId.value = '';
ok.value = paymentId
? 'پرداخت حقوق به‌روز شد.'
: 'پرداخت حقوق از حساب ثبت شد. اگر مانده مانده باشد، مرحلهٔ بعد را همین‌جا بزنید.';
await load(); await load();
const still = rows.value.find( const still = rows.value.find((row) => row.id === keepId && row.status !== 'void');
(row) => row.id === keepId && row.status !== 'settled' && row.status !== 'void',
);
pay.value = still pay.value = still
? { id: still.id, amount: still.remaining, accountId } ? { id: still.id, amount: paymentId ? '' : still.status === 'settled' ? '' : still.remaining, accountId: paymentId ? undefined : accountId }
: { id: '', amount: '', accountId }; : { id: '', amount: '', accountId: undefined };
} catch (e) { } catch (e) {
error.value = apiMessage(e); error.value = apiMessage(e);
} finally { } finally {
@@ -261,13 +466,14 @@ onMounted(() => {
<a-alert v-if="error" type="error" show-icon :message="error" /> <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="ok" type="success" show-icon :message="ok" />
<a-card title="تعیین حقوق" :bordered="false"> <a-card id="payroll-setup" title="تعیین حقوق" :bordered="false">
<a-form layout="vertical" :model="form" @finish="createRow" @finishFailed="onFinishFailed"> <a-form layout="vertical" :model="form" @finish="createRow" @finishFailed="onFinishFailed">
<a-row :gutter="[16, 0]"> <a-row :gutter="[8, 0]">
<a-col :xs="24" :md="12"> <a-col :xs="24" :sm="12">
<a-form-item <a-form-item
label="پرسنل" label="پرسنل"
name="userId" name="userId"
style="margin-bottom: 8px"
:rules="[{ required: true, message: 'پرسنل الزامی است' }]" :rules="[{ required: true, message: 'پرسنل الزامی است' }]"
> >
<a-select <a-select
@@ -281,55 +487,75 @@ onMounted(() => {
/> />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="12"> <a-col :xs="24" :sm="12">
<a-form-item <a-form-item label="دوره" style="margin-bottom: 8px">
label="دوره" <JalaliDateInput
name="period" v-model="periodDate"
:rules="[{ required: true, message: 'دوره الزامی است' }]" start-view="months"
> :clearable="false"
<a-input v-model:value="form.period" placeholder="۱۴۰۵-۰۷" /> :display-text="formatPeriod(builtPeriod.key)"
placeholder="انتخاب دوره"
/>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="12" :md="6">
<a-form-item <a-form-item
label="پایه" label="پایه"
name="baseAmount" name="baseAmount"
style="margin-bottom: 8px"
:rules="[{ required: true, message: 'مبلغ پایه الزامی است' }]" :rules="[{ required: true, message: 'مبلغ پایه الزامی است' }]"
> >
<MoneyInput v-model="form.baseAmount" :decimals="0" /> <MoneyInput v-model="form.baseAmount" addon-after="تومان" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="12" :md="6">
<a-form-item label="مزایا" name="benefits"> <a-form-item label="مزایا" name="benefits" style="margin-bottom: 8px">
<MoneyInput v-model="form.benefits" :decimals="0" /> <MoneyInput v-model="form.benefits" addon-after="تومان" :decimals="0" />
</a-form-item>
<a-form-item v-if="moneyFilled(form.benefits)" label="دلیل" style="margin-bottom: 8px">
<a-input v-model:value="form.benefitsReason" placeholder="اختیاری" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="12" :md="6">
<a-form-item label="پاداش" name="bonus"> <a-form-item label="پاداش" name="bonus" style="margin-bottom: 8px">
<MoneyInput v-model="form.bonus" :decimals="0" /> <MoneyInput v-model="form.bonus" addon-after="تومان" :decimals="0" />
</a-form-item>
<a-form-item v-if="moneyFilled(form.bonus)" label="دلیل" style="margin-bottom: 8px">
<a-input v-model:value="form.bonusReason" placeholder="اختیاری" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="12" :md="6">
<a-form-item label="کسورات" name="deductions"> <a-form-item label="کسورات" name="deductions" style="margin-bottom: 8px">
<MoneyInput v-model="form.deductions" :decimals="0" /> <MoneyInput v-model="form.deductions" addon-after="تومان" :decimals="0" />
</a-form-item>
<a-form-item v-if="moneyFilled(form.deductions)" label="دلیل" style="margin-bottom: 8px">
<a-input v-model:value="form.deductionsReason" placeholder="اختیاری" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="16"> <a-col :span="24">
<a-form-item label="قابل پرداخت"> <a-flex align="center" justify="space-between" wrap="wrap" :gap="8">
<a-typography-text strong>{{ toman(payablePreview) }}</a-typography-text> <a-typography-text>
</a-form-item> قابل پرداخت
<a-typography-text strong>{{ toman(payablePreview) }}</a-typography-text>
</a-typography-text>
<a-space>
<a-button v-if="editingId" :disabled="saving" @click="cancelEdit">انصراف</a-button>
<a-button type="primary" html-type="submit" :loading="saving">
{{ editingId ? 'ذخیره تغییرات' : 'ثبت حقوق' }}
</a-button>
</a-space>
</a-flex>
</a-col> </a-col>
</a-row> </a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت حقوق</a-button>
</a-form> </a-form>
</a-card> </a-card>
<a-card title="پرداخت" :bordered="false"> <a-card id="payroll-pay" title="پرداخت" :bordered="false">
<a-flex vertical :gap="16"> <a-flex vertical :gap="16">
<a-typography-text type="secondary"> <a-typography-text type="secondary">
می‌توانید کل مانده را یکجا بدهید، یا هر بار بخشی را تا تمام شدن مانده ثبت کنید. می‌توانید کل مانده را یکجا بدهید، یا هر بار بخشی را تا تمام شدن مانده ثبت کنید.
</a-typography-text> </a-typography-text>
<a-empty v-if="!openRows.length" description="حقوق باز برای پرداخت نیست." /> <a-empty v-if="!openRows.length && !pay.id" description="حقوق باز برای پرداخت نیست." />
<a-form <a-form
v-else v-else
layout="vertical" layout="vertical"
@@ -346,12 +572,13 @@ onMounted(() => {
> >
<a-select <a-select
:value="pay.id || undefined" :value="pay.id || undefined"
allow-clear
show-search show-search
option-filter-prop="label" option-filter-prop="label"
style="width: 100%" style="width: 100%"
placeholder="انتخاب پرسنل و دوره" placeholder="انتخاب دوره"
:options="payOptions" :options="payOptions"
@update:value="selectPay(String($event ?? ''))" @change="onPayPick"
/> />
</a-form-item> </a-form-item>
</a-col> </a-col>
@@ -361,7 +588,7 @@ onMounted(() => {
name="amount" name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]" :rules="[{ required: true, message: 'مبلغ الزامی است' }]"
> >
<MoneyInput v-model="pay.amount" :decimals="0" /> <MoneyInput v-model="pay.amount" addon-after="تومان" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="6"> <a-col :xs="24" :md="6">
@@ -387,9 +614,12 @@ onMounted(() => {
<a-typography-text>پرداخت‌شده {{ toman(selectedPay.paidAmount) }}</a-typography-text> <a-typography-text>پرداخت‌شده {{ toman(selectedPay.paidAmount) }}</a-typography-text>
<a-typography-text strong>مانده {{ toman(selectedPay.remaining) }}</a-typography-text> <a-typography-text strong>مانده {{ toman(selectedPay.remaining) }}</a-typography-text>
</a-flex> </a-flex>
<a-button type="primary" html-type="submit" :loading="saving" :disabled="!pay.id"> <a-space>
ثبت پرداخت <a-button v-if="editingPaymentId" :disabled="saving" @click="cancelPaymentEdit">انصراف</a-button>
</a-button> <a-button type="primary" html-type="submit" :loading="saving" :disabled="!pay.id">
{{ editingPaymentId ? 'ذخیره پرداخت' : 'ثبت پرداخت' }}
</a-button>
</a-space>
</a-form> </a-form>
<a-table <a-table
@@ -398,7 +628,31 @@ onMounted(() => {
:data-source="selectedPay.payments" :data-source="selectedPay.payments"
:pagination="false" :pagination="false"
row-key="id" row-key="id"
/> >
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'occurredAt'">
{{ formatJalaliNumeric(new Date(record.occurredAt)) }}
</template>
<template v-else-if="column.key === 'amount'">
{{ toman(record.amount) }}
</template>
<template v-else-if="column.key === 'action'">
<a-tooltip title="ویرایش">
<a-button
type="text"
aria-label="ویرایش"
:style="{ color: token.colorInfo }"
@click="startEditPayment(record as PayrollPayment)"
>
<template #icon><EditOutlined /></template>
</a-button>
</a-tooltip>
</template>
<template v-else>
{{ record.accountName }}
</template>
</template>
</a-table>
</a-flex> </a-flex>
</a-card> </a-card>
@@ -420,7 +674,26 @@ onMounted(() => {
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="دوره"> <a-form-item label="دوره">
<a-input v-model:value="filterPeriod" placeholder="مثلاً ۱۴۰۵-۰۷" allow-clear /> <a-row :gutter="[8, 0]">
<a-col :span="12">
<a-select
v-model:value="filterYear"
allow-clear
placeholder="سال"
style="width: 100%"
:options="yearOptions"
/>
</a-col>
<a-col :span="12">
<a-select
v-model:value="filterMonth"
allow-clear
placeholder="ماه"
style="width: 100%"
:options="monthOptions"
/>
</a-col>
</a-row>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
@@ -430,12 +703,12 @@ onMounted(() => {
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="حداقل قابل پرداخت"> <a-form-item label="حداقل قابل پرداخت">
<MoneyInput v-model="filterMin" :decimals="0" /> <MoneyInput v-model="filterMin" addon-after="تومان" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="حداکثر قابل پرداخت"> <a-form-item label="حداکثر قابل پرداخت">
<MoneyInput v-model="filterMax" :decimals="0" /> <MoneyInput v-model="filterMax" addon-after="تومان" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
@@ -451,14 +724,43 @@ onMounted(() => {
<template v-if="column.key === 'status'"> <template v-if="column.key === 'status'">
{{ statusLabel[record.status] ?? record.status }} {{ statusLabel[record.status] ?? record.status }}
</template> </template>
<template v-else-if="column.key === 'period'">
{{ formatPeriod(record.period) }}
</template>
<template v-else-if="column.key === 'action'"> <template v-else-if="column.key === 'action'">
<a-button <a-space :size="4">
v-if="record.status === 'open' || record.status === 'partial'" <a-tooltip v-if="record.status !== 'void'" title="ویرایش">
type="link" <a-button
@click="selectPay(record.id)" type="text"
> aria-label="ویرایش"
پرداخت :style="{ color: token.colorInfo }"
</a-button> @click="startEdit(record as PayrollRow)"
>
<template #icon><EditOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="record.status === 'open' || record.status === 'partial'" title="پرداخت">
<a-button
type="text"
aria-label="پرداخت"
:style="{ color: token.colorPrimary }"
@click="onPayPick(record.id)"
>
<template #icon><WalletOutlined /></template>
</a-button>
</a-tooltip>
</a-space>
</template>
<template v-else-if="column.key === 'benefits' || column.key === 'bonus' || column.key === 'deductions'">
<a-flex vertical>
<span>{{ toman(record[column.key as 'benefits' | 'bonus' | 'deductions']) }}</span>
<a-typography-text
v-if="amountReason(record, column.key as 'benefits' | 'bonus' | 'deductions')"
type="secondary"
>
{{ amountReason(record, column.key as 'benefits' | 'bonus' | 'deductions') }}
</a-typography-text>
</a-flex>
</template> </template>
<template v-else-if="moneyKeys.includes(column.key as (typeof moneyKeys)[number])"> <template v-else-if="moneyKeys.includes(column.key as (typeof moneyKeys)[number])">
{{ toman(record[column.key as (typeof moneyKeys)[number]]) }} {{ toman(record[column.key as (typeof moneyKeys)[number]]) }}
@@ -234,12 +234,12 @@ onMounted(() => {
name="totalAmount" name="totalAmount"
:rules="[{ required: true, message: 'مبلغ کل الزامی است' }]" :rules="[{ required: true, message: 'مبلغ کل الزامی است' }]"
> >
<MoneyInput v-model="form.totalAmount" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="form.totalAmount" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="پیش‌پرداخت" name="prepaidAmount"> <a-form-item label="پیش‌پرداخت" name="prepaidAmount">
<MoneyInput v-model="form.prepaidAmount" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="form.prepaidAmount" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
@@ -285,7 +285,7 @@ onMounted(() => {
<a-row v-for="(line, index) in customLines" :key="index" :gutter="[16, 0]"> <a-row v-for="(line, index) in customLines" :key="index" :gutter="[16, 0]">
<a-col :xs="24" :md="12"> <a-col :xs="24" :md="12">
<a-form-item :label="`مبلغ قسط ${index + 1}`"> <a-form-item :label="`مبلغ قسط ${index + 1}`">
<MoneyInput v-model="line.amount" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="line.amount" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="12"> <a-col :xs="24" :md="12">
@@ -320,7 +320,7 @@ onMounted(() => {
name="amount" name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]" :rules="[{ required: true, message: 'مبلغ الزامی است' }]"
> >
<MoneyInput v-model="collect.amount" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="collect.amount" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
@@ -123,7 +123,7 @@ onMounted(() => {
name="amount" name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]" :rules="[{ required: true, message: 'مبلغ الزامی است' }]"
> >
<MoneyInput v-model="form.amount" :decimals="0" /> <MoneyInput addon-after="تومان" v-model="form.amount" :decimals="0" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">