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, Button as AButton,
Card as ACard, Card as ACard,
Col as ACol, Col as ACol,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
Empty as AEmpty, Empty as AEmpty,
Flex as AFlex, Flex as AFlex,
Form as AForm, Form as AForm,
FormItem as AFormItem, FormItem as AFormItem,
Input as AInput, Modal as AModal,
Row as ARow, Row as ARow,
Select as ASelect, Select as ASelect,
Space as ASpace, Space as ASpace,
Table as ATable, Table as ATable,
Textarea as ATextarea,
Tooltip as ATooltip, Tooltip as ATooltip,
Typography, Typography,
message, message,
theme, theme,
} from 'ant-design-vue'; } 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 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';
@@ -99,6 +102,7 @@ const form = ref({
const pay = ref({ id: '', amount: '', accountId: undefined as string | undefined }); const pay = ref({ id: '', amount: '', accountId: undefined as string | undefined });
const editingId = ref(''); const editingId = ref('');
const editingPaymentId = ref(''); const editingPaymentId = ref('');
const detail = ref<PayrollRow | null>(null);
const filterUserId = ref<string | undefined>(undefined); const filterUserId = ref<string | undefined>(undefined);
const filterYear = ref<number | undefined>(undefined); const filterYear = ref<number | undefined>(undefined);
const filterMonth = ref<number | undefined>(undefined); const filterMonth = ref<number | undefined>(undefined);
@@ -203,33 +207,36 @@ const paymentColumns: TableColumnsType<PayrollPayment> = [
{ title: 'عملیات', key: 'action' }, { title: 'عملیات', key: 'action' },
]; ];
const detailPaymentColumns: TableColumnsType<PayrollPayment> = [
{ title: 'تاریخ', key: 'occurredAt' },
{ title: 'حساب', dataIndex: 'accountName', key: 'accountName' },
{ title: 'مبلغ', key: 'amount' },
];
function moneyFilled(value: string) { function moneyFilled(value: string) {
return (parseMoneyNumber(String(value ?? ''), 0) ?? 0) > 0; 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) { function reasonFor(amount: string, reason: string) {
if (!moneyFilled(amount)) return null; if (!moneyFilled(amount)) return null;
const text = reason.trim(); const text = reason.trim();
return text || null; 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) { 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 startDay = Math.min(Math.max(1, Math.trunc(Number(day)) || 1), jalaliMonthLength(year, month));
const next = addJalaliMonths(year, month, 1); const next = addJalaliMonths(year, month, 1);
@@ -512,24 +519,48 @@ onMounted(() => {
<a-form-item label="مزایا" name="benefits" style="margin-bottom: 8px"> <a-form-item label="مزایا" name="benefits" style="margin-bottom: 8px">
<MoneyInput v-model="form.benefits" addon-after="تومان" :decimals="0" /> <MoneyInput v-model="form.benefits" addon-after="تومان" :decimals="0" />
</a-form-item> </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>
<a-col :xs="12" :md="6"> <a-col :xs="12" :md="6">
<a-form-item label="پاداش" name="bonus" style="margin-bottom: 8px"> <a-form-item label="پاداش" name="bonus" style="margin-bottom: 8px">
<MoneyInput v-model="form.bonus" addon-after="تومان" :decimals="0" /> <MoneyInput v-model="form.bonus" addon-after="تومان" :decimals="0" />
</a-form-item> </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>
<a-col :xs="12" :md="6"> <a-col :xs="12" :md="6">
<a-form-item label="کسورات" name="deductions" style="margin-bottom: 8px"> <a-form-item label="کسورات" name="deductions" style="margin-bottom: 8px">
<MoneyInput v-model="form.deductions" addon-after="تومان" :decimals="0" /> <MoneyInput v-model="form.deductions" addon-after="تومان" :decimals="0" />
</a-form-item> </a-form-item>
<a-form-item v-if="moneyFilled(form.deductions)" label="دلیل" style="margin-bottom: 8px"> </a-col>
<a-input v-model:value="form.deductionsReason" placeholder="اختیاری" /> <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-form-item>
</a-col> </a-col>
<a-col :span="24"> <a-col :span="24">
@@ -575,8 +606,9 @@ onMounted(() => {
allow-clear allow-clear
show-search show-search
option-filter-prop="label" option-filter-prop="label"
:filter-option="filterPayOption"
style="width: 100%" style="width: 100%"
placeholder="انتخاب دوره" placeholder="جستجوی نام یا دوره"
:options="payOptions" :options="payOptions"
@change="onPayPick" @change="onPayPick"
/> />
@@ -729,6 +761,16 @@ onMounted(() => {
</template> </template>
<template v-else-if="column.key === 'action'"> <template v-else-if="column.key === 'action'">
<a-space :size="4"> <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-tooltip v-if="record.status !== 'void'" title="ویرایش">
<a-button <a-button
type="text" type="text"
@@ -751,17 +793,6 @@ onMounted(() => {
</a-tooltip> </a-tooltip>
</a-space> </a-space>
</template> </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])"> <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]]) }}
</template> </template>
@@ -771,5 +802,58 @@ onMounted(() => {
</template> </template>
</a-table> </a-table>
</a-card> </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> </a-flex>
</template> </template>