feat(finance): open payroll details from mobile cards

Show each payroll as a card on a phone and slide its details up from the bottom so a short sheet leaves the top of the screen clear.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-08 18:13:38 +03:30
co-authored by Cursor
parent d33d88e76d
commit 06036b00d3
@@ -12,6 +12,7 @@ import {
Flex as AFlex, Flex as AFlex,
Form as AForm, Form as AForm,
FormItem as AFormItem, FormItem as AFormItem,
Grid,
Modal as AModal, Modal as AModal,
Row as ARow, Row as ARow,
Select as ASelect, Select as ASelect,
@@ -46,6 +47,8 @@ import { parseMoneyNumber } from '@/lib/money';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const { useToken } = theme; const { useToken } = theme;
const { token } = useToken(); const { token } = useToken();
const screens = Grid.useBreakpoint();
const isMobile = computed(() => !screens.value.md);
type PayrollPayment = { type PayrollPayment = {
id: string; id: string;
@@ -207,12 +210,6 @@ 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;
} }
@@ -745,6 +742,95 @@ onMounted(() => {
</a-col> </a-col>
</a-row> </a-row>
</a-form> </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 <a-table
:columns="reportColumns" :columns="reportColumns"
:data-source="filtered" :data-source="filtered"
@@ -801,19 +887,32 @@ onMounted(() => {
</template> </template>
</template> </template>
</a-table> </a-table>
</a-col>
</a-row>
</a-card> </a-card>
<a-modal <a-modal
:open="detail != null" :open="detail != null"
:title="detail ? `جزئیات حقوق · ${detail.fullName}` : ''"
:footer="null" :footer="null"
:width="'min(640px, calc(100vw - 32px))'" :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" @cancel="detail = null"
> >
<a-flex v-if="detail" vertical :gap="16"> <template #title>
<a-descriptions bordered :column="1"> <a-flex v-if="detail" justify="space-between" align="center" wrap="wrap" :gap="8" :style="{ paddingInlineEnd: '32px' }">
<a-descriptions-item label="پرسنل">{{ detail.fullName }}</a-descriptions-item> <span>جزئیات حقوق · {{ detail.fullName }}</span>
<a-descriptions-item label="دوره">{{ formatPeriod(detail.period) }}</a-descriptions-item> <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="وضعیت">{{ statusLabel[detail.status] ?? detail.status }}</a-descriptions-item>
<a-descriptions-item label="پایه">{{ toman(detail.baseAmount) }}</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 label="مزایا">{{ toman(detail.benefits) }}</a-descriptions-item>
@@ -834,25 +933,22 @@ onMounted(() => {
</a-descriptions> </a-descriptions>
<a-typography-text strong>پرداخت‌ها</a-typography-text> <a-typography-text strong>پرداخت‌ها</a-typography-text>
<a-empty v-if="!detail.payments.length" description="پرداختی ثبت نشده است." /> <a-empty v-if="!detail.payments.length" description="پرداختی ثبت نشده است." />
<a-table <a-flex v-else vertical :gap="8">
v-else <a-flex
:columns="detailPaymentColumns" v-for="payment in detail.payments"
:data-source="detail.payments" :key="payment.id"
:pagination="false" justify="space-between"
row-key="id" align="center"
> wrap="wrap"
<template #bodyCell="{ column, record }"> :gap="8"
<template v-if="column.key === 'occurredAt'"> >
{{ formatJalaliNumeric(new Date(record.occurredAt)) }} <a-flex vertical>
</template> <span>{{ formatJalaliNumeric(new Date(payment.occurredAt)) }}</span>
<template v-else-if="column.key === 'amount'"> <a-typography-text type="secondary">{{ payment.accountName }}</a-typography-text>
{{ toman(record.amount) }} </a-flex>
</template> <span>{{ toman(payment.amount) }}</span>
<template v-else> </a-flex>
{{ record.accountName }} </a-flex>
</template>
</template>
</a-table>
</a-flex> </a-flex>
</a-modal> </a-modal>
</a-flex> </a-flex>