Files
gongxue-base/apps/admin/src/pages/Bills/index.tsx
wangziqi 375c7ec60b 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
2026-07-18 12:54:10 +00:00

531 lines
17 KiB
TypeScript

import React, { useEffect, useState, useMemo, useCallback } from 'react';
import {
Table,
Modal,
Form,
DatePicker,
Space,
Tag,
Descriptions,
Popconfirm,
Input,
Select,
Spin,
Empty,
} from 'antd';
import {
FileTextOutlined,
InboxOutlined,
DownloadOutlined,
FilePdfOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { downloadBlob } from '../../utils/download';
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' },
paid: { text: '已支付', color: 'green' },
cancelled: { text: '已取消', color: 'default' },
};
const typeMap: Record<string, string> = {
water: '水费',
electricity: '电费',
cleaning: '保洁费',
damage: '损坏赔偿',
penalty: '罚款',
other: '其他',
};
const BillsPage: React.FC = () => {
const [bills, setBills] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [generateModal, setGenerateModal] = useState(false);
const [detailModal, setDetailModal] = useState<any>(null);
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [filterExpenseType, setFilterExpenseType] = useState<string | undefined>(undefined);
const [generateForm] = Form.useForm();
const [saving, setSaving] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [batchLoading, setBatchLoading] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
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[];
setBills(res);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
}
setLoading(false);
}, [filterStatus, filterExpenseType]);
useEffect(() => {
fetchData();
}, [fetchData]);
const filteredBills = useMemo(() => {
return bills.filter((b: any) => {
if (searchText) {
const s = searchText.toLowerCase();
const matchName = b.student?.name?.toLowerCase().includes(s);
const matchPeriod = `${b.periodStart} ~ ${b.periodEnd}`.includes(s);
if (!matchName && !matchPeriod) return false;
}
if (filterStatus && b.status !== filterStatus) return false;
return true;
});
}, [bills, searchText, filterStatus]);
const handleGenerate = async () => {
setSaving(true);
const values = await generateForm.validateFields();
try {
const res: any = await api.post('/bills/generate', {
operationId: newOperationId(),
billingMonth: values.billingMonth.format('YYYY-MM'),
});
message.success(res.message || '生成成功');
setGenerateModal(false);
generateForm.resetFields();
fetchData();
} catch (e: any) {
message.error(e?.message || '生成失败');
} finally {
setSaving(false);
}
};
const showDetail = async (id: number) => {
setDetailLoading(true);
try {
const res = await api.get(`/bills/${id}`);
setDetailModal(res);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
} finally {
setDetailLoading(false);
}
};
const handleCancel = async (id: number) => {
let reason = '';
Modal.confirm({
title: '取消账单并退回已扣余额',
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(),
});
message.success('账单已取消,已扣余额已冲正退回');
fetchData();
},
});
};
const handleArchive = async (id: number) => {
try {
await api.delete(`/bills/${id}`);
message.success('账单已归档');
fetchData();
} catch (error: any) {
message.error(error?.message || '归档失败');
}
};
const batchArchive = async () => {
if (selectedRows.length === 0) return message.warning('请先选择账单');
if (batchLoading) return;
setBatchLoading(true);
try {
await api.post('/bills/batch/delete', { ids: selectedRows });
message.success(`已归档 ${selectedRows.length} 条账单`);
setSelectedRows([]);
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setBatchLoading(false);
}
};
const handleExportExcel = () => {
downloadBlob('/bills/export/excel', `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`).then(
() => message.success('Excel 导出成功'),
() => message.error('导出失败'),
);
};
const handleExportPdf = async (billId: number) => {
const printWindow = window.open('', '_blank');
if (!printWindow) {
message.error('浏览器阻止了打印窗口,请允许弹出窗口后重试');
return;
}
printWindow.document.write(
'<p style="font-family:sans-serif;padding:24px">正在加载账单...</p>',
);
try {
const bill = await api.get<BillPrintData>(`/bills/${billId}`);
printWindow.document.open();
printWindow.document.write(buildBillPrintHtml(bill));
printWindow.document.close();
} catch (error: any) {
printWindow.close();
message.error(error?.message || '账单加载失败');
}
};
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)}
>
</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>
<div className="responsive-toolbar">
<Space wrap className="responsive-toolbar__group">
<Input.Search
placeholder="搜索学生姓名或账单周期"
allowClear
style={{ width: 220 }}
onSearch={(v) => setSearchText(v)}
onChange={(e) => {
if (!e.target.value) setSearchText('');
}}
/>
<Select
placeholder="状态筛选"
allowClear
style={{ width: 120 }}
value={filterStatus}
onChange={(v) => setFilterStatus(v)}
options={[
{ value: 'unpaid', label: '待支付' },
{ value: 'partially_paid', label: '部分支付' },
{ value: 'paid', label: '已支付' },
{ 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: '其他' },
]}
/>
<Popconfirm
title={`确定归档选中的 ${selectedRows.length} 条账单?`}
onConfirm={batchArchive}
okText="归档"
cancelText="取消"
disabled={selectedRows.length === 0}
>
<PermissionButton
permission="bill:delete"
danger
disabled={selectedRows.length === 0}
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
</Space>
<Space wrap className="responsive-toolbar__group">
<PermissionButton
permission="bill:generate"
type="primary"
icon={<FileTextOutlined />}
onClick={() => {
generateForm.resetFields();
setGenerateModal(true);
}}
>
</PermissionButton>
<PermissionButton
permission="bill:export-excel"
icon={<DownloadOutlined />}
onClick={handleExportExcel}
>
Excel
</PermissionButton>
</Space>
</div>
<Table
scroll={{ x: 1400 }}
columns={columns}
dataSource={filteredBills}
rowKey="id"
loading={loading}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
locale={{ emptyText: <Empty description="暂无数据" /> }}
rowSelection={{
selectedRowKeys: selectedRows,
onChange: (keys) => setSelectedRows(keys as number[]),
}}
/>
<Modal
title="生成账单"
open={generateModal}
onOk={handleGenerate}
onCancel={() => setGenerateModal(false)}
okText="生成"
confirmLoading={saving}
>
<Form form={generateForm} layout="vertical">
<Form.Item
name="billingMonth"
label="账单月份"
rules={[{ required: true, message: '请选择账单月份' }]}
extra="只能选择已结束月份,每个月只能生成一次账单"
>
<DatePicker
style={{ width: '100%' }}
picker="month"
placeholder="选择月份"
format="YYYY-MM"
disabledDate={(current) =>
!!current && !current.endOf('month').isBefore(dayjs(), 'day')
}
/>
</Form.Item>
</Form>
</Modal>
<Modal
title="账单详情"
open={!!detailModal}
onCancel={() => setDetailModal(null)}
footer={null}
width={800}
>
{detailModal && (
<Spin spinning={detailLoading}>
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}>
<Descriptions.Item label="学生">{detailModal.student?.name}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={statusMap[detailModal.status]?.color}>
{statusMap[detailModal.status]?.text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="账单周期">
{detailModal.periodStart} ~ {detailModal.periodEnd}
</Descriptions.Item>
<Descriptions.Item label="生成时间">
{dayjs(detailModal.generatedAt).format('YYYY-MM-DD HH:mm')}
</Descriptions.Item>
<Descriptions.Item label="分摊费用">
¥{Number(detailModal.sharedAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="个人费用">
¥{Number(detailModal.personalAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="合计" span={2}>
<strong style={{ fontSize: 18, color: '#007AFF' }}>
¥{Number(detailModal.totalAmount).toFixed(2)}
</strong>
</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>
<h4></h4>
<Table
scroll={{ x: 700 }}
dataSource={detailModal.items || []}
rowKey="id"
pagination={false}
size="small"
columns={[
{ title: '类型', dataIndex: 'expenseType', render: (v: string) => typeMap[v] || v },
{ title: '说明', dataIndex: 'description' },
{
title: '计费天数',
dataIndex: 'days',
render: (v: number) => (v > 0 ? `${v}` : '-'),
},
{
title: '宿舍总人天',
dataIndex: 'totalRoomDays',
render: (v: number) => (v > 0 ? `${v}` : '-'),
},
{
title: '宿舍总费用',
dataIndex: 'roomTotalAmount',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '应分摊',
dataIndex: 'studentAmount',
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
},
]}
/>
</Spin>
)}
</Modal>
</div>
);
};
export default BillsPage;