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>
974 lines
36 KiB
Vue
974 lines
36 KiB
Vue
<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>
|