Initial commit: ERP system with advance verification fixes
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Card, Space, Tag, Modal, Form, Input, InputNumber, Select, DatePicker, message, Descriptions, Image, Divider } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, UndoOutlined, PlusCircleOutlined, MinusCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import FileUpload from '../../components/FileUpload';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface DetailItem {
|
||||
id?: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
category: string;
|
||||
attachments?: string[];
|
||||
}
|
||||
|
||||
const ReimbursementsPage: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const [reimbursements, setReimbursements] = useState<any[]>([]);
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [selectedRecord, setSelectedRecord] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [deleteForm] = Form.useForm();
|
||||
const [currentEditingStatus, setCurrentEditingStatus] = useState<string>('');
|
||||
const [detailItems, setDetailItems] = useState<DetailItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReimbursements();
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
const fetchReimbursements = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/reimbursements');
|
||||
const data = await res.json();
|
||||
if (data.success) setReimbursements(data.data);
|
||||
} catch (error) {
|
||||
message.error('获取报销列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/projects');
|
||||
const data = await res.json();
|
||||
if (data.success) setProjects(data.data);
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingId(null);
|
||||
setDetailItems([]);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
reimbursement_date: dayjs(),
|
||||
currency: 'CNY',
|
||||
expense_type: 'company',
|
||||
applicant: user?.name || user?.username || '当前用户',
|
||||
attachments: []
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: any) => {
|
||||
setEditingId(record.id);
|
||||
setCurrentEditingStatus(record.status);
|
||||
setDetailItems(record.detail_items || []);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
reimbursement_date: record.reimbursement_date ? dayjs(record.reimbursement_date) : null,
|
||||
attachments: record.attachments || []
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleView = (record: any) => {
|
||||
setSelectedRecord(record);
|
||||
setDetailModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
// 重置删除表单
|
||||
deleteForm.resetFields();
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: (
|
||||
<Form form={deleteForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="请输入密码确认删除"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password placeholder="输入密码" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
onOk: async () => {
|
||||
try {
|
||||
const values = await deleteForm.validateFields();
|
||||
// 这里可以添加密码验证逻辑,暂时直接删除
|
||||
await fetch('/api/reimbursements/' + id, { method: 'DELETE' });
|
||||
message.success('删除成功');
|
||||
fetchReimbursements();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleWithdraw = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认撤回',
|
||||
content: '撤回后可重新编辑提交,确认撤回吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await fetch('/api/reimbursements/' + id + '/withdraw', { method: 'POST' });
|
||||
message.success('已撤回,可重新编辑');
|
||||
fetchReimbursements();
|
||||
} catch (error) {
|
||||
message.error('撤回失败');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 保存操作:只保存信息,不改变状态
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
// 保存时使用编辑时的状态
|
||||
const saveStatus = currentEditingStatus || 'pending_edit';
|
||||
const data = {
|
||||
...values,
|
||||
reimbursement_date: values.reimbursement_date?.format('YYYY-MM-DD'),
|
||||
detail_items: detailItems,
|
||||
amount: detailItems.reduce((sum, item) => sum + (item.amount || 0), 0),
|
||||
applicant: user?.name || user?.username,
|
||||
status: saveStatus
|
||||
};
|
||||
const url = editingId ? '/api/reimbursements/' + editingId : '/api/reimbursements';
|
||||
const method = editingId ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
message.success(editingId ? '保存成功' : '创建成功');
|
||||
setModalVisible(false);
|
||||
fetchReimbursements();
|
||||
} else {
|
||||
message.error(result.error || '保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 提交操作:提交到待审批状态
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
// 提交时使用pending状态
|
||||
const saveStatus = 'pending';
|
||||
const data = {
|
||||
...values,
|
||||
reimbursement_date: values.reimbursement_date?.format('YYYY-MM-DD'),
|
||||
detail_items: detailItems,
|
||||
amount: detailItems.reduce((sum, item) => sum + (item.amount || 0), 0),
|
||||
applicant: user?.name || user?.username,
|
||||
status: saveStatus
|
||||
};
|
||||
const url = editingId ? '/api/reimbursements/' + editingId : '/api/reimbursements';
|
||||
const method = editingId ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
message.success(editingId ? '提交成功' : '创建成功');
|
||||
setModalVisible(false);
|
||||
fetchReimbursements();
|
||||
} else {
|
||||
message.error(result.error || '提交失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('提交失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitAndSubmit = async () => {
|
||||
await handleSubmit();
|
||||
};
|
||||
|
||||
const addDetailItem = () => {
|
||||
setDetailItems([...detailItems, { description: '', amount: 0, category: '', attachments: [] }]);
|
||||
};
|
||||
|
||||
const updateDetailItem = (index: number, field: keyof DetailItem, value: any) => {
|
||||
const newItems = [...detailItems];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
setDetailItems(newItems);
|
||||
};
|
||||
|
||||
const removeDetailItem = (index: number) => {
|
||||
setDetailItems(detailItems.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'processing', text: '待审批' },
|
||||
approved: { color: 'success', text: '已批准' },
|
||||
rejected: { color: 'error', text: '已退回' },
|
||||
withdrawn: { color: 'default', text: '已撤回' },
|
||||
paid: { color: 'blue', text: '已付款' },
|
||||
pending_edit: { color: 'warning', text: '待编辑' },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number, currency: string = 'CNY') => {
|
||||
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
|
||||
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
|
||||
<>
|
||||
<div>{formatAmount(v, r.currency)}</div>
|
||||
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}>≈ ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
|
||||
</>
|
||||
) },
|
||||
{ title: '报销日期', dataIndex: 'reimbursement_date', key: 'reimbursement_date', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
|
||||
{ title: '编号', dataIndex: 'reimbursement_code', key: 'reimbursement_code', width: 120 },
|
||||
{
|
||||
title: '操作', key: 'action', width: 250,
|
||||
render: (_: any, record: any) => (
|
||||
<Space wrap>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(record)}>详情</Button>
|
||||
{record.status === 'pending' && (
|
||||
<>
|
||||
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}>撤回</Button>
|
||||
</>
|
||||
)}
|
||||
{(record.status === 'rejected' || record.status === 'withdrawn' || record.status === 'pending_edit') && (
|
||||
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑重提</Button>
|
||||
)}
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>删除</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const currency = Form.useWatch('currency', form);
|
||||
const expenseType = Form.useWatch('expense_type', form);
|
||||
const totalAmount = detailItems.reduce((sum, item) => sum + (item.amount || 0), 0);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ marginBottom: 8 }}>报销申请</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>管理费用报销申请</p>
|
||||
</div>
|
||||
|
||||
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建报销</Button>}>
|
||||
<Table dataSource={reimbursements} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1100 }} />
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingId ? '编辑报销' : '新建报销'}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setModalVisible(false)}>取消</Button>,
|
||||
<Button key="save" onClick={handleSave}>保存</Button>,
|
||||
<Button key="submit" type="primary" onClick={handleSubmitAndSubmit}>提交</Button>
|
||||
]}
|
||||
width={900}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="applicant" label="申请人">
|
||||
<Input disabled style={{ color: 'rgba(0,0,0,0.85)', backgroundColor: '#f5f5f5' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="reimbursement_date" label="报销日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="currency" label="币种" rules={[{ required: true }]}>
|
||||
<Select style={{ width: 200 }}>
|
||||
<Option value="CNY">人民币 (CNY)</Option>
|
||||
<Option value="USD">美元 (USD)</Option>
|
||||
<Option value="LAK">老挝基普 (LAK)</Option>
|
||||
<Option value="THB">泰铢 (THB)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="expense_type" label="支出类型" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择支出类型" onChange={() => form.setFieldsValue({ project_id: undefined })}>
|
||||
<Option value="company">公司支出</Option>
|
||||
<Option value="project">项目支出</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{expenseType === 'project' && (
|
||||
<Form.Item name="project_id" label="选择项目" rules={[{ required: true, message: '请选择项目' }]}>
|
||||
<Select placeholder="选择项目" showSearch optionFilterProp="children">
|
||||
{projects.map((p: any) => <Option key={p.id} value={p.id}>{p.name}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item name="reason" label="事由" rules={[{ required: true }]}>
|
||||
<TextArea rows={2} placeholder="请输入报销事由" />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>报销明细</Divider>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button type="dashed" icon={<PlusCircleOutlined />} onClick={addDetailItem}>添加明细</Button>
|
||||
<span style={{ marginLeft: 16, color: '#888' }}>
|
||||
合计: {formatAmount(totalAmount, currency)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{detailItems.map((item, index) => (
|
||||
<Card key={index} size="small" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}>费用说明</label>
|
||||
<Input
|
||||
value={item.description}
|
||||
onChange={(e) => updateDetailItem(index, 'description', e.target.value)}
|
||||
placeholder="费用说明"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 180 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}>支出分类</label>
|
||||
<Select
|
||||
value={item.category}
|
||||
onChange={(v) => updateDetailItem(index, 'category', v)}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="选择支出分类"
|
||||
>
|
||||
{expenseType === 'project' ? (
|
||||
<>
|
||||
<Option value="accommodation">住宿</Option>
|
||||
<Option value="food">餐饮</Option>
|
||||
<Option value="fuel">加油</Option>
|
||||
<Option value="materials">零散材料</Option>
|
||||
<Option value="customer_relations">客户关系</Option>
|
||||
<Option value="subcontract_relations">分包关系</Option>
|
||||
<Option value="edl_relations">EDL关系</Option>
|
||||
<Option value="extra_construction">额外施工</Option>
|
||||
<Option value="other">其他</Option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Option value="general_operations">通用运营(房租/耗材)</Option>
|
||||
<Option value="transportation">交通通勤</Option>
|
||||
<Option value="business_expansion">业扩营销</Option>
|
||||
<Option value="power_system_relations">电力系统关系</Option>
|
||||
<Option value="employee_benefits">员工福利</Option>
|
||||
<Option value="express_logistics">快递物流</Option>
|
||||
<Option value="other">其他</Option>
|
||||
</>
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
<div style={{ width: 150 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}>金额</label>
|
||||
<InputNumber
|
||||
value={item.amount}
|
||||
onChange={(v) => updateDetailItem(index, 'amount', v)}
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="金额"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 2, minWidth: 300 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}>凭证附件</label>
|
||||
<FileUpload
|
||||
value={item.attachments || []}
|
||||
onChange={(urls) => updateDetailItem(index, 'attachments', urls)}
|
||||
maxCount={3}
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
<Button type="text" danger icon={<MinusCircleOutlined />} onClick={() => removeDetailItem(index)} style={{ marginTop: 24 }} />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Divider>主附件</Divider>
|
||||
<Form.Item name="attachments" label="整体凭证附件">
|
||||
<FileUpload
|
||||
value={form.getFieldValue('attachments')}
|
||||
onChange={(urls) => form.setFieldsValue({ attachments: urls })}
|
||||
maxCount={9}
|
||||
accept="image/*"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="报销详情" open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={null} width={900}>
|
||||
{selectedRecord && (
|
||||
<>
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="报销编号">{selectedRecord.reimbursement_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label="报销日期">{selectedRecord.reimbursement_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="币种">{selectedRecord.currency}</Descriptions.Item>
|
||||
<Descriptions.Item label="支出类型">{selectedRecord.expense_type === 'project' ? '项目支出' : '公司支出'}</Descriptions.Item>
|
||||
{selectedRecord.project_id && (
|
||||
<Descriptions.Item label="关联项目" span={2}>
|
||||
{projects.find(p => p.id === selectedRecord.project_id)?.name || '未知项目'}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="金额">
|
||||
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
|
||||
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>≈ ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{Array.isArray(selectedRecord.detail_items) && selectedRecord.detail_items.length > 0 && (
|
||||
<>
|
||||
<Divider>报销明细</Divider>
|
||||
<Table
|
||||
dataSource={selectedRecord.detail_items}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '费用说明', dataIndex: 'description', key: 'description' },
|
||||
{
|
||||
title: '支出分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
render: (v: string) => {
|
||||
const categoryMap: Record<string, string> = {
|
||||
// Project expense categories
|
||||
accommodation: '住宿',
|
||||
food: '餐饮',
|
||||
fuel: '加油',
|
||||
materials: '零散材料',
|
||||
customer_relations: '客户关系',
|
||||
subcontract_relations: '分包关系',
|
||||
edl_relations: 'EDL关系',
|
||||
extra_construction: '额外施工',
|
||||
// Company expense categories
|
||||
general_operations: '通用运营(房租/耗材)',
|
||||
transportation: '交通通勤',
|
||||
business_expansion: '业扩营销',
|
||||
power_system_relations: '电力系统关系',
|
||||
employee_benefits: '员工福利',
|
||||
express_logistics: '快递物流',
|
||||
other: '其他'
|
||||
};
|
||||
return categoryMap[v] || v;
|
||||
}
|
||||
},
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number) => formatAmount(v, selectedRecord.currency) },
|
||||
{ title: '附件', dataIndex: 'attachments', key: 'attachments', render: (v: string[]) => v?.length ? `${v.length}张` : '-' }
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{Array.isArray(selectedRecord.attachments) && selectedRecord.attachments.length > 0 && (
|
||||
<>
|
||||
<Divider>整体凭证附件</Divider>
|
||||
<Image.PreviewGroup>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{selectedRecord.attachments.map((url: string, index: number) => (
|
||||
<Image key={index} src={url} width={100} height={100} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReimbursementsPage;
|
||||
Reference in New Issue
Block a user