Files
gongxue-base/apps/admin/src/pages/Bills/index.tsx

532 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState, useMemo } from 'react';
import {
Table,
Button,
Modal,
Form,
DatePicker,
Space,
message,
Tag,
Descriptions,
Popconfirm,
Input,
Select,
Tooltip,
} from 'antd';
import {
FileTextOutlined,
DeleteOutlined,
DownloadOutlined,
FilePdfOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
const { RangePicker } = DatePicker;
const statusMap: Record<string, { text: string; color: string }> = {
draft: { text: '草稿', color: 'default' },
confirmed: { text: '已确认', color: 'blue' },
paid: { text: '已支付', color: 'green' },
};
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 [generateForm] = Form.useForm();
const fetchData = async () => {
setLoading(true);
try {
const res: any = await api.get('/bills');
setBills(res);
} catch (e) {
console.error(e);
}
setLoading(false);
};
useEffect(() => {
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 () => {
const values = await generateForm.validateFields();
try {
const res: any = await api.post('/bills/generate', {
periodStart: values.period[0].format('YYYY-MM-DD'),
periodEnd: values.period[1].format('YYYY-MM-DD'),
});
message.success(res.message || '生成成功');
setGenerateModal(false);
generateForm.resetFields();
fetchData();
} catch (e: any) {
message.error(e?.message || '生成失败');
}
};
const showDetail = async (id: number) => {
try {
const res = await api.get(`/bills/${id}`);
setDetailModal(res);
} catch (e) {
console.error(e);
}
};
const updateStatus = async (id: number, status: string) => {
try {
await api.put(`/bills/${id}/status`, { status });
message.success('状态更新成功');
fetchData();
if (detailModal?.id === id) {
setDetailModal({ ...detailModal, status });
}
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const batchUpdateStatus = async (status: string) => {
if (selectedRows.length === 0) return message.warning('请先选择账单');
try {
await api.put('/bills/batch/status', { ids: selectedRows, status });
message.success(`已批量更新 ${selectedRows.length} 条账单`);
setSelectedRows([]);
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/bills/${id}`);
message.success('账单已删除');
fetchData();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const batchDelete = async () => {
if (selectedRows.length === 0) return message.warning('请先选择账单');
try {
await api.post('/bills/batch/delete', { ids: selectedRows });
message.success(`已删除 ${selectedRows.length} 条账单`);
setSelectedRows([]);
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleExportExcel = () => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
const url = `${baseURL}/bills/export/excel`;
const a = document.createElement('a');
// 使用 fetch 来携带 token
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then((res) => res.blob())
.then((blob) => {
const blobUrl = URL.createObjectURL(blob);
a.href = blobUrl;
a.download = `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
a.click();
URL.revokeObjectURL(blobUrl);
message.success('Excel 导出成功');
})
.catch(() => message.error('导出失败'));
};
const handleExportPdf = (billId: number) => {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/bills/export/pdf/${billId}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `账单_${billId}.pdf`;
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('导出失败'));
};
const columns = [
{ title: '学生', render: (_: any, r: any) => r.student?.name || '-' },
{ title: '账单周期', render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}` },
{
title: '分摊费用',
dataIndex: 'sharedAmount',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '个人费用',
dataIndex: 'personalAmount',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '总计',
dataIndex: 'totalAmount',
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
},
{
title: '可用押金',
dataIndex: 'availableDeposit',
render: (v: number) =>
v > 0 ? (
<span style={{ color: '#52c41a' }}>¥{Number(v).toFixed(2)}</span>
) : (
<span style={{ color: '#999' }}>-</span>
),
},
{
title: '抵扣后应付',
dataIndex: 'amountAfterDeposit',
render: (v: number, r: any) => {
const has = Number(r.availableDeposit || 0) > 0;
if (!has) return <span style={{ color: '#999' }}>-</span>;
const after = Number(v ?? r.totalAmount).toFixed(2);
const applied = Number(r.depositApplied || 0).toFixed(2);
return (
<Tooltip title={`已抵扣押金 ¥${applied}`}>
<strong style={{ color: '#fa541c' }}>¥{after}</strong>
</Tooltip>
);
},
},
{
title: '状态',
dataIndex: 'status',
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text}</Tag>,
},
{
title: '生成时间',
dataIndex: 'generatedAt',
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>
{record.status === 'draft' && (
<PermissionButton
permission="bill:confirm"
size="small"
onClick={() => updateStatus(record.id, 'confirmed')}
>
</PermissionButton>
)}
{record.status === 'confirmed' && (
<PermissionButton
permission="bill:confirm"
size="small"
type="primary"
onClick={() => updateStatus(record.id, 'paid')}
>
</PermissionButton>
)}
<PermissionButton
permission="bill:export-pdf"
size="small"
icon={<FilePdfOutlined />}
onClick={() => handleExportPdf(record.id)}
>
PDF
</PermissionButton>
<PermissionButton permission="bill:delete">
<Popconfirm
title="确定删除此账单?"
onConfirm={() => handleDelete(record.id)}
okText="删除"
cancelText="取消"
>
<Button size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</PermissionButton>
</Space>
),
},
];
return (
<div>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<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: 'draft', label: '草稿' },
{ value: 'confirmed', label: '已确认' },
{ value: 'paid', label: '已支付' },
]}
/>
<PermissionButton
permission="bill:confirm"
onClick={() => batchUpdateStatus('confirmed')}
disabled={selectedRows.length === 0}
>
</PermissionButton>
<PermissionButton
permission="bill:confirm"
type="primary"
onClick={() => batchUpdateStatus('paid')}
disabled={selectedRows.length === 0}
>
</PermissionButton>
<PermissionButton permission="bill:delete">
<Popconfirm
title={`确定删除选中的 ${selectedRows.length} 条账单?`}
onConfirm={batchDelete}
okText="删除"
cancelText="取消"
disabled={selectedRows.length === 0}
>
<Button danger disabled={selectedRows.length === 0} icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</PermissionButton>
</Space>
<Space>
<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
columns={columns}
dataSource={filteredBills}
rowKey="id"
loading={loading}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
rowSelection={{
selectedRowKeys: selectedRows,
onChange: (keys) => setSelectedRows(keys as number[]),
}}
/>
<Modal
title="生成账单"
open={generateModal}
onOk={handleGenerate}
onCancel={() => setGenerateModal(false)}
okText="生成"
>
<Form form={generateForm} layout="vertical">
<Form.Item
name="period"
label="账单周期"
rules={[{ required: true, message: '请选择账单周期' }]}
extra="选择费用对应的时间段,系统将自动计算每个学生的分摊费用"
>
<RangePicker
style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']}
format="YYYY-MM-DD"
/>
</Form.Item>
</Form>
</Modal>
<Modal
title="账单详情"
open={!!detailModal}
onCancel={() => setDetailModal(null)}
footer={null}
width={800}
>
{detailModal && (
<>
<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>
{Number(detailModal.availableDeposit || 0) > 0 && (
<div
style={{
marginBottom: 16,
padding: 12,
background: '#f6ffed',
border: '1px solid #b7eb8f',
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.depositApplied || 0).toFixed(2)}
</strong>
</span>
<span>
<strong style={{ color: '#fa541c', fontSize: 16 }}>
¥
{Number(detailModal.amountAfterDeposit ?? detailModal.totalAmount).toFixed(2)}
</strong>
</span>
</Space>
</div>
)}
<h4></h4>
<Table
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>,
},
]}
/>
</>
)}
</Modal>
</div>
);
};
export default BillsPage;