Backup project on 2026-03-27

This commit is contained in:
System Administrator
2026-03-27 10:18:00 +07:00
parent 563ca12d76
commit 841f19e3f8
12 changed files with 1077 additions and 161 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

+1 -1
View File
@@ -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();
}
})();