forked from wangziqi/gongxue-base
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
531 lines
17 KiB
TypeScript
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;
|