diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 70a01af..59c48e5 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -14,6 +14,7 @@ const RoomsPage = lazy(() => import('./pages/Rooms')); const OccupanciesPage = lazy(() => import('./pages/Occupancies')); const ExpensesPage = lazy(() => import('./pages/Expenses')); const BillsPage = lazy(() => import('./pages/Bills')); +const WalletsPage = lazy(() => import('./pages/Wallets')); const RoomVisualPage = lazy(() => import('./pages/RoomVisual')); const OperationLogsPage = lazy(() => import('./pages/OperationLogs')); const UsersPage = lazy(() => import('./pages/Users')); @@ -134,6 +135,14 @@ const App: React.FC = () => { } /> + + + + } + /> { // ---- Tab Components ---- +const ATTENDANCE_STATUS_MAP: Record = { + present: { text: '出勤', color: 'green' }, + late: { text: '迟到', color: 'orange' }, + absent: { text: '缺勤', color: 'red' }, + leave: { text: '请假', color: 'blue' }, + pending: { text: '待确认', color: 'default' }, +}; + +const SESSION_LABELS: Record = { + morning_reading: '早自习', + morning: '上午', + afternoon: '下午', + evening_study: '晚自习', + night_check: '晚寝', +}; + +const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => { + const columns: ColumnsType = [ + { title: '日期', dataIndex: 'attendanceDate', width: 120 }, + { title: '课程', render: (_: unknown, record) => record.schedule?.subject || record.class?.name || '课程考勤' }, + { title: '时段', dataIndex: 'session', width: 100, render: (value: string) => SESSION_LABELS[value] || value || '-' }, + { + title: '结果', dataIndex: 'status', width: 90, + render: (value: string) => { + const meta = ATTENDANCE_STATUS_MAP[value] || { text: value || '-', color: 'default' }; + return {meta.text}; + }, + }, + { title: '打卡时间', dataIndex: 'punchTime', width: 170, render: (value?: string | null) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-' }, + { + title: '打卡设备', + render: (_: unknown, record) => { + const name = record.punchDeviceName?.trim(); + const id = record.punchDeviceId?.trim(); + if (name && id && name !== id) return `${name}(${id})`; + return name || id || (record.source === 'manual' ? '老师手动标记' : '-'); + }, + }, + { title: '备注', dataIndex: 'remark', render: (value?: string | null) => value || '-' }, + ]; + + return data.length > 0 ? ( + + columns={columns} + dataSource={data} + rowKey="id" + scroll={{ x: 900 }} + pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50] }} + /> + ) : ; +}; + interface TabProps { studentId: number; onRefresh: () => void; @@ -779,7 +846,7 @@ const StudentProfileContent: React.FC = ({ const tabItems = useMemo(() => { if (!aggregateData) return []; - const { profile, enrollments, examScores, learningRecords, result, attachments } = aggregateData; + const { profile, enrollments, examScores, learningRecords, result, attachments, attendances } = aggregateData; return [ { key: 'profile', @@ -807,8 +874,8 @@ const StudentProfileContent: React.FC = ({ }, { key: 'attendance', - label: '出勤记录', - children: , + label: `出勤记录 (${attendances.length})`, + children: , }, { key: 'learning', diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index de557e1..c59a81e 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -51,6 +51,7 @@ const iconMap: Record = { expense: , bill: , deposit: , + wallet: , classroom: , rental: , organization: , diff --git a/apps/admin/src/pages/Bills/index.tsx b/apps/admin/src/pages/Bills/index.tsx index 0a45510..a8ab646 100644 --- a/apps/admin/src/pages/Bills/index.tsx +++ b/apps/admin/src/pages/Bills/index.tsx @@ -10,7 +10,6 @@ import { Popconfirm, Input, Select, - Tooltip, Spin, Empty, } from 'antd'; @@ -26,174 +25,23 @@ import PermissionButton from '../../components/PermissionButton'; import { downloadBlob } from '../../utils/download'; import { message } from '../../ui/app-message'; + const statusMap: Record = { - draft: { text: '草稿', color: 'default' }, + unpaid: { text: '待支付', color: 'orange' }, + partially_paid: { text: '部分支付', color: 'gold' }, paid: { text: '已支付', color: 'green' }, + cancelled: { text: '已取消', color: 'default' }, }; const typeMap: Record = { water: '水费', electricity: '电费', cleaning: '保洁费', - rent: '租金', damage: '损坏赔偿', penalty: '罚款', other: '其他', }; -const escapeHtml = (value: unknown) => - String(value ?? '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); - -const money = (value: unknown) => `¥${Number(value || 0).toFixed(2)}`; - -const buildBillPrintHtml = (bill: any) => { - const studentName = bill.student?.name || '-'; - const status = statusMap[bill.status]?.text || bill.status || '-'; - const generatedAt = bill.generatedAt - ? dayjs(bill.generatedAt).format('YYYY-MM-DD HH:mm') - : dayjs().format('YYYY-MM-DD HH:mm'); - const hasDeposit = Number(bill.availableDeposit || 0) > 0; - const depositDeducted = Number(bill.depositDeductedAmount || 0); - const items = bill.items || []; - - return ` - - - - 账单_${escapeHtml(studentName)}_${escapeHtml(bill.id)} - - - - -
-

恭学教育基地水电费账单

-
生成时间: ${escapeHtml(generatedAt)}
- -
-
学生姓名: ${escapeHtml(studentName)}
-
计费周期: ${escapeHtml(bill.periodStart)} ~ ${escapeHtml(bill.periodEnd)}
-
账单状态: ${escapeHtml(status)}
-
- -
-
费用汇总
-
-
分摊费用: ${escapeHtml(money(bill.sharedAmount))}
-
个人费用: ${escapeHtml(money(bill.personalAmount))}
-
应付总额: ${escapeHtml(money(bill.totalAmount))}
- ${ - hasDeposit || depositDeducted > 0 - ? `
可用押金: ${escapeHtml(money(bill.availableDeposit))}
- ${depositDeducted > 0 ? `
已扣押金: -${escapeHtml(money(depositDeducted))}
` : ''}` - : '' - } -
-
- -
-
费用明细
- - - - - - - - - - - - ${ - items.length - ? items - .map( - (item: any) => ` - - - - - - `, - ) - .join('') - : '' - } - -
费用类型说明天数总人天金额(元)
${escapeHtml(typeMap[item.expenseType] || item.expenseType || '-')}${escapeHtml(item.description || '-')}${escapeHtml(item.days || 0)}${escapeHtml(item.totalRoomDays || 0)}${escapeHtml(Number(item.studentAmount || 0).toFixed(2))}
暂无费用明细
-
- - -
- - -`; -}; - const BillsPage: React.FC = () => { const [bills, setBills] = useState([]); const [loading, setLoading] = useState(false); @@ -239,34 +87,19 @@ const BillsPage: React.FC = () => { }); }, [bills, searchText, filterStatus]); - const selectedBillRows = useMemo( - () => bills.filter((bill: any) => selectedRows.includes(bill.id)), - [bills, selectedRows], - ); - const canBatchConfirm = - selectedBillRows.length > 0 - && selectedBillRows.every((bill: any) => bill.status === 'draft' && bill.depositSufficient); - const canBatchDelete = - selectedBillRows.length > 0 && selectedBillRows.every((bill: any) => bill.status !== 'paid'); - const handleGenerate = async () => { - if (saving) return; - + setSaving(true); + const values = await generateForm.validateFields(); try { - const values = await generateForm.validateFields(); - setSaving(true); const res: any = await api.post('/bills/generate', { billingMonth: values.billingMonth.format('YYYY-MM'), }); message.success(res.message || '生成成功'); setGenerateModal(false); generateForm.resetFields(); - void fetchData(); + fetchData(); } catch (e: any) { - // Ant Design 的表单校验失败会 reject;字段本身已展示错误,无需再弹“生成失败”。 - if (!e?.errorFields) { - message.error(e?.message || '生成失败'); - } + message.error(e?.message || '生成失败'); } finally { setSaving(false); } @@ -284,43 +117,29 @@ const BillsPage: React.FC = () => { } }; - const updateStatus = async (id: number, status: string) => { - try { - await api.put(`/bills/${id}/status`, { status }); - message.success('账单已确认支付,押金已自动扣除'); - fetchData(); - if (detailModal?.id === id) { - void showDetail(id); - } - } catch (e: any) { - message.error(e?.message || '操作失败'); - } - }; - const batchUpdateStatus = async (status: string) => { - if (selectedRows.length === 0) return message.warning('请先选择账单'); - if (batchLoading) return; - setBatchLoading(true); - try { - await api.put('/bills/batch/status', { ids: selectedRows, status }); - message.success(`已确认支付 ${selectedRows.length} 条账单,并自动扣除押金`); - setSelectedRows([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); - } finally { - setBatchLoading(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`, { reason: reason.trim() }); + message.success('账单已取消,已扣余额已冲正退回'); + fetchData(); + }, + }); }; const handleDelete = async (id: number) => { try { await api.delete(`/bills/${id}`); - message.success('账单已删除'); + message.success('删除成功'); fetchData(); - } catch (e: any) { - message.error(e?.message || '删除失败'); - } + } catch (error: any) { message.error(error?.message || '删除失败'); } }; const batchDelete = async () => { @@ -346,24 +165,11 @@ const BillsPage: React.FC = () => { ); }; - const handleExportPdf = useCallback(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 (e: any) { - printWindow.close(); - message.error(e?.message || '账单数据加载失败'); - } - }, []); + const handleExportPdf = (billId: number) => { + downloadBlob(`/bills/export/pdf/${billId}`, `账单_${billId}.pdf`).catch(() => + message.error('导出失败'), + ); + }; const columns = useMemo(() => [ { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, @@ -390,26 +196,16 @@ const BillsPage: React.FC = () => { render: (v: number) => ¥{Number(v).toFixed(2)}, }, { - title: '可用押金', - dataIndex: 'availableDeposit', - width: 120, - render: (v: number) => - v > 0 ? ( - ¥{Number(v).toFixed(2)} - ) : ( - - - ), + title: '已扣余额', dataIndex: 'paidAmount', width: 110, + render: (value: number) => ¥{Number(value || 0).toFixed(2)}, }, { - title: '已扣押金', - dataIndex: 'depositDeductedAmount', - width: 120, - render: (v: number) => - Number(v || 0) > 0 ? ( - ¥{Number(v).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: '状态', @@ -436,25 +232,6 @@ const BillsPage: React.FC = () => { > 详情 - {record.status === 'draft' && ( - - updateStatus(record.id, 'paid')} - > - 确认支付 - - - )} { > PDF - handleDelete(record.id)} - okText="删除" - cancelText="取消" - > - } - > - 删除 + {record.status !== 'cancelled' && ( + handleCancel(record.id)}> + 取消并冲正 - + )} + {Number(record.paidAmount || 0) === 0 && record.status !== 'cancelled' && ( + handleDelete(record.id)} okText="删除" cancelText="取消"> + }>删除 + + )} ), }, - ], [showDetail, updateStatus, handleDelete, handleExportPdf]); + ], [showDetail, handleDelete, handleCancel, handleExportPdf]); return (
@@ -512,31 +283,25 @@ const BillsPage: React.FC = () => { value={filterStatus} onChange={(v) => setFilterStatus(v)} options={[ - { value: 'draft', label: '草稿' }, + { value: 'unpaid', label: '待支付' }, + { value: 'partially_paid', label: '部分支付' }, { value: 'paid', label: '已支付' }, + { value: 'cancelled', label: '已取消' }, ]} /> ({ value: student.id, label: `${student.name} (${student.studentNo || `#${student.id}`})` }))} /> + +