438 lines
18 KiB
TypeScript
438 lines
18 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Table, Button, Card, Space, Tag, Modal, Form, Input, InputNumber, Select, DatePicker, message, Descriptions, Image, Divider, Tabs } from 'antd';
|
|
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, UndoOutlined } from '@ant-design/icons';
|
|
import dayjs from 'dayjs';
|
|
import { useAuthStore } from '../../store/authStore';
|
|
import FileUpload from '../../components/FileUpload';
|
|
|
|
const { Option } = Select;
|
|
const { TextArea } = Input;
|
|
|
|
const AdvancesPage: React.FC = () => {
|
|
const { user } = useAuthStore();
|
|
const [advances, setAdvances] = useState<any[]>([]);
|
|
const [completedAdvances, setCompletedAdvances] = useState<any[]>([]);
|
|
const [activeTab, setActiveTab] = useState('active');
|
|
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 [exchangeRates, setExchangeRates] = useState<Record<string, number>>({});
|
|
|
|
useEffect(() => {
|
|
fetchAdvances();
|
|
fetchProjects();
|
|
fetchExchangeRates();
|
|
}, []);
|
|
|
|
const fetchAdvances = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch('/api/advances');
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
// 分离活跃的和已完结的预支
|
|
const active = data.data.filter((item: any) => ['pending', 'approved', 'rejected', 'withdrawn', 'pending_edit'].includes(item.status));
|
|
const completed = data.data.filter((item: any) => ['executed', 'settled'].includes(item.status));
|
|
setAdvances(active);
|
|
setCompletedAdvances(completed);
|
|
}
|
|
} 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 fetchExchangeRates = async () => {
|
|
try {
|
|
const res = await fetch('/api/exchange-rates/latest');
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
const rates: Record<string, number> = {};
|
|
Object.keys(data.data).forEach(key => {
|
|
rates[key] = parseFloat(data.data[key]) || 1;
|
|
});
|
|
setExchangeRates(rates);
|
|
}
|
|
} catch (error) {}
|
|
};
|
|
|
|
const handleCreate = () => {
|
|
setEditingId(null);
|
|
form.resetFields();
|
|
form.setFieldsValue({
|
|
advance_date: dayjs(),
|
|
currency: 'CNY',
|
|
applicant: user?.name || user?.username || '当前用户',
|
|
attachments: []
|
|
});
|
|
setModalVisible(true);
|
|
};
|
|
|
|
const handleEdit = (record: any) => {
|
|
setEditingId(record.id);
|
|
setCurrentEditingStatus(record.status);
|
|
form.setFieldsValue({
|
|
...record,
|
|
advance_date: record.advance_date ? dayjs(record.advance_date) : null,
|
|
attachments: record.attachments || []
|
|
});
|
|
setModalVisible(true);
|
|
};
|
|
|
|
const handleView = async (record: any) => {
|
|
try {
|
|
const res = await fetch(`/api/advances/${record.id}`);
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
setSelectedRecord(data.data);
|
|
setDetailModalVisible(true);
|
|
} else {
|
|
message.error('获取详情失败');
|
|
}
|
|
} catch (error) {
|
|
message.error('获取详情失败');
|
|
}
|
|
};
|
|
|
|
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/advances/' + id, { method: 'DELETE' });
|
|
message.success('删除成功');
|
|
fetchAdvances();
|
|
} catch (error) {
|
|
message.error('删除失败');
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleWithdraw = async (id: number) => {
|
|
Modal.confirm({
|
|
title: '确认撤回',
|
|
content: '撤回后可重新编辑提交,确认撤回吗?',
|
|
onOk: async () => {
|
|
try {
|
|
await fetch('/api/advances/' + id + '/withdraw', { method: 'POST' });
|
|
message.success('已撤回,可重新编辑');
|
|
fetchAdvances();
|
|
} catch (error) {
|
|
message.error('撤回失败');
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
// 保存操作:只保存信息,不改变状态
|
|
const handleSave = async () => {
|
|
try {
|
|
const values = await form.validateFields();
|
|
// 保存时使用编辑时的状态
|
|
const saveStatus = currentEditingStatus || 'pending_edit';
|
|
console.log('保存操作 - 状态:', saveStatus);
|
|
console.log('currentEditingStatus:', currentEditingStatus);
|
|
const data = {
|
|
...values,
|
|
advance_date: values.advance_date?.format('YYYY-MM-DD'),
|
|
amount_cny: values.currency === 'CNY' ? values.amount : convertToCNY(values.amount, values.currency),
|
|
applicant: user?.name || user?.username,
|
|
status: saveStatus
|
|
};
|
|
console.log('保存操作 - 提交的数据:', data);
|
|
const url = editingId ? '/api/advances/' + editingId : '/api/advances';
|
|
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();
|
|
console.log('保存操作 - 响应:', result);
|
|
if (result.success) {
|
|
message.success(editingId ? '保存成功' : '创建成功');
|
|
setModalVisible(false);
|
|
fetchAdvances();
|
|
} else {
|
|
message.error(result.error || '保存失败');
|
|
}
|
|
} catch (error) {
|
|
console.error('保存操作 - 错误:', error);
|
|
message.error('保存失败');
|
|
}
|
|
};
|
|
|
|
// 提交操作:提交到待审批状态
|
|
const handleSubmit = async () => {
|
|
try {
|
|
const values = await form.validateFields();
|
|
// 提交时使用pending状态
|
|
const saveStatus = 'pending';
|
|
console.log('提交操作 - 状态:', saveStatus);
|
|
const data = {
|
|
...values,
|
|
advance_date: values.advance_date?.format('YYYY-MM-DD'),
|
|
amount_cny: values.currency === 'CNY' ? values.amount : convertToCNY(values.amount, values.currency),
|
|
applicant: user?.name || user?.username,
|
|
status: saveStatus
|
|
};
|
|
console.log('提交操作 - 提交的数据:', data);
|
|
const url = editingId ? '/api/advances/' + editingId : '/api/advances';
|
|
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();
|
|
console.log('提交操作 - 响应:', result);
|
|
if (result.success) {
|
|
message.success(editingId ? '提交成功' : '创建成功');
|
|
setModalVisible(false);
|
|
fetchAdvances();
|
|
} else {
|
|
message.error(result.error || '提交失败');
|
|
}
|
|
} catch (error) {
|
|
console.error('提交操作 - 错误:', error);
|
|
message.error('提交失败');
|
|
}
|
|
};
|
|
|
|
const handleSubmitAndSubmit = async () => {
|
|
await handleSubmit();
|
|
};
|
|
|
|
const convertToCNY = (amount: number, currency: string): number => {
|
|
if (currency === 'CNY') return amount;
|
|
const rateKey = 'CNY_' + currency;
|
|
const rate = exchangeRates[rateKey] || 1;
|
|
return amount / rate;
|
|
};
|
|
|
|
const amount = Form.useWatch('amount', form);
|
|
const currency = Form.useWatch('currency', form);
|
|
const amountCNY = React.useMemo(() => {
|
|
return amount && currency ? convertToCNY(amount, currency) : 0;
|
|
}, [amount, currency, exchangeRates]);
|
|
|
|
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: '已撤回' },
|
|
settled: { 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 });
|
|
};
|
|
|
|
// Format number with thousand separator for input display
|
|
const formatNumberWithSeparator = (value: number | undefined, currency: string): string => {
|
|
if (value === undefined || value === null) return '';
|
|
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
|
|
const symbol = symbols[currency] || '¥';
|
|
return symbol + value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
};
|
|
|
|
// Parse formatted string back to number
|
|
const parseFormattedNumber = (value: string): number => {
|
|
// Remove currency symbols and thousand separators
|
|
const cleaned = value.replace(/[¥$₭฿,]/g, '');
|
|
return parseFloat(cleaned) || 0;
|
|
};
|
|
|
|
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: 'advance_date', key: 'advance_date', width: 100 },
|
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
|
|
{ title: '编号', dataIndex: 'advance_code', key: 'advance_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>
|
|
)
|
|
}
|
|
];
|
|
|
|
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>}>
|
|
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
|
<Tabs.TabPane tab="活跃申请" key="active">
|
|
<Table dataSource={advances} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
|
|
</Tabs.TabPane>
|
|
<Tabs.TabPane tab="已完结" key="completed">
|
|
<Table dataSource={completedAdvances} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
|
|
</Tabs.TabPane>
|
|
</Tabs>
|
|
</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={700}
|
|
>
|
|
<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="advance_date" label="预支日期" rules={[{ required: true }]}>
|
|
<DatePicker style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
|
|
|
|
|
|
<Form.Item label="金额" required>
|
|
<Space>
|
|
<Form.Item name="currency" noStyle initialValue="CNY">
|
|
<Select style={{ width: 140 }}>
|
|
<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="amount" noStyle rules={[{ required: true, message: '请输入金额' }]}>
|
|
<InputNumber
|
|
style={{ width: 200 }}
|
|
min={0}
|
|
precision={2}
|
|
placeholder="输入金额"
|
|
formatter={(value) => formatNumberWithSeparator(value as number, currency || 'CNY')}
|
|
parser={(value) => parseFormattedNumber(value || '0')}
|
|
/>
|
|
</Form.Item>
|
|
</Space>
|
|
{amountCNY > 0 && (
|
|
<div style={{ marginTop: 8, color: '#888', fontSize: 13 }}>
|
|
等价人民币:¥ {amountCNY.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
</div>
|
|
)}
|
|
</Form.Item>
|
|
|
|
<Form.Item name="reason" label="事由" rules={[{ required: true }]}>
|
|
<TextArea rows={3} placeholder="请输入预支事由" />
|
|
</Form.Item>
|
|
|
|
<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={800}>
|
|
{selectedRecord && (
|
|
<>
|
|
<Descriptions bordered column={2} size="small">
|
|
<Descriptions.Item label="预支编号">{selectedRecord.advance_code}</Descriptions.Item>
|
|
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
|
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
|
|
<Descriptions.Item label="预支日期">{selectedRecord.advance_date}</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.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 AdvancesPage;
|