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 = { unpaid: { text: '待支付', color: 'orange' }, partially_paid: { text: '部分支付', color: 'gold' }, paid: { text: '已支付', color: 'green' }, cancelled: { text: '已取消', color: 'default' }, }; const typeMap: Record = { water: '水费', electricity: '电费', cleaning: '保洁费', damage: '损坏赔偿', penalty: '罚款', other: '其他', }; const BillsPage: React.FC = () => { const [bills, setBills] = useState([]); const [loading, setLoading] = useState(false); const [generateModal, setGenerateModal] = useState(false); const [detailModal, setDetailModal] = useState(null); const [selectedRows, setSelectedRows] = useState([]); const [searchText, setSearchText] = useState(''); const [filterStatus, setFilterStatus] = useState(undefined); const [filterExpenseType, setFilterExpenseType] = useState(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 = {}; 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: ( { 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( '

正在加载账单...

', ); try { const bill = await api.get(`/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) => ¥{Number(v).toFixed(2)}, }, { title: '已扣余额', dataIndex: 'paidAmount', width: 110, render: (value: number) => ( ¥{Number(value || 0).toFixed(2)} ), }, { title: '待补缴', dataIndex: 'outstandingAmount', width: 110, render: (value: number) => ( 0 ? '#cf1322' : '#389e0d' }}> ¥{Number(value || 0).toFixed(2)} ), }, { title: '钱包余额', dataIndex: 'walletBalance', width: 110, render: (value: number) => `¥${Number(value || 0).toFixed(2)}`, }, { title: '状态', dataIndex: 'status', width: 90, render: (s: string) => {statusMap[s]?.text}, }, { title: '生成时间', dataIndex: 'generatedAt', width: 160, render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), }, { title: '操作', width: 320, render: (_: any, record: any) => ( showDetail(record.id)} > 详情 } onClick={() => handleExportPdf(record.id)} > PDF {record.status !== 'cancelled' && ( handleCancel(record.id)} > 取消并冲正 )} {Number(record.paidAmount || 0) === 0 && record.status !== 'cancelled' && ( handleArchive(record.id)} okText="归档" cancelText="取消" > } > 归档 )} ), }, ], [showDetail, handleArchive, handleCancel, handleExportPdf], ); return (
setSearchText(v)} onChange={(e) => { if (!e.target.value) setSearchText(''); }} /> } > 批量归档 } onClick={() => { generateForm.resetFields(); setGenerateModal(true); }} > 生成账单 } onClick={handleExportExcel} > 导出Excel
`共 ${total} 条` }} locale={{ emptyText: }} rowSelection={{ selectedRowKeys: selectedRows, onChange: (keys) => setSelectedRows(keys as number[]), }} /> setGenerateModal(false)} okText="生成" confirmLoading={saving} >
!!current && !current.endOf('month').isBefore(dayjs(), 'day') } />
setDetailModal(null)} footer={null} width={800} > {detailModal && ( {detailModal.student?.name} {statusMap[detailModal.status]?.text} {detailModal.periodStart} ~ {detailModal.periodEnd} {dayjs(detailModal.generatedAt).format('YYYY-MM-DD HH:mm')} ¥{Number(detailModal.sharedAmount).toFixed(2)} ¥{Number(detailModal.personalAmount).toFixed(2)} ¥{Number(detailModal.totalAmount).toFixed(2)} ¥{Number(detailModal.paidAmount || 0).toFixed(2)} ¥{Number(detailModal.outstandingAmount || 0).toFixed(2)} ¥{Number(detailModal.walletBalance || 0).toFixed(2)}

费用明细

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) => ¥{Number(v).toFixed(2)}, }, ]} /> )} ); }; export default BillsPage;