diff --git a/company-finance-system/frontend/login-page.png b/company-finance-system/frontend/login-page.png new file mode 100644 index 0000000..0008cfc Binary files /dev/null and b/company-finance-system/frontend/login-page.png differ diff --git a/company-finance-system/frontend/login-test.png b/company-finance-system/frontend/login-test.png new file mode 100644 index 0000000..0008cfc Binary files /dev/null and b/company-finance-system/frontend/login-test.png differ diff --git a/company-finance-system/frontend/src/App.tsx b/company-finance-system/frontend/src/App.tsx index c283e6b..4a1cddd 100644 --- a/company-finance-system/frontend/src/App.tsx +++ b/company-finance-system/frontend/src/App.tsx @@ -106,7 +106,7 @@ function App() { }, }} > - + } /> diff --git a/company-finance-system/frontend/src/config/api.ts b/company-finance-system/frontend/src/config/api.ts index 62ce015..0418899 100644 --- a/company-finance-system/frontend/src/config/api.ts +++ b/company-finance-system/frontend/src/config/api.ts @@ -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', diff --git a/company-finance-system/frontend/src/pages/CustomersPage.tsx b/company-finance-system/frontend/src/pages/CustomersPage.tsx index 2b61eb0..a5a467f 100644 --- a/company-finance-system/frontend/src/pages/CustomersPage.tsx +++ b/company-finance-system/frontend/src/pages/CustomersPage.tsx @@ -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 = [ { 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 未设置 + return ( +
+
{primary.bank_name || '-'}
+
户名: {primary.account_name || '-'}
+
账号: {primary.bank_account ? primary.bank_account.slice(-4).padStart(primary.bank_account.length, '*') : '-'}
+
+ ) + } + }, { 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) => 0 ? '#ff4d4f' : '#52c41a' }}>¥{(v || 0).toLocaleString()} }, { 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 = () => { - `共 ${total} 条` }} scroll={{ x: 900 }} /> +
`共 ${total} 条` }} scroll={{ x: 1100 }} /> - { setModalVisible(false); form.resetFields(); setEditingCustomer(null) }} onOk={() => form.submit()} width={700}> + { setModalVisible(false); form.resetFields(); setEditingCustomer(null) }} + onOk={() => form.submit()} + width={800} + >
- - - + + + + + + + + + +

联系人

{(fields, { add, remove }) => (
{fields.map(({ key, name, ...restField }) => (
- - - + + + + + + + + + {
)} + +

收款信息

+ + {(fields, { add, remove }) => ( +
+ {fields.map(({ key, name, ...restField }) => ( +
+
+ + + + + + +
+
+ + + + + handlePaymentInfoChange(name, 'is_primary', e.target.checked)} + /> 主要收款账户 + +
+ + + + {fields.length > 0 && ( + + )} +
+ ))} + +
+ )} +
diff --git a/company-finance-system/frontend/src/pages/PaymentRequestsPage.tsx b/company-finance-system/frontend/src/pages/PaymentRequestsPage.tsx index 7f60ed3..140f620 100644 --- a/company-finance-system/frontend/src/pages/PaymentRequestsPage.tsx +++ b/company-finance-system/frontend/src/pages/PaymentRequestsPage.tsx @@ -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([]); @@ -57,9 +71,9 @@ const PaymentRequestsPage: React.FC = () => { const [exchangeRates, setExchangeRates] = useState>({}); // 数据列表 - const [subcontractors, setSubcontractors] = useState([]); - const [suppliers, setSuppliers] = useState([]); - const [customers, setCustomers] = useState([]); + const [subcontractors, setSubcontractors] = useState([]); + const [suppliers, setSuppliers] = useState([]); + const [customers, setCustomers] = useState([]); const [projects, setProjects] = useState([]); 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 &&
≈ ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}
} ) }, - { 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 (
@@ -381,8 +437,38 @@ const PaymentRequestsPage: React.FC = () => { - - + + {/* 第2项:支出类型和支出分类 */} + + + + + {/* 项目支出 - 选择项目 */} + {expenseType === 'project' && ( + + + + )} + + {/* 支出分类 */} + + + + + {/* 申请日期(原付款日期,不显示) */} + @@ -431,41 +517,22 @@ const PaymentRequestsPage: React.FC = () => { )} + {/* 收款户名 - 新增字段 */} + + + + - + - + - {/* 支出类型 */} - - - - - {/* 项目支出 - 选择项目 */} - {expenseType === 'project' && ( - - - - )} - - {/* 支出分类 */} - - + {/* 收款码 - 新增字段 */} + + @@ -505,9 +572,10 @@ const PaymentRequestsPage: React.FC = () => { {selectedRecord.request_code} {getStatusTag(selectedRecord.status)} {selectedRecord.applicant} - {selectedRecord.payment_date} + {selectedRecord.application_date || selectedRecord.payment_date} {getPayeeTypeLabel(selectedRecord.payee_type)} {selectedRecord.payee} + {selectedRecord.account_name || '-'} {selectedRecord.bank_account || '-'} {selectedRecord.bank_name || '-'} @@ -530,6 +598,13 @@ const PaymentRequestsPage: React.FC = () => { {selectedRecord.reason} + {selectedRecord.qr_code && ( + <> + 收款码 + + + )} + {selectedRecord.attachments && selectedRecord.attachments.length > 0 && ( <> 凭证附件 @@ -549,4 +624,4 @@ const PaymentRequestsPage: React.FC = () => { ); }; -export default PaymentRequestsPage; \ No newline at end of file +export default PaymentRequestsPage; diff --git a/company-finance-system/frontend/src/pages/ProductPage.tsx b/company-finance-system/frontend/src/pages/ProductPage.tsx index 96c70ee..86d5958 100644 --- a/company-finance-system/frontend/src/pages/ProductPage.tsx +++ b/company-finance-system/frontend/src/pages/ProductPage.tsx @@ -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([]) 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="取消" diff --git a/company-finance-system/frontend/src/pages/PurchaseRequestsPage.tsx b/company-finance-system/frontend/src/pages/PurchaseRequestsPage.tsx index 34a7050..619611f 100644 --- a/company-finance-system/frontend/src/pages/PurchaseRequestsPage.tsx +++ b/company-finance-system/frontend/src/pages/PurchaseRequestsPage.tsx @@ -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([]) const [suppliers, setSuppliers] = useState([]) - const [products, setProducts] = useState<{ id: number; name: string }[]>([]) + const [products, setProducts] = useState([]) + const [categories, setCategories] = useState([]) // 筛选状态 const [selectedProjectId, setSelectedProjectId] = useState(null) const [selectedStatus, setSelectedStatus] = useState(null) + // 商品选择筛选状态 + const [selectedCategoryId, setSelectedCategoryId] = useState(null) + const [productSearchText, setProductSearchText] = useState('') + // 弹窗状态 const [modalVisible, setModalVisible] = useState(false) const [detailModalVisible, setDetailModalVisible] = useState(false) + const [paymentInfoModalVisible, setPaymentInfoModalVisible] = useState(false) const [editingRequest, setEditingRequest] = useState(null) const [viewingRequest, setViewingRequest] = useState(null) + const [selectedSupplier, setSelectedSupplier] = useState(null) + + // 采购类型状态 + const [purchaseType, setPurchaseType] = useState<'inventory' | 'project'>('inventory') + + // 币种状态 + const [currency, setCurrency] = useState('CNY') + + // 总金额状态 + const [totalAmount, setTotalAmount] = useState(0) + const [totalAmountCNY, setTotalAmountCNY] = useState(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: '请选择采购类型' }]} > - { + setPurchaseType(value as 'inventory' | 'project') + }} + > 库存采购 项目采购
- { - const purchaseType = form.getFieldValue('purchase_type'); - if (purchaseType === 'project' && !value) { - callback('项目采购必须关联项目'); - } else { - callback(); - } + {purchaseType === 'project' && ( + - - + ]} + > + + + )} @@ -588,15 +751,6 @@ const PurchaseRequestsPage: React.FC = () => { - - - - - { - - - - - - +
+ + +
@@ -636,34 +798,122 @@ const PurchaseRequestsPage: React.FC = () => { + + + + + + + + {(fields, { add, remove }) => ( <> {fields.map(({ key, name, ...restField }) => ( - + - 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) => ( +
+
+ +
+ {menu} +
+ )} + > + {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 => ( + +
+ {product.name} + + {product.model || product.specification || ''} | {product.unit} + +
+
+ ))}
- + - + - + - + @@ -672,7 +922,24 @@ const PurchaseRequestsPage: React.FC = () => { name={[name, 'quantity']} rules={[{ required: true, message: '请输入数量' }]} > - + { + // 自动计算小计 + 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 }) + }} + /> @@ -681,7 +948,29 @@ const PurchaseRequestsPage: React.FC = () => { name={[name, 'unit_price']} rules={[{ required: true, message: '请输入单价' }]} > - +
+ + {{ CNY: '¥', USD: '$', EUR: '€', LAK: '₭' }[form.getFieldValue('currency') || 'CNY'] || ''} + + { + // 自动计算小计 + 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 }) + }} + /> +
@@ -690,30 +979,96 @@ const PurchaseRequestsPage: React.FC = () => { name={[name, 'total_price']} rules={[{ required: true, message: '请输入小计' }]} > - + - - + - - ))} - + + + + )} + + {/* 总金额显示 */} + + + +
+
+ 总金额: + + {form.getFieldValue('currency') || 'CNY'} {totalAmount.toFixed(2)} + +
+ {form.getFieldValue('currency') !== 'CNY' && ( +
+ 等价人民币:¥ {totalAmountCNY.toFixed(2)} +
+ )} +
+ + + {/* 供应商收款信息弹窗 */} + setPaymentInfoModalVisible(false)} + onCancel={() => setPaymentInfoModalVisible(false)} + width={600} + > + {selectedSupplier && ( +
+

{selectedSupplier.name}

+ + {(() => { + const paymentInfo = getPrimaryPaymentInfo(selectedSupplier) + if (!paymentInfo) { + return + } + return ( + + {paymentInfo.account_name || '-'} + {paymentInfo.bank_account || '-'} + {paymentInfo.bank_name || '-'} + {paymentInfo.qr_code && ( + + + + )} + + ) + })()} +
+ )} +
+ {/* 详情弹窗 */} { return primary?.name || '-' } + const getPrimaryPaymentInfo = (paymentInfos: PaymentInfo[]) => { + const primary = paymentInfos?.find(p => p.is_primary) + return primary + } + const columns: ColumnsType = [ { 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 未设置 + return ( +
+
{primary.bank_name || '-'}
+
户名: {primary.account_name || '-'}
+
账号: {primary.bank_account ? primary.bank_account.slice(-4).padStart(primary.bank_account.length, '*') : '-'}
+
+ ) + } + }, { title: '国家', dataIndex: 'country', key: 'country', width: 80, render: (c) => {c || '-'} }, { 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) => 0 ? '#ff4d4f' : '#52c41a' }}>¥{(v || 0).toLocaleString()} }, @@ -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 = () => { -
`共 ${total} 条` }} scroll={{ x: 900 }} /> +
`共 ${total} 条` }} scroll={{ x: 1100 }} /> - { setModalVisible(false); form.resetFields(); setEditingSubcontractor(null) }} onOk={() => form.submit()} width={700}> + { setModalVisible(false); form.resetFields(); setEditingSubcontractor(null) }} + onOk={() => form.submit()} + width={800} + >
- + + +
- + + + @@ -190,17 +260,28 @@ const SubcontractorPage: React.FC = () => { - - + + + + + + +

联系人

{(fields, { add, remove }) => (
{fields.map(({ key, name, ...restField }) => (
- - - + + + + + + + + + {
)} + +

收款信息

+ + {(fields, { add, remove }) => ( +
+ {fields.map(({ key, name, ...restField }) => ( +
+
+ + + + + + +
+
+ + + + + handlePaymentInfoChange(name, 'is_primary', e.target.checked)} + /> 主要收款账户 + +
+ + + + {fields.length > 0 && ( + + )} +
+ ))} + +
+ )} +
diff --git a/company-finance-system/frontend/src/pages/SuppliersPage.tsx b/company-finance-system/frontend/src/pages/SuppliersPage.tsx index f566c85..e992d27 100644 --- a/company-finance-system/frontend/src/pages/SuppliersPage.tsx +++ b/company-finance-system/frontend/src/pages/SuppliersPage.tsx @@ -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([]) const [loading, setLoading] = useState(false) const [modalVisible, setModalVisible] = useState(false) const [editingSupplier, setEditingSupplier] = useState(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 = [ { 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 未设置 + return ( +
+
{primary.bank_name || '-'}
+
户名: {primary.account_name || '-'}
+
账号: {primary.bank_account ? primary.bank_account.slice(-4).padStart(primary.bank_account.length, '*') : '-'}
+
+ ) + } + }, { title: '国家', dataIndex: 'country', key: 'country', width: 80, render: (country) => {country || '-'} }, { 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) => 0 ? '#ff4d4f' : '#52c41a' }}>¥{(amount || 0).toLocaleString()} }, @@ -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 (
@@ -192,10 +260,16 @@ const SupplierPage: React.FC = () => { -
`共 ${total} 条` }} scroll={{ x: 900 }} /> +
`共 ${total} 条` }} scroll={{ x: 1100 }} /> - { setModalVisible(false); form.resetFields(); setEditingSupplier(null) }} onOk={() => form.submit()} width={700}> + { setModalVisible(false); form.resetFields(); setEditingSupplier(null) }} + onOk={() => form.submit()} + width={800} + >
@@ -218,15 +292,22 @@ const SupplierPage: React.FC = () => { +

联系人

{(fields, { add, remove }) => (
{fields.map(({ key, name, ...restField }) => (
- - - + + + + + + + + + {
)} + +

收款信息

+ + {(fields, { add, remove }) => ( +
+ {fields.map(({ key, name, ...restField }) => ( +
+
+ + + + + + +
+
+ + + + + handlePaymentInfoChange(name, 'is_primary', e.target.checked)} + /> 主要收款账户 + +
+ + + + {fields.length > 0 && ( + + )} +
+ ))} + +
+ )} +
diff --git a/company-finance-system/frontend/test-login.js b/company-finance-system/frontend/test-login.js new file mode 100644 index 0000000..db22669 --- /dev/null +++ b/company-finance-system/frontend/test-login.js @@ -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(); + } +})(); diff --git a/company-finance-system/frontend/test-login.mjs b/company-finance-system/frontend/test-login.mjs new file mode 100644 index 0000000..f017e42 --- /dev/null +++ b/company-finance-system/frontend/test-login.mjs @@ -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(); + } +})();