Backup project on 2026-03-27
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 315 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 315 KiB |
@@ -106,7 +106,7 @@ function App() {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Router>
|
||||
<Router future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ export const API_CONFIG = {
|
||||
// API端点
|
||||
export const API_ENDPOINTS = {
|
||||
auth: {
|
||||
login: '/auth/login',
|
||||
logout: '/auth/logout',
|
||||
me: '/auth/me',
|
||||
login: '/v1/auth/login',
|
||||
logout: '/v1/auth/logout',
|
||||
me: '/v1/auth/me',
|
||||
},
|
||||
products: '/products',
|
||||
customers: '/customers',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Table, Button, Modal, Form, Input, message, Space, Tag, Card, Row, Col, Statistic } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, HomeOutlined } from '@ant-design/icons'
|
||||
import { Table, Button, Modal, Form, Input, message, Space, Tag, Card, Row, Col, Statistic, Image } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, HomeOutlined, BankOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import FileUpload from '../components/FileUpload'
|
||||
|
||||
interface Contact {
|
||||
name: string
|
||||
@@ -11,12 +12,21 @@ interface Contact {
|
||||
is_primary?: boolean
|
||||
}
|
||||
|
||||
interface PaymentInfo {
|
||||
account_name: string
|
||||
bank_account: string
|
||||
bank_name: string
|
||||
qr_code?: string
|
||||
is_primary: boolean
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
address: string
|
||||
contacts: Contact[]
|
||||
payment_infos: PaymentInfo[]
|
||||
remark: string
|
||||
total_contract_amount: number
|
||||
total_received: number
|
||||
@@ -59,6 +69,11 @@ const CustomerPage: React.FC = () => {
|
||||
return primary?.name || '-'
|
||||
}
|
||||
|
||||
const getPrimaryPaymentInfo = (paymentInfos: PaymentInfo[]) => {
|
||||
const primary = paymentInfos?.find(p => p.is_primary)
|
||||
return primary
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Customer> = [
|
||||
{ title: '编号', dataIndex: 'code', key: 'code', width: 120 },
|
||||
{
|
||||
@@ -69,6 +84,22 @@ const CustomerPage: React.FC = () => {
|
||||
},
|
||||
{ title: '地址', dataIndex: 'address', key: 'address', width: 150, render: (t) => t || '-' },
|
||||
{ title: '主联系人', key: 'primary_contact', width: 100, render: (_, record) => getPrimaryContact(record.contacts || []) },
|
||||
{
|
||||
title: '收款信息',
|
||||
key: 'payment_info',
|
||||
width: 200,
|
||||
render: (_, record) => {
|
||||
const primary = getPrimaryPaymentInfo(record.payment_infos || [])
|
||||
if (!primary) return <Tag>未设置</Tag>
|
||||
return (
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<div><BankOutlined /> {primary.bank_name || '-'}</div>
|
||||
<div>户名: {primary.account_name || '-'}</div>
|
||||
<div>账号: {primary.bank_account ? primary.bank_account.slice(-4).padStart(primary.bank_account.length, '*') : '-'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{ title: '合同金额', dataIndex: 'total_contract_amount', key: 'total_contract_amount', width: 100, render: (v) => `¥${(v || 0).toLocaleString()}` },
|
||||
{ title: '应收金额', dataIndex: 'total_receivable', key: 'total_receivable', width: 100, render: (v) => <span style={{ color: v > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(v || 0).toLocaleString()}</span> },
|
||||
{ title: '操作', key: 'actions', width: 100, render: (_, record) => (
|
||||
@@ -89,7 +120,6 @@ const CustomerPage: React.FC = () => {
|
||||
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
|
||||
@@ -97,16 +127,37 @@ const CustomerPage: React.FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
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 => c.is_primary)
|
||||
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 = editingCustomer ? `/api/customers/${editingCustomer.id}` : '/api/customers'
|
||||
const method = editingCustomer ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...values, contacts }) })
|
||||
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(editingCustomer ? '更新成功' : '创建成功')
|
||||
@@ -125,8 +176,11 @@ const CustomerPage: React.FC = () => {
|
||||
const handleEdit = (customer: Customer) => {
|
||||
setEditingCustomer(customer)
|
||||
form.setFieldsValue({
|
||||
name: customer.name, address: customer.address, remark: customer.remark,
|
||||
contacts: customer.contacts?.length ? customer.contacts : [{ name: '', position: '', phone: '', is_primary: true }]
|
||||
name: customer.name,
|
||||
address: customer.address,
|
||||
remark: customer.remark,
|
||||
contacts: customer.contacts?.length ? customer.contacts : [{ name: '', position: '', phone: '', is_primary: true }],
|
||||
payment_infos: customer.payment_infos?.length ? customer.payment_infos : []
|
||||
})
|
||||
setModalVisible(true)
|
||||
}
|
||||
@@ -148,7 +202,10 @@ const CustomerPage: React.FC = () => {
|
||||
const handleAdd = () => {
|
||||
setEditingCustomer(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ contacts: [{ name: '', position: '', phone: '', is_primary: true }] })
|
||||
form.setFieldsValue({
|
||||
contacts: [{ name: '', position: '', phone: '', is_primary: true }],
|
||||
payment_infos: []
|
||||
})
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
@@ -168,23 +225,42 @@ const CustomerPage: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table columns={columns} dataSource={filteredCustomers} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 900 }} />
|
||||
<Table columns={columns} dataSource={filteredCustomers} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 1100 }} />
|
||||
</Card>
|
||||
|
||||
<Modal title={editingCustomer ? '编辑客户' : '新增客户'} open={modalVisible} onCancel={() => { setModalVisible(false); form.resetFields(); setEditingCustomer(null) }} onOk={() => form.submit()} width={700}>
|
||||
<Modal
|
||||
title={editingCustomer ? '编辑客户' : '新增客户'}
|
||||
open={modalVisible}
|
||||
onCancel={() => { setModalVisible(false); form.resetFields(); setEditingCustomer(null) }}
|
||||
onOk={() => form.submit()}
|
||||
width={800}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}><Input placeholder="客户名称" /></Form.Item>
|
||||
<Form.Item name="address" label="地址"><Input placeholder="客户地址" /></Form.Item>
|
||||
<Form.Item name="remark" label="备注"><Input.TextArea rows={2} placeholder="备注信息" /></Form.Item>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="客户名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="地址">
|
||||
<Input placeholder="客户地址" />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="备注信息" />
|
||||
</Form.Item>
|
||||
|
||||
<h4>联系人</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="姓名" /></Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}><Input placeholder="职位" /></Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}><Input placeholder="电话" /></Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="姓名" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="职位" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="电话" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -198,6 +274,46 @@ const CustomerPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<h4 style={{ marginTop: 24 }}>收款信息</h4>
|
||||
<Form.List name="payment_infos">
|
||||
{(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="收款户名" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="收款户名" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'bank_name']} label="开户银行" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="开户银行" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
||||
<Form.Item {...restField} name={[name, 'bank_account']} label="银行账号" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="银行账号" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginTop: 30 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => handlePaymentInfoChange(name, 'is_primary', e.target.checked)}
|
||||
/> 主要收款账户
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item {...restField} name={[name, 'qr_code']} label="收款码" style={{ marginBottom: 0 }}>
|
||||
<FileUpload maxCount={1} accept="image/*" />
|
||||
</Form.Item>
|
||||
{fields.length > 0 && (
|
||||
<Button type="link" danger onClick={() => remove(name)} style={{ marginTop: 8 }}>删除此收款信息</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add({ account_name: '', bank_account: '', bank_name: '', is_primary: false })} style={{ width: '100%' }}>
|
||||
+ 添加收款信息
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Card, Space, Tag, Modal, Form, Input, InputNumber, Select, DatePicker, message, Descriptions, Image, Divider, Cascader, Tabs } from 'antd';
|
||||
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';
|
||||
@@ -43,6 +43,20 @@ const COMPANY_EXPENSE_CATEGORIES = [
|
||||
{ value: 'other', label: '其他支出' }
|
||||
];
|
||||
|
||||
interface PaymentInfo {
|
||||
account_name: string;
|
||||
bank_account: string;
|
||||
bank_name: string;
|
||||
qr_code?: string;
|
||||
is_primary: boolean;
|
||||
}
|
||||
|
||||
interface PayeeEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
payment_infos?: PaymentInfo[];
|
||||
}
|
||||
|
||||
const PaymentRequestsPage: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const [requests, setRequests] = useState<any[]>([]);
|
||||
@@ -57,9 +71,9 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({});
|
||||
|
||||
// 数据列表
|
||||
const [subcontractors, setSubcontractors] = useState<any[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<any[]>([]);
|
||||
const [customers, setCustomers] = useState<any[]>([]);
|
||||
const [subcontractors, setSubcontractors] = useState<PayeeEntity[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<PayeeEntity[]>([]);
|
||||
const [customers, setCustomers] = useState<PayeeEntity[]>([]);
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -161,11 +175,36 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 获取主要收款信息
|
||||
const getPrimaryPaymentInfo = (paymentInfos?: PaymentInfo[]): PaymentInfo | null => {
|
||||
if (!paymentInfos || paymentInfos.length === 0) return null;
|
||||
return paymentInfos.find(p => p.is_primary) || paymentInfos[0];
|
||||
};
|
||||
|
||||
// 根据收款单位类型和ID获取收款信息
|
||||
const getPayeePaymentInfo = (payeeType: string, payeeId: string): PaymentInfo | null => {
|
||||
let entity: PayeeEntity | undefined;
|
||||
switch (payeeType) {
|
||||
case 'subcontractor':
|
||||
entity = subcontractors.find(s => s.id === payeeId);
|
||||
break;
|
||||
case 'supplier':
|
||||
entity = suppliers.find(s => s.id === payeeId);
|
||||
break;
|
||||
case 'customer':
|
||||
entity = customers.find(c => c.id === payeeId);
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return entity ? getPrimaryPaymentInfo(entity.payment_infos) : null;
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingId(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
payment_date: dayjs(),
|
||||
application_date: dayjs(),
|
||||
currency: 'CNY',
|
||||
applicant: user?.name || user?.username || '当前用户',
|
||||
attachments: [],
|
||||
@@ -179,7 +218,7 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
setEditingId(record.id);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
payment_date: record.payment_date ? dayjs(record.payment_date) : null,
|
||||
application_date: record.application_date ? dayjs(record.application_date) : (record.payment_date ? dayjs(record.payment_date) : null),
|
||||
attachments: record.attachments || []
|
||||
});
|
||||
setModalVisible(true);
|
||||
@@ -249,7 +288,8 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
...values,
|
||||
payee,
|
||||
payee_id,
|
||||
payment_date: values.payment_date?.format('YYYY-MM-DD'),
|
||||
application_date: values.application_date?.format('YYYY-MM-DD'),
|
||||
payment_date: values.application_date?.format('YYYY-MM-DD'), // 兼容旧字段
|
||||
applicant: user?.name || user?.username
|
||||
};
|
||||
|
||||
@@ -325,7 +365,7 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
{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: 'application_date', key: 'application_date', width: 100, render: (v: string, r: any) => v || r.payment_date },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
|
||||
{ title: '编号', dataIndex: 'request_code', key: 'request_code', width: 120 },
|
||||
{
|
||||
@@ -350,6 +390,7 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
|
||||
// 监听表单值变化
|
||||
const payeeType = Form.useWatch('payee_type', form);
|
||||
const payeeSelect = Form.useWatch('payee_select', form);
|
||||
const expenseType = Form.useWatch('expense_type', form);
|
||||
const amount = Form.useWatch('amount', form);
|
||||
const currency = Form.useWatch('currency', form);
|
||||
@@ -358,6 +399,21 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
return amount && currency ? convertToCNY(amount, currency) : 0;
|
||||
}, [amount, currency, exchangeRates]);
|
||||
|
||||
// 当选择收款单位时,自动填充收款信息
|
||||
useEffect(() => {
|
||||
if (payeeType && payeeSelect && ['subcontractor', 'supplier', 'customer'].includes(payeeType)) {
|
||||
const paymentInfo = getPayeePaymentInfo(payeeType, payeeSelect);
|
||||
if (paymentInfo) {
|
||||
form.setFieldsValue({
|
||||
account_name: paymentInfo.account_name,
|
||||
bank_account: paymentInfo.bank_account,
|
||||
bank_name: paymentInfo.bank_name,
|
||||
qr_code: paymentInfo.qr_code
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [payeeType, payeeSelect]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
@@ -381,8 +437,38 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
<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 }]}>
|
||||
|
||||
{/* 第2项:支出类型和支出分类 */}
|
||||
<Form.Item name="expense_type" label="支出类型" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择支出类型">
|
||||
{EXPENSE_TYPES.map(type => (
|
||||
<Option key={type.value} value={type.value}>{type.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{/* 项目支出 - 选择项目 */}
|
||||
{expenseType === 'project' && (
|
||||
<Form.Item name="project_id" label="关联项目" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择项目" showSearch optionFilterProp="children">
|
||||
{projects.map(proj => (
|
||||
<Option key={proj.id} value={proj.id}>{proj.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* 支出分类 */}
|
||||
<Form.Item name="expense_category" label="支出分类" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择支出分类">
|
||||
{(expenseType === 'project' ? PROJECT_EXPENSE_CATEGORIES : COMPANY_EXPENSE_CATEGORIES).map(cat => (
|
||||
<Option key={cat.value} value={cat.value}>{cat.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{/* 申请日期(原付款日期,不显示) */}
|
||||
<Form.Item name="application_date" label="申请日期" rules={[{ required: true }]} style={{ display: 'none' }}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -431,41 +517,22 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* 收款户名 - 新增字段 */}
|
||||
<Form.Item name="account_name" label="收款户名">
|
||||
<Input placeholder="收款户名(选择分包商/供应商/客户时自动填充)" readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="bank_account" label="银行账号">
|
||||
<Input placeholder="收款银行账号" />
|
||||
<Input placeholder="收款银行账号(选择分包商/供应商/客户时自动填充)" readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="bank_name" label="开户银行">
|
||||
<Input placeholder="开户银行名称" />
|
||||
<Input placeholder="开户银行名称(选择分包商/供应商/客户时自动填充)" readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} />
|
||||
</Form.Item>
|
||||
|
||||
{/* 支出类型 */}
|
||||
<Form.Item name="expense_type" label="支出类型" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择支出类型">
|
||||
{EXPENSE_TYPES.map(type => (
|
||||
<Option key={type.value} value={type.value}>{type.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{/* 项目支出 - 选择项目 */}
|
||||
{expenseType === 'project' && (
|
||||
<Form.Item name="project_id" label="关联项目" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择项目" showSearch optionFilterProp="children">
|
||||
{projects.map(proj => (
|
||||
<Option key={proj.id} value={proj.id}>{proj.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* 支出分类 */}
|
||||
<Form.Item name="expense_category" label="支出分类" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择支出分类">
|
||||
{(expenseType === 'project' ? PROJECT_EXPENSE_CATEGORIES : COMPANY_EXPENSE_CATEGORIES).map(cat => (
|
||||
<Option key={cat.value} value={cat.value}>{cat.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
{/* 收款码 - 新增字段 */}
|
||||
<Form.Item name="qr_code" label="收款码">
|
||||
<FileUpload maxCount={1} accept="image/*" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="currency" label="币种" rules={[{ required: true }]}>
|
||||
@@ -505,9 +572,10 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
<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.application_date || selectedRecord.payment_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="收款单位类型">{getPayeeTypeLabel(selectedRecord.payee_type)}</Descriptions.Item>
|
||||
<Descriptions.Item label="收款单位">{selectedRecord.payee}</Descriptions.Item>
|
||||
<Descriptions.Item label="收款户名">{selectedRecord.account_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="银行账号">{selectedRecord.bank_account || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开户银行">{selectedRecord.bank_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="支出类型">
|
||||
@@ -530,6 +598,13 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
<Descriptions.Item label="付款事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{selectedRecord.qr_code && (
|
||||
<>
|
||||
<Divider>收款码</Divider>
|
||||
<Image src={selectedRecord.qr_code} width={200} style={{ borderRadius: 4 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedRecord.attachments && selectedRecord.attachments.length > 0 && (
|
||||
<>
|
||||
<Divider>凭证附件</Divider>
|
||||
@@ -549,4 +624,4 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentRequestsPage;
|
||||
export default PaymentRequestsPage;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
Table, Button, Modal, Form, Input, Select, message, Space, Tag, Card,
|
||||
Row, Col, Statistic, TreeSelect, Image, Popconfirm, Tabs, Empty, Spin, InputNumber,
|
||||
@@ -43,6 +44,12 @@ interface Product {
|
||||
|
||||
// ==================== 组件 ====================
|
||||
const ProductPage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
// 从 location state 中获取返回路径
|
||||
const returnTo = (location.state as { returnTo?: string })?.returnTo
|
||||
|
||||
// 商品列表状态
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -227,6 +234,13 @@ const ProductPage: React.FC = () => {
|
||||
setProductModalVisible(true)
|
||||
}
|
||||
|
||||
// 如果是从采购申请页面跳转过来的,自动打开新增商品弹窗
|
||||
useEffect(() => {
|
||||
if (returnTo && (location.state as { openAddModal?: boolean })?.openAddModal) {
|
||||
handleAddProduct()
|
||||
}
|
||||
}, [returnTo, location.state])
|
||||
|
||||
// 打开编辑商品弹窗
|
||||
const handleEditProduct = (product: Product) => {
|
||||
setEditingProduct(product)
|
||||
@@ -279,6 +293,19 @@ const ProductPage: React.FC = () => {
|
||||
setProductModalVisible(false)
|
||||
fetchProducts()
|
||||
fetchCategories() // 刷新分类
|
||||
|
||||
// 如果是从采购申请页面跳转过来的,创建成功后返回
|
||||
if (returnTo && !editingProduct) {
|
||||
// 延迟导航,确保状态更新
|
||||
setTimeout(() => {
|
||||
navigate(returnTo, {
|
||||
state: {
|
||||
productCreated: true,
|
||||
fromPurchaseRequest: true
|
||||
}
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
} else {
|
||||
message.error(data.error || '操作失败')
|
||||
}
|
||||
@@ -861,7 +888,20 @@ const ProductPage: React.FC = () => {
|
||||
title={editingProduct ? '编辑商品' : '新增商品'}
|
||||
open={productModalVisible}
|
||||
onOk={handleSaveProduct}
|
||||
onCancel={() => setProductModalVisible(false)}
|
||||
onCancel={() => {
|
||||
setProductModalVisible(false)
|
||||
// 如果是从采购申请页面跳转过来的,取消后返回
|
||||
if (returnTo) {
|
||||
// 延迟导航,确保状态更新
|
||||
setTimeout(() => {
|
||||
navigate(returnTo, {
|
||||
state: {
|
||||
fromPurchaseRequest: true
|
||||
}
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
}}
|
||||
width={600}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
Table, Button, Modal, Form, Input, Select, message, Space, Tag, Card,
|
||||
Row, Col, Statistic, DatePicker, InputNumber, Popconfirm, Tabs, Empty, Spin
|
||||
Row, Col, Statistic, DatePicker, InputNumber, Popconfirm, Tabs, Empty, Spin, Descriptions, Image, Divider
|
||||
} from 'antd'
|
||||
import {
|
||||
PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined,
|
||||
@@ -47,9 +48,34 @@ interface Project {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface PaymentInfo {
|
||||
account_name: string
|
||||
bank_account: string
|
||||
bank_name: string
|
||||
qr_code?: string
|
||||
is_primary: boolean
|
||||
}
|
||||
|
||||
interface Supplier {
|
||||
id: number
|
||||
name: string
|
||||
payment_infos?: PaymentInfo[]
|
||||
}
|
||||
|
||||
interface ProductCategory {
|
||||
id: number
|
||||
name: string
|
||||
parent_id: number | null
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: number
|
||||
name: string
|
||||
specification: string | null
|
||||
unit: string
|
||||
category_id: number
|
||||
category_name: string
|
||||
model: string | null
|
||||
}
|
||||
|
||||
// ==================== 组件 ====================
|
||||
@@ -59,20 +85,47 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([])
|
||||
const [products, setProducts] = useState<{ id: number; name: string }[]>([])
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [categories, setCategories] = useState<ProductCategory[]>([])
|
||||
|
||||
// 筛选状态
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(null)
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | null>(null)
|
||||
|
||||
// 商品选择筛选状态
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null)
|
||||
const [productSearchText, setProductSearchText] = useState<string>('')
|
||||
|
||||
// 弹窗状态
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false)
|
||||
const [paymentInfoModalVisible, setPaymentInfoModalVisible] = useState(false)
|
||||
const [editingRequest, setEditingRequest] = useState<PurchaseRequest | null>(null)
|
||||
const [viewingRequest, setViewingRequest] = useState<PurchaseRequest | null>(null)
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null)
|
||||
|
||||
// 采购类型状态
|
||||
const [purchaseType, setPurchaseType] = useState<'inventory' | 'project'>('inventory')
|
||||
|
||||
// 币种状态
|
||||
const [currency, setCurrency] = useState<string>('CNY')
|
||||
|
||||
// 总金额状态
|
||||
const [totalAmount, setTotalAmount] = useState<number>(0)
|
||||
const [totalAmountCNY, setTotalAmountCNY] = useState<number>(0)
|
||||
|
||||
// 表单
|
||||
const [form] = Form.useForm()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
// 汇率(固定汇率,实际项目中应该从API获取)
|
||||
const exchangeRates = {
|
||||
CNY: 1,
|
||||
USD: 7.2,
|
||||
EUR: 7.8,
|
||||
LAK: 0.0004
|
||||
}
|
||||
|
||||
// ==================== 数据加载 ====================
|
||||
|
||||
@@ -135,6 +188,18 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/categories')
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setCategories(data.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取分类列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRequestDetail = async (id: number) => {
|
||||
try {
|
||||
const response = await fetch(`/api/purchase-requests/${id}`)
|
||||
@@ -155,16 +220,98 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
fetchProjects()
|
||||
fetchSuppliers()
|
||||
fetchProducts()
|
||||
fetchCategories()
|
||||
}, [])
|
||||
|
||||
// 检测是否从供应商或商品页面返回,重新加载列表
|
||||
useEffect(() => {
|
||||
// 从 location state 中获取返回信息
|
||||
const state = location.state as {
|
||||
returnTo?: string;
|
||||
supplierCreated?: boolean;
|
||||
productCreated?: boolean;
|
||||
formValues?: any
|
||||
}
|
||||
if (state?.supplierCreated || state?.productCreated) {
|
||||
// 重新加载供应商和商品列表
|
||||
if (state?.supplierCreated) fetchSuppliers()
|
||||
if (state?.productCreated) fetchProducts()
|
||||
}
|
||||
}, [location.state])
|
||||
|
||||
// 处理返回的表单数据
|
||||
useEffect(() => {
|
||||
// 首先尝试从 location.state 中获取
|
||||
const state = location.state as {
|
||||
formValues?: any,
|
||||
fromPurchaseRequest?: boolean
|
||||
}
|
||||
|
||||
let formValues = state?.formValues
|
||||
|
||||
// 如果没有,尝试从 sessionStorage 中获取
|
||||
if (!formValues) {
|
||||
const storedValues = sessionStorage.getItem('purchaseRequestFormValues')
|
||||
if (storedValues) {
|
||||
formValues = JSON.parse(storedValues)
|
||||
// 清除存储的数据
|
||||
sessionStorage.removeItem('purchaseRequestFormValues')
|
||||
}
|
||||
}
|
||||
|
||||
if (formValues || state?.fromPurchaseRequest) {
|
||||
// 延迟设置表单值,确保 form 已经初始化
|
||||
setTimeout(() => {
|
||||
if (formValues) {
|
||||
// 确保日期字段被正确转换为 dayjs 对象
|
||||
const values = {
|
||||
...formValues,
|
||||
request_date: formValues.request_date ? dayjs(formValues.request_date) : undefined
|
||||
}
|
||||
form.setFieldsValue(values)
|
||||
}
|
||||
// 重新打开采购申请弹窗
|
||||
setModalVisible(true)
|
||||
}, 100)
|
||||
}
|
||||
}, [location.state, form])
|
||||
|
||||
// 自动计算总金额
|
||||
useEffect(() => {
|
||||
const items = form.getFieldValue('items') || []
|
||||
const currency = form.getFieldValue('currency') || 'CNY'
|
||||
|
||||
// 计算总金额
|
||||
const total = items.reduce((sum: number, item: any) => sum + (item.total_price || 0), 0)
|
||||
setTotalAmount(total)
|
||||
|
||||
// 计算等价人民币
|
||||
const rate = exchangeRates[currency as keyof typeof exchangeRates] || 1
|
||||
setTotalAmountCNY(total * rate)
|
||||
}, [form])
|
||||
|
||||
useEffect(() => {
|
||||
fetchPurchaseRequests()
|
||||
}, [selectedProjectId, selectedStatus])
|
||||
|
||||
// 监听供应商选择变化
|
||||
useEffect(() => {
|
||||
const supplierId = form.getFieldValue('supplier_id')
|
||||
if (supplierId) {
|
||||
const supplier = suppliers.find(s => s.id === supplierId)
|
||||
if (supplier) {
|
||||
setSelectedSupplier(supplier)
|
||||
// 显示收款信息弹窗
|
||||
setPaymentInfoModalVisible(true)
|
||||
}
|
||||
}
|
||||
}, [suppliers])
|
||||
|
||||
// ==================== 操作函数 ====================
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingRequest(null)
|
||||
setPurchaseType('inventory')
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
purchase_type: 'inventory',
|
||||
@@ -178,6 +325,7 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
|
||||
const handleEdit = (record: PurchaseRequest) => {
|
||||
setEditingRequest(record)
|
||||
setPurchaseType(record.purchase_type as 'inventory' | 'project')
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
request_date: dayjs(record.request_date)
|
||||
@@ -322,6 +470,21 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到新建供应商页面
|
||||
const handleCreateSupplier = () => {
|
||||
setModalVisible(false)
|
||||
// 跳转到供应商新建页面,并传递返回参数
|
||||
navigate('/suppliers', { state: { returnTo: '/purchase-requests' } })
|
||||
}
|
||||
|
||||
// 获取主要收款信息
|
||||
const getPrimaryPaymentInfo = (supplier: Supplier): PaymentInfo | null => {
|
||||
if (!supplier.payment_infos || supplier.payment_infos.length === 0) {
|
||||
return null
|
||||
}
|
||||
return supplier.payment_infos.find(p => p.is_primary) || supplier.payment_infos[0]
|
||||
}
|
||||
|
||||
// ==================== 渲染 ====================
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
@@ -530,38 +693,38 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
label="采购类型"
|
||||
rules={[{ required: true, message: '请选择采购类型' }]}
|
||||
>
|
||||
<Select placeholder="请选择采购类型">
|
||||
<Select
|
||||
placeholder="请选择采购类型"
|
||||
onChange={(value) => {
|
||||
setPurchaseType(value as 'inventory' | 'project')
|
||||
}}
|
||||
>
|
||||
<Select.Option value="inventory">库存采购</Select.Option>
|
||||
<Select.Option value="project">项目采购</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="project_id"
|
||||
label="关联项目"
|
||||
dependencies={['purchase_type']}
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value, callback) => {
|
||||
const purchaseType = form.getFieldValue('purchase_type');
|
||||
if (purchaseType === 'project' && !value) {
|
||||
callback('项目采购必须关联项目');
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
{purchaseType === 'project' && (
|
||||
<Form.Item
|
||||
name="project_id"
|
||||
label="关联项目"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '项目采购必须关联项目'
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select placeholder="请选择项目" allowClear>
|
||||
{projects.map(project => (
|
||||
<Select.Option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
]}
|
||||
>
|
||||
<Select placeholder="请选择项目" allowClear>
|
||||
{projects.map(project => (
|
||||
<Select.Option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -588,15 +751,6 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="request_date"
|
||||
label="申请日期"
|
||||
rules={[{ required: true, message: '请选择申请日期' }]}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="expense_category"
|
||||
@@ -616,18 +770,26 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="supplier_id" label="供应商">
|
||||
<Select placeholder="请选择供应商" allowClear>
|
||||
{suppliers.map(supplier => (
|
||||
<Select.Option key={supplier.id} value={supplier.id}>
|
||||
{supplier.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="supplier_name" label="供应商名称(手动输入)">
|
||||
<Input placeholder="请输入供应商名称" />
|
||||
<div>
|
||||
<Select
|
||||
placeholder="请选择供应商"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{suppliers.map(supplier => (
|
||||
<Select.Option key={supplier.id} value={supplier.id}>
|
||||
{supplier.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
type="link"
|
||||
style={{ marginTop: 8 }}
|
||||
onClick={handleCreateSupplier}
|
||||
>
|
||||
新建供应商
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -636,34 +798,122 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
<Input.TextArea rows={3} placeholder="请输入备注" />
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="currency"
|
||||
label="币种"
|
||||
rules={[{ required: true, message: '请选择币种' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择币种"
|
||||
onChange={(value) => {
|
||||
setCurrency(value)
|
||||
}}
|
||||
>
|
||||
<Select.Option value="CNY">人民币</Select.Option>
|
||||
<Select.Option value="USD">美元</Select.Option>
|
||||
<Select.Option value="EUR">欧元</Select.Option>
|
||||
<Select.Option value="LAK">老挝基普</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.List name="items" label="商品明细">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Row key={key} gutter={8} style={{ marginBottom: 8 }}>
|
||||
<Col span={6}>
|
||||
<Col span={5}>
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'product_id']}
|
||||
rules={[{ required: true, message: '请选择商品' }]}
|
||||
>
|
||||
<Select placeholder="请选择商品">
|
||||
{products.map(product => (
|
||||
<Select.Option key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
<Select
|
||||
placeholder="搜索或选择商品"
|
||||
showSearch
|
||||
allowClear
|
||||
filterOption={false}
|
||||
onSearch={(value) => setProductSearchText(value)}
|
||||
onChange={(value) => {
|
||||
// 当选择商品时,自动填充规格和单位
|
||||
const selectedProduct = products.find(p => p.id === value)
|
||||
if (selectedProduct) {
|
||||
const items = form.getFieldValue('items') || []
|
||||
items[name] = {
|
||||
...items[name],
|
||||
product_id: value,
|
||||
specification: selectedProduct.specification || selectedProduct.model || '',
|
||||
unit: selectedProduct.unit,
|
||||
product_name: selectedProduct.name
|
||||
}
|
||||
form.setFieldsValue({ items })
|
||||
}
|
||||
}}
|
||||
popupRender={(menu) => (
|
||||
<div>
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0' }}>
|
||||
<Select
|
||||
placeholder="筛选分类"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
value={selectedCategoryId}
|
||||
onChange={(value) => {
|
||||
setSelectedCategoryId(value)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<Select.Option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
{menu}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{products
|
||||
.filter(product => {
|
||||
// 分类筛选
|
||||
if (selectedCategoryId && product.category_id !== selectedCategoryId) {
|
||||
return false
|
||||
}
|
||||
// 模糊搜索
|
||||
if (productSearchText) {
|
||||
const searchLower = productSearchText.toLowerCase()
|
||||
return (
|
||||
product.name.toLowerCase().includes(searchLower) ||
|
||||
(product.model && product.model.toLowerCase().includes(searchLower)) ||
|
||||
(product.specification && product.specification.toLowerCase().includes(searchLower))
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(product => (
|
||||
<Select.Option key={product.id} value={product.id}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>{product.name}</span>
|
||||
<span style={{ color: '#999', fontSize: 12 }}>
|
||||
{product.model || product.specification || ''} | {product.unit}
|
||||
</span>
|
||||
</div>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Col span={3}>
|
||||
<Form.Item {...restField} name={[name, 'specification']}>
|
||||
<Input placeholder="规格" />
|
||||
<Input placeholder="规格" disabled />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
<Col span={2}>
|
||||
<Form.Item {...restField} name={[name, 'unit']}>
|
||||
<Input placeholder="单位" />
|
||||
<Input placeholder="单位" disabled />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
@@ -672,7 +922,24 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
name={[name, 'quantity']}
|
||||
rules={[{ required: true, message: '请输入数量' }]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} placeholder="数量" min={0} />
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder="数量"
|
||||
min={0}
|
||||
onChange={(value) => {
|
||||
// 自动计算小计
|
||||
const items = form.getFieldValue('items') || []
|
||||
const item = items[name] || {}
|
||||
const quantity = value || 0
|
||||
const unitPrice = item.unit_price || 0
|
||||
items[name] = {
|
||||
...item,
|
||||
quantity,
|
||||
total_price: quantity * unitPrice
|
||||
}
|
||||
form.setFieldsValue({ items })
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
@@ -681,7 +948,29 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
name={[name, 'unit_price']}
|
||||
rules={[{ required: true, message: '请输入单价' }]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} placeholder="单价" min={0} />
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ marginRight: 8, color: '#666' }}>
|
||||
{{ CNY: '¥', USD: '$', EUR: '€', LAK: '₭' }[form.getFieldValue('currency') || 'CNY'] || ''}
|
||||
</span>
|
||||
<InputNumber
|
||||
style={{ flex: 1 }}
|
||||
placeholder="单价"
|
||||
min={0}
|
||||
onChange={(value) => {
|
||||
// 自动计算小计
|
||||
const items = form.getFieldValue('items') || []
|
||||
const item = items[name] || {}
|
||||
const quantity = item.quantity || 0
|
||||
const unitPrice = value || 0
|
||||
items[name] = {
|
||||
...item,
|
||||
unit_price: unitPrice,
|
||||
total_price: quantity * unitPrice
|
||||
}
|
||||
form.setFieldsValue({ items })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
@@ -690,30 +979,96 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
name={[name, 'total_price']}
|
||||
rules={[{ required: true, message: '请输入小计' }]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} placeholder="小计" min={0} />
|
||||
<InputNumber style={{ width: '100%' }} placeholder="小计" min={0} disabled />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={2}>
|
||||
<Space direction="vertical">
|
||||
<Col span={5}>
|
||||
<Button type="text" danger onClick={() => remove(name)}>
|
||||
删除
|
||||
</Button>
|
||||
<Button type="text" onClick={() => {/* 跳转到新建商品页面 */}}>
|
||||
新建商品
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
添加商品
|
||||
</Button>
|
||||
<Space style={{ width: '100%' }}>
|
||||
<Button type="dashed" onClick={() => add({ quantity: 0, unit_price: 0, total_price: 0 })} style={{ flex: 1 }} icon={<PlusOutlined />}>
|
||||
添加商品
|
||||
</Button>
|
||||
<Button type="dashed" onClick={() => {
|
||||
// 保存当前表单数据,以便返回时恢复
|
||||
const formValues = form.getFieldsValue()
|
||||
// 保存当前编辑状态
|
||||
sessionStorage.setItem('purchaseRequestFormValues', JSON.stringify(formValues))
|
||||
navigate('/products', {
|
||||
state: {
|
||||
returnTo: '/purchase-requests',
|
||||
openAddModal: true,
|
||||
fromPurchaseRequest: true
|
||||
}
|
||||
})
|
||||
}} icon={<PlusOutlined />}>
|
||||
新建商品
|
||||
</Button>
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
{/* 总金额显示 */}
|
||||
<Row gutter={16} style={{ marginTop: 24, paddingTop: 16, borderTop: '1px solid #f0f0f0' }}>
|
||||
<Col span={16}></Col>
|
||||
<Col span={8}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<span style={{ marginRight: 16 }}>总金额:</span>
|
||||
<strong style={{ fontSize: 16 }}>
|
||||
{form.getFieldValue('currency') || 'CNY'} {totalAmount.toFixed(2)}
|
||||
</strong>
|
||||
</div>
|
||||
{form.getFieldValue('currency') !== 'CNY' && (
|
||||
<div style={{ color: '#666', fontSize: 12 }}>
|
||||
等价人民币:¥ {totalAmountCNY.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 供应商收款信息弹窗 */}
|
||||
<Modal
|
||||
title="供应商收款信息"
|
||||
open={paymentInfoModalVisible}
|
||||
onOk={() => setPaymentInfoModalVisible(false)}
|
||||
onCancel={() => setPaymentInfoModalVisible(false)}
|
||||
width={600}
|
||||
>
|
||||
{selectedSupplier && (
|
||||
<div>
|
||||
<h3>{selectedSupplier.name}</h3>
|
||||
<Divider />
|
||||
{(() => {
|
||||
const paymentInfo = getPrimaryPaymentInfo(selectedSupplier)
|
||||
if (!paymentInfo) {
|
||||
return <Empty description="该供应商暂无收款信息" />
|
||||
}
|
||||
return (
|
||||
<Descriptions bordered column={1}>
|
||||
<Descriptions.Item label="收款户名">{paymentInfo.account_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="银行账号">{paymentInfo.bank_account || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开户银行">{paymentInfo.bank_name || '-'}</Descriptions.Item>
|
||||
{paymentInfo.qr_code && (
|
||||
<Descriptions.Item label="收款码">
|
||||
<Image src={paymentInfo.qr_code} width={150} style={{ borderRadius: 4 }} />
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
title="采购申请详情"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect } 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 } from '@ant-design/icons'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, SolutionOutlined, BankOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import FileUpload from '../components/FileUpload'
|
||||
|
||||
interface Contact {
|
||||
name: string
|
||||
@@ -11,6 +12,14 @@ interface Contact {
|
||||
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
|
||||
@@ -19,6 +28,7 @@ interface Subcontractor {
|
||||
features: string
|
||||
country: string
|
||||
contacts: Contact[]
|
||||
payment_infos: PaymentInfo[]
|
||||
remark: string
|
||||
total_contract_amount: number
|
||||
total_paid: number
|
||||
@@ -61,6 +71,11 @@ const SubcontractorPage: React.FC = () => {
|
||||
return primary?.name || '-'
|
||||
}
|
||||
|
||||
const getPrimaryPaymentInfo = (paymentInfos: PaymentInfo[]) => {
|
||||
const primary = paymentInfos?.find(p => p.is_primary)
|
||||
return primary
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Subcontractor> = [
|
||||
{ title: '编号', dataIndex: 'code', key: 'code', width: 120 },
|
||||
{
|
||||
@@ -71,6 +86,22 @@ const SubcontractorPage: React.FC = () => {
|
||||
},
|
||||
{ title: '承包范围', dataIndex: 'scope', key: 'scope', width: 120 },
|
||||
{ title: '主联系人', key: 'primary_contact', width: 100, render: (_, record) => getPrimaryContact(record.contacts || []) },
|
||||
{
|
||||
title: '收款信息',
|
||||
key: 'payment_info',
|
||||
width: 200,
|
||||
render: (_, record) => {
|
||||
const primary = getPrimaryPaymentInfo(record.payment_infos || [])
|
||||
if (!primary) return <Tag>未设置</Tag>
|
||||
return (
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<div><BankOutlined /> {primary.bank_name || '-'}</div>
|
||||
<div>户名: {primary.account_name || '-'}</div>
|
||||
<div>账号: {primary.bank_account ? primary.bank_account.slice(-4).padStart(primary.bank_account.length, '*') : '-'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{ title: '国家', dataIndex: 'country', key: 'country', width: 80, render: (c) => <Tag>{c || '-'}</Tag> },
|
||||
{ title: '合同金额', dataIndex: 'total_contract_amount', key: 'total_contract_amount', width: 100, render: (v) => `¥${(v || 0).toLocaleString()}` },
|
||||
{ title: '应付金额', dataIndex: 'total_payable', key: 'total_payable', width: 100, render: (v) => <span style={{ color: v > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(v || 0).toLocaleString()}</span> },
|
||||
@@ -92,7 +123,6 @@ const SubcontractorPage: React.FC = () => {
|
||||
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
|
||||
@@ -100,16 +130,37 @@ const SubcontractorPage: React.FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
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 => c.is_primary)
|
||||
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 }) })
|
||||
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 ? '更新成功' : '创建成功')
|
||||
@@ -128,8 +179,13 @@ const SubcontractorPage: React.FC = () => {
|
||||
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 }]
|
||||
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)
|
||||
}
|
||||
@@ -151,7 +207,11 @@ const SubcontractorPage: React.FC = () => {
|
||||
const handleAdd = () => {
|
||||
setEditingSubcontractor(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ country: 'Laos', contacts: [{ name: '', position: '', phone: '', is_primary: true }] })
|
||||
form.setFieldsValue({
|
||||
country: 'Laos',
|
||||
contacts: [{ name: '', position: '', phone: '', is_primary: true }],
|
||||
payment_infos: []
|
||||
})
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
@@ -171,15 +231,25 @@ const SubcontractorPage: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table columns={columns} dataSource={filteredSubcontractors} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 900 }} />
|
||||
<Table columns={columns} dataSource={filteredSubcontractors} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 1100 }} />
|
||||
</Card>
|
||||
|
||||
<Modal title={editingSubcontractor ? '编辑分包商' : '新增分包商'} open={modalVisible} onCancel={() => { setModalVisible(false); form.resetFields(); setEditingSubcontractor(null) }} onOk={() => form.submit()} width={700}>
|
||||
<Modal
|
||||
title={editingSubcontractor ? '编辑分包商' : '新增分包商'}
|
||||
open={modalVisible}
|
||||
onCancel={() => { setModalVisible(false); form.resetFields(); setEditingSubcontractor(null) }}
|
||||
onOk={() => form.submit()}
|
||||
width={800}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}><Input placeholder="分包商名称" /></Form.Item>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="分包商名称" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="scope" label="承包范围"><Input placeholder="手填:如电力安装、土建工程" /></Form.Item>
|
||||
<Form.Item name="scope" label="承包范围">
|
||||
<Input placeholder="手填:如电力安装、土建工程" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="country" label="国家" initialValue="Laos">
|
||||
@@ -190,17 +260,28 @@ const SubcontractorPage: React.FC = () => {
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="features" label="特点"><Input.TextArea rows={2} placeholder="手填:如专业团队、设备齐全、价格合理等" /></Form.Item>
|
||||
<Form.Item name="remark" label="备注"><Input.TextArea rows={2} placeholder="备注信息" /></Form.Item>
|
||||
<Form.Item name="features" label="特点">
|
||||
<Input.TextArea rows={2} placeholder="手填:如专业团队、设备齐全、价格合理等" />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="备注信息" />
|
||||
</Form.Item>
|
||||
|
||||
<h4>联系人</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="姓名" /></Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}><Input placeholder="职位" /></Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}><Input placeholder="电话" /></Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="姓名" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="职位" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="电话" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -214,6 +295,46 @@ const SubcontractorPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<h4 style={{ marginTop: 24 }}>收款信息</h4>
|
||||
<Form.List name="payment_infos">
|
||||
{(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="收款户名" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="收款户名" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'bank_name']} label="开户银行" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="开户银行" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
||||
<Form.Item {...restField} name={[name, 'bank_account']} label="银行账号" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="银行账号" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginTop: 30 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => handlePaymentInfoChange(name, 'is_primary', e.target.checked)}
|
||||
/> 主要收款账户
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item {...restField} name={[name, 'qr_code']} label="收款码" style={{ marginBottom: 0 }}>
|
||||
<FileUpload maxCount={1} accept="image/*" />
|
||||
</Form.Item>
|
||||
{fields.length > 0 && (
|
||||
<Button type="link" danger onClick={() => remove(name)} style={{ marginTop: 8 }}>删除此收款信息</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add({ account_name: '', bank_account: '', bank_name: '', is_primary: false })} style={{ width: '100%' }}>
|
||||
+ 添加收款信息
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, useLocation } 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, ShopOutlined } from '@ant-design/icons'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ShopOutlined, BankOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import FileUpload from '../components/FileUpload'
|
||||
|
||||
interface Contact {
|
||||
name: string
|
||||
@@ -11,6 +12,14 @@ interface Contact {
|
||||
is_primary?: boolean
|
||||
}
|
||||
|
||||
interface PaymentInfo {
|
||||
account_name: string
|
||||
bank_account: string
|
||||
bank_name: string
|
||||
qr_code?: string
|
||||
is_primary: boolean
|
||||
}
|
||||
|
||||
interface Supplier {
|
||||
id: number
|
||||
code: string
|
||||
@@ -18,6 +27,7 @@ interface Supplier {
|
||||
supply_category: string
|
||||
country: string
|
||||
contacts: Contact[]
|
||||
payment_infos: PaymentInfo[]
|
||||
remark: string
|
||||
total_purchase_amount: number
|
||||
total_paid: number
|
||||
@@ -27,12 +37,16 @@ interface Supplier {
|
||||
|
||||
const SupplierPage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [form] = Form.useForm()
|
||||
|
||||
// 从 location state 中获取返回路径
|
||||
const returnTo = (location.state as { returnTo?: string })?.returnTo
|
||||
|
||||
const fetchSuppliers = async () => {
|
||||
setLoading(true)
|
||||
@@ -60,6 +74,11 @@ const SupplierPage: React.FC = () => {
|
||||
return primary?.name || '-'
|
||||
}
|
||||
|
||||
const getPrimaryPaymentInfo = (paymentInfos: PaymentInfo[]) => {
|
||||
const primary = paymentInfos?.find(p => p.is_primary)
|
||||
return primary
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Supplier> = [
|
||||
{ title: '编号', dataIndex: 'code', key: 'code', width: 120 },
|
||||
{
|
||||
@@ -74,6 +93,22 @@ const SupplierPage: React.FC = () => {
|
||||
},
|
||||
{ title: '供应类别', dataIndex: 'supply_category', key: 'supply_category', width: 120 },
|
||||
{ title: '主联系人', key: 'primary_contact', width: 100, render: (_, record) => getPrimaryContact(record.contacts || []) },
|
||||
{
|
||||
title: '收款信息',
|
||||
key: 'payment_info',
|
||||
width: 200,
|
||||
render: (_, record) => {
|
||||
const primary = getPrimaryPaymentInfo(record.payment_infos || [])
|
||||
if (!primary) return <Tag>未设置</Tag>
|
||||
return (
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<div><BankOutlined /> {primary.bank_name || '-'}</div>
|
||||
<div>户名: {primary.account_name || '-'}</div>
|
||||
<div>账号: {primary.bank_account ? primary.bank_account.slice(-4).padStart(primary.bank_account.length, '*') : '-'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{ title: '国家', dataIndex: 'country', key: 'country', width: 80, render: (country) => <Tag>{country || '-'}</Tag> },
|
||||
{ title: '采购金额', dataIndex: 'total_purchase_amount', key: 'total_purchase_amount', width: 100, render: (amount) => `¥${(amount || 0).toLocaleString()}` },
|
||||
{ title: '应付金额', dataIndex: 'total_payable', key: 'total_payable', width: 100, render: (amount) => <span style={{ color: amount > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(amount || 0).toLocaleString()}</span> },
|
||||
@@ -100,7 +135,6 @@ const SupplierPage: React.FC = () => {
|
||||
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
|
||||
@@ -108,19 +142,36 @@ const SupplierPage: React.FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
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 => c.is_primary)
|
||||
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 = editingSupplier ? `/api/suppliers/${editingSupplier.id}` : '/api/suppliers'
|
||||
const method = editingSupplier ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...values, contacts })
|
||||
body: JSON.stringify({ ...values, contacts, payment_infos: paymentInfos })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
@@ -130,6 +181,11 @@ const SupplierPage: React.FC = () => {
|
||||
form.resetFields()
|
||||
setEditingSupplier(null)
|
||||
fetchSuppliers()
|
||||
|
||||
// 如果是从采购申请页面跳转过来的,创建成功后返回
|
||||
if (returnTo && !editingSupplier) {
|
||||
navigate(returnTo, { state: { supplierCreated: true } })
|
||||
}
|
||||
} else {
|
||||
message.error(data.message || '操作失败')
|
||||
}
|
||||
@@ -145,7 +201,8 @@ const SupplierPage: React.FC = () => {
|
||||
supply_category: supplier.supply_category,
|
||||
country: supplier.country,
|
||||
remark: supplier.remark,
|
||||
contacts: supplier.contacts?.length ? supplier.contacts : [{ name: '', position: '', phone: '', is_primary: true }]
|
||||
contacts: supplier.contacts?.length ? supplier.contacts : [{ name: '', position: '', phone: '', is_primary: true }],
|
||||
payment_infos: supplier.payment_infos?.length ? supplier.payment_infos : []
|
||||
})
|
||||
setModalVisible(true)
|
||||
}
|
||||
@@ -172,9 +229,20 @@ const SupplierPage: React.FC = () => {
|
||||
const handleAdd = () => {
|
||||
setEditingSupplier(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ country: 'Laos', contacts: [{ name: '', position: '', phone: '', is_primary: true }] })
|
||||
form.setFieldsValue({
|
||||
country: 'Laos',
|
||||
contacts: [{ name: '', position: '', phone: '', is_primary: true }],
|
||||
payment_infos: []
|
||||
})
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
// 如果是从采购申请页面跳转过来的,自动打开新增供应商弹窗
|
||||
useEffect(() => {
|
||||
if (returnTo) {
|
||||
handleAdd()
|
||||
}
|
||||
}, [returnTo])
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
@@ -192,10 +260,16 @@ const SupplierPage: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table columns={columns} dataSource={filteredSuppliers} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 900 }} />
|
||||
<Table columns={columns} dataSource={filteredSuppliers} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 1100 }} />
|
||||
</Card>
|
||||
|
||||
<Modal title={editingSupplier ? '编辑供应商' : '新增供应商'} open={modalVisible} onCancel={() => { setModalVisible(false); form.resetFields(); setEditingSupplier(null) }} onOk={() => form.submit()} width={700}>
|
||||
<Modal
|
||||
title={editingSupplier ? '编辑供应商' : '新增供应商'}
|
||||
open={modalVisible}
|
||||
onCancel={() => { setModalVisible(false); form.resetFields(); setEditingSupplier(null) }}
|
||||
onOk={() => form.submit()}
|
||||
width={800}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="供应商名称" />
|
||||
@@ -218,15 +292,22 @@ const SupplierPage: React.FC = () => {
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="备注信息" />
|
||||
</Form.Item>
|
||||
|
||||
<h4>联系人</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="姓名" /></Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}><Input placeholder="职位" /></Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}><Input placeholder="电话" /></Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="姓名" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="职位" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="电话" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -240,6 +321,46 @@ const SupplierPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<h4 style={{ marginTop: 24 }}>收款信息</h4>
|
||||
<Form.List name="payment_infos">
|
||||
{(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="收款户名" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="收款户名" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'bank_name']} label="开户银行" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="开户银行" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
||||
<Form.Item {...restField} name={[name, 'bank_account']} label="银行账号" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="银行账号" />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginTop: 30 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => handlePaymentInfoChange(name, 'is_primary', e.target.checked)}
|
||||
/> 主要收款账户
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item {...restField} name={[name, 'qr_code']} label="收款码" style={{ marginBottom: 0 }}>
|
||||
<FileUpload maxCount={1} accept="image/*" />
|
||||
</Form.Item>
|
||||
{fields.length > 0 && (
|
||||
<Button type="link" danger onClick={() => remove(name)} style={{ marginTop: 8 }}>删除此收款信息</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add({ account_name: '', bank_account: '', bank_name: '', is_primary: false })} style={{ width: '100%' }}>
|
||||
+ 添加收款信息
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
const { chromium } = require('playwright-core');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
||||
|
||||
try {
|
||||
// 访问登录页面
|
||||
await page.goto('http://localhost:3002/login');
|
||||
console.log('✓ 登录页面加载成功');
|
||||
|
||||
// 等待页面加载完成
|
||||
await page.waitForSelector('input[name="username"]');
|
||||
console.log('✓ 用户名输入框可见');
|
||||
|
||||
// 输入用户名
|
||||
await page.fill('input[name="username"]', 'admin');
|
||||
console.log('✓ 输入用户名: admin');
|
||||
|
||||
// 输入密码
|
||||
await page.fill('input[name="password"]', 'X123c321@');
|
||||
console.log('✓ 输入密码: X123c321@');
|
||||
|
||||
// 点击登录按钮
|
||||
await page.click('button[type="submit"]');
|
||||
console.log('✓ 点击登录按钮');
|
||||
|
||||
// 等待登录完成(等待跳转到 dashboard)
|
||||
await page.waitForURL('**/dashboard', { timeout: 10000 });
|
||||
console.log('✓ 登录成功,已跳转到 dashboard');
|
||||
|
||||
// 截图保存
|
||||
await page.screenshot({ path: 'login-success.png', fullPage: true });
|
||||
console.log('✓ 截图已保存: login-success.png');
|
||||
|
||||
console.log('\n🎉 登录测试成功!');
|
||||
} catch (error) {
|
||||
console.error('\n❌ 登录测试失败:', error.message);
|
||||
await page.screenshot({ path: 'login-failed.png', fullPage: true });
|
||||
console.log('✓ 错误截图已保存: login-failed.png');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,44 @@
|
||||
import { chromium } from 'playwright-core';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
||||
|
||||
try {
|
||||
// 访问登录页面
|
||||
await page.goto('http://localhost:3002/login');
|
||||
console.log('✓ 登录页面加载成功');
|
||||
|
||||
// 等待页面加载完成
|
||||
await page.waitForSelector('input[name="username"]');
|
||||
console.log('✓ 用户名输入框可见');
|
||||
|
||||
// 输入用户名
|
||||
await page.fill('input[name="username"]', 'admin');
|
||||
console.log('✓ 输入用户名: admin');
|
||||
|
||||
// 输入密码
|
||||
await page.fill('input[name="password"]', 'X123c321@');
|
||||
console.log('✓ 输入密码: X123c321@');
|
||||
|
||||
// 点击登录按钮
|
||||
await page.click('button[type="submit"]');
|
||||
console.log('✓ 点击登录按钮');
|
||||
|
||||
// 等待登录完成(等待跳转到 dashboard)
|
||||
await page.waitForURL('**/dashboard', { timeout: 10000 });
|
||||
console.log('✓ 登录成功,已跳转到 dashboard');
|
||||
|
||||
// 截图保存
|
||||
await page.screenshot({ path: 'login-success.png', fullPage: true });
|
||||
console.log('✓ 截图已保存: login-success.png');
|
||||
|
||||
console.log('\n🎉 登录测试成功!');
|
||||
} catch (error) {
|
||||
console.error('\n❌ 登录测试失败:', error.message);
|
||||
await page.screenshot({ path: 'login-failed.png', fullPage: true });
|
||||
console.log('✓ 错误截图已保存: login-failed.png');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user