368 lines
16 KiB
TypeScript
368 lines
16 KiB
TypeScript
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;
|
|
attachments?: string[];
|
|
}
|
|
|
|
const PaymentRequestsPage: React.FC = () => {
|
|
const { user } = useAuthStore();
|
|
const [requests, setRequests] = 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 [detailItems, setDetailItems] = useState<DetailItem[]>([]);
|
|
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({});
|
|
|
|
useEffect(() => {
|
|
fetchRequests();
|
|
}, []);
|
|
|
|
const fetchRequests = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch('/api/payment-requests');
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
// 解析JSON字符串字段
|
|
const parsedRequests = data.data.map((request: any) => ({
|
|
...request,
|
|
detail_items: request.detail_items ? JSON.parse(request.detail_items) : [],
|
|
attachments: request.attachments ? JSON.parse(request.attachments) : []
|
|
}));
|
|
setRequests(parsedRequests);
|
|
}
|
|
} catch (error) {
|
|
console.error('获取付款申请列表失败:', error);
|
|
message.error('获取付款申请列表失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCreate = () => {
|
|
setEditingId(null);
|
|
setDetailItems([]);
|
|
form.resetFields();
|
|
form.setFieldsValue({
|
|
payment_date: dayjs(),
|
|
currency: 'CNY',
|
|
applicant: user?.name || user?.username || '当前用户',
|
|
attachments: []
|
|
});
|
|
setModalVisible(true);
|
|
};
|
|
|
|
const handleEdit = (record: any) => {
|
|
setEditingId(record.id);
|
|
setDetailItems(record.detail_items || []);
|
|
form.setFieldsValue({
|
|
...record,
|
|
payment_date: record.payment_date ? dayjs(record.payment_date) : null,
|
|
attachments: record.attachments || []
|
|
});
|
|
setModalVisible(true);
|
|
};
|
|
|
|
const handleView = (record: any) => {
|
|
setSelectedRecord(record);
|
|
setDetailModalVisible(true);
|
|
};
|
|
|
|
const handleDelete = async (id: number) => {
|
|
Modal.confirm({
|
|
title: '确认删除',
|
|
content: '确定要删除这条付款申请吗?',
|
|
onOk: async () => {
|
|
try {
|
|
await fetch('/api/payment-requests/' + id, { method: 'DELETE' });
|
|
message.success('删除成功');
|
|
fetchRequests();
|
|
} catch (error) {
|
|
message.error('删除失败');
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleWithdraw = async (id: number) => {
|
|
Modal.confirm({
|
|
title: '确认撤回',
|
|
content: '撤回后可重新编辑提交,确认撤回吗?',
|
|
onOk: async () => {
|
|
try {
|
|
await fetch('/api/payment-requests/' + id + '/withdraw', { method: 'POST' });
|
|
message.success('已撤回,可重新编辑');
|
|
fetchRequests();
|
|
} catch (error) {
|
|
message.error('撤回失败');
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
try {
|
|
const values = await form.validateFields();
|
|
const data = {
|
|
...values,
|
|
payment_date: values.payment_date?.format('YYYY-MM-DD'),
|
|
detail_items: detailItems,
|
|
amount: detailItems.reduce((sum, item) => sum + (item.amount || 0), 0),
|
|
applicant: user?.name || user?.username
|
|
};
|
|
const url = editingId ? '/api/payment-requests/' + editingId : '/api/payment-requests';
|
|
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);
|
|
fetchRequests();
|
|
} else {
|
|
message.error(result.error || '操作失败');
|
|
}
|
|
} catch (error) {
|
|
message.error('操作失败');
|
|
}
|
|
};
|
|
|
|
const addDetailItem = () => setDetailItems([...detailItems, { description: '', amount: 0, attachments: [] }]);
|
|
const updateDetailItem = (index: number, field: keyof DetailItem, value: any) => {
|
|
const newItems = [...detailItems];
|
|
newItems[index] = { ...newItems[index], [field]: value };
|
|
setDetailItems(newItems);
|
|
};
|
|
const convertToCNY = (amount: number, curr: string): number => {
|
|
if (curr === "CNY") return amount;
|
|
const rateKey = curr + "_CNY";
|
|
const rate = exchangeRates[rateKey] || 1;
|
|
return amount * rate;
|
|
};
|
|
|
|
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: '已付款' },
|
|
};
|
|
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: 'payee', key: 'payee', width: 150, ellipsis: true },
|
|
{ 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: 'payment_date', key: 'payment_date', width: 100 },
|
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
|
|
{ title: '编号', dataIndex: 'request_code', key: 'request_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={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
|
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}>撤回</Button>
|
|
</>
|
|
)}
|
|
{(record.status === 'rejected' || record.status === 'withdrawn') && (
|
|
<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);
|
|
|
|
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={requests} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
|
|
</Card>
|
|
|
|
<Modal title={editingId ? '编辑付款申请' : '新建付款申请'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} 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="payment_date" label="付款日期" rules={[{ required: true }]}>
|
|
<DatePicker style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
|
|
<Form.Item name="payee" label="收款单位" rules={[{ required: true }]}>
|
|
<Input placeholder="收款单位/供应商名称" />
|
|
</Form.Item>
|
|
|
|
<Form.Item name="bank_account" label="银行账号">
|
|
<Input placeholder="收款银行账号" />
|
|
</Form.Item>
|
|
|
|
<Form.Item name="bank_name" label="开户银行">
|
|
<Input placeholder="开户银行名称" />
|
|
</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="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(detailItems.reduce((sum, item) => sum + (item.amount || 0), 0), 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: 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 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.request_code}</Descriptions.Item>
|
|
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
|
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
|
|
<Descriptions.Item label="付款日期">{selectedRecord.payment_date}</Descriptions.Item>
|
|
<Descriptions.Item label="收款单位">{selectedRecord.payee}</Descriptions.Item>
|
|
<Descriptions.Item label="银行账号">{selectedRecord.bank_account}</Descriptions.Item>
|
|
<Descriptions.Item label="开户银行">{selectedRecord.bank_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>
|
|
|
|
{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: 'amount', key: 'amount', render: (v: number) => formatAmount(v, selectedRecord.currency) },
|
|
{ title: '附件', dataIndex: 'attachments', key: 'attachments', render: (v: string[]) => v?.length ? `${v.length}张` : '-' }
|
|
]}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{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 PaymentRequestsPage;
|