feat(finance): show payroll details and search payment periods

Keep long reasons out of the report table, open them with payments in a details view, and let a payment period be found by name or month.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-08 17:34:12 +03:30
co-authored by Cursor
parent 5ef693990c
commit d33d88e76d
@@ -6,21 +6,24 @@ import {
Button as AButton,
Card as ACard,
Col as ACol,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
Modal as AModal,
Row as ARow,
Select as ASelect,
Space as ASpace,
Table as ATable,
Textarea as ATextarea,
Tooltip as ATooltip,
Typography,
message,
theme,
} from 'ant-design-vue';
import { EditOutlined, WalletOutlined } from '@ant-design/icons-vue';
import { EditOutlined, EyeOutlined, WalletOutlined } from '@ant-design/icons-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
@@ -99,6 +102,7 @@ const form = ref({
const pay = ref({ id: '', amount: '', accountId: undefined as string | undefined });
const editingId = ref('');
const editingPaymentId = ref('');
const detail = ref<PayrollRow | null>(null);
const filterUserId = ref<string | undefined>(undefined);
const filterYear = ref<number | undefined>(undefined);
const filterMonth = ref<number | undefined>(undefined);
@@ -203,33 +207,36 @@ const paymentColumns: TableColumnsType<PayrollPayment> = [
{ title: 'عملیات', key: 'action' },
];
const detailPaymentColumns: TableColumnsType<PayrollPayment> = [
{ title: 'تاریخ', key: 'occurredAt' },
{ title: 'حساب', dataIndex: 'accountName', key: 'accountName' },
{ title: 'مبلغ', key: 'amount' },
];
function moneyFilled(value: string) {
return (parseMoneyNumber(String(value ?? ''), 0) ?? 0) > 0;
}
function normalizeSearch(value: string) {
return value
.trim()
.replace(/[يى]/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/[۰-۹]/g, (digit) => String(digit.charCodeAt(0) - 0x06f0))
.replace(/[٠-٩]/g, (digit) => String(digit.charCodeAt(0) - 0x0660))
.toLowerCase();
}
function filterPayOption(input: string, option?: { label?: string }) {
return normalizeSearch(String(option?.label ?? '')).includes(normalizeSearch(input));
}
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);
@@ -512,24 +519,48 @@ onMounted(() => {
<a-form-item label="مزایا" name="benefits" style="margin-bottom: 8px">
<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-col>
<a-col :xs="12" :md="6">
<a-form-item label="پاداش" name="bonus" style="margin-bottom: 8px">
<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-col>
<a-col :xs="12" :md="6">
<a-form-item label="کسورات" name="deductions" style="margin-bottom: 8px">
<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-col>
<a-col v-if="moneyFilled(form.benefits)" :span="24">
<a-form-item label="دلیل مزایا" style="margin-bottom: 8px">
<a-textarea
v-model:value="form.benefitsReason"
placeholder="اختیاری"
:maxlength="500"
show-count
:auto-size="{ minRows: 2, maxRows: 6 }"
/>
</a-form-item>
</a-col>
<a-col v-if="moneyFilled(form.bonus)" :span="24">
<a-form-item label="دلیل پاداش" style="margin-bottom: 8px">
<a-textarea
v-model:value="form.bonusReason"
placeholder="اختیاری"
:maxlength="500"
show-count
:auto-size="{ minRows: 2, maxRows: 6 }"
/>
</a-form-item>
</a-col>
<a-col v-if="moneyFilled(form.deductions)" :span="24">
<a-form-item label="دلیل کسورات" style="margin-bottom: 8px">
<a-textarea
v-model:value="form.deductionsReason"
placeholder="اختیاری"
:maxlength="500"
show-count
:auto-size="{ minRows: 2, maxRows: 6 }"
/>
</a-form-item>
</a-col>
<a-col :span="24">
@@ -575,8 +606,9 @@ onMounted(() => {
allow-clear
show-search
option-filter-prop="label"
:filter-option="filterPayOption"
style="width: 100%"
placeholder="انتخاب دوره"
placeholder="جستجوی نام یا دوره"
:options="payOptions"
@change="onPayPick"
/>
@@ -729,6 +761,16 @@ onMounted(() => {
</template>
<template v-else-if="column.key === 'action'">
<a-space :size="4">
<a-tooltip title="جزئیات">
<a-button
type="text"
aria-label="جزئیات"
:style="{ color: token.colorWarning }"
@click="detail = record as PayrollRow"
>
<template #icon><EyeOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="record.status !== 'void'" title="ویرایش">
<a-button
type="text"
@@ -751,17 +793,6 @@ onMounted(() => {
</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 v-else-if="moneyKeys.includes(column.key as (typeof moneyKeys)[number])">
{{ toman(record[column.key as (typeof moneyKeys)[number]]) }}
</template>
@@ -771,5 +802,58 @@ onMounted(() => {
</template>
</a-table>
</a-card>
<a-modal
:open="detail != null"
:title="detail ? `جزئیات حقوق · ${detail.fullName}` : ''"
:footer="null"
:width="'min(640px, calc(100vw - 32px))'"
@cancel="detail = null"
>
<a-flex v-if="detail" vertical :gap="16">
<a-descriptions bordered :column="1">
<a-descriptions-item label="پرسنل">{{ detail.fullName }}</a-descriptions-item>
<a-descriptions-item label="دوره">{{ formatPeriod(detail.period) }}</a-descriptions-item>
<a-descriptions-item label="وضعیت">{{ statusLabel[detail.status] ?? detail.status }}</a-descriptions-item>
<a-descriptions-item label="پایه">{{ toman(detail.baseAmount) }}</a-descriptions-item>
<a-descriptions-item label="مزایا">{{ toman(detail.benefits) }}</a-descriptions-item>
<a-descriptions-item v-if="detail.benefitsReason?.trim()" label="دلیل مزایا">
<span style="white-space: pre-wrap">{{ detail.benefitsReason }}</span>
</a-descriptions-item>
<a-descriptions-item label="پاداش">{{ toman(detail.bonus) }}</a-descriptions-item>
<a-descriptions-item v-if="detail.bonusReason?.trim()" label="دلیل پاداش">
<span style="white-space: pre-wrap">{{ detail.bonusReason }}</span>
</a-descriptions-item>
<a-descriptions-item label="کسورات">{{ toman(detail.deductions) }}</a-descriptions-item>
<a-descriptions-item v-if="detail.deductionsReason?.trim()" label="دلیل کسورات">
<span style="white-space: pre-wrap">{{ detail.deductionsReason }}</span>
</a-descriptions-item>
<a-descriptions-item label="قابل پرداخت">{{ toman(detail.payable) }}</a-descriptions-item>
<a-descriptions-item label="پرداخت‌شده">{{ toman(detail.paidAmount) }}</a-descriptions-item>
<a-descriptions-item label="مانده">{{ toman(detail.remaining) }}</a-descriptions-item>
</a-descriptions>
<a-typography-text strong>پرداخت‌ها</a-typography-text>
<a-empty v-if="!detail.payments.length" description="پرداختی ثبت نشده است." />
<a-table
v-else
:columns="detailPaymentColumns"
:data-source="detail.payments"
:pagination="false"
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>
{{ record.accountName }}
</template>
</template>
</a-table>
</a-flex>
</a-modal>
</a-flex>
</template>