390 lines
17 KiB
TypeScript
390 lines
17 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { Table, Button, Modal, Form, Input, Select, message, Space, Tag, Card, Row, Col, Statistic } from 'antd'
|
|
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, SolutionOutlined, BankOutlined } from '@ant-design/icons'
|
|
import type { ColumnsType } from 'antd/es/table'
|
|
import FileUpload from '../components/FileUpload'
|
|
import useFormDraft from '../hooks/useFormDraft'
|
|
import { useLanguageStore } from '../store/languageStore'
|
|
|
|
interface Contact {
|
|
name: string
|
|
position: string
|
|
phone: string
|
|
is_primary?: boolean
|
|
}
|
|
|
|
interface PaymentInfo {
|
|
account_name: string
|
|
bank_account: string
|
|
bank_name: string
|
|
qr_code?: string
|
|
is_primary: boolean
|
|
}
|
|
|
|
interface Subcontractor {
|
|
id: number
|
|
code: string
|
|
name: string
|
|
scope: string
|
|
features: string
|
|
country: string
|
|
contacts: Contact[]
|
|
payment_infos: PaymentInfo[]
|
|
remark: string
|
|
total_contract_amount: number
|
|
total_paid: number
|
|
total_payable: number
|
|
created_at: string
|
|
}
|
|
|
|
const SubcontractorPage: React.FC = () => {
|
|
const navigate = useNavigate()
|
|
const { t, currentLanguage } = useLanguageStore()
|
|
const [subcontractors, setSubcontractors] = useState<Subcontractor[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [modalVisible, setModalVisible] = useState(false)
|
|
const [editingSubcontractor, setEditingSubcontractor] = useState<Subcontractor | null>(null)
|
|
const [searchText, setSearchText] = useState('')
|
|
const [form] = Form.useForm()
|
|
|
|
// 表单草稿保护
|
|
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
|
|
form,
|
|
storageKey: 'subcontractor_create',
|
|
})
|
|
|
|
const handleFormChange = useCallback(() => {
|
|
saveDraft()
|
|
}, [saveDraft])
|
|
|
|
const fetchSubcontractors = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const response = await fetch('/api/subcontractors')
|
|
const data = await response.json()
|
|
if (data.success) setSubcontractors(data.data || [])
|
|
} catch (error) {
|
|
message.error(t('subcontractor.getListFailed'))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => { fetchSubcontractors() }, [])
|
|
|
|
const stats = {
|
|
total: subcontractors.length,
|
|
totalContract: subcontractors.reduce((sum, s) => sum + (s.total_contract_amount || 0), 0),
|
|
totalPayable: subcontractors.reduce((sum, s) => sum + (s.total_payable || 0), 0)
|
|
}
|
|
|
|
const getPrimaryContact = (contacts: Contact[]) => {
|
|
const primary = contacts?.find(c => c.is_primary)
|
|
return primary?.name || '-'
|
|
}
|
|
|
|
const getPrimaryPaymentInfo = (paymentInfos: PaymentInfo[]) => {
|
|
const primary = paymentInfos?.find(p => p.is_primary)
|
|
return primary
|
|
}
|
|
|
|
const columns: ColumnsType<Subcontractor> = [
|
|
{
|
|
title: t('subcontractor.name'), dataIndex: 'name', key: 'name',
|
|
render: (text, record) => (
|
|
<Button type="link" style={{ padding: 0, fontWeight: 'bold' }} onClick={() => navigate(`/subcontractors/${record.id}`)}>{text}</Button>
|
|
)
|
|
},
|
|
{ title: t('subcontractor.scope'), dataIndex: 'scope', key: 'scope', width: 120 },
|
|
{ title: t('subcontractor.country'), dataIndex: 'country', key: 'country', width: 80, render: (c) => <Tag>{c || '-'}</Tag> },
|
|
{ title: t('subcontractor.contractAmount'), dataIndex: 'total_contract_amount', key: 'total_contract_amount', width: 100, render: (v) => `¥${(v || 0).toLocaleString()}` },
|
|
{ title: t('subcontractor.payableAmount'), dataIndex: 'total_payable', key: 'total_payable', width: 100, render: (v) => <span style={{ color: v > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(v || 0).toLocaleString()}</span> },
|
|
{ title: t('common.action'), key: 'actions', width: 100, render: (_, record) => (
|
|
<Space>
|
|
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" />
|
|
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} size="small" />
|
|
</Space>
|
|
)}
|
|
]
|
|
|
|
const filteredSubcontractors = subcontractors.filter(s =>
|
|
s.code?.toLowerCase().includes(searchText.toLowerCase()) ||
|
|
s.name?.toLowerCase().includes(searchText.toLowerCase()) ||
|
|
s.scope?.toLowerCase().includes(searchText.toLowerCase())
|
|
)
|
|
|
|
const handleContactChange = (index: number, field: string, value: any) => {
|
|
form.setFieldsValue({
|
|
contacts: form.getFieldValue('contacts').map((contact: any, i: number) => {
|
|
if (field === 'is_primary' && value) {
|
|
return i === index ? { ...contact, [field]: value } : { ...contact, is_primary: false }
|
|
}
|
|
return i === index ? { ...contact, [field]: value } : contact
|
|
})
|
|
})
|
|
}
|
|
|
|
const handlePaymentInfoChange = (index: number, field: string, value: any) => {
|
|
form.setFieldsValue({
|
|
payment_infos: form.getFieldValue('payment_infos').map((info: any, i: number) => {
|
|
if (field === 'is_primary' && value) {
|
|
return i === index ? { ...info, [field]: value } : { ...info, is_primary: false }
|
|
}
|
|
return i === index ? { ...info, [field]: value } : info
|
|
})
|
|
})
|
|
}
|
|
|
|
const handleSubmit = async (values: any) => {
|
|
try {
|
|
let contacts = values.contacts || [{ name: '', position: '', phone: '', is_primary: true }]
|
|
const hasPrimary = contacts.some((c: Contact) => c.is_primary)
|
|
if (!hasPrimary && contacts[0].name) contacts[0].is_primary = true
|
|
|
|
let paymentInfos = values.payment_infos || []
|
|
const hasPrimaryPayment = paymentInfos.some((p: PaymentInfo) => p.is_primary)
|
|
if (!hasPrimaryPayment && paymentInfos.length > 0 && paymentInfos[0].account_name) {
|
|
paymentInfos[0].is_primary = true
|
|
}
|
|
|
|
const url = editingSubcontractor ? `/api/subcontractors/${editingSubcontractor.id}` : '/api/subcontractors'
|
|
const method = editingSubcontractor ? 'PUT' : 'POST'
|
|
|
|
const response = await fetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ...values, contacts, payment_infos: paymentInfos })
|
|
})
|
|
const data = await response.json()
|
|
if (data.success) {
|
|
message.success(editingSubcontractor ? t('common.updateSuccess') : t('common.createSuccess'))
|
|
clearDraft()
|
|
setModalVisible(false)
|
|
form.resetFields()
|
|
setEditingSubcontractor(null)
|
|
fetchSubcontractors()
|
|
} else {
|
|
message.error(data.message || t('common.operationFailed'))
|
|
}
|
|
} catch (error) {
|
|
message.error(t('common.operationFailed'))
|
|
}
|
|
}
|
|
|
|
const handleEdit = (subcontractor: Subcontractor) => {
|
|
setEditingSubcontractor(subcontractor)
|
|
form.setFieldsValue({
|
|
name: subcontractor.name,
|
|
scope: subcontractor.scope,
|
|
features: subcontractor.features,
|
|
country: subcontractor.country,
|
|
remark: subcontractor.remark,
|
|
contacts: subcontractor.contacts?.length ? subcontractor.contacts : [{ name: '', position: '', phone: '', is_primary: true }],
|
|
payment_infos: subcontractor.payment_infos?.length ? subcontractor.payment_infos : []
|
|
})
|
|
setModalVisible(true)
|
|
}
|
|
|
|
const handleDelete = async (id: number) => {
|
|
Modal.confirm({
|
|
title: t('common.confirmDelete'), content: t('subcontractor.confirmDeleteMsg'), okText: t('common.confirm'), cancelText: t('common.cancel'),
|
|
onOk: async () => {
|
|
try {
|
|
const response = await fetch(`/api/subcontractors/${id}`, { method: 'DELETE' })
|
|
const data = await response.json()
|
|
if (data.success) { message.success(t('common.deleteSuccess')); fetchSubcontractors() }
|
|
else message.error(data.message || t('common.deleteFailed'))
|
|
} catch (error) { message.error(t('common.deleteFailed')) }
|
|
}
|
|
})
|
|
}
|
|
|
|
const handleAdd = () => {
|
|
setEditingSubcontractor(null)
|
|
form.resetFields()
|
|
form.setFieldsValue({
|
|
country: 'Laos',
|
|
contacts: [{ name: '', position: '', phone: '', is_primary: true }],
|
|
payment_infos: []
|
|
})
|
|
setModalVisible(true)
|
|
// 检查是否有草稿,提示用户是否恢复
|
|
setTimeout(() => {
|
|
if (hasDraft()) {
|
|
Modal.confirm({
|
|
title: t('common.draftFound'),
|
|
content: t('common.draftRestore'),
|
|
okText: t('common.restoreDraft'),
|
|
cancelText: t('common.reFill'),
|
|
onOk: () => {
|
|
restoreDraft()
|
|
},
|
|
onCancel: () => {
|
|
clearDraft()
|
|
form.resetFields()
|
|
form.setFieldsValue({
|
|
country: 'Laos',
|
|
contacts: [{ name: '', position: '', phone: '', is_primary: true }],
|
|
payment_infos: []
|
|
})
|
|
},
|
|
})
|
|
}
|
|
}, 0)
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: 24 }}>
|
|
<Row gutter={16} style={{ marginBottom: 24 }}>
|
|
<Col span={8}><Card><Statistic title={t('subcontractor.totalCount')} value={stats.total} prefix={<SolutionOutlined />} /></Card></Col>
|
|
<Col span={8}><Card><Statistic title={t('subcontractor.totalContract')} value={stats.totalContract} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
|
|
<Col span={8}><Card><Statistic title={t('subcontractor.totalPayable')} value={stats.totalPayable} prefix="¥" valueStyle={{ color: stats.totalPayable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
|
|
</Row>
|
|
|
|
<Card style={{ marginBottom: 16 }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<Input placeholder={t('subcontractor.searchPlaceholder')} prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>{t('subcontractor.newSubcontractor')}</Button>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<Table columns={columns} dataSource={filteredSubcontractors} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => t('common.totalCount', { total }) }} scroll={{ x: 1100 }} />
|
|
</Card>
|
|
|
|
<Modal
|
|
title={editingSubcontractor ? t('subcontractor.editSubcontractor') : t('subcontractor.newSubcontractor')}
|
|
open={modalVisible}
|
|
onCancel={() => {
|
|
if (form.isFieldsTouched()) {
|
|
Modal.confirm({
|
|
title: t('common.closeConfirm'),
|
|
content: t('common.closeConfirmMsg'),
|
|
okText: t('common.close'),
|
|
cancelText: t('common.continueEdit'),
|
|
onOk: () => {
|
|
saveDraft()
|
|
form.resetFields();
|
|
setModalVisible(false)
|
|
setEditingSubcontractor(null)
|
|
},
|
|
})
|
|
} else {
|
|
form.resetFields();
|
|
setModalVisible(false)
|
|
setEditingSubcontractor(null)
|
|
}
|
|
}}
|
|
onOk={() => form.submit()}
|
|
destroyOnClose
|
|
width={800}
|
|
maskClosable={false}
|
|
>
|
|
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={handleFormChange}>
|
|
<Form.Item name="name" label={t('subcontractor.name')} rules={[{ required: true, message: t('subcontractor.nameRequired') }]}>
|
|
<Input placeholder={t('subcontractor.namePlaceholder')} />
|
|
</Form.Item>
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Form.Item name="scope" label={t('subcontractor.scope')}>
|
|
<Input placeholder={t('subcontractor.scopePlaceholder')} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Form.Item name="country" label={t('subcontractor.country')} initialValue="Laos">
|
|
<Select>
|
|
<Select.Option value="China">{t('subcontractor.china')}</Select.Option>
|
|
<Select.Option value="Laos">{t('subcontractor.laos')}</Select.Option>
|
|
</Select>
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
<Form.Item name="features" label={t('subcontractor.feature')}>
|
|
<Input.TextArea rows={2} placeholder={t('subcontractor.featurePlaceholder')} />
|
|
</Form.Item>
|
|
<Form.Item name="remark" label={t('common.remark')}>
|
|
<Input.TextArea rows={2} placeholder={t('subcontractor.remarkPlaceholder')} />
|
|
</Form.Item>
|
|
|
|
<h4>{t('subcontractor.contact')}</h4>
|
|
<Form.List name="contacts" initialValue={[{ name: '', position: '', phone: '', is_primary: true }]}>
|
|
{(fields, { add, remove }) => (
|
|
<div>
|
|
{fields.map(({ key, name, ...restField }) => (
|
|
<div key={key} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
|
<Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}>
|
|
<Input placeholder={t('logistics.name')} />
|
|
</Form.Item>
|
|
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}>
|
|
<Input placeholder={t('common.position')} />
|
|
</Form.Item>
|
|
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}>
|
|
<Input placeholder={t('common.phone')} />
|
|
</Form.Item>
|
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
|
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}>
|
|
<input
|
|
type="checkbox"
|
|
onChange={(e) => handleContactChange(name, 'is_primary', e.target.checked)}
|
|
/>
|
|
</Form.Item>
|
|
<span>{t('subcontractor.mainContact')}</span>
|
|
</div>
|
|
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}>{t('common.delete')}</Button>}
|
|
</div>
|
|
))}
|
|
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>{t('subcontractor.addContact')}</Button>
|
|
</div>
|
|
)}
|
|
</Form.List>
|
|
|
|
<h4 style={{ marginTop: 24 }}>{t('subcontractor.paymentInfo')}</h4>
|
|
<Form.List name="payment_infos" initialValue={[]}>
|
|
{(fields, { add, remove }) => (
|
|
<div>
|
|
{fields.map(({ key, name, ...restField }) => (
|
|
<div key={key} style={{ border: '1px solid #e8e8e8', padding: 16, marginBottom: 16, borderRadius: 4, backgroundColor: '#fafafa' }}>
|
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
|
<Form.Item {...restField} name={[name, 'account_name']} label={t('subcontractor.accountName')} style={{ marginBottom: 0, flex: 1 }}>
|
|
<Input placeholder={t('subcontractor.accountName')} />
|
|
</Form.Item>
|
|
<Form.Item {...restField} name={[name, 'bank_name']} label={t('subcontractor.bankName')} style={{ marginBottom: 0, flex: 1 }}>
|
|
<Input placeholder={t('subcontractor.bankName')} />
|
|
</Form.Item>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
|
<Form.Item {...restField} name={[name, 'bank_account']} label={t('subcontractor.bankAccount')} style={{ marginBottom: 0, flex: 1 }}>
|
|
<Input placeholder={t('subcontractor.bankAccount')} />
|
|
</Form.Item>
|
|
<div style={{ display: 'flex', alignItems: 'center', marginTop: 30 }}>
|
|
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}>
|
|
<input
|
|
type="checkbox"
|
|
onChange={(e) => handlePaymentInfoChange(name, 'is_primary', e.target.checked)}
|
|
/>
|
|
</Form.Item>
|
|
<span>{t('subcontractor.mainAccount')}</span>
|
|
</div>
|
|
</div>
|
|
<Form.Item {...restField} name={[name, 'qr_code']} label={t('subcontractor.qrCode')} style={{ marginBottom: 0 }}>
|
|
<FileUpload maxCount={1} accept="image/*" />
|
|
</Form.Item>
|
|
{fields.length > 0 && (
|
|
<Button type="link" danger onClick={() => remove(name)} style={{ marginTop: 8 }}>{t('subcontractor.deletePaymentInfo')}</Button>
|
|
)}
|
|
</div>
|
|
))}
|
|
<Button type="dashed" onClick={() => add({ account_name: '', bank_account: '', bank_name: '', is_primary: false })} style={{ width: '100%' }}>
|
|
{t('subcontractor.addPaymentInfo')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</Form.List>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default SubcontractorPage |