forked from wangziqi/gongxue-base
feat: refine admin forms, attendance and finance workflows
Squash merge PR #23. Included changes: - complete occupancy check-in required fields/default payload - improve responsive admin management pages - fix attendance edge cases and attendance period config - refine wallet/finance-related workflow handling Checks: - npm run typecheck -w apps/admin - npm run typecheck -w apps/server
This commit is contained in:
@@ -27,7 +27,6 @@ import { message } from '../../ui/app-message';
|
||||
import { buildBillPrintHtml, type BillPrintData } from './bill-print';
|
||||
import { newOperationId } from '../../utils/operation-id';
|
||||
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
unpaid: { text: '待支付', color: 'orange' },
|
||||
partially_paid: { text: '部分支付', color: 'gold' },
|
||||
@@ -64,7 +63,7 @@ const BillsPage: React.FC = () => {
|
||||
const params: Record<string, string | undefined> = {};
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (filterExpenseType) params.expenseType = filterExpenseType;
|
||||
const res = await api.get('/bills', { params }) as unknown[];
|
||||
const res = (await api.get('/bills', { params })) as unknown[];
|
||||
setBills(res);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载失败,请稍后重试');
|
||||
@@ -120,17 +119,30 @@ const BillsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleCancel = async (id: number) => {
|
||||
let reason = '';
|
||||
Modal.confirm({
|
||||
title: '取消账单并退回已扣余额',
|
||||
content: <Input.TextArea placeholder="请输入取消原因" maxLength={300} onChange={(event) => { reason = event.target.value; }} />,
|
||||
okText: '确认取消', cancelText: '返回',
|
||||
content: (
|
||||
<Input.TextArea
|
||||
placeholder="请输入取消原因"
|
||||
maxLength={300}
|
||||
onChange={(event) => {
|
||||
reason = event.target.value;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
okText: '确认取消',
|
||||
cancelText: '返回',
|
||||
onOk: async () => {
|
||||
if (!reason.trim()) { message.error('请输入取消原因'); throw new Error('reason required'); }
|
||||
await api.post(`/bills/${id}/cancel`, { operationId: newOperationId(), reason: reason.trim() });
|
||||
if (!reason.trim()) {
|
||||
message.error('请输入取消原因');
|
||||
throw new Error('reason required');
|
||||
}
|
||||
await api.post(`/bills/${id}/cancel`, {
|
||||
operationId: newOperationId(),
|
||||
reason: reason.trim(),
|
||||
});
|
||||
message.success('账单已取消,已扣余额已冲正退回');
|
||||
fetchData();
|
||||
},
|
||||
@@ -142,7 +154,9 @@ const BillsPage: React.FC = () => {
|
||||
await api.delete(`/bills/${id}`);
|
||||
message.success('账单已归档');
|
||||
fetchData();
|
||||
} catch (error: any) { message.error(error?.message || '归档失败'); }
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '归档失败');
|
||||
}
|
||||
};
|
||||
|
||||
const batchArchive = async () => {
|
||||
@@ -175,7 +189,9 @@ const BillsPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
printWindow.document.write('<p style="font-family:sans-serif;padding:24px">正在加载账单...</p>');
|
||||
printWindow.document.write(
|
||||
'<p style="font-family:sans-serif;padding:24px">正在加载账单...</p>',
|
||||
);
|
||||
try {
|
||||
const bill = await api.get<BillPrintData>(`/bills/${billId}`);
|
||||
printWindow.document.open();
|
||||
@@ -187,89 +203,125 @@ const BillsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
||||
{ title: '账单周期', width: 200, render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}` },
|
||||
{
|
||||
title: '分摊费用',
|
||||
dataIndex: 'sharedAmount',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '个人费用',
|
||||
dataIndex: 'personalAmount',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '总计',
|
||||
dataIndex: 'totalAmount',
|
||||
width: 100,
|
||||
align: 'right' as const,
|
||||
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
|
||||
},
|
||||
{
|
||||
title: '已扣余额', dataIndex: 'paidAmount', width: 110,
|
||||
render: (value: number) => <span style={{ color: '#389e0d' }}>¥{Number(value || 0).toFixed(2)}</span>,
|
||||
},
|
||||
{
|
||||
title: '待补缴', dataIndex: 'outstandingAmount', width: 110,
|
||||
render: (value: number) => <strong style={{ color: Number(value) > 0 ? '#cf1322' : '#389e0d' }}>¥{Number(value || 0).toFixed(2)}</strong>,
|
||||
},
|
||||
{
|
||||
title: '钱包余额', dataIndex: 'walletBalance', width: 110,
|
||||
render: (value: number) => `¥${Number(value || 0).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '生成时间',
|
||||
dataIndex: 'generatedAt',
|
||||
width: 160,
|
||||
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 320,
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="bill:view"
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => showDetail(record.id)}
|
||||
>
|
||||
详情
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="bill:export-pdf"
|
||||
size="small"
|
||||
icon={<FilePdfOutlined />}
|
||||
onClick={() => handleExportPdf(record.id)}
|
||||
>
|
||||
PDF
|
||||
</PermissionButton>
|
||||
{record.status !== 'cancelled' && (
|
||||
<PermissionButton permission="bill:delete" size="small" danger onClick={() => handleCancel(record.id)}>
|
||||
取消并冲正
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
||||
{
|
||||
title: '账单周期',
|
||||
width: 200,
|
||||
render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}`,
|
||||
},
|
||||
{
|
||||
title: '分摊费用',
|
||||
dataIndex: 'sharedAmount',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '个人费用',
|
||||
dataIndex: 'personalAmount',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '总计',
|
||||
dataIndex: 'totalAmount',
|
||||
width: 100,
|
||||
align: 'right' as const,
|
||||
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
|
||||
},
|
||||
{
|
||||
title: '已扣余额',
|
||||
dataIndex: 'paidAmount',
|
||||
width: 110,
|
||||
render: (value: number) => (
|
||||
<span style={{ color: '#389e0d' }}>¥{Number(value || 0).toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '待补缴',
|
||||
dataIndex: 'outstandingAmount',
|
||||
width: 110,
|
||||
render: (value: number) => (
|
||||
<strong style={{ color: Number(value) > 0 ? '#cf1322' : '#389e0d' }}>
|
||||
¥{Number(value || 0).toFixed(2)}
|
||||
</strong>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '钱包余额',
|
||||
dataIndex: 'walletBalance',
|
||||
width: 110,
|
||||
render: (value: number) => `¥${Number(value || 0).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '生成时间',
|
||||
dataIndex: 'generatedAt',
|
||||
width: 160,
|
||||
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 320,
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="bill:view"
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => showDetail(record.id)}
|
||||
>
|
||||
详情
|
||||
</PermissionButton>
|
||||
)}
|
||||
{Number(record.paidAmount || 0) === 0 && record.status !== 'cancelled' && (
|
||||
<Popconfirm title="确定归档此未支付账单?" onConfirm={() => handleArchive(record.id)} okText="归档" cancelText="取消">
|
||||
<PermissionButton permission="bill:delete" size="small" danger icon={<InboxOutlined />}>归档</PermissionButton>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
], [showDetail, handleArchive, handleCancel, handleExportPdf]);
|
||||
<PermissionButton
|
||||
permission="bill:export-pdf"
|
||||
size="small"
|
||||
icon={<FilePdfOutlined />}
|
||||
onClick={() => handleExportPdf(record.id)}
|
||||
>
|
||||
PDF
|
||||
</PermissionButton>
|
||||
{record.status !== 'cancelled' && (
|
||||
<PermissionButton
|
||||
permission="bill:delete"
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => handleCancel(record.id)}
|
||||
>
|
||||
取消并冲正
|
||||
</PermissionButton>
|
||||
)}
|
||||
{Number(record.paidAmount || 0) === 0 && record.status !== 'cancelled' && (
|
||||
<Popconfirm
|
||||
title="确定归档此未支付账单?"
|
||||
onConfirm={() => handleArchive(record.id)}
|
||||
okText="归档"
|
||||
cancelText="取消"
|
||||
>
|
||||
<PermissionButton
|
||||
permission="bill:delete"
|
||||
size="small"
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
>
|
||||
归档
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[showDetail, handleArchive, handleCancel, handleExportPdf],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -297,8 +349,20 @@ const BillsPage: React.FC = () => {
|
||||
{ value: 'cancelled', label: '已取消' },
|
||||
]}
|
||||
/>
|
||||
<Select placeholder="费用类型" allowClear style={{ width: 120 }} value={filterExpenseType} onChange={setFilterExpenseType}
|
||||
options={[{value:'water',label:'水费'},{value:'electricity',label:'电费'},{value:'cleaning',label:'保洁费'},{value:'rent',label:'租金'},{value:'other',label:'其他'}]} />
|
||||
<Select
|
||||
placeholder="费用类型"
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
value={filterExpenseType}
|
||||
onChange={setFilterExpenseType}
|
||||
options={[
|
||||
{ value: 'water', label: '水费' },
|
||||
{ value: 'electricity', label: '电费' },
|
||||
{ value: 'cleaning', label: '保洁费' },
|
||||
{ value: 'rent', label: '租金' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={`确定归档选中的 ${selectedRows.length} 条账单?`}
|
||||
onConfirm={batchArchive}
|
||||
@@ -371,7 +435,9 @@ const BillsPage: React.FC = () => {
|
||||
picker="month"
|
||||
placeholder="选择月份"
|
||||
format="YYYY-MM"
|
||||
disabledDate={(current) => !!current && !current.endOf('month').isBefore(dayjs(), 'day')}
|
||||
disabledDate={(current) =>
|
||||
!!current && !current.endOf('month').isBefore(dayjs(), 'day')
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -412,9 +478,15 @@ const BillsPage: React.FC = () => {
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Descriptions bordered size="small" column={3} style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="已扣余额">¥{Number(detailModal.paidAmount || 0).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="待补缴">¥{Number(detailModal.outstandingAmount || 0).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="当前钱包余额">¥{Number(detailModal.walletBalance || 0).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="已扣余额">
|
||||
¥{Number(detailModal.paidAmount || 0).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="待补缴">
|
||||
¥{Number(detailModal.outstandingAmount || 0).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="当前钱包余额">
|
||||
¥{Number(detailModal.walletBalance || 0).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<h4>费用明细</h4>
|
||||
<Table
|
||||
|
||||
Reference in New Issue
Block a user