feat: add student wallet utility billing
This commit is contained in:
@@ -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 = () => {
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wallets"
|
||||
element={
|
||||
<PermissionRoute permission="wallet:view">
|
||||
<WalletsPage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="bills"
|
||||
element={
|
||||
|
||||
@@ -73,6 +73,7 @@ const SECTIONS: MenuSection[] = [
|
||||
{ key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' },
|
||||
{ key: '/expenses', label: '费用管理', icon: 'expense', permission: 'expense:view' },
|
||||
{ key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' },
|
||||
{ key: '/wallets', label: '学生余额', icon: 'wallet', permission: 'wallet:view' },
|
||||
{ key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' },
|
||||
],
|
||||
},
|
||||
@@ -111,8 +112,9 @@ export function getRoleDomains(roles: readonly string[], permissions: readonly s
|
||||
normalized.add('academic');
|
||||
}
|
||||
if (
|
||||
permissions.includes('room:view') &&
|
||||
(permissions.includes('occupancy:view') || permissions.includes('expense:view'))
|
||||
(permissions.includes('room:view') &&
|
||||
(permissions.includes('occupancy:view') || permissions.includes('expense:view'))) ||
|
||||
permissions.includes('wallet:view')
|
||||
) {
|
||||
normalized.add('accommodation');
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ const iconMap: Record<string, React.ReactNode> = {
|
||||
expense: <DollarOutlined />,
|
||||
bill: <FileTextOutlined />,
|
||||
deposit: <WalletOutlined />,
|
||||
wallet: <WalletOutlined />,
|
||||
classroom: <ReadOutlined />,
|
||||
rental: <FileProtectOutlined />,
|
||||
organization: <TagsOutlined />,
|
||||
|
||||
@@ -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<string, { text: string; color: string }> = {
|
||||
draft: { text: '草稿', color: 'default' },
|
||||
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: '保洁费',
|
||||
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 `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>账单_${escapeHtml(studentName)}_${escapeHtml(bill.id)}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 0; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
color: #000;
|
||||
background: #f5f5f5;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", Arial, sans-serif;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.page {
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
margin: 0 auto;
|
||||
padding: 50px;
|
||||
background: #fff;
|
||||
}
|
||||
h1 { margin: 0; text-align: center; font-size: 20px; line-height: 1.35; font-weight: 700; }
|
||||
.generated-time { margin-top: 8px; text-align: center; color: #666; font-size: 10px; }
|
||||
.basic-info { margin-top: 24px; font-size: 12px; line-height: 1.8; }
|
||||
.section-title { margin: 16px 0 6px; font-size: 14px; font-weight: 700; text-decoration: underline; }
|
||||
.amount-summary { font-size: 12px; line-height: 1.75; }
|
||||
.total { color: #007aff; font-size: 14px; font-weight: 700; }
|
||||
.deposit { color: #52c41a; font-size: 11px; }
|
||||
.deposit-deducted { color: #fa8c16; font-size: 11px; }
|
||||
table { width: 100%; border-collapse: collapse; table-layout: fixed; margin-top: 8px; }
|
||||
th, td { padding: 5px 6px; border-bottom: 1px solid #ccc; font-size: 9px; line-height: 1.45; text-align: left; vertical-align: top; word-break: break-word; }
|
||||
th { color: #333; font-weight: 700; }
|
||||
td.amount, th.amount { text-align: right; white-space: nowrap; }
|
||||
.footer { margin-top: 34px; text-align: center; color: #999; font-size: 8px; }
|
||||
.print-actions {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
top: 18px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.print-actions button {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #1f6feb;
|
||||
border-radius: 4px;
|
||||
background: #1f6feb;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
.page { margin: 0; }
|
||||
.print-actions { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="print-actions">
|
||||
<button onclick="window.print()">打印 / 另存为 PDF</button>
|
||||
</div>
|
||||
<main class="page">
|
||||
<h1>恭学教育基地水电费账单</h1>
|
||||
<div class="generated-time">生成时间: ${escapeHtml(generatedAt)}</div>
|
||||
|
||||
<div class="basic-info">
|
||||
<div>学生姓名: ${escapeHtml(studentName)}</div>
|
||||
<div>计费周期: ${escapeHtml(bill.periodStart)} ~ ${escapeHtml(bill.periodEnd)}</div>
|
||||
<div>账单状态: ${escapeHtml(status)}</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div class="section-title">费用汇总</div>
|
||||
<div class="amount-summary">
|
||||
<div>分摊费用: ${escapeHtml(money(bill.sharedAmount))}</div>
|
||||
<div>个人费用: ${escapeHtml(money(bill.personalAmount))}</div>
|
||||
<div class="total">应付总额: ${escapeHtml(money(bill.totalAmount))}</div>
|
||||
${
|
||||
hasDeposit || depositDeducted > 0
|
||||
? `<div class="deposit">可用押金: ${escapeHtml(money(bill.availableDeposit))}</div>
|
||||
${depositDeducted > 0 ? `<div class="deposit-deducted">已扣押金: -${escapeHtml(money(depositDeducted))}</div>` : ''}`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-title">费用明细</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 22%;">费用类型</th>
|
||||
<th>说明</th>
|
||||
<th style="width: 11%;">天数</th>
|
||||
<th style="width: 12%;">总人天</th>
|
||||
<th class="amount" style="width: 16%;">金额(元)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${
|
||||
items.length
|
||||
? items
|
||||
.map(
|
||||
(item: any) => `<tr>
|
||||
<td>${escapeHtml(typeMap[item.expenseType] || item.expenseType || '-')}</td>
|
||||
<td>${escapeHtml(item.description || '-')}</td>
|
||||
<td>${escapeHtml(item.days || 0)}</td>
|
||||
<td>${escapeHtml(item.totalRoomDays || 0)}</td>
|
||||
<td class="amount">${escapeHtml(Number(item.studentAmount || 0).toFixed(2))}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')
|
||||
: '<tr><td colspan="5" style="text-align:center; color:#999;">暂无费用明细</td></tr>'
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<div class="footer">
|
||||
本账单由恭学教育基地管理系统自动生成
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => window.print(), 250);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
const BillsPage: React.FC = () => {
|
||||
const [bills, setBills] = useState<any[]>([]);
|
||||
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: <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`, { 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('<!doctype html><title>账单加载中</title><body>账单加载中...</body>');
|
||||
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) => <strong>¥{Number(v).toFixed(2)}</strong>,
|
||||
},
|
||||
{
|
||||
title: '可用押金',
|
||||
dataIndex: 'availableDeposit',
|
||||
width: 120,
|
||||
render: (v: number) =>
|
||||
v > 0 ? (
|
||||
<span style={{ color: '#52c41a' }}>¥{Number(v).toFixed(2)}</span>
|
||||
) : (
|
||||
<span style={{ color: '#999' }}>-</span>
|
||||
),
|
||||
title: '已扣余额', dataIndex: 'paidAmount', width: 110,
|
||||
render: (value: number) => <span style={{ color: '#389e0d' }}>¥{Number(value || 0).toFixed(2)}</span>,
|
||||
},
|
||||
{
|
||||
title: '已扣押金',
|
||||
dataIndex: 'depositDeductedAmount',
|
||||
width: 120,
|
||||
render: (v: number) =>
|
||||
Number(v || 0) > 0 ? (
|
||||
<span style={{ color: '#fa8c16' }}>¥{Number(v).toFixed(2)}</span>
|
||||
) : (
|
||||
<span style={{ color: '#999' }}>-</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: '状态',
|
||||
@@ -436,25 +232,6 @@ const BillsPage: React.FC = () => {
|
||||
>
|
||||
详情
|
||||
</PermissionButton>
|
||||
{record.status === 'draft' && (
|
||||
<Tooltip
|
||||
title={
|
||||
record.depositSufficient
|
||||
? '确认后将自动从该学生押金余额中扣除账单金额'
|
||||
: '押金不足,请先到押金管理收取押金'
|
||||
}
|
||||
>
|
||||
<PermissionButton
|
||||
permission="bill:confirm"
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={!record.depositSufficient}
|
||||
onClick={() => updateStatus(record.id, 'paid')}
|
||||
>
|
||||
确认支付
|
||||
</PermissionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<PermissionButton
|
||||
permission="bill:export-pdf"
|
||||
size="small"
|
||||
@@ -463,26 +240,20 @@ const BillsPage: React.FC = () => {
|
||||
>
|
||||
PDF
|
||||
</PermissionButton>
|
||||
<Popconfirm
|
||||
title="确定删除此账单?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<PermissionButton
|
||||
permission="bill:delete"
|
||||
size="small"
|
||||
danger
|
||||
disabled={record.status === 'paid'}
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
{record.status !== 'cancelled' && (
|
||||
<PermissionButton permission="bill:delete" size="small" danger onClick={() => handleCancel(record.id)}>
|
||||
取消并冲正
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{Number(record.paidAmount || 0) === 0 && record.status !== 'cancelled' && (
|
||||
<Popconfirm title="确定删除此未支付账单?" onConfirm={() => handleDelete(record.id)} okText="删除" cancelText="取消">
|
||||
<PermissionButton permission="bill:delete" size="small" danger icon={<DeleteOutlined />}>删除</PermissionButton>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
], [showDetail, updateStatus, handleDelete, handleExportPdf]);
|
||||
], [showDetail, handleDelete, handleCancel, handleExportPdf]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -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: '已取消' },
|
||||
]}
|
||||
/>
|
||||
<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:'其他'}]} />
|
||||
<PermissionButton
|
||||
permission="bill:confirm"
|
||||
type="primary"
|
||||
onClick={() => batchUpdateStatus('paid')}
|
||||
disabled={!canBatchConfirm}
|
||||
>
|
||||
批量确认支付
|
||||
</PermissionButton>
|
||||
<Popconfirm
|
||||
title={`确定删除选中的 ${selectedRows.length} 条账单?`}
|
||||
onConfirm={batchDelete}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
disabled={!canBatchDelete}
|
||||
disabled={selectedRows.length === 0}
|
||||
>
|
||||
<PermissionButton
|
||||
permission="bill:delete"
|
||||
danger
|
||||
disabled={!canBatchDelete}
|
||||
disabled={selectedRows.length === 0}
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
批量删除
|
||||
@@ -570,12 +335,7 @@ const BillsPage: React.FC = () => {
|
||||
dataSource={filteredBills}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedRows,
|
||||
@@ -603,9 +363,7 @@ 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>
|
||||
@@ -645,37 +403,11 @@ const BillsPage: React.FC = () => {
|
||||
</strong>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{(Number(detailModal.availableDeposit || 0) > 0
|
||||
|| Number(detailModal.depositDeductedAmount || 0) > 0
|
||||
|| detailModal.status === 'draft') && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
background: detailModal.depositSufficient || detailModal.status === 'paid' ? '#f6ffed' : '#fff2f0',
|
||||
border: `1px solid ${detailModal.depositSufficient || detailModal.status === 'paid' ? '#b7eb8f' : '#ffccc7'}`,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>
|
||||
押金联动(确认支付时实时扣除,不在生成账单时冻结)
|
||||
</div>
|
||||
<Space size={24} wrap>
|
||||
<span>
|
||||
当前可用押金:
|
||||
<strong style={{ color: '#52c41a' }}>
|
||||
¥{Number(detailModal.availableDeposit).toFixed(2)}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
已扣押金:
|
||||
<strong style={{ color: '#fa8c16' }}>
|
||||
-¥{Number(detailModal.depositDeductedAmount || 0).toFixed(2)}
|
||||
</strong>
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
<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 }}
|
||||
|
||||
@@ -46,10 +46,12 @@ const ExpensesPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [roomModal, setRoomModal] = useState(false);
|
||||
const [personalModal, setPersonalModal] = useState(false);
|
||||
const [utilityModal, setUtilityModal] = useState(false);
|
||||
const [editingRoom, setEditingRoom] = useState<any>(null);
|
||||
const [editingPersonal, setEditingPersonal] = useState<any>(null);
|
||||
const [roomForm] = Form.useForm();
|
||||
const [personalForm] = Form.useForm();
|
||||
const [utilityForm] = Form.useForm();
|
||||
const [roomSearch, setRoomSearch] = useState('');
|
||||
const [roomTypeFilter, setRoomTypeFilter] = useState<string | undefined>(undefined);
|
||||
const [personalSearch, setPersonalSearch] = useState('');
|
||||
@@ -192,6 +194,27 @@ const ExpensesPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStudentUtility = async () => {
|
||||
const values = await utilityForm.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
const result: any = await api.post('/expenses/student-utility', {
|
||||
studentId: values.studentId,
|
||||
expenseType: values.expenseType,
|
||||
amount: values.amount,
|
||||
periodStart: values.period[0].format('YYYY-MM-DD'),
|
||||
periodEnd: values.period[1].format('YYYY-MM-DD'),
|
||||
description: values.description,
|
||||
});
|
||||
const bill = result.bill;
|
||||
message.success(`账单已生成,已从余额扣除 ¥${Number(bill.paidAmount || 0).toFixed(2)},待补缴 ¥${Number(bill.outstandingAmount || 0).toFixed(2)}`);
|
||||
setUtilityModal(false);
|
||||
utilityForm.resetFields();
|
||||
fetchData();
|
||||
} catch (e: any) { message.error(e?.message || '水电费出账失败'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handlePersonalExpense = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -560,6 +583,13 @@ const ExpensesPage: React.FC = () => {
|
||||
批量删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
<PermissionButton
|
||||
permission="expense:create"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => { utilityForm.resetFields(); setUtilityModal(true); }}
|
||||
>
|
||||
添加学生水电费
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="expense:create"
|
||||
type="primary"
|
||||
@@ -639,6 +669,19 @@ const ExpensesPage: React.FC = () => {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
|
||||
<Modal title="添加学生水电费并立即出账" open={utilityModal} onOk={handleStudentUtility} onCancel={() => setUtilityModal(false)} okText="生成账单并扣余额" confirmLoading={saving}>
|
||||
<Form form={utilityForm} layout="vertical">
|
||||
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
|
||||
<Select showSearch optionFilterProp="label" options={students.map((student: any) => ({ value: student.id, label: `${student.name} (${student.studentNo || `#${student.id}`})` }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}><Select options={[{ value: 'water', label: '水费' }, { value: 'electricity', label: '电费' }]} /></Form.Item>
|
||||
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}><InputNumber min={0.01} precision={2} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="period" label="账单周期" rules={[{ required: true }]}><RangePicker style={{ width: '100%' }} format="YYYY-MM-DD" /></Form.Item>
|
||||
<Form.Item name="description" label="说明"><Input.TextArea rows={2} maxLength={300} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={editingPersonal ? '编辑个人费用' : '录入个人附加费'}
|
||||
open={personalModal}
|
||||
|
||||
102
apps/admin/src/pages/Wallets/index.tsx
Normal file
102
apps/admin/src/pages/Wallets/index.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Drawer, Form, Input, InputNumber, Modal, Radio, Space, Switch, Table, Tag } from 'antd';
|
||||
import { HistoryOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
interface WalletRow {
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
studentNo?: string;
|
||||
balance: number;
|
||||
outstandingAmount: number;
|
||||
}
|
||||
|
||||
const transactionNames: Record<string, string> = {
|
||||
recharge: '充值', adjustment: '调账', bill_payment: '账单扣款', bill_refund: '账单冲正',
|
||||
};
|
||||
|
||||
const WalletsPage: React.FC = () => {
|
||||
const [rows, setRows] = useState<WalletRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [debtOnly, setDebtOnly] = useState(false);
|
||||
const [selected, setSelected] = useState<WalletRow | null>(null);
|
||||
const [transactions, setTransactions] = useState<any[]>([]);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchRows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.get('/wallets', { params: { keyword: keyword || undefined, debtOnly } });
|
||||
setRows(data as WalletRow[]);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载学生余额失败');
|
||||
} finally { setLoading(false); }
|
||||
}, [keyword, debtOnly]);
|
||||
|
||||
useEffect(() => { void fetchRows(); }, [fetchRows]);
|
||||
|
||||
const openChange = (row: WalletRow) => {
|
||||
setSelected(row);
|
||||
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
|
||||
};
|
||||
|
||||
const submitChange = async () => {
|
||||
if (!selected) return;
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
const result: any = await api.post('/wallets/change-balance', { studentId: selected.studentId, ...values });
|
||||
const paid = (result.payments || []).reduce((sum: number, bill: any) => sum + Number(bill.paidAmount || 0), 0);
|
||||
message.success(paid > 0 ? `余额已更新,并自动补扣历史账单` : '余额已更新');
|
||||
setSelected(null);
|
||||
await fetchRows();
|
||||
} catch (error: any) { message.error(error?.message || '余额操作失败'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const showTransactions = async (row: WalletRow) => {
|
||||
setSelected(row); setDrawerOpen(true);
|
||||
try { setTransactions(await api.get('/wallets/transactions', { params: { studentId: row.studentId } }) as any[]); }
|
||||
catch (error: any) { message.error(error?.message || '加载流水失败'); }
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ title: '学生', render: (_: unknown, row: WalletRow) => <><strong>{row.studentName}</strong><div style={{ color: '#999' }}>{row.studentNo || `#${row.studentId}`}</div></> },
|
||||
{ title: '可用余额', dataIndex: 'balance', render: (value: number) => <strong style={{ color: Number(value) > 0 ? '#1677ff' : undefined }}>¥{Number(value).toFixed(2)}</strong> },
|
||||
{ title: '未付账单', dataIndex: 'outstandingAmount', render: (value: number) => Number(value) > 0 ? <Tag color="red">¥{Number(value).toFixed(2)}</Tag> : <Tag color="green">无欠费</Tag> },
|
||||
{ title: '操作', render: (_: unknown, row: WalletRow) => <Space><PermissionButton permission="wallet:edit" type="primary" size="small" icon={<PlusOutlined />} onClick={() => openChange(row)}>充值/调账</PermissionButton><Button size="small" icon={<HistoryOutlined />} onClick={() => showTransactions(row)}>流水</Button></Space> },
|
||||
], []);
|
||||
|
||||
return <div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<Space wrap><Input.Search allowClear placeholder="搜索姓名或学号" style={{ width: 240 }} onSearch={setKeyword} onChange={(event) => !event.target.value && setKeyword('')} /><span>仅看欠费</span><Switch checked={debtOnly} onChange={setDebtOnly} /></Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchRows}>刷新</Button>
|
||||
</div>
|
||||
<Table rowKey="studentId" loading={loading} dataSource={rows} columns={columns} pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 人` }} />
|
||||
<Modal title={`${selected?.studentName || ''} - 余额操作`} open={!!selected && !drawerOpen} onCancel={() => setSelected(null)} onOk={submitChange} confirmLoading={saving} okText="确认">
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="type" label="操作类型" rules={[{ required: true }]}><Radio.Group options={[{ label: '充值', value: 'recharge' }, { label: '调账', value: 'adjustment' }]} /></Form.Item>
|
||||
<Form.Item name="amount" label="变动金额" extra="充值填正数;调减余额时填写负数。充值后会按最早账单优先自动补扣。" rules={[{ required: true, message: '请输入金额' }]}><InputNumber precision={2} style={{ width: '100%' }} addonBefore="¥" /></Form.Item>
|
||||
<Form.Item name="description" label="备注"><Input.TextArea maxLength={300} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<Drawer title={`${selected?.studentName || ''} - 余额流水`} width={680} open={drawerOpen} onClose={() => { setDrawerOpen(false); setSelected(null); }}>
|
||||
<Table rowKey="id" dataSource={transactions} pagination={{ pageSize: 10 }} columns={[
|
||||
{ title: '时间', dataIndex: 'createdAt', render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm') },
|
||||
{ title: '类型', dataIndex: 'type', render: (value: string) => transactionNames[value] || value },
|
||||
{ title: '金额', dataIndex: 'amount', render: (value: number) => <span style={{ color: Number(value) >= 0 ? '#389e0d' : '#cf1322' }}>{Number(value) >= 0 ? '+' : ''}¥{Number(value).toFixed(2)}</span> },
|
||||
{ title: '变动后余额', dataIndex: 'balanceAfter', render: (value: number) => `¥${Number(value).toFixed(2)}` },
|
||||
{ title: '关联账单', dataIndex: 'billId', render: (value: number) => value ? `#${value}` : '-' },
|
||||
{ title: '说明', dataIndex: 'description' },
|
||||
]} />
|
||||
</Drawer>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default WalletsPage;
|
||||
Reference in New Issue
Block a user