Files
CRM_Montzar/apps/web/src/views/treasury/TreasuryPayrollView.vue
T
MahdiandCursor d12d7b91cb feat(treasury): separate receipts from income and fit finance on a phone
A receipt or payment only reduces the party balance, while an income or expense keeps both amounts. Finance screens stack into cards on a phone so the forms and lists stay readable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 12:28:27 +03:30

974 lines
36 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
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,
Grid,
Modal as AModal,
Row as ARow,
Select as ASelect,
Space as ASpace,
Table as ATable,
List as AList,
Textarea as ATextarea,
Tooltip as ATooltip,
Typography,
message,
theme,
} from 'ant-design-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';
import MoneyInput from '@/components/MoneyInput.vue';
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 { parseMoneyNumber } from '@/lib/money';
const { Text: ATypographyText } = Typography;
const { useToken } = theme;
const { token } = useToken();
const screens = Grid.useBreakpoint();
const isMobile = computed(() => !screens.value.md);
const AListItem = AList.Item;
type PayrollPayment = {
id: string;
amount: string;
occurredAt: string;
accountId?: string;
accountName: string;
};
type PayrollRow = {
id: string;
userId: string;
fullName: string;
period: string;
baseAmount: string;
benefits: string;
benefitsReason?: string | null;
bonus: string;
bonusReason?: string | null;
deductions: string;
deductionsReason?: string | null;
payable: string;
paidAmount: string;
remaining: string;
status: string;
payments: PayrollPayment[];
};
const statusLabel: Record<string, string> = {
open: 'پرداخت‌نشده',
partial: 'پرداخت جزئی',
settled: 'تسویه‌شده',
void: 'باطل',
};
const rows = ref<PayrollRow[]>([]);
const users = ref<OrgUserDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
const ok = ref('');
const saving = ref(false);
const todayJalali = dateToJalali(new Date());
const periodDate = ref(formatLocalDateInput(jalaliToDate(todayJalali.jy, todayJalali.jm, 1)));
const form = ref({
userId: undefined as string | undefined,
baseAmount: '',
benefits: '',
benefitsReason: '',
bonus: '',
bonusReason: '',
deductions: '',
deductionsReason: '',
});
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);
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 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 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(() =>
rows.value
.filter((row) => row.status === 'open' || row.status === 'partial' || row.id === pay.value.id)
.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 filtered = computed(() => {
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 (!periodMatches(row.period, filterYear.value, filterMonth.value)) 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' },
{ title: 'حساب', dataIndex: 'accountName', key: 'accountName' },
{ title: 'مبلغ', key: 'amount' },
{ title: 'عملیات', key: 'action' },
];
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 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) {
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : msg || 'عملیات ناموفق بود.';
}
function onFinishFailed() {
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
}
async function load() {
const [payroll, people, acc] = await Promise.all([
api.get<PayrollRow[]>('/treasury/payroll'),
api.get<OrgUserDto[]>('/users'),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
]);
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);
}
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() {
error.value = '';
ok.value = '';
saving.value = true;
const id = editingId.value;
try {
if (id) await api.patch(`/treasury/payroll/${id}`, payrollPayload());
else await api.post('/treasury/payroll', payrollPayload());
editingId.value = '';
blankForm();
ok.value = id
? 'حقوق به‌روز شد.'
: 'حقوق ثبت شد. تا وقتی پرداخت نکنید موجودی حساب عوض نمی‌شود.';
await load();
if (!id) pay.value = { id: '', amount: '', accountId: undefined };
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
function onPayPick(value: unknown) {
editingPaymentId.value = '';
if (value == null || value === '') {
pay.value = { id: '', amount: '', accountId: undefined };
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 = {
id: pay.value.id,
amount: moneyField(payment.amount),
accountId: payment.accountId,
};
scrollToPayroll('payroll-pay');
}
function cancelPaymentEdit() {
editingPaymentId.value = '';
pay.value = { id: pay.value.id, amount: '', accountId: undefined };
}
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;
}
const paymentId = editingPaymentId.value;
if (paymentId) {
await api.patch(`/treasury/payroll/${keepId}/payments/${paymentId}`, { amount, accountId });
} else {
await api.post(`/treasury/payroll/${keepId}/pay`, {
amount,
accountId,
clientKey: crypto.randomUUID(),
});
}
editingPaymentId.value = '';
ok.value = paymentId
? 'پرداخت حقوق به‌روز شد.'
: 'پرداخت حقوق از حساب ثبت شد. اگر مانده مانده باشد، مرحلهٔ بعد را همین‌جا بزنید.';
await load();
const still = rows.value.find((row) => row.id === keepId && row.status !== 'void');
pay.value = still
? { id: still.id, amount: paymentId ? '' : still.status === 'settled' ? '' : still.remaining, accountId: paymentId ? undefined : accountId }
: { id: '', amount: '', accountId: undefined };
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری حقوق ناموفق بود.';
});
});
</script>
<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-card id="payroll-setup" title="تعیین حقوق" :bordered="false">
<a-form layout="vertical" :model="form" @finish="createRow" @finishFailed="onFinishFailed">
<a-row :gutter="[8, 0]">
<a-col :xs="24" :sm="12">
<a-form-item
label="پرسنل"
name="userId"
style="margin-bottom: 8px"
: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" :sm="12">
<a-form-item label="دوره" style="margin-bottom: 8px">
<JalaliDateInput
v-model="periodDate"
start-view="months"
:clearable="false"
:display-text="formatPeriod(builtPeriod.key)"
placeholder="انتخاب دوره"
/>
</a-form-item>
</a-col>
<a-col :xs="12" :md="6">
<a-form-item
label="پایه"
name="baseAmount"
style="margin-bottom: 8px"
:rules="[{ required: true, message: 'مبلغ پایه الزامی است' }]"
>
<MoneyInput v-model="form.baseAmount" addon-after="تومان" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="12" :md="6">
<a-form-item label="مزایا" name="benefits" style="margin-bottom: 8px">
<MoneyInput v-model="form.benefits" addon-after="تومان" :decimals="0" />
</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-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-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">
<a-flex align="center" justify="space-between" wrap="wrap" :gap="8">
<a-typography-text>
قابل پرداخت
<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-row>
</a-form>
</a-card>
<a-card id="payroll-pay" title="پرداخت" :bordered="false">
<a-flex vertical :gap="16">
<a-typography-text type="secondary">
می‌توانید کل مانده را یکجا بدهید، یا هر بار بخشی را تا تمام شدن مانده ثبت کنید.
</a-typography-text>
<a-empty v-if="!openRows.length && !pay.id" 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"
allow-clear
show-search
option-filter-prop="label"
:filter-option="filterPayOption"
style="width: 100%"
placeholder="جستجوی نام یا دوره"
:options="payOptions"
@change="onPayPick"
/>
</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" addon-after="تومان" :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-space>
<a-button v-if="editingPaymentId" :disabled="saving" @click="cancelPaymentEdit">انصراف</a-button>
<a-button type="primary" html-type="submit" :loading="saving" :disabled="!pay.id">
{{ editingPaymentId ? 'ذخیره پرداخت' : 'ثبت پرداخت' }}
</a-button>
</a-space>
</a-form>
<a-list v-if="isMobile && selectedPay?.payments.length" :data-source="selectedPay.payments">
<template #renderItem="{ item }">
<a-list-item>
<a-card style="width: 100%">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="تاریخ">
{{ formatJalaliNumeric(new Date(item.occurredAt)) }}
</a-descriptions-item>
<a-descriptions-item label="حساب">{{ item.accountName }}</a-descriptions-item>
<a-descriptions-item label="مبلغ">{{ toman(item.amount) }}</a-descriptions-item>
</a-descriptions>
<a-button type="link" @click="startEditPayment(item)">ویرایش</a-button>
</a-card>
</a-list-item>
</template>
</a-list>
<a-table
v-else-if="selectedPay?.payments.length"
:columns="paymentColumns"
:data-source="selectedPay.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-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-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-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-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" addon-after="تومان" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حداکثر قابل پرداخت">
<MoneyInput v-model="filterMax" addon-after="تومان" :decimals="0" />
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-row>
<a-col :xs="24" :md="0">
<a-empty v-if="!filtered.length" description="رکوردی با این فیلتر نیست." />
<a-flex v-else vertical :gap="8">
<a-card
v-for="row in filtered"
:key="row.id"
hoverable
@click="detail = row"
>
<a-flex vertical :gap="8">
<a-flex justify="space-between" align="center" :gap="8">
<a-typography-text strong>{{ row.fullName }}</a-typography-text>
<a-typography-text type="secondary">{{ statusLabel[row.status] ?? row.status }}</a-typography-text>
</a-flex>
<a-typography-text>{{ formatPeriod(row.period) }}</a-typography-text>
<a-row :gutter="[8, 8]">
<a-col :span="12">
<a-flex vertical>
<a-typography-text type="secondary">پایه</a-typography-text>
<span>{{ toman(row.baseAmount) }}</span>
</a-flex>
</a-col>
<a-col :span="12">
<a-flex vertical>
<a-typography-text type="secondary">مزایا</a-typography-text>
<span>{{ toman(row.benefits) }}</span>
</a-flex>
</a-col>
<a-col :span="12">
<a-flex vertical>
<a-typography-text type="secondary">پاداش</a-typography-text>
<span>{{ toman(row.bonus) }}</span>
</a-flex>
</a-col>
<a-col :span="12">
<a-flex vertical>
<a-typography-text type="secondary">کسورات</a-typography-text>
<span>{{ toman(row.deductions) }}</span>
</a-flex>
</a-col>
<a-col :span="12">
<a-flex vertical>
<a-typography-text type="secondary">قابل پرداخت</a-typography-text>
<span>{{ toman(row.payable) }}</span>
</a-flex>
</a-col>
<a-col :span="12">
<a-flex vertical>
<a-typography-text type="secondary">پرداخت‌شده</a-typography-text>
<span>{{ toman(row.paidAmount) }}</span>
</a-flex>
</a-col>
<a-col :span="24">
<a-flex justify="space-between" align="center" :gap="8">
<a-flex vertical>
<a-typography-text type="secondary">مانده</a-typography-text>
<a-typography-text strong>{{ toman(row.remaining) }}</a-typography-text>
</a-flex>
<a-flex :gap="4" align="center" @click.stop>
<a-tooltip v-if="row.status !== 'void'" title="ویرایش">
<a-button
type="text"
aria-label="ویرایش"
:style="{ color: token.colorInfo }"
@click="startEdit(row)"
>
<template #icon><EditOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="row.status === 'open' || row.status === 'partial'" title="پرداخت">
<a-button
type="text"
aria-label="پرداخت"
:style="{ color: token.colorPrimary }"
@click="onPayPick(row.id)"
>
<template #icon><WalletOutlined /></template>
</a-button>
</a-tooltip>
</a-flex>
</a-flex>
</a-col>
</a-row>
</a-flex>
</a-card>
</a-flex>
</a-col>
<a-col :xs="0" :md="24">
<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>
<template v-else-if="column.key === 'period'">
{{ formatPeriod(record.period) }}
</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"
aria-label="ویرایش"
:style="{ color: token.colorInfo }"
@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="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-col>
</a-row>
</a-card>
<a-modal
:open="detail != null"
:footer="null"
:width="isMobile ? '100%' : 'min(640px, calc(100vw - 32px))'"
:wrap-style="isMobile ? { overflow: 'hidden' } : undefined"
:style="isMobile ? { position: 'absolute', top: 'auto', bottom: 0, left: 0, right: 0, width: '100%', maxWidth: '100%', height: 'auto', maxHeight: '100%', margin: 0, paddingBottom: 0, overflow: 'hidden' } : undefined"
:body-style="isMobile ? { maxHeight: 'calc(100svh - 72px)', overflowX: 'hidden', overflowY: 'auto', scrollbarWidth: 'none' } : undefined"
@cancel="detail = null"
>
<template #title>
<a-flex v-if="detail" justify="space-between" align="center" wrap="wrap" :gap="8" :style="{ paddingInlineEnd: '32px' }">
<span>جزئیات حقوق · {{ detail.fullName }}</span>
<span>{{ formatPeriod(detail.period) }}</span>
</a-flex>
</template>
<a-flex v-if="detail" vertical :gap="16" style="max-width: 100%; overflow-x: hidden">
<a-descriptions
bordered
:column="1"
:label-style="{ whiteSpace: 'normal' }"
:content-style="{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }"
>
<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-flex v-else vertical :gap="8">
<a-flex
v-for="payment in detail.payments"
:key="payment.id"
justify="space-between"
align="center"
wrap="wrap"
:gap="8"
>
<a-flex vertical>
<span>{{ formatJalaliNumeric(new Date(payment.occurredAt)) }}</span>
<a-typography-text type="secondary">{{ payment.accountName }}</a-typography-text>
</a-flex>
<span>{{ toman(payment.amount) }}</span>
</a-flex>
</a-flex>
</a-flex>
</a-modal>
</a-flex>
</template>