Initial commit: ERP system with advance verification fixes
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Card, Descriptions, Tag, Spin, Empty, Row, Col, Statistic, Table, Button, Divider, Typography, Badge
|
||||
} from 'antd'
|
||||
import {
|
||||
ArrowLeftOutlined, HomeOutlined, FileTextOutlined, DollarOutlined, UserOutlined, PhoneOutlined
|
||||
} from '@ant-design/icons'
|
||||
import axios from 'axios'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
interface Contact {
|
||||
name: string
|
||||
position: string
|
||||
phone: string
|
||||
is_primary?: boolean
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
address: string
|
||||
contacts: Contact[]
|
||||
remark: string
|
||||
total_contract_amount: number
|
||||
total_received: number
|
||||
total_receivable: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: number
|
||||
project_code: string
|
||||
name: string
|
||||
contract_amount: string
|
||||
status: string
|
||||
customer_id: number
|
||||
}
|
||||
|
||||
interface PaymentNode {
|
||||
id: number
|
||||
project_id: number
|
||||
amount: number
|
||||
paid_amount: number
|
||||
}
|
||||
|
||||
interface Quotation {
|
||||
id: number
|
||||
version: number
|
||||
quotation_date: string
|
||||
amount: number
|
||||
currency: string
|
||||
status: string
|
||||
file_url?: string
|
||||
remark?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface BudgetProject {
|
||||
id: number
|
||||
name: string
|
||||
customer_id: number
|
||||
customer_name: string
|
||||
manager_id: number
|
||||
manager_name: string
|
||||
location?: string
|
||||
survey_date?: string
|
||||
intermediary?: string
|
||||
intermediary_fee_type?: string
|
||||
intermediary_fee_value?: number
|
||||
customer_requirements?: string
|
||||
project_overview?: string
|
||||
attachments?: string[]
|
||||
survey_photos?: string[]
|
||||
status: string
|
||||
days_in_status: number
|
||||
created_at: string
|
||||
quotations: Quotation[]
|
||||
}
|
||||
|
||||
const CustomerDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [customer, setCustomer] = useState<Customer | null>(null)
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [paymentNodes, setPaymentNodes] = useState<PaymentNode[]>([])
|
||||
const [budgetProjects, setBudgetProjects] = useState<BudgetProject[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomerDetail()
|
||||
fetchRelatedProjects()
|
||||
fetchRelatedBudgetProjects()
|
||||
}, [id])
|
||||
|
||||
const fetchCustomerDetail = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/customers/${id}`)
|
||||
const data = await res.json()
|
||||
if (data.success) setCustomer(data.data)
|
||||
} catch (error) {
|
||||
console.error('获取客户详情失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRelatedProjects = async () => {
|
||||
try {
|
||||
// 获取所有项目,筛选关联到此客户的
|
||||
const res = await fetch('/api/projects')
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
const customerProjects = (data.data || []).filter((p: Project) => p.customer_id === parseInt(id))
|
||||
setProjects(customerProjects)
|
||||
|
||||
// 获取所有付款节点
|
||||
const nodesRes = await fetch('/api/payment-nodes')
|
||||
const nodesData = await nodesRes.json()
|
||||
if (nodesData.success) {
|
||||
setPaymentNodes(nodesData.data || [])
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRelatedBudgetProjects = async () => {
|
||||
try {
|
||||
// 获取与当前客户关联的预算项目
|
||||
const res = await axios.get('/api/budget-projects', {
|
||||
params: { customer_id: id }
|
||||
})
|
||||
if (res.data.success) {
|
||||
setBudgetProjects(res.data.data || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取预算项目失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Spin style={{ display: 'flex', justifyContent: 'center', padding: 50 }} />
|
||||
if (!customer) return <Empty description="客户不存在" style={{ marginTop: 100 }} />
|
||||
|
||||
// 计算财务数据
|
||||
const totalContract = projects.reduce((sum, p) => sum + (parseFloat(p.contract_amount) || 0), 0)
|
||||
// 从付款节点计算已收金额
|
||||
const projectIds = projects.map(p => p.id)
|
||||
const relatedNodes = paymentNodes.filter(n => projectIds.includes(n.project_id))
|
||||
const totalReceived = relatedNodes.reduce((sum, n) => sum + (n.paid_amount || 0), 0)
|
||||
const totalReceivable = relatedNodes.reduce((sum, n) => sum + ((n.amount || 0) - (n.paid_amount || 0)), 0)
|
||||
|
||||
const projectColumns = [
|
||||
{ title: '项目编号', dataIndex: 'project_code', key: 'project_code', width: 120 },
|
||||
{ title: '项目名称', dataIndex: 'name', key: 'name', render: (v: string) => <Text strong>{v}</Text> },
|
||||
{ title: '合同金额', dataIndex: 'contract_amount', key: 'contract_amount', align: 'right' as const, render: (v: string) => `¥${(parseFloat(v) || 0).toLocaleString()}` },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: (v: string) => <Badge status={v === 'completed' ? 'success' : 'processing'} text={v === 'completed' ? '已完成' : v === 'planning' ? '规划中' : v === 'in_progress' ? '进行中' : v} /> }
|
||||
]
|
||||
|
||||
const budgetProjectColumns = [
|
||||
{ title: '项目名称', dataIndex: 'name', key: 'name', render: (v: string, record: BudgetProject) => (
|
||||
<Text strong onClick={() => navigate(`/budget-projects/${record.id}`)} style={{ cursor: 'pointer', color: '#1890ff' }}>
|
||||
{v}
|
||||
</Text>
|
||||
) },
|
||||
{ title: '业务经理', dataIndex: 'manager_name', key: 'manager_name' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: (v: string) => {
|
||||
const statusMap: Record<string, { status: 'success' | 'processing' | 'error' | 'default'; text: string }> = {
|
||||
negotiating: { status: 'processing', text: '商谈中' },
|
||||
signed: { status: 'success', text: '已签约' },
|
||||
unsigned: { status: 'error', text: '未签约' }
|
||||
}
|
||||
const config = statusMap[v] || { status: 'default', text: v }
|
||||
return <Badge status={config.status} text={config.text} />
|
||||
} },
|
||||
{ title: '报价版本数', dataIndex: 'quotations', key: 'quotations', align: 'center' as const, render: (quotations: Quotation[]) => (quotations || []).length },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', render: (v: string) => v.split('T')[0] }
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/customers')} style={{ marginBottom: 16 }} type="text">
|
||||
返回列表
|
||||
</Button>
|
||||
|
||||
<Title level={4} style={{ marginBottom: 24 }}>
|
||||
<HomeOutlined style={{ marginRight: 8, color: '#52c41a' }} />
|
||||
{customer.name}
|
||||
</Title>
|
||||
|
||||
{/* ========== 卡片1:基本信息 ========== */}
|
||||
<Card title={<><UserOutlined /> 基本信息</>} style={{ marginBottom: 24, borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
|
||||
<Descriptions bordered column={{ xs: 1, sm: 2 }} size="small">
|
||||
<Descriptions.Item label="编号">{customer.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="地址">{customer.address || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{customer.remark && (
|
||||
<>
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
<div><Text type="secondary">备注:</Text><div style={{ marginTop: 8, padding: 12, background: '#fafafa', borderRadius: 4 }}>{customer.remark}</div></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
<div style={{ marginBottom: 8 }}><Text type="secondary"><PhoneOutlined style={{ marginRight: 4 }} />联系人</Text></div>
|
||||
<Row gutter={[16, 16]}>
|
||||
{(customer.contacts || []).map((contact, i) => (
|
||||
<Col key={i} xs={24} sm={12} lg={8}>
|
||||
<Card size="small" style={{ borderLeft: contact.is_primary ? '3px solid #52c41a' : '3px solid #d9d9d9', background: contact.is_primary ? '#f6ffed' : '#fff' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Text strong>{contact.name || '未命名'}</Text>
|
||||
{contact.is_primary && <Tag color="green" size="small">主联系人</Tag>}
|
||||
</div>
|
||||
<div style={{ color: '#666', fontSize: 13 }}>
|
||||
{contact.position && <div>职位:{contact.position}</div>}
|
||||
{contact.phone && <div>电话:{contact.phone}</div>}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
{(customer.contacts || []).length === 0 && <Empty description="暂无联系人" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
</Card>
|
||||
|
||||
{/* ========== 卡片2:关联项目 ========== */}
|
||||
<Card title={<><FileTextOutlined /> 关联项目 ({projects.length}个)</>} style={{ marginBottom: 24, borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
|
||||
{projects.length > 0 ? (
|
||||
<Table columns={projectColumns} dataSource={projects} rowKey="id" size="small" pagination={false} bordered />
|
||||
) : (
|
||||
<Empty description="暂无关联项目(在项目管理中选择此客户后会自动显示)" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ========== 卡片4:关联预算项目 ========== */}
|
||||
<Card title={<><DollarOutlined /> 关联预算项目 ({budgetProjects.length}个)</>} style={{ marginBottom: 24, borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
|
||||
{budgetProjects.length > 0 ? (
|
||||
<Table columns={budgetProjectColumns} dataSource={budgetProjects} rowKey="id" size="small" pagination={false} bordered />
|
||||
) : (
|
||||
<Empty description="暂无关联预算项目(在预算报价管理中选择此客户后会自动显示)" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ========== 卡片3:财务信息 ========== */}
|
||||
<Card title={<><DollarOutlined /> 财务信息</>} style={{ borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#e6f7ff', border: '1px solid #91d5ff' }}>
|
||||
<Statistic title="合同总金额" value={totalContract} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||
<Statistic title="已收总金额" value={totalReceived} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#fff2f0', border: '1px solid #ffccc7' }}>
|
||||
<Statistic title="应收总金额" value={totalReceivable} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#fffbe6', border: '1px solid #ffe58f' }}>
|
||||
<Statistic title="未结金额" value={totalReceivable} prefix="¥" valueStyle={{ color: '#faad14', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
<div style={{ marginBottom: 16 }}><Text type="secondary">项目明细</Text></div>
|
||||
{projects.length > 0 ? (
|
||||
<Table columns={projectColumns} dataSource={projects} rowKey="id" size="small" pagination={false} bordered />
|
||||
) : (
|
||||
<Empty description="暂无财务数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomerDetail
|
||||
@@ -0,0 +1,207 @@
|
||||
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 type { ColumnsType } from 'antd/es/table'
|
||||
|
||||
interface Contact {
|
||||
name: string
|
||||
position: string
|
||||
phone: string
|
||||
is_primary?: boolean
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
address: string
|
||||
contacts: Contact[]
|
||||
remark: string
|
||||
total_contract_amount: number
|
||||
total_received: number
|
||||
total_receivable: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const CustomerPage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const fetchCustomers = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/customers')
|
||||
const data = await response.json()
|
||||
if (data.success) setCustomers(data.data || [])
|
||||
} catch (error) {
|
||||
message.error('获取客户列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchCustomers() }, [])
|
||||
|
||||
const stats = {
|
||||
total: customers.length,
|
||||
totalContract: customers.reduce((sum, c) => sum + (c.total_contract_amount || 0), 0),
|
||||
totalReceivable: customers.reduce((sum, c) => sum + (c.total_receivable || 0), 0)
|
||||
}
|
||||
|
||||
const getPrimaryContact = (contacts: Contact[]) => {
|
||||
const primary = contacts?.find(c => c.is_primary)
|
||||
return primary?.name || '-'
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Customer> = [
|
||||
{ title: '编号', dataIndex: 'code', key: 'code', width: 120 },
|
||||
{
|
||||
title: '名称', dataIndex: 'name', key: 'name',
|
||||
render: (text, record) => (
|
||||
<Button type="link" style={{ padding: 0, fontWeight: 'bold' }} onClick={() => navigate(`/customers/${record.id}`)}>{text}</Button>
|
||||
)
|
||||
},
|
||||
{ title: '地址', dataIndex: 'address', key: 'address', width: 150, render: (t) => t || '-' },
|
||||
{ title: '主联系人', key: 'primary_contact', width: 100, render: (_, record) => getPrimaryContact(record.contacts || []) },
|
||||
{ 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) => (
|
||||
<Space>
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" />
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} size="small" />
|
||||
</Space>
|
||||
)}
|
||||
]
|
||||
|
||||
const filteredCustomers = customers.filter(c =>
|
||||
c.code?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
c.name?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
c.address?.toLowerCase().includes(searchText.toLowerCase())
|
||||
)
|
||||
|
||||
const handleContactChange = (index: number, field: string, value: any) => {
|
||||
form.setFieldsValue({
|
||||
contacts: form.getFieldValue('contacts').map((contact: any, i: number) => {
|
||||
if (field === 'is_primary' && value) {
|
||||
// 如果勾选了主联系人,取消其他联系人的主联系人选项
|
||||
return i === index ? { ...contact, [field]: value } : { ...contact, is_primary: false }
|
||||
}
|
||||
return i === index ? { ...contact, [field]: value } : contact
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
let contacts = values.contacts || [{ name: '', position: '', phone: '', is_primary: true }]
|
||||
const hasPrimary = contacts.some(c => c.is_primary)
|
||||
if (!hasPrimary && contacts[0].name) contacts[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 data = await response.json()
|
||||
if (data.success) {
|
||||
message.success(editingCustomer ? '更新成功' : '创建成功')
|
||||
setModalVisible(false)
|
||||
form.resetFields()
|
||||
setEditingCustomer(null)
|
||||
fetchCustomers()
|
||||
} else {
|
||||
message.error(data.message || '操作失败')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
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 }]
|
||||
})
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除', content: '确定要删除此客户吗?', okText: '确定', cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/customers/${id}`, { method: 'DELETE' })
|
||||
const data = await response.json()
|
||||
if (data.success) { message.success('删除成功'); fetchCustomers() }
|
||||
else message.error(data.message || '删除失败')
|
||||
} catch (error) { message.error('删除失败') }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingCustomer(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ contacts: [{ name: '', position: '', phone: '', is_primary: true }] })
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={8}><Card><Statistic title="客户总数" value={stats.total} prefix={<HomeOutlined />} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title="合同总金额" value={stats.totalContract} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title="应收总金额" value={stats.totalReceivable} prefix="¥" valueStyle={{ color: stats.totalReceivable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
|
||||
</Row>
|
||||
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Input placeholder="搜索客户编号、名称或地址" prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增客户</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table columns={columns} dataSource={filteredCustomers} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 900 }} />
|
||||
</Card>
|
||||
|
||||
<Modal title={editingCustomer ? '编辑客户' : '新增客户'} open={modalVisible} onCancel={() => { setModalVisible(false); form.resetFields(); setEditingCustomer(null) }} onOk={() => form.submit()} width={700}>
|
||||
<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>
|
||||
<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, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => handleContactChange(name, 'is_primary', e.target.checked)}
|
||||
/> 主联系人
|
||||
</Form.Item>
|
||||
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}>删除</Button>}
|
||||
</div>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>+ 添加联系人</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomerPage
|
||||
@@ -0,0 +1,380 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Row, Col, InputNumber, message, Typography, Divider, Spin, Button, Table, Space, Tag } from 'antd';
|
||||
import { CheckOutlined, HistoryOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const RATE_PAIRS = [
|
||||
{ key: 'CNY_LAK', label: '中老汇率', from: 'CNY', to: 'LAK', fromLabel: '人民币', toLabel: '老挝基普' },
|
||||
{ key: 'CNY_USD', label: '中美汇率', from: 'CNY', to: 'USD', fromLabel: '人民币', toLabel: '美元' },
|
||||
{ key: 'CNY_THB', label: '中泰汇率', from: 'CNY', to: 'THB', fromLabel: '人民币', toLabel: '泰铢' },
|
||||
{ key: 'USD_LAK', label: '美老汇率', from: 'USD', to: 'LAK', fromLabel: '美元', toLabel: '老挝基普' },
|
||||
{ key: 'THB_LAK', label: '泰老汇率', from: 'THB', to: 'LAK', fromLabel: '泰铢', toLabel: '老挝基普' },
|
||||
];
|
||||
|
||||
interface RateItem {
|
||||
leftValue: number;
|
||||
rightValue: number;
|
||||
actualRate: number;
|
||||
}
|
||||
|
||||
interface HistoryRate {
|
||||
id: number;
|
||||
pair_key: string;
|
||||
rate: number;
|
||||
effective_date: string;
|
||||
created_at: string;
|
||||
created_by_name?: string;
|
||||
}
|
||||
|
||||
const ExchangeRatePage: React.FC = () => {
|
||||
const [rates, setRates] = useState<Record<string, RateItem>>({});
|
||||
const [initialRates, setInitialRates] = useState<Record<string, number>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [historyRates, setHistoryRates] = useState<HistoryRate[]>([]);
|
||||
const [lastUpdateTime, setLastUpdateTime] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRates();
|
||||
fetchHistory();
|
||||
}, []);
|
||||
|
||||
const fetchRates = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get('/api/exchange-rates/latest');
|
||||
if (res.data.success) {
|
||||
const data = res.data.data;
|
||||
const newRates: Record<string, RateItem> = {};
|
||||
const newInitialRates: Record<string, number> = {};
|
||||
RATE_PAIRS.forEach(pair => {
|
||||
const rate = parseFloat(data[pair.key]) || 1;
|
||||
newRates[pair.key] = { leftValue: 1, rightValue: rate, actualRate: rate };
|
||||
newInitialRates[pair.key] = rate;
|
||||
});
|
||||
setRates(newRates);
|
||||
setInitialRates(newInitialRates);
|
||||
|
||||
if (res.data.updated_at) {
|
||||
setLastUpdateTime(res.data.updated_at);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取汇率失败');
|
||||
const defaultRates: Record<string, RateItem> = {};
|
||||
const defaultInitialRates: Record<string, number> = {};
|
||||
RATE_PAIRS.forEach(pair => {
|
||||
const defaultRate = pair.key === 'CNY_LAK' ? 3000 : pair.key === 'CNY_USD' ? 0.14 : pair.key === 'CNY_THB' ? 4.5 : pair.key === 'USD_LAK' ? 21000 : 670;
|
||||
defaultRates[pair.key] = { leftValue: 1, rightValue: defaultRate, actualRate: defaultRate };
|
||||
defaultInitialRates[pair.key] = defaultRate;
|
||||
});
|
||||
setRates(defaultRates);
|
||||
setInitialRates(defaultInitialRates);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/exchange-rates/history?limit=20');
|
||||
if (res.data.success) {
|
||||
setHistoryRates(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取历史汇率失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 左侧输入 - 右侧自动变为1,重新计算汇率
|
||||
const handleLeftChange = (key: string, value: number | null) => {
|
||||
if (value === null || value <= 0) return;
|
||||
const pair = RATE_PAIRS.find(p => p.key === key);
|
||||
if (!pair) return;
|
||||
|
||||
// 当左侧输入值时,右侧变为1,计算新的汇率
|
||||
const newRate = 1 / value;
|
||||
|
||||
setRates(prev => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
leftValue: value,
|
||||
rightValue: 1,
|
||||
actualRate: newRate
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
// 右侧输入 - 左侧自动变为1,重新计算汇率
|
||||
const handleRightChange = (key: string, value: number | null) => {
|
||||
if (value === null || value <= 0) return;
|
||||
const pair = RATE_PAIRS.find(p => p.key === key);
|
||||
if (!pair) return;
|
||||
|
||||
// 当右侧输入值时,左侧变为1,计算新的汇率
|
||||
const newRate = value;
|
||||
|
||||
setRates(prev => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
leftValue: 1,
|
||||
rightValue: value,
|
||||
actualRate: newRate
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
// 计算实际汇率显示
|
||||
const getActualRateDisplay = (key: string) => {
|
||||
const item = rates[key];
|
||||
if (!item) return '1 : 1.00';
|
||||
|
||||
const pair = RATE_PAIRS.find(p => p.key === key);
|
||||
const actualRate = item.actualRate;
|
||||
|
||||
// 根据汇率对选择合适的小数位数
|
||||
const decimalPlaces = pair?.key === 'CNY_USD' ? 5 : 2;
|
||||
|
||||
return `1 ${pair?.from} = ${actualRate.toFixed(decimalPlaces)} ${pair?.to}`;
|
||||
};
|
||||
|
||||
// 确认保存
|
||||
const handleConfirm = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const savePromises = RATE_PAIRS.map(pair => {
|
||||
const item = rates[pair.key];
|
||||
if (!item) return null;
|
||||
|
||||
const actualRate = item.rightValue / item.leftValue;
|
||||
const initialRate = initialRates[pair.key];
|
||||
|
||||
// 只保存有变化的汇率
|
||||
if (Math.abs(actualRate - initialRate) < 0.0001) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return axios.post('/api/exchange-rates', {
|
||||
pair_key: pair.key,
|
||||
rate: actualRate,
|
||||
effective_date: dayjs().format('YYYY-MM-DD')
|
||||
});
|
||||
});
|
||||
|
||||
const validPromises = savePromises.filter(Boolean) as Promise<any>[];
|
||||
|
||||
if (validPromises.length === 0) {
|
||||
message.info('没有汇率发生变化');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(validPromises);
|
||||
|
||||
message.success('汇率保存成功');
|
||||
setLastUpdateTime(dayjs().format('YYYY-MM-DD HH:mm:ss'));
|
||||
fetchHistory();
|
||||
// 更新初始汇率为当前汇率
|
||||
const newInitialRates: Record<string, number> = {};
|
||||
RATE_PAIRS.forEach(pair => {
|
||||
const item = rates[pair.key];
|
||||
if (item) {
|
||||
newInitialRates[pair.key] = item.rightValue / item.leftValue;
|
||||
}
|
||||
});
|
||||
setInitialRates(newInitialRates);
|
||||
} catch (error) {
|
||||
message.error('保存汇率失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 历史汇率表格列
|
||||
const historyColumns = [
|
||||
{
|
||||
title: '汇率对',
|
||||
dataIndex: 'from_currency',
|
||||
key: 'from_currency',
|
||||
render: (_: string, record: HistoryRate) => {
|
||||
const pairKey = `${record.from_currency}_${record.to_currency}`;
|
||||
const pair = RATE_PAIRS.find(p => p.key === pairKey);
|
||||
return pair?.label || pairKey;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '汇率',
|
||||
dataIndex: 'rate',
|
||||
key: 'rate',
|
||||
render: (rate: number, record: HistoryRate) => {
|
||||
const pairKey = `${record.from_currency}_${record.to_currency}`;
|
||||
const pair = RATE_PAIRS.find(p => p.key === pairKey);
|
||||
return `1 ${record.from_currency} = ${parseFloat(rate).toFixed(pair?.key === 'CNY_USD' ? 4 : 2)} ${record.to_currency}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '生效日期',
|
||||
dataIndex: 'effective_date',
|
||||
key: 'effective_date',
|
||||
render: (date: string) => dayjs(date).format('YYYY-MM-DD')
|
||||
},
|
||||
{
|
||||
title: '设置时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm')
|
||||
},
|
||||
{
|
||||
title: '设置人',
|
||||
dataIndex: 'created_by_name',
|
||||
key: 'created_by_name',
|
||||
render: (name: string) => name || '-'
|
||||
}
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}><Spin size="large" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
<Title level={2} style={{ marginBottom: 8 }}>汇率管理</Title>
|
||||
<Space>
|
||||
<Text type="secondary">设置各币种汇率,输入任意一侧自动计算</Text>
|
||||
{lastUpdateTime && (
|
||||
<Tag color="blue">上次更新: {lastUpdateTime}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{RATE_PAIRS.map(pair => {
|
||||
const item = rates[pair.key];
|
||||
if (!item) return null;
|
||||
return (
|
||||
<Col xs={24} sm={12} lg={8} key={pair.key}>
|
||||
<Card title={pair.label} size="small" style={{ background: '#fafafa' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>{pair.fromLabel}</div>
|
||||
<InputNumber
|
||||
style={{
|
||||
width: '100%',
|
||||
borderColor: '#d9d9d9',
|
||||
'&:hover': {
|
||||
borderColor: '#1890ff',
|
||||
},
|
||||
'&:focus': {
|
||||
borderColor: '#1890ff',
|
||||
boxShadow: '0 0 0 2px rgba(24, 144, 255, 0.2)',
|
||||
}
|
||||
}}
|
||||
value={item.leftValue}
|
||||
onChange={(v) => handleLeftChange(pair.key, v)}
|
||||
precision={6}
|
||||
size="large"
|
||||
min={0.000001}
|
||||
onFocus={(e) => {
|
||||
if (e.target && e.target.select) {
|
||||
e.target.select();
|
||||
}
|
||||
}}
|
||||
placeholder={`输入${pair.fromLabel}金额`}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ padding: '20px 8px 0', fontSize: 18, color: '#1890ff', fontWeight: 'bold' }}>=</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>{pair.toLabel}</div>
|
||||
<InputNumber
|
||||
style={{
|
||||
width: '100%',
|
||||
borderColor: '#d9d9d9',
|
||||
'&:hover': {
|
||||
borderColor: '#1890ff',
|
||||
},
|
||||
'&:focus': {
|
||||
borderColor: '#1890ff',
|
||||
boxShadow: '0 0 0 2px rgba(24, 144, 255, 0.2)',
|
||||
}
|
||||
}}
|
||||
value={item.rightValue}
|
||||
onChange={(v) => handleRightChange(pair.key, v)}
|
||||
precision={pair.key === 'CNY_USD' ? 4 : 2}
|
||||
size="large"
|
||||
min={0.000001}
|
||||
onFocus={(e) => {
|
||||
if (e.target && e.target.select) {
|
||||
e.target.select();
|
||||
}
|
||||
}}
|
||||
placeholder={`输入${pair.toLabel}金额`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
实际汇率: {getActualRateDisplay(pair.key)}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
|
||||
{/* 确认按钮 */}
|
||||
<div style={{ marginTop: 24, textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={handleConfirm}
|
||||
loading={saving}
|
||||
style={{ minWidth: 200 }}
|
||||
>
|
||||
确认保存汇率
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 历史汇率表 */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<HistoryOutlined />
|
||||
<span>历史汇率记录</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginTop: 24 }}
|
||||
>
|
||||
<Table
|
||||
dataSource={historyRates}
|
||||
columns={historyColumns}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 10 }}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginTop: 16, background: '#fffbe6', borderColor: '#ffe58f' }}>
|
||||
<Text type="warning">
|
||||
提示:输入任意一侧数值,另一侧会自动计算。实际汇率实时显示为 1左侧币种 = X右侧币种。点击"确认保存汇率"按钮保存当前设置到数据库。
|
||||
</Text>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExchangeRatePage;
|
||||
@@ -0,0 +1,438 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card, Typography, Button, Space, Tag, Table, List, Avatar,
|
||||
Row, Col, Divider, Tabs, Progress, Badge, Rate, Timeline,
|
||||
Statistic, Switch, Alert, Empty
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined, StarOutlined, LikeOutlined, MessageOutlined,
|
||||
EyeOutlined, HeartOutlined, ShoppingCartOutlined,
|
||||
CalendarOutlined, ClockCircleOutlined, CheckCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
/**
|
||||
* 布局样式预览页面
|
||||
* 展示各种常见UI布局类型及其适用场景
|
||||
*/
|
||||
|
||||
const LayoutShowcase: React.FC = () => {
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
||||
|
||||
// 模拟数据
|
||||
const listData = [
|
||||
{ id: 1, title: '项目A - 博纳斯线路改造', status: 'active', progress: 75, manager: '张三' },
|
||||
{ id: 2, title: '项目B - 变压器安装工程', status: 'pending', progress: 0, manager: '李四' },
|
||||
{ id: 3, title: '项目C - 电缆敷设施工', status: 'completed', progress: 100, manager: '王五' },
|
||||
];
|
||||
|
||||
const tableColumns = [
|
||||
{ title: '项目名称', dataIndex: 'title', key: 'title' },
|
||||
{ title: '负责人', dataIndex: 'manager', key: 'manager' },
|
||||
{ title: '进度', dataIndex: 'progress', key: 'progress', render: (v: number) => `${v}%` },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (v: string) => {
|
||||
const colors: Record<string, string> = { active: 'processing', pending: 'default', completed: 'success' };
|
||||
const texts: Record<string, string> = { active: '进行中', pending: '待开始', completed: '已完成' };
|
||||
return <Tag color={colors[v]}>{texts[v]}</Tag>;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
// ============ 布局类型1: 卡片列表 ============
|
||||
const CardListDemo = () => (
|
||||
<div>
|
||||
<Alert
|
||||
message="卡片列表布局"
|
||||
description="适用于:项目列表、任务列表、产品展示。特点:信息层次清晰、视觉分隔明确、适合移动端"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
{listData.map(item => (
|
||||
<Card
|
||||
key={item.id}
|
||||
style={{ marginBottom: 16, borderRadius: 12 }}
|
||||
hoverable
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text strong style={{ fontSize: 16 }}>{item.title}</Text>
|
||||
<br />
|
||||
<Text type="secondary">负责人: {item.manager}</Text>
|
||||
</div>
|
||||
<Tag color={item.status === 'active' ? 'processing' : item.status === 'completed' ? 'success' : 'default'}>
|
||||
{item.status === 'active' ? '进行中' : item.status === 'completed' ? '已完成' : '待开始'}
|
||||
</Tag>
|
||||
</div>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Progress percent={item.progress} showInfo={false} />
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
|
||||
<Button size="small" type="primary">查看详情</Button>
|
||||
<Button size="small">编辑</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============ 布局类型2: 表格布局 ============
|
||||
const TableDemo = () => (
|
||||
<div>
|
||||
<Alert
|
||||
message="表格布局"
|
||||
description="适用于:数据管理、批量操作、对比分析。特点:信息密集、支持排序筛选、适合桌面端大量数据"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Table
|
||||
dataSource={listData}
|
||||
columns={tableColumns}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============ 布局类型3: 网格卡片 ============
|
||||
const GridCardDemo = () => (
|
||||
<div>
|
||||
<Alert
|
||||
message="网格卡片布局"
|
||||
description="适用于:仪表板、快捷入口、统计展示。特点:空间利用率高、视觉均衡、适合展示统计信息"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} md={8} lg={6}>
|
||||
<Card hoverable style={{ borderRadius: 12, textAlign: 'center' }}>
|
||||
<Statistic title="进行中项目" value={12} suffix="个" />
|
||||
<Progress percent={60} showInfo={false} style={{ marginTop: 8 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={6}>
|
||||
<Card hoverable style={{ borderRadius: 12, textAlign: 'center' }}>
|
||||
<Statistic title="待处理任务" value={5} suffix="项" valueStyle={{ color: '#cf1322' }} />
|
||||
<Progress percent={25} showInfo={false} strokeColor="#cf1322" style={{ marginTop: 8 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={6}>
|
||||
<Card hoverable style={{ borderRadius: 12, textAlign: 'center' }}>
|
||||
<Statistic title="本月完成" value={28} suffix="个" valueStyle={{ color: '#3f8600' }} />
|
||||
<Progress percent={85} showInfo={false} strokeColor="#3f8600" style={{ marginTop: 8 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={6}>
|
||||
<Card hoverable style={{ borderRadius: 12, textAlign: 'center' }}>
|
||||
<Statistic title="团队成员" value={8} suffix="人" />
|
||||
<Progress percent={100} showInfo={false} style={{ marginTop: 8 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============ 布局类型4: 时间线布局 ============
|
||||
const TimelineDemo = () => (
|
||||
<div>
|
||||
<Alert
|
||||
message="时间线布局"
|
||||
description="适用于:审批流程、施工进度、操作日志。特点:顺序清晰、时间节点明确、适合流程展示"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Timeline
|
||||
items={[
|
||||
{
|
||||
color: 'green',
|
||||
children: (
|
||||
<>
|
||||
<Text strong>项目启动</Text>
|
||||
<br />
|
||||
<Text type="secondary">2026-03-01 - 确定项目范围和团队</Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
color: 'blue',
|
||||
children: (
|
||||
<>
|
||||
<Text strong>施工准备</Text>
|
||||
<br />
|
||||
<Text type="secondary">2026-03-05 - 材料采购、人员调配</Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
color: 'blue',
|
||||
children: (
|
||||
<>
|
||||
<Text strong>施工进行中</Text>
|
||||
<br />
|
||||
<Text type="secondary">2026-03-10 - 开始现场施工</Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
color: 'gray',
|
||||
children: (
|
||||
<>
|
||||
<Text strong>竣工验收</Text>
|
||||
<br />
|
||||
<Text type="secondary">预计 2026-04-01</Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============ 布局类型5: 瀑布流/Feed布局 ============
|
||||
const FeedDemo = () => (
|
||||
<div>
|
||||
<Alert
|
||||
message="Feed流布局"
|
||||
description="适用于:动态消息、施工日志、社交媒体风格。特点:沉浸式阅读、时间倒序、适合移动端滑动"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<List
|
||||
itemLayout="vertical"
|
||||
dataSource={[
|
||||
{
|
||||
title: '今日施工进展',
|
||||
description: '完成了3号杆塔的基础浇筑工作,混凝土养护中。',
|
||||
author: '张三',
|
||||
date: '今天 14:30',
|
||||
avatar: '👨🔧',
|
||||
},
|
||||
{
|
||||
title: '材料到货通知',
|
||||
description: '电缆材料已到货,存放在仓库A区,请施工组负责人安排领取。',
|
||||
author: '李四',
|
||||
date: '今天 10:15',
|
||||
avatar: '📦',
|
||||
},
|
||||
{
|
||||
title: '安全检查完成',
|
||||
description: '本周安全检查已完成,未发现重大隐患。',
|
||||
author: '王五',
|
||||
date: '昨天 16:00',
|
||||
avatar: '✅',
|
||||
},
|
||||
]}
|
||||
renderItem={(item: any) => (
|
||||
<List.Item>
|
||||
<Card style={{ width: '100%', marginBottom: 12, borderRadius: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div style={{ fontSize: 32 }}>{item.avatar}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Text strong>{item.author}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{item.date}</Text>
|
||||
</div>
|
||||
<Text strong style={{ fontSize: 15, display: 'block', marginTop: 4 }}>{item.title}</Text>
|
||||
<Text type="secondary">{item.description}</Text>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 16 }}>
|
||||
<Space>
|
||||
<LikeOutlined /> 赞
|
||||
</Space>
|
||||
<Space>
|
||||
<MessageOutlined /> 评论
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============ 布局类型6: 详情页布局 ============
|
||||
const DetailDemo = () => (
|
||||
<div>
|
||||
<Alert
|
||||
message="详情页布局"
|
||||
description="适用于:项目详情、订单详情、用户档案。特点:信息分组明确、主次分明、适合深度阅读"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Card style={{ borderRadius: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>项目详情</Title>
|
||||
<Tag color="processing">进行中</Tag>
|
||||
</div>
|
||||
|
||||
<Row gutter={[24, 16]}>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Text type="secondary">项目名称</Text>
|
||||
<br />
|
||||
<Text strong>博纳斯线路改造工程</Text>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Text type="secondary">客户</Text>
|
||||
<br />
|
||||
<Text strong>博纳斯稀土开采公司</Text>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Text type="secondary">项目经理</Text>
|
||||
<br />
|
||||
<Text strong>罗仕林</Text>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Text type="secondary">合同金额</Text>
|
||||
<br />
|
||||
<Text strong>¥1,250,000</Text>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Text type="secondary">开工日期</Text>
|
||||
<br />
|
||||
<Text strong>2026-03-01</Text>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Text type="secondary">预计完工</Text>
|
||||
<br />
|
||||
<Text strong>2026-05-30</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text type="secondary">项目描述</Text>
|
||||
<Paragraph>
|
||||
本项目包括7公里22kV高压线路改造,以及1250kVA变压器安装工程。
|
||||
施工地点位于老挝博纳斯矿区,需考虑当地气候条件。
|
||||
</Paragraph>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text type="secondary">施工进度</Text>
|
||||
</div>
|
||||
<Progress percent={65} status="active" />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============ 布局对比总结 ============
|
||||
const ComparisonTable = () => (
|
||||
<Card title="布局类型对比" style={{ marginTop: 24, borderRadius: 12 }}>
|
||||
<Table
|
||||
dataSource={[
|
||||
{
|
||||
key: '1',
|
||||
layout: '卡片列表',
|
||||
bestFor: '项目/任务列表',
|
||||
mobile: '⭐⭐⭐⭐⭐',
|
||||
desktop: '⭐⭐⭐⭐',
|
||||
dataDensity: '中',
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
layout: '表格',
|
||||
bestFor: '数据管理/分析',
|
||||
mobile: '⭐⭐',
|
||||
desktop: '⭐⭐⭐⭐⭐',
|
||||
dataDensity: '高',
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
layout: '网格卡片',
|
||||
bestFor: '仪表板/统计',
|
||||
mobile: '⭐⭐⭐⭐',
|
||||
desktop: '⭐⭐⭐⭐⭐',
|
||||
dataDensity: '中',
|
||||
},
|
||||
{
|
||||
key: '4',
|
||||
layout: '时间线',
|
||||
bestFor: '流程/进度',
|
||||
mobile: '⭐⭐⭐⭐',
|
||||
desktop: '⭐⭐⭐',
|
||||
dataDensity: '低',
|
||||
},
|
||||
{
|
||||
key: '5',
|
||||
layout: 'Feed流',
|
||||
bestFor: '动态/日志',
|
||||
mobile: '⭐⭐⭐⭐⭐',
|
||||
desktop: '⭐⭐⭐',
|
||||
dataDensity: '低',
|
||||
},
|
||||
{
|
||||
key: '6',
|
||||
layout: '详情页',
|
||||
bestFor: '深度信息',
|
||||
mobile: '⭐⭐⭐',
|
||||
desktop: '⭐⭐⭐⭐⭐',
|
||||
dataDensity: '中',
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
{ title: '布局类型', dataIndex: 'layout', key: 'layout' },
|
||||
{ title: '适用场景', dataIndex: 'bestFor', key: 'bestFor' },
|
||||
{ title: '移动端', dataIndex: 'mobile', key: 'mobile' },
|
||||
{ title: '桌面端', dataIndex: 'desktop', key: 'desktop' },
|
||||
{ title: '数据密度', dataIndex: 'dataDensity', key: 'dataDensity' },
|
||||
]}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 12 : 24, maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Title level={2}>布局样式预览</Title>
|
||||
<Paragraph type="secondary">
|
||||
此页面展示各种常见UI布局类型,帮助开发者选择合适的布局方式
|
||||
</Paragraph>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Tabs defaultActiveKey="1" tabPosition="top">
|
||||
<TabPane tab="📋 卡片列表" key="1">
|
||||
<CardListDemo />
|
||||
</TabPane>
|
||||
<TabPane tab="📊 表格布局" key="2">
|
||||
<TableDemo />
|
||||
</TabPane>
|
||||
<TabPane tab="🔲 网格卡片" key="3">
|
||||
<GridCardDemo />
|
||||
</TabPane>
|
||||
<TabPane tab="⏱️ 时间线" key="4">
|
||||
<TimelineDemo />
|
||||
</TabPane>
|
||||
<TabPane tab="📝 Feed流" key="5">
|
||||
<FeedDemo />
|
||||
</TabPane>
|
||||
<TabPane tab="📄 详情页" key="6">
|
||||
<DetailDemo />
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
|
||||
<ComparisonTable />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutShowcase;
|
||||
@@ -0,0 +1,511 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Card, Space, Tag, Modal, Form, Input, InputNumber, Select, DatePicker, message, Descriptions, Image, Divider, Cascader } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, UndoOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import FileUpload from '../components/FileUpload';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
// 收款单位类型
|
||||
const PAYEE_TYPES = [
|
||||
{ value: 'subcontractor', label: '分包商' },
|
||||
{ value: 'supplier', label: '供应商' },
|
||||
{ value: 'customer', label: '客户' },
|
||||
{ value: 'other', label: '其他' }
|
||||
];
|
||||
|
||||
// 支出类型
|
||||
const EXPENSE_TYPES = [
|
||||
{ value: 'company', label: '公司支出' },
|
||||
{ value: 'project', label: '项目支出' }
|
||||
];
|
||||
|
||||
// 项目支出分类
|
||||
const PROJECT_EXPENSE_CATEGORIES = [
|
||||
{ value: 'material_purchase', label: '材料采购' },
|
||||
{ value: 'equipment_purchase', label: '设备采购' },
|
||||
{ value: 'pole_crossarm', label: '电杆横担支出' },
|
||||
{ value: 'freight', label: '运费支出' },
|
||||
{ value: 'construction', label: '施工费支出' },
|
||||
{ value: 'other', label: '其他支出' }
|
||||
];
|
||||
|
||||
// 公司支出分类
|
||||
const COMPANY_EXPENSE_CATEGORIES = [
|
||||
{ value: 'office_operations', label: '通用运营(房租/耗材)' },
|
||||
{ value: 'transportation', label: '交通通勤' },
|
||||
{ value: 'marketing', label: '业扩营销' },
|
||||
{ value: 'power_system', label: '电力系统关系' },
|
||||
{ value: 'employee_welfare', label: '员工福利' },
|
||||
{ value: 'logistics', label: '快递物流' },
|
||||
{ value: 'other', label: '其他支出' }
|
||||
];
|
||||
|
||||
const PaymentRequestsPage: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const [requests, setRequests] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [selectedRecord, setSelectedRecord] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({});
|
||||
|
||||
// 数据列表
|
||||
const [subcontractors, setSubcontractors] = useState<any[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<any[]>([]);
|
||||
const [customers, setCustomers] = useState<any[]>([]);
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRequests();
|
||||
fetchSubcontractors();
|
||||
fetchSuppliers();
|
||||
fetchCustomers();
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
const fetchRequests = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/payment-requests');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
// 解析JSON字符串字段
|
||||
const parsedRequests = data.data.map((request: any) => ({
|
||||
...request,
|
||||
detail_items: typeof request.detail_items === 'string' ? JSON.parse(request.detail_items) : request.detail_items || [],
|
||||
attachments: typeof request.attachments === 'string' ? JSON.parse(request.attachments) : request.attachments || []
|
||||
}));
|
||||
setRequests(parsedRequests);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取付款申请列表失败:', error);
|
||||
message.error('获取付款申请列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSubcontractors = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/subcontractors');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setSubcontractors(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取分包商列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSuppliers = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/suppliers');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setSuppliers(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取供应商列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCustomers = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/customers');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setCustomers(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取客户列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/projects');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setProjects(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingId(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
payment_date: dayjs(),
|
||||
currency: 'CNY',
|
||||
applicant: user?.name || user?.username || '当前用户',
|
||||
attachments: [],
|
||||
payee_type: 'other',
|
||||
expense_type: 'company'
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: any) => {
|
||||
setEditingId(record.id);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
payment_date: record.payment_date ? dayjs(record.payment_date) : null,
|
||||
attachments: record.attachments || []
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleView = (record: any) => {
|
||||
setSelectedRecord(record);
|
||||
setDetailModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这条付款申请吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await fetch('/api/payment-requests/' + id, { method: 'DELETE' });
|
||||
message.success('删除成功');
|
||||
fetchRequests();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleWithdraw = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认撤回',
|
||||
content: '撤回后可重新编辑提交,确认撤回吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await fetch('/api/payment-requests/' + id + '/withdraw', { method: 'POST' });
|
||||
message.success('已撤回,可重新编辑');
|
||||
fetchRequests();
|
||||
} catch (error) {
|
||||
message.error('撤回失败');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
// 处理收款单位
|
||||
let payee = '';
|
||||
let payee_id = null;
|
||||
if (values.payee_type === 'subcontractor') {
|
||||
const sub = subcontractors.find(s => s.id === values.payee_select);
|
||||
payee = sub?.name || '';
|
||||
payee_id = values.payee_select;
|
||||
} else if (values.payee_type === 'supplier') {
|
||||
const sup = suppliers.find(s => s.id === values.payee_select);
|
||||
payee = sup?.name || '';
|
||||
payee_id = values.payee_select;
|
||||
} else if (values.payee_type === 'customer') {
|
||||
const cust = customers.find(c => c.id === values.payee_select);
|
||||
payee = cust?.name || '';
|
||||
payee_id = values.payee_select;
|
||||
} else {
|
||||
payee = values.payee_input || '';
|
||||
}
|
||||
|
||||
const data = {
|
||||
...values,
|
||||
payee,
|
||||
payee_id,
|
||||
payment_date: values.payment_date?.format('YYYY-MM-DD'),
|
||||
applicant: user?.name || user?.username
|
||||
};
|
||||
|
||||
// 删除临时字段
|
||||
delete data.payee_select;
|
||||
delete data.payee_input;
|
||||
|
||||
const url = editingId ? '/api/payment-requests/' + editingId : '/api/payment-requests';
|
||||
const method = editingId ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
message.success(editingId ? '更新成功' : '创建成功');
|
||||
setModalVisible(false);
|
||||
fetchRequests();
|
||||
} else {
|
||||
message.error(result.error || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const convertToCNY = (amount: number, curr: string): number => {
|
||||
if (curr === "CNY") return amount;
|
||||
const rateKey = curr + "_CNY";
|
||||
const rate = exchangeRates[rateKey] || 1;
|
||||
return amount * rate;
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'processing', text: '待审批' },
|
||||
approved: { color: 'success', text: '已批准' },
|
||||
rejected: { color: 'error', text: '已退回' },
|
||||
withdrawn: { color: 'default', text: '已撤回' },
|
||||
paid: { color: 'blue', text: '已付款' },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number, currency: string = 'CNY') => {
|
||||
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
|
||||
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
// 获取支出分类标签
|
||||
const getExpenseCategoryLabel = (type: string, category: string) => {
|
||||
if (type === 'project') {
|
||||
return PROJECT_EXPENSE_CATEGORIES.find(c => c.value === category)?.label || category;
|
||||
} else {
|
||||
return COMPANY_EXPENSE_CATEGORIES.find(c => c.value === category)?.label || category;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取收款单位类型标签
|
||||
const getPayeeTypeLabel = (type: string) => {
|
||||
return PAYEE_TYPES.find(t => t.value === type)?.label || type;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: '收款单位', dataIndex: 'payee', key: 'payee', width: 150, ellipsis: true },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
|
||||
<>
|
||||
<div>{formatAmount(v, r.currency)}</div>
|
||||
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}>≈ ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
|
||||
</>
|
||||
) },
|
||||
{ title: '付款日期', dataIndex: 'payment_date', key: 'payment_date', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
|
||||
{ title: '编号', dataIndex: 'request_code', key: 'request_code', width: 120 },
|
||||
{
|
||||
title: '操作', key: 'action', width: 250,
|
||||
render: (_: any, record: any) => (
|
||||
<Space wrap>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(record)}>详情</Button>
|
||||
{record.status === 'pending' && (
|
||||
<>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}>撤回</Button>
|
||||
</>
|
||||
)}
|
||||
{(record.status === 'rejected' || record.status === 'withdrawn') && (
|
||||
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑重提</Button>
|
||||
)}
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>删除</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
// 监听表单值变化
|
||||
const payeeType = Form.useWatch('payee_type', form);
|
||||
const expenseType = Form.useWatch('expense_type', form);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ marginBottom: 8 }}>付款申请</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>管理对外付款申请</p>
|
||||
</div>
|
||||
|
||||
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建付款申请</Button>}>
|
||||
<Table dataSource={requests} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
|
||||
</Card>
|
||||
|
||||
<Modal title={editingId ? '编辑付款申请' : '新建付款申请'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} width={900}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="applicant" label="申请人">
|
||||
<Input disabled style={{ color: 'rgba(0,0,0,0.85)', backgroundColor: '#f5f5f5' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="payment_date" label="付款日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
{/* 收款单位 - 二级选择 */}
|
||||
<Form.Item name="payee_type" label="收款单位类型" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择收款单位类型">
|
||||
{PAYEE_TYPES.map(type => (
|
||||
<Option key={type.value} value={type.value}>{type.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{payeeType === 'subcontractor' && (
|
||||
<Form.Item name="payee_select" label="选择分包商" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择分包商" showSearch optionFilterProp="children">
|
||||
{subcontractors.map(sub => (
|
||||
<Option key={sub.id} value={sub.id}>{sub.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{payeeType === 'supplier' && (
|
||||
<Form.Item name="payee_select" label="选择供应商" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择供应商" showSearch optionFilterProp="children">
|
||||
{suppliers.map(sup => (
|
||||
<Option key={sup.id} value={sup.id}>{sup.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{payeeType === 'customer' && (
|
||||
<Form.Item name="payee_select" label="选择客户" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择客户" showSearch optionFilterProp="children">
|
||||
{customers.map(cust => (
|
||||
<Option key={cust.id} value={cust.id}>{cust.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{payeeType === 'other' && (
|
||||
<Form.Item name="payee_input" label="收款单位" rules={[{ required: true }]}>
|
||||
<Input placeholder="手动输入收款单位名称" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item name="bank_account" label="银行账号">
|
||||
<Input placeholder="收款银行账号" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="bank_name" label="开户银行">
|
||||
<Input placeholder="开户银行名称" />
|
||||
</Form.Item>
|
||||
|
||||
{/* 支出类型 */}
|
||||
<Form.Item name="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="currency" label="币种" rules={[{ required: true }]}>
|
||||
<Select style={{ width: 200 }}>
|
||||
<Option value="CNY">人民币 (CNY)</Option>
|
||||
<Option value="USD">美元 (USD)</Option>
|
||||
<Option value="LAK">老挝基普 (LAK)</Option>
|
||||
<Option value="THB">泰铢 (THB)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{/* 金额 - 直接输入 */}
|
||||
<Form.Item name="amount" label="付款金额" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} placeholder="输入付款金额" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="reason" label="付款事由" rules={[{ required: true }]}>
|
||||
<TextArea rows={2} placeholder="付款原因" />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>凭证附件</Divider>
|
||||
<Form.Item name="attachments" label="上传凭证附件">
|
||||
<FileUpload maxCount={9} accept="image/*" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="付款申请详情" open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={null} width={900}>
|
||||
{selectedRecord && (
|
||||
<>
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="申请编号">{selectedRecord.request_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label="付款日期">{selectedRecord.payment_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="收款单位类型">{getPayeeTypeLabel(selectedRecord.payee_type)}</Descriptions.Item>
|
||||
<Descriptions.Item label="收款单位">{selectedRecord.payee}</Descriptions.Item>
|
||||
<Descriptions.Item label="银行账号">{selectedRecord.bank_account || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开户银行">{selectedRecord.bank_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="支出类型">
|
||||
{selectedRecord.expense_type === 'company' ? '公司支出' : '项目支出'}
|
||||
</Descriptions.Item>
|
||||
{selectedRecord.expense_type === 'project' && (
|
||||
<Descriptions.Item label="关联项目">
|
||||
{projects.find(p => p.id === selectedRecord.project_id)?.name || '-'}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="支出分类">
|
||||
{getExpenseCategoryLabel(selectedRecord.expense_type, selectedRecord.expense_category)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
|
||||
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>≈ ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="付款事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{selectedRecord.attachments && selectedRecord.attachments.length > 0 && (
|
||||
<>
|
||||
<Divider>凭证附件</Divider>
|
||||
<Image.PreviewGroup>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{selectedRecord.attachments.map((url: string, index: number) => (
|
||||
<Image key={index} src={url} width={100} height={100} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentRequestsPage;
|
||||
@@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, Select, DatePicker, InputNumber, message, Row, Col, Statistic } from 'antd';
|
||||
import { PlusOutlined, SearchOutlined, ShoppingOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const ProcurementPage: React.FC = () => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [modalVisible, setModalVisible] = React.useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const columns = [
|
||||
{ title: '采购单号', dataIndex: 'code', key: 'code', width: 140 },
|
||||
{ title: '采购日期', dataIndex: 'date', key: 'date', width: 120 },
|
||||
{ title: '供应商', dataIndex: 'supplier', key: 'supplier' },
|
||||
{ title: '物料名称', dataIndex: 'material', key: 'material' },
|
||||
{ title: '数量', dataIndex: 'quantity', key: 'quantity', width: 80 },
|
||||
{ title: '单价', dataIndex: 'unitPrice', key: 'unitPrice', width: 100, render: (v: number) => `¥${v}` },
|
||||
{ title: '总金额', dataIndex: 'amount', key: 'amount', width: 120, render: (v: number) => `¥${v?.toLocaleString()}` },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (v: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
pending: 'default',
|
||||
approved: 'processing',
|
||||
received: 'success',
|
||||
rejected: 'error'
|
||||
};
|
||||
const texts: Record<string, string> = {
|
||||
pending: '待审批',
|
||||
approved: '已批准',
|
||||
received: '已入库',
|
||||
rejected: '已拒绝'
|
||||
};
|
||||
return <Tag color={colors[v]}>{texts[v]}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
render: () => (
|
||||
<Space>
|
||||
<Button size="small" type="link">查看</Button>
|
||||
<Button size="small" type="link">审批</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const data = [
|
||||
{ key: '1', code: 'PO20260318001', date: '2026-03-18', supplier: '老挝电力设备公司', material: '电缆 3x120', quantity: 1000, unitPrice: 45, amount: 45000, status: 'pending' },
|
||||
{ key: '2', code: 'PO20260317002', date: '2026-03-17', supplier: '万象建材供应商', material: '钢管 DN50', quantity: 200, unitPrice: 120, amount: 24000, status: 'approved' },
|
||||
{ key: '3', code: 'PO20260316003', date: '2026-03-16', supplier: '沙湾五金店', material: '螺栓 M12', quantity: 500, unitPrice: 5, amount: 2500, status: 'received' },
|
||||
];
|
||||
|
||||
const handleSubmit = () => {
|
||||
message.success('采购申请已提交');
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 0 }}>采购管理</Title>
|
||||
<Paragraph type="secondary">管理采购订单和物料入库</Paragraph>
|
||||
</div>
|
||||
<Space>
|
||||
<RangePicker placeholder={['开始日期', '结束日期']} />
|
||||
<Input.Search placeholder="搜索采购单号" style={{ width: 200 }} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
|
||||
新建采购
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="待审批" value={5} prefix={<ShoppingOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="已批准" value={12} valueStyle={{ color: '#1890ff' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="已入库" value={28} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="本月采购额" value={156000} prefix="¥" />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card>
|
||||
<Table columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1200 }} />
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="新建采购申请"
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
onOk={handleSubmit}
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="供应商" name="supplier" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择供应商" options={[
|
||||
{ value: 'supplier1', label: '老挝电力设备公司' },
|
||||
{ value: 'supplier2', label: '万象建材供应商' },
|
||||
{ value: 'supplier3', label: '沙湾五金店' }
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item label="物料名称" name="material" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入物料名称" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="数量" name="quantity" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="单价" name="unitPrice" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} precision={2} prefix="¥" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item label="备注" name="remark">
|
||||
<Input.TextArea rows={3} placeholder="请输入备注说明" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProcurementPage;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, Checkbox, message, Tree } from 'antd';
|
||||
import { PlusOutlined, SearchOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
const RolesPage: React.FC = () => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [modalVisible, setModalVisible] = React.useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const permissionTree = [
|
||||
{
|
||||
title: '项目管理',
|
||||
key: 'project',
|
||||
children: [
|
||||
{ title: '查看项目', key: 'project:view' },
|
||||
{ title: '创建项目', key: 'project:create' },
|
||||
{ title: '编辑项目', key: 'project:edit' },
|
||||
{ title: '删除项目', key: 'project:delete' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '财务管理',
|
||||
key: 'finance',
|
||||
children: [
|
||||
{ title: '查看财务', key: 'finance:view' },
|
||||
{ title: '预支审批', key: 'finance:advance' },
|
||||
{ title: '报销审批', key: 'finance:reimburse' },
|
||||
{ title: '付款审批', key: 'finance:payment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '采购管理',
|
||||
key: 'procurement',
|
||||
children: [
|
||||
{ title: '查看采购', key: 'procurement:view' },
|
||||
{ title: '创建采购', key: 'procurement:create' },
|
||||
{ title: '审批采购', key: 'procurement:approve' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统设置',
|
||||
key: 'system',
|
||||
children: [
|
||||
{ title: '用户管理', key: 'system:users' },
|
||||
{ title: '角色管理', key: 'system:roles' },
|
||||
{ title: '系统配置', key: 'system:config' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ title: '角色ID', dataIndex: 'id', key: 'id', width: 100 },
|
||||
{ title: '角色名称', dataIndex: 'name', key: 'name', width: 150 },
|
||||
{ title: '角色描述', dataIndex: 'description', key: 'description' },
|
||||
{
|
||||
title: '权限数量',
|
||||
dataIndex: 'permissionCount',
|
||||
key: 'permissionCount',
|
||||
width: 100,
|
||||
render: (v: number) => <Tag color="blue">{v} 项</Tag>
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 150 },
|
||||
{ title: '创建人', dataIndex: 'creator', key: 'creator', width: 120 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
render: () => (
|
||||
<Space>
|
||||
<Button size="small" type="link">查看权限</Button>
|
||||
<Button size="small" type="link">编辑</Button>
|
||||
<Button size="small" type="link" danger>删除</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const data = [
|
||||
{ key: '1', id: 'R001', name: '超级管理员', description: '拥有系统所有权限', permissionCount: 50, createdAt: '2026-01-01', creator: '系统' },
|
||||
{ key: '2', id: 'R002', name: '项目经理', description: '项目管理、施工管理权限', permissionCount: 25, createdAt: '2026-01-15', creator: 'admin' },
|
||||
{ key: '3', id: 'R003', name: '财务经理', description: '财务管理、审批权限', permissionCount: 18, createdAt: '2026-02-01', creator: 'admin' },
|
||||
{ key: '4', id: 'R004', name: '普通员工', description: '查看和申请权限', permissionCount: 10, createdAt: '2026-02-15', creator: 'admin' },
|
||||
];
|
||||
|
||||
const handleSubmit = () => {
|
||||
message.success('角色已创建');
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 0 }}>角色权限</Title>
|
||||
<Paragraph type="secondary">管理系统角色和权限分配</Paragraph>
|
||||
</div>
|
||||
<Space>
|
||||
<Input.Search placeholder="搜索角色" style={{ width: 200 }} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
|
||||
新增角色
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 10 }} />
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="新增角色"
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
onOk={handleSubmit}
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="角色名称" name="name" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入角色名称" prefix={<SafetyOutlined />} />
|
||||
</Form.Item>
|
||||
<Form.Item label="角色描述" name="description">
|
||||
<Input placeholder="请输入角色描述" />
|
||||
</Form.Item>
|
||||
<Form.Item label="权限配置" name="permissions">
|
||||
<Tree
|
||||
checkable
|
||||
defaultExpandedKeys={['project', 'finance']}
|
||||
treeData={permissionTree}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RolesPage;
|
||||
@@ -0,0 +1,203 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Card, Descriptions, Tag, Spin, Empty, Row, Col, Statistic, Table, Button, Divider, Typography, Badge
|
||||
} from 'antd'
|
||||
import {
|
||||
ArrowLeftOutlined, SolutionOutlined, FileTextOutlined, DollarOutlined, UserOutlined, PhoneOutlined
|
||||
} from '@ant-design/icons'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
interface Contact {
|
||||
name: string
|
||||
position: string
|
||||
phone: string
|
||||
is_primary?: boolean
|
||||
}
|
||||
|
||||
interface Subcontractor {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
scope: string
|
||||
features: string
|
||||
country: string
|
||||
contacts: Contact[]
|
||||
remark: string
|
||||
total_contract_amount: number
|
||||
total_paid: number
|
||||
total_payable: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: number
|
||||
project_code: string
|
||||
name: string
|
||||
contract_amount: string
|
||||
status: string
|
||||
subcontractor_id: number
|
||||
}
|
||||
|
||||
const SubcontractorDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [subcontractor, setSubcontractor] = useState<Subcontractor | null>(null)
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubcontractorDetail()
|
||||
fetchRelatedProjects()
|
||||
}, [id])
|
||||
|
||||
const fetchSubcontractorDetail = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/subcontractors/${id}`)
|
||||
const data = await res.json()
|
||||
if (data.success) setSubcontractor(data.data)
|
||||
} catch (error) {
|
||||
console.error('获取分包商详情失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRelatedProjects = async () => {
|
||||
try {
|
||||
// 获取所有项目,筛选关联到此分包商的
|
||||
// 注意:需要后端在projects表中添加subcontractor_id字段
|
||||
// 或者建立project_subcontractors关联表
|
||||
const res = await fetch('/api/projects')
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
// 暂时通过subcontractor_id筛选(后端需要添加此字段)
|
||||
const subcontractorProjects = (data.data || []).filter((p: Project) => p.subcontractor_id === parseInt(id))
|
||||
setProjects(subcontractorProjects)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Spin style={{ display: 'flex', justifyContent: 'center', padding: 50 }} />
|
||||
if (!subcontractor) return <Empty description="分包商不存在" style={{ marginTop: 100 }} />
|
||||
|
||||
const totalContract = projects.reduce((sum, p) => sum + (parseFloat(p.contract_amount) || 0), 0)
|
||||
const totalPaid = 0 // 从付款节点计算
|
||||
const totalPayable = 0
|
||||
|
||||
const projectColumns = [
|
||||
{ title: '项目编号', dataIndex: 'project_code', key: 'project_code', width: 120 },
|
||||
{ title: '项目名称', dataIndex: 'name', key: 'name', render: (v: string) => <Text strong>{v}</Text> },
|
||||
{ title: '合同金额', dataIndex: 'contract_amount', key: 'contract_amount', align: 'right' as const, render: (v: string) => `¥${(parseFloat(v) || 0).toLocaleString()}` },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: (v: string) => <Badge status={v === 'completed' ? 'success' : 'processing'} text={v === 'completed' ? '已完成' : v === 'planning' ? '规划中' : v === 'in_progress' ? '进行中' : v} /> }
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/subcontractors')} style={{ marginBottom: 16 }} type="text">
|
||||
返回列表
|
||||
</Button>
|
||||
|
||||
<Title level={4} style={{ marginBottom: 24 }}>
|
||||
<SolutionOutlined style={{ marginRight: 8, color: '#722ed1' }} />
|
||||
{subcontractor.name}
|
||||
</Title>
|
||||
|
||||
{/* ========== 卡片1:基本信息 ========== */}
|
||||
<Card title={<><UserOutlined /> 基本信息</>} style={{ marginBottom: 24, borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
|
||||
<Descriptions bordered column={{ xs: 1, sm: 2, md: 3 }} size="small">
|
||||
<Descriptions.Item label="编号">{subcontractor.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="承包范围">{subcontractor.scope || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="国家"><Tag color="purple">{subcontractor.country || '-'}</Tag></Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{(subcontractor.features || subcontractor.remark) && (
|
||||
<>
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
<Row gutter={16}>
|
||||
{subcontractor.features && (
|
||||
<Col span={24}>
|
||||
<div style={{ marginBottom: 8 }}><Text type="secondary">特点:</Text></div>
|
||||
<div style={{ padding: 12, background: '#f9f0ff', borderRadius: 4, border: '1px solid #d3adf7' }}>{subcontractor.features}</div>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
{subcontractor.remark && (
|
||||
<>
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
<div><Text type="secondary">备注:</Text><div style={{ marginTop: 8, padding: 12, background: '#fafafa', borderRadius: 4 }}>{subcontractor.remark}</div></div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
<div style={{ marginBottom: 8 }}><Text type="secondary"><PhoneOutlined style={{ marginRight: 4 }} />联系人</Text></div>
|
||||
<Row gutter={[16, 16]}>
|
||||
{(subcontractor.contacts || []).map((contact, i) => (
|
||||
<Col key={i} xs={24} sm={12} lg={8}>
|
||||
<Card size="small" style={{ borderLeft: contact.is_primary ? '3px solid #722ed1' : '3px solid #d9d9d9', background: contact.is_primary ? '#f9f0ff' : '#fff' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Text strong>{contact.name || '未命名'}</Text>
|
||||
{contact.is_primary && <Tag color="purple" size="small">主联系人</Tag>}
|
||||
</div>
|
||||
<div style={{ color: '#666', fontSize: 13 }}>
|
||||
{contact.position && <div>职位:{contact.position}</div>}
|
||||
{contact.phone && <div>电话:{contact.phone}</div>}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
{(subcontractor.contacts || []).length === 0 && <Empty description="暂无联系人" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
</Card>
|
||||
|
||||
{/* ========== 卡片2:关联项目 ========== */}
|
||||
<Card title={<><FileTextOutlined /> 关联项目 ({projects.length}个)</>} style={{ marginBottom: 24, borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
|
||||
{projects.length > 0 ? (
|
||||
<Table columns={projectColumns} dataSource={projects} rowKey="id" size="small" pagination={false} bordered />
|
||||
) : (
|
||||
<Empty description="暂无关联项目(在项目管理中选择此分包商后会自动显示)" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ========== 卡片3:财务信息 ========== */}
|
||||
<Card title={<><DollarOutlined /> 财务信息</>} style={{ borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#e6f7ff', border: '1px solid #91d5ff' }}>
|
||||
<Statistic title="合同总金额" value={totalContract} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||
<Statistic title="已付总金额" value={totalPaid} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#fff2f0', border: '1px solid #ffccc7' }}>
|
||||
<Statistic title="应付总金额" value={totalPayable} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#fffbe6', border: '1px solid #ffe58f' }}>
|
||||
<Statistic title="未结金额" value={totalPayable} prefix="¥" valueStyle={{ color: '#faad14', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
<div style={{ marginBottom: 16 }}><Text type="secondary">项目明细</Text></div>
|
||||
{projects.length > 0 ? (
|
||||
<Table columns={projectColumns} dataSource={projects} rowKey="id" size="small" pagination={false} bordered />
|
||||
) : (
|
||||
<Empty description="暂无财务数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubcontractorDetail
|
||||
@@ -0,0 +1,223 @@
|
||||
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 type { ColumnsType } from 'antd/es/table'
|
||||
|
||||
interface Contact {
|
||||
name: string
|
||||
position: string
|
||||
phone: string
|
||||
is_primary?: boolean
|
||||
}
|
||||
|
||||
interface Subcontractor {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
scope: string
|
||||
features: string
|
||||
country: string
|
||||
contacts: Contact[]
|
||||
remark: string
|
||||
total_contract_amount: number
|
||||
total_paid: number
|
||||
total_payable: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const SubcontractorPage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [subcontractors, setSubcontractors] = useState<Subcontractor[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [editingSubcontractor, setEditingSubcontractor] = useState<Subcontractor | null>(null)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const fetchSubcontractors = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/subcontractors')
|
||||
const data = await response.json()
|
||||
if (data.success) setSubcontractors(data.data || [])
|
||||
} catch (error) {
|
||||
message.error('获取分包商列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchSubcontractors() }, [])
|
||||
|
||||
const stats = {
|
||||
total: subcontractors.length,
|
||||
totalContract: subcontractors.reduce((sum, s) => sum + (s.total_contract_amount || 0), 0),
|
||||
totalPayable: subcontractors.reduce((sum, s) => sum + (s.total_payable || 0), 0)
|
||||
}
|
||||
|
||||
const getPrimaryContact = (contacts: Contact[]) => {
|
||||
const primary = contacts?.find(c => c.is_primary)
|
||||
return primary?.name || '-'
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Subcontractor> = [
|
||||
{ title: '编号', dataIndex: 'code', key: 'code', width: 120 },
|
||||
{
|
||||
title: '名称', dataIndex: 'name', key: 'name',
|
||||
render: (text, record) => (
|
||||
<Button type="link" style={{ padding: 0, fontWeight: 'bold' }} onClick={() => navigate(`/subcontractors/${record.id}`)}>{text}</Button>
|
||||
)
|
||||
},
|
||||
{ title: '承包范围', dataIndex: 'scope', key: 'scope', width: 120 },
|
||||
{ title: '主联系人', key: 'primary_contact', width: 100, render: (_, record) => getPrimaryContact(record.contacts || []) },
|
||||
{ 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> },
|
||||
{ title: '操作', key: 'actions', width: 100, render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" />
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} size="small" />
|
||||
</Space>
|
||||
)}
|
||||
]
|
||||
|
||||
const filteredSubcontractors = subcontractors.filter(s =>
|
||||
s.code?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
s.name?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
s.scope?.toLowerCase().includes(searchText.toLowerCase())
|
||||
)
|
||||
|
||||
const handleContactChange = (index: number, field: string, value: any) => {
|
||||
form.setFieldsValue({
|
||||
contacts: form.getFieldValue('contacts').map((contact: any, i: number) => {
|
||||
if (field === 'is_primary' && value) {
|
||||
// 如果勾选了主联系人,取消其他联系人的主联系人选项
|
||||
return i === index ? { ...contact, [field]: value } : { ...contact, is_primary: false }
|
||||
}
|
||||
return i === index ? { ...contact, [field]: value } : contact
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
let contacts = values.contacts || [{ name: '', position: '', phone: '', is_primary: true }]
|
||||
const hasPrimary = contacts.some(c => c.is_primary)
|
||||
if (!hasPrimary && contacts[0].name) contacts[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 data = await response.json()
|
||||
if (data.success) {
|
||||
message.success(editingSubcontractor ? '更新成功' : '创建成功')
|
||||
setModalVisible(false)
|
||||
form.resetFields()
|
||||
setEditingSubcontractor(null)
|
||||
fetchSubcontractors()
|
||||
} else {
|
||||
message.error(data.message || '操作失败')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
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 }]
|
||||
})
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除', content: '确定要删除此分包商吗?', okText: '确定', cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/subcontractors/${id}`, { method: 'DELETE' })
|
||||
const data = await response.json()
|
||||
if (data.success) { message.success('删除成功'); fetchSubcontractors() }
|
||||
else message.error(data.message || '删除失败')
|
||||
} catch (error) { message.error('删除失败') }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingSubcontractor(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ country: 'Laos', contacts: [{ name: '', position: '', phone: '', is_primary: true }] })
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={8}><Card><Statistic title="分包商总数" value={stats.total} prefix={<SolutionOutlined />} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title="合同总金额" value={stats.totalContract} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title="应付总金额" value={stats.totalPayable} prefix="¥" valueStyle={{ color: stats.totalPayable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
|
||||
</Row>
|
||||
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Input placeholder="搜索分包商编号、名称或承包范围" prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增分包商</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table columns={columns} dataSource={filteredSubcontractors} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 900 }} />
|
||||
</Card>
|
||||
|
||||
<Modal title={editingSubcontractor ? '编辑分包商' : '新增分包商'} open={modalVisible} onCancel={() => { setModalVisible(false); form.resetFields(); setEditingSubcontractor(null) }} onOk={() => form.submit()} width={700}>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<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>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="country" label="国家" initialValue="Laos">
|
||||
<Select>
|
||||
<Select.Option value="China">中国</Select.Option>
|
||||
<Select.Option value="Laos">老挝</Select.Option>
|
||||
</Select>
|
||||
</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>
|
||||
<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, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => handleContactChange(name, 'is_primary', e.target.checked)}
|
||||
/> 主联系人
|
||||
</Form.Item>
|
||||
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}>删除</Button>}
|
||||
</div>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>+ 添加联系人</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubcontractorPage
|
||||
@@ -0,0 +1,190 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Card, Descriptions, Tag, Spin, Empty, Row, Col, Statistic, Table, Button, Divider, Typography, Badge
|
||||
} from 'antd'
|
||||
import {
|
||||
ArrowLeftOutlined, ShopOutlined, FileTextOutlined, DollarOutlined, UserOutlined, PhoneOutlined
|
||||
} from '@ant-design/icons'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
interface Contact {
|
||||
name: string
|
||||
position: string
|
||||
phone: string
|
||||
is_primary?: boolean
|
||||
}
|
||||
|
||||
interface Supplier {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
supply_category: string
|
||||
country: string
|
||||
contacts: Contact[]
|
||||
remark: string
|
||||
total_purchase_amount: number
|
||||
total_paid: number
|
||||
total_payable: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: number
|
||||
project_code: string
|
||||
name: string
|
||||
contract_amount: number
|
||||
status: string
|
||||
customer_id: number
|
||||
supplier_id: number
|
||||
}
|
||||
|
||||
const SupplierDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [supplier, setSupplier] = useState<Supplier | null>(null)
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchSupplierDetail()
|
||||
fetchRelatedProjects()
|
||||
}, [id])
|
||||
|
||||
const fetchSupplierDetail = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/suppliers/${id}`)
|
||||
const data = await res.json()
|
||||
if (data.success) setSupplier(data.data)
|
||||
} catch (error) {
|
||||
console.error('获取供应商详情失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRelatedProjects = async () => {
|
||||
try {
|
||||
// 获取所有项目,筛选关联到此供应商的
|
||||
const res = await fetch('/api/projects')
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
// 供应商暂无supplier_id关联,先显示空
|
||||
// 后续可以在项目中添加供应商关联字段
|
||||
const supplierProjects = (data.data || []).filter((p: Project) => p.supplier_id === parseInt(id))
|
||||
setProjects(supplierProjects)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Spin style={{ display: 'flex', justifyContent: 'center', padding: 50 }} />
|
||||
if (!supplier) return <Empty description="供应商不存在" style={{ marginTop: 100 }} />
|
||||
|
||||
const totalContract = projects.reduce((sum, p) => sum + (parseFloat(p.contract_amount) || 0), 0)
|
||||
// 供应商暂无已付/应付数据,暂时显示0
|
||||
const totalPaid = 0
|
||||
const totalPayable = 0
|
||||
|
||||
const projectColumns = [
|
||||
{ title: '项目编号', dataIndex: 'project_code', key: 'project_code', width: 120 },
|
||||
{ title: '项目名称', dataIndex: 'name', key: 'name', render: (v: string) => <Text strong>{v}</Text> },
|
||||
{ title: '合同金额', dataIndex: 'contract_amount', key: 'contract_amount', align: 'right' as const, render: (v: string) => `¥${(parseFloat(v) || 0).toLocaleString()}` },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: (v: string) => <Badge status={v === 'completed' ? 'success' : 'processing'} text={v === 'completed' ? '已完成' : v === 'planning' ? '规划中' : v === 'in_progress' ? '进行中' : v} /> }
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/suppliers')} style={{ marginBottom: 16 }} type="text">
|
||||
返回列表
|
||||
</Button>
|
||||
|
||||
<Title level={4} style={{ marginBottom: 24 }}>
|
||||
<ShopOutlined style={{ marginRight: 8, color: '#1890ff' }} />
|
||||
{supplier.name}
|
||||
</Title>
|
||||
|
||||
{/* ========== 卡片1:基本信息 ========== */}
|
||||
<Card title={<><UserOutlined /> 基本信息</>} style={{ marginBottom: 24, borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
|
||||
<Descriptions bordered column={{ xs: 1, sm: 2, md: 3 }} size="small">
|
||||
<Descriptions.Item label="编号">{supplier.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="供应类别">{supplier.supply_category || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="国家"><Tag color="blue">{supplier.country || '-'}</Tag></Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{supplier.remark && (
|
||||
<>
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
<div><Text type="secondary">备注:</Text><div style={{ marginTop: 8, padding: 12, background: '#fafafa', borderRadius: 4 }}>{supplier.remark}</div></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
<div style={{ marginBottom: 8 }}><Text type="secondary"><PhoneOutlined style={{ marginRight: 4 }} />联系人</Text></div>
|
||||
<Row gutter={[16, 16]}>
|
||||
{(supplier.contacts || []).map((contact, i) => (
|
||||
<Col key={i} xs={24} sm={12} lg={8}>
|
||||
<Card size="small" style={{ borderLeft: contact.is_primary ? '3px solid #1890ff' : '3px solid #d9d9d9', background: contact.is_primary ? '#f0f5ff' : '#fff' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Text strong>{contact.name || '未命名'}</Text>
|
||||
{contact.is_primary && <Tag color="blue" size="small">主联系人</Tag>}
|
||||
</div>
|
||||
<div style={{ color: '#666', fontSize: 13 }}>
|
||||
{contact.position && <div>职位:{contact.position}</div>}
|
||||
{contact.phone && <div>电话:{contact.phone}</div>}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
{(supplier.contacts || []).length === 0 && <Empty description="暂无联系人" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
</Card>
|
||||
|
||||
{/* ========== 卡片2:关联项目 ========== */}
|
||||
<Card title={<><FileTextOutlined /> 关联项目</>} style={{ marginBottom: 24, borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
|
||||
{projects.length > 0 ? (
|
||||
<Table columns={projectColumns} dataSource={projects} rowKey="id" size="small" pagination={false} bordered />
|
||||
) : (
|
||||
<Empty description="暂无关联项目(在项目管理中添加供应商关联后会自动显示)" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ========== 卡片3:财务信息 ========== */}
|
||||
<Card title={<><DollarOutlined /> 财务信息</>} style={{ borderRadius: 8 }} headStyle={{ background: '#f5f5f5', fontWeight: 600 }}>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#e6f7ff', border: '1px solid #91d5ff' }}>
|
||||
<Statistic title="合同总金额" value={totalContract} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||
<Statistic title="已付总金额" value={totalPaid} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#fff2f0', border: '1px solid #ffccc7' }}>
|
||||
<Statistic title="应付总金额" value={totalPayable} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#fffbe6', border: '1px solid #ffe58f' }}>
|
||||
<Statistic title="未结金额" value={totalPayable} prefix="¥" valueStyle={{ color: '#faad14', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
<div style={{ marginBottom: 16 }}><Text type="secondary">项目明细</Text></div>
|
||||
{projects.length > 0 ? (
|
||||
<Table columns={projectColumns} dataSource={projects} rowKey="id" size="small" pagination={false} bordered />
|
||||
) : (
|
||||
<Empty description="暂无财务数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SupplierDetail
|
||||
@@ -0,0 +1,313 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
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 type { ColumnsType } from 'antd/es/table'
|
||||
|
||||
interface Supplier {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
type: string
|
||||
country: string
|
||||
total_purchase_amount: number
|
||||
total_paid: number
|
||||
total_payable: number
|
||||
rating: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const SupplierPage: React.FC = () => {
|
||||
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()
|
||||
|
||||
// 统计数据
|
||||
const stats = {
|
||||
total: suppliers.length,
|
||||
totalPurchase: suppliers.reduce((sum, s) => sum + s.total_purchase_amount, 0),
|
||||
totalPayable: suppliers.reduce((sum, s) => sum + s.total_payable, 0),
|
||||
avgRating: suppliers.length > 0
|
||||
? suppliers.reduce((sum, s) => sum + s.rating, 0) / suppliers.length
|
||||
: 0
|
||||
}
|
||||
|
||||
// 获取供应商列表
|
||||
const fetchSuppliers = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/suppliers')
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setSuppliers(data.data || [])
|
||||
} else {
|
||||
message.error('获取供应商列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取供应商失败:', error)
|
||||
message.error('网络错误')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSuppliers()
|
||||
}, [])
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<Supplier> = [
|
||||
{
|
||||
title: '编号',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
width: 120,
|
||||
sorter: (a, b) => a.code.localeCompare(b.code)
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text) => <span style={{ fontWeight: 'bold' }}>{text}</span>
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 100,
|
||||
render: (type) => {
|
||||
const typeMap: Record<string, { color: string, text: string }> = {
|
||||
'china': { color: 'red', text: '中国供应商' },
|
||||
'local': { color: 'green', text: '本地供应商' },
|
||||
'international': { color: 'blue', text: '国际供应商' }
|
||||
}
|
||||
const info = typeMap[type] || { color: 'default', text: type }
|
||||
return <Tag color={info.color}>{info.text}</Tag>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '国家',
|
||||
dataIndex: 'country',
|
||||
key: 'country',
|
||||
width: 100,
|
||||
render: (country) => (
|
||||
<Tag color={country === 'China' ? 'red' : country === 'Thailand' ? 'purple' : 'blue'}>
|
||||
{country}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '评分',
|
||||
dataIndex: 'rating',
|
||||
key: 'rating',
|
||||
width: 100,
|
||||
render: (rating) => {
|
||||
const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating)
|
||||
return (
|
||||
<div style={{ color: rating >= 4 ? '#52c41a' : rating >= 3 ? '#faad14' : '#ff4d4f' }}>
|
||||
{stars}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
sorter: (a, b) => a.rating - b.rating
|
||||
},
|
||||
{
|
||||
title: '采购金额',
|
||||
dataIndex: 'total_purchase_amount',
|
||||
key: 'total_purchase_amount',
|
||||
width: 150,
|
||||
render: (amount) => `¥${amount.toLocaleString()}`,
|
||||
sorter: (a, b) => a.total_purchase_amount - b.total_purchase_amount
|
||||
},
|
||||
{
|
||||
title: '应付金额',
|
||||
dataIndex: 'total_payable',
|
||||
key: 'total_payable',
|
||||
width: 150,
|
||||
render: (amount) => (
|
||||
<span style={{ color: amount > 0 ? '#ff4d4f' : '#52c41a' }}>
|
||||
¥{amount.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
sorter: (a, b) => a.total_payable - b.total_payable
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" />
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} size="small" />
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
// 处理搜索
|
||||
const filteredSuppliers = suppliers.filter(supplier =>
|
||||
supplier.code.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
supplier.name.toLowerCase().includes(searchText.toLowerCase())
|
||||
)
|
||||
|
||||
// 处理提交
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
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)
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
message.success(editingSupplier ? '更新成功' : '创建成功')
|
||||
setModalVisible(false)
|
||||
form.resetFields()
|
||||
setEditingSupplier(null)
|
||||
fetchSuppliers()
|
||||
} else {
|
||||
message.error(data.message || '操作失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存供应商失败:', error)
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (supplier: Supplier) => {
|
||||
setEditingSupplier(supplier)
|
||||
form.setFieldsValue(supplier)
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除此供应商吗?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/suppliers/${id}`, { method: 'DELETE' })
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
message.success('删除成功')
|
||||
fetchSuppliers()
|
||||
} else {
|
||||
message.error(data.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingSupplier(null)
|
||||
form.resetFields()
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="供应商总数" value={stats.total} prefix={<ShopOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="采购总金额" value={stats.totalPurchase} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="应付总金额" value={stats.totalPayable} prefix="¥" valueStyle={{ color: stats.totalPayable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="平均评分" value={stats.avgRating} precision={1} prefix="★" suffix="/5" /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 操作栏 */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Input
|
||||
placeholder="搜索供应商编号或名称"
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 300 }}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增供应商</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 表格 */}
|
||||
<Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredSuppliers}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 模态框 */}
|
||||
<Modal
|
||||
title={editingSupplier ? '编辑供应商' : '新增供应商'}
|
||||
open={modalVisible}
|
||||
onCancel={() => { setModalVisible(false); form.resetFields(); setEditingSupplier(null) }}
|
||||
onOk={() => form.submit()}
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="code" label="编号" rules={[{ required: true, message: '请输入编号' }]}>
|
||||
<Input placeholder="如:SUP-001" />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="供应商名称" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="type" label="类型" initialValue="local">
|
||||
<Select>
|
||||
<Select.Option value="china">中国供应商</Select.Option>
|
||||
<Select.Option value="local">本地供应商</Select.Option>
|
||||
<Select.Option value="international">国际供应商</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="country" label="国家" initialValue="Laos">
|
||||
<Select>
|
||||
<Select.Option value="China">中国</Select.Option>
|
||||
<Select.Option value="Thailand">泰国</Select.Option>
|
||||
<Select.Option value="Laos">老挝</Select.Option>
|
||||
<Select.Option value="Vietnam">越南</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="rating" label="评分" initialValue={5}>
|
||||
<Select>
|
||||
<Select.Option value={5}>★★★★★ (5)</Select.Option>
|
||||
<Select.Option value={4}>★★★★☆ (4)</Select.Option>
|
||||
<Select.Option value={3}>★★★☆☆ (3)</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SupplierPage
|
||||
@@ -0,0 +1,249 @@
|
||||
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, ShopOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
|
||||
interface Contact {
|
||||
name: string
|
||||
position: string
|
||||
phone: string
|
||||
is_primary?: boolean
|
||||
}
|
||||
|
||||
interface Supplier {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
supply_category: string
|
||||
country: string
|
||||
contacts: Contact[]
|
||||
remark: string
|
||||
total_purchase_amount: number
|
||||
total_paid: number
|
||||
total_payable: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const SupplierPage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
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()
|
||||
|
||||
const fetchSuppliers = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/suppliers')
|
||||
const data = await response.json()
|
||||
if (data.success) setSuppliers(data.data || [])
|
||||
} catch (error) {
|
||||
message.error('获取供应商列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchSuppliers() }, [])
|
||||
|
||||
const stats = {
|
||||
total: suppliers.length,
|
||||
totalPurchase: suppliers.reduce((sum, s) => sum + (s.total_purchase_amount || 0), 0),
|
||||
totalPayable: suppliers.reduce((sum, s) => sum + (s.total_payable || 0), 0)
|
||||
}
|
||||
|
||||
const getPrimaryContact = (contacts: Contact[]) => {
|
||||
const primary = contacts?.find(c => c.is_primary)
|
||||
return primary?.name || '-'
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Supplier> = [
|
||||
{ title: '编号', dataIndex: 'code', key: 'code', width: 120 },
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, record) => (
|
||||
<Button type="link" style={{ padding: 0, fontWeight: 'bold' }} onClick={() => navigate(`/suppliers/${record.id}`)}>
|
||||
{text}
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
{ title: '供应类别', dataIndex: 'supply_category', key: 'supply_category', width: 120 },
|
||||
{ title: '主联系人', key: 'primary_contact', width: 100, render: (_, record) => getPrimaryContact(record.contacts || []) },
|
||||
{ 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> },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" />
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} size="small" />
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
const filteredSuppliers = suppliers.filter(s =>
|
||||
s.code?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
s.name?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
s.supply_category?.toLowerCase().includes(searchText.toLowerCase())
|
||||
)
|
||||
|
||||
const handleContactChange = (index: number, field: string, value: any) => {
|
||||
form.setFieldsValue({
|
||||
contacts: form.getFieldValue('contacts').map((contact: any, i: number) => {
|
||||
if (field === 'is_primary' && value) {
|
||||
// 如果勾选了主联系人,取消其他联系人的主联系人选项
|
||||
return i === index ? { ...contact, [field]: value } : { ...contact, is_primary: false }
|
||||
}
|
||||
return i === index ? { ...contact, [field]: value } : contact
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
let contacts = values.contacts || [{ name: '', position: '', phone: '', is_primary: true }]
|
||||
const hasPrimary = contacts.some(c => c.is_primary)
|
||||
if (!hasPrimary && contacts[0].name) contacts[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 })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
message.success(editingSupplier ? '更新成功' : '创建成功')
|
||||
setModalVisible(false)
|
||||
form.resetFields()
|
||||
setEditingSupplier(null)
|
||||
fetchSuppliers()
|
||||
} else {
|
||||
message.error(data.message || '操作失败')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (supplier: Supplier) => {
|
||||
setEditingSupplier(supplier)
|
||||
form.setFieldsValue({
|
||||
name: supplier.name,
|
||||
supply_category: supplier.supply_category,
|
||||
country: supplier.country,
|
||||
remark: supplier.remark,
|
||||
contacts: supplier.contacts?.length ? supplier.contacts : [{ name: '', position: '', phone: '', is_primary: true }]
|
||||
})
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除此供应商吗?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/suppliers/${id}`, { method: 'DELETE' })
|
||||
const data = await response.json()
|
||||
if (data.success) { message.success('删除成功'); fetchSuppliers() }
|
||||
else message.error(data.message || '删除失败')
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingSupplier(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ country: 'Laos', contacts: [{ name: '', position: '', phone: '', is_primary: true }] })
|
||||
setModalVisible(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={8}><Card><Statistic title="供应商总数" value={stats.total} prefix={<ShopOutlined />} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title="采购总金额" value={stats.totalPurchase} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title="应付总金额" value={stats.totalPayable} prefix="¥" valueStyle={{ color: stats.totalPayable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
|
||||
</Row>
|
||||
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Input placeholder="搜索供应商编号、名称或供应类别" prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增供应商</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table columns={columns} dataSource={filteredSuppliers} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 900 }} />
|
||||
</Card>
|
||||
|
||||
<Modal title={editingSupplier ? '编辑供应商' : '新增供应商'} open={modalVisible} onCancel={() => { setModalVisible(false); form.resetFields(); setEditingSupplier(null) }} onOk={() => form.submit()} width={700}>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="供应商名称" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="supply_category" label="供应类别">
|
||||
<Input placeholder="手填:如电力设备、建筑材料" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="country" label="国家" initialValue="Laos">
|
||||
<Select>
|
||||
<Select.Option value="China">中国</Select.Option>
|
||||
<Select.Option value="Laos">老挝</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<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, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => handleContactChange(name, 'is_primary', e.target.checked)}
|
||||
/> 主联系人
|
||||
</Form.Item>
|
||||
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}>删除</Button>}
|
||||
</div>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>+ 添加联系人</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SupplierPage
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { Card, Typography, Button, Table, Tag, Space, Select, DatePicker, Input } from 'antd';
|
||||
import { SearchOutlined, DownloadOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const SystemLogsPage: React.FC = () => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const columns = [
|
||||
{ title: '日志ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{ title: '时间', dataIndex: 'timestamp', key: 'timestamp', width: 180 },
|
||||
{
|
||||
title: '级别',
|
||||
dataIndex: 'level',
|
||||
key: 'level',
|
||||
width: 100,
|
||||
render: (v: string) => {
|
||||
const colors: Record<string, string> = { 'info': 'blue', 'warning': 'orange', 'error': 'red', 'success': 'green' };
|
||||
return <Tag color={colors[v]}>{v.toUpperCase()}</Tag>;
|
||||
}
|
||||
},
|
||||
{ title: '模块', dataIndex: 'module', key: 'module', width: 120 },
|
||||
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 120 },
|
||||
{ title: '操作', dataIndex: 'action', key: 'action' },
|
||||
{ title: 'IP地址', dataIndex: 'ip', key: 'ip', width: 130 },
|
||||
{ title: '详情', dataIndex: 'detail', key: 'detail', ellipsis: true },
|
||||
];
|
||||
|
||||
const data = [
|
||||
{ key: '1', id: 1001, timestamp: '2026-03-18 17:15:30', level: 'info', module: '用户管理', operator: 'admin', action: '用户登录', ip: '192.168.1.100', detail: '用户 admin 成功登录系统' },
|
||||
{ key: '2', id: 1002, timestamp: '2026-03-18 17:14:25', level: 'info', module: '项目管理', operator: 'manager', action: '创建项目', ip: '192.168.1.101', detail: '创建新项目: 博纳斯线路改造' },
|
||||
{ key: '3', id: 1003, timestamp: '2026-03-18 17:13:10', level: 'warning', module: '财务管理', operator: 'admin', action: '审批预支', ip: '192.168.1.100', detail: '预支申请单 ADV20260318001 审批通过' },
|
||||
{ key: '4', id: 1004, timestamp: '2026-03-18 17:12:05', level: 'success', module: '系统', operator: 'system', action: '数据备份', ip: '127.0.0.1', detail: '自动备份完成,耗时 45 秒' },
|
||||
{ key: '5', id: 1005, timestamp: '2026-03-18 17:10:00', level: 'error', module: 'API', operator: 'anonymous', action: '接口访问', ip: '10.0.0.55', detail: '无效的 API Token 访问尝试' },
|
||||
{ key: '6', id: 1006, timestamp: '2026-03-18 17:09:30', level: 'info', module: '采购管理', operator: 'pm1', action: '创建采购', ip: '192.168.1.102', detail: '创建采购申请: PO20260318002' },
|
||||
{ key: '7', id: 1007, timestamp: '2026-03-18 17:08:15', level: 'info', module: '用户管理', operator: 'admin', action: '修改角色', ip: '192.168.1.100', detail: '修改用户 zhang 的角色为项目经理' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 0 }}>系统日志</Title>
|
||||
<Paragraph type="secondary">查看系统操作记录和审计日志</Paragraph>
|
||||
</div>
|
||||
<Space>
|
||||
<Select placeholder="日志级别" style={{ width: 120 }} allowClear options={[
|
||||
{ value: 'info', label: 'Info' },
|
||||
{ value: 'warning', label: 'Warning' },
|
||||
{ value: 'error', label: 'Error' },
|
||||
{ value: 'success', label: 'Success' }
|
||||
]} />
|
||||
<Select placeholder="模块" style={{ width: 150 }} allowClear options={[
|
||||
{ value: 'user', label: '用户管理' },
|
||||
{ value: 'project', label: '项目管理' },
|
||||
{ value: 'finance', label: '财务管理' },
|
||||
{ value: 'system', label: '系统' }
|
||||
]} />
|
||||
<RangePicker placeholder={['开始日期', '结束日期']} />
|
||||
<Input.Search placeholder="搜索日志内容" style={{ width: 200 }} />
|
||||
<Button icon={<DownloadOutlined />}>导出</Button>
|
||||
<Button icon={<DeleteOutlined />} danger>清理</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 15 }} scroll={{ x: 1400 }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemLogsPage;
|
||||
@@ -0,0 +1,147 @@
|
||||
import React from 'react';
|
||||
import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, Select, message, Row, Col, Avatar, Switch } from 'antd';
|
||||
import { PlusOutlined, SearchOutlined, UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
const UsersPage: React.FC = () => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [modalVisible, setModalVisible] = React.useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const columns = [
|
||||
{ title: '用户ID', dataIndex: 'id', key: 'id', width: 100 },
|
||||
{
|
||||
title: '头像',
|
||||
dataIndex: 'avatar',
|
||||
key: 'avatar',
|
||||
width: 80,
|
||||
render: () => <Avatar icon={<UserOutlined />} />
|
||||
},
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username', width: 120 },
|
||||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 120 },
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email' },
|
||||
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
width: 120,
|
||||
render: (v: string) => {
|
||||
const colors: Record<string, string> = { 'admin': 'red', 'manager': 'blue', 'user': 'green' };
|
||||
const texts: Record<string, string> = { 'admin': '管理员', 'manager': '经理', 'user': '普通用户' };
|
||||
return <Tag color={colors[v]}>{texts[v]}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (v: boolean) => <Switch checked={v} onChange={() => {}} />
|
||||
},
|
||||
{ title: '最后登录', dataIndex: 'lastLogin', key: 'lastLogin', width: 150 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
render: () => (
|
||||
<Space>
|
||||
<Button size="small" type="link">编辑</Button>
|
||||
<Button size="small" type="link">重置密码</Button>
|
||||
<Button size="small" type="link" danger>删除</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const data = [
|
||||
{ key: '1', id: 'U001', username: 'admin', name: '系统管理员', email: 'admin@qingyuan.com', phone: '+856 20 0000 0001', role: 'admin', status: true, lastLogin: '2026-03-18 15:30' },
|
||||
{ key: '2', id: 'U002', username: 'manager', name: '罗仕林', email: 'luo@qingyuan.com', phone: '+856 20 0000 0002', role: 'manager', status: true, lastLogin: '2026-03-18 14:20' },
|
||||
{ key: '3', id: 'U003', username: 'pm1', name: '张三', email: 'zhang@qingyuan.com', phone: '+856 20 0000 0003', role: 'user', status: true, lastLogin: '2026-03-17 10:15' },
|
||||
];
|
||||
|
||||
const handleSubmit = () => {
|
||||
message.success('用户已添加');
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 0 }}>用户管理</Title>
|
||||
<Paragraph type="secondary">管理系统用户账号和权限</Paragraph>
|
||||
</div>
|
||||
<Space>
|
||||
<Select placeholder="选择角色" style={{ width: 150 }} allowClear options={[
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'manager', label: '经理' },
|
||||
{ value: 'user', label: '普通用户' }
|
||||
]} />
|
||||
<Input.Search placeholder="搜索用户" style={{ width: 200 }} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
|
||||
新增用户
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1400 }} />
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="新增用户"
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
onOk={handleSubmit}
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="用户名" name="username" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入用户名" prefix={<UserOutlined />} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="姓名" name="name" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入姓名" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="邮箱" name="email" rules={[{ required: true, type: 'email' }]}>
|
||||
<Input placeholder="email@example.com" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="手机号" name="phone">
|
||||
<Input placeholder="+856 20 xxxx xxxx" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="角色" name="role" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择角色" options={[
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'manager', label: '经理' },
|
||||
{ value: 'user', label: '普通用户' }
|
||||
]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="初始密码" name="password" rules={[{ required: true }]}>
|
||||
<Input.Password placeholder="请输入初始密码" prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersPage;
|
||||
@@ -0,0 +1,399 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Card, Space, Tag, Modal, Form, Input, InputNumber, Select, DatePicker, message, Descriptions, Image, Divider, AutoComplete } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, UndoOutlined, PlusCircleOutlined, MinusCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import FileUpload from '../components/FileUpload';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface DetailItem {
|
||||
id?: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
attachments?: string[];
|
||||
}
|
||||
|
||||
const VerificationPage: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const [records, setRecords] = useState<any[]>([]);
|
||||
const [advances, setAdvances] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [selectedRecord, setSelectedRecord] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [detailItems, setDetailItems] = useState<DetailItem[]>([]);
|
||||
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchExchangeRates();
|
||||
fetchRecords(); fetchAdvances(); }, []);
|
||||
|
||||
const fetchExchangeRates = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/exchange-rates/latest");
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const rates: Record<string, number> = {};
|
||||
Object.keys(data.data).forEach(key => {
|
||||
rates[key] = parseFloat(data.data[key]) || 1;
|
||||
});
|
||||
setExchangeRates(rates);
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
const fetchRecords = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/verifications');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
// 解析JSON字符串字段
|
||||
const parsedRecords = data.data.map((record: any) => ({
|
||||
...record,
|
||||
detail_items: record.detail_items ? JSON.parse(record.detail_items) : [],
|
||||
attachments: record.attachments ? JSON.parse(record.attachments) : []
|
||||
}));
|
||||
setRecords(parsedRecords);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取核销列表失败:', error);
|
||||
message.error('获取核销列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAdvances = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/advances?status=approved');
|
||||
const data = await res.json();
|
||||
if (data.success) setAdvances(data.data);
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingId(null);
|
||||
setDetailItems([]);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
verification_date: dayjs(),
|
||||
currency: 'CNY',
|
||||
applicant: user?.name || user?.username || '当前用户',
|
||||
attachments: []
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: any) => {
|
||||
setEditingId(record.id);
|
||||
setDetailItems(record.detail_items || []);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
verification_date: record.verification_date ? dayjs(record.verification_date) : null,
|
||||
attachments: record.attachments || []
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleView = (record: any) => {
|
||||
setSelectedRecord(record);
|
||||
setDetailModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这条核销记录吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await fetch('/api/verifications/' + id, { method: 'DELETE' });
|
||||
message.success('删除成功');
|
||||
fetchRecords();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleWithdraw = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认撤回',
|
||||
content: '撤回后可重新编辑提交,确认撤回吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await fetch('/api/verifications/' + id + '/withdraw', { method: 'POST' });
|
||||
message.success('已撤回,可重新编辑');
|
||||
fetchRecords();
|
||||
} catch (error) {
|
||||
message.error('撤回失败');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const data = {
|
||||
...values,
|
||||
verification_date: values.verification_date?.format('YYYY-MM-DD'),
|
||||
detail_items: detailItems,
|
||||
amount: detailItems.reduce((sum, item) => sum + (item.amount || 0), 0),
|
||||
applicant: user?.name || user?.username
|
||||
};
|
||||
const url = editingId ? '/api/verifications/' + editingId : '/api/verifications';
|
||||
const method = editingId ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
message.success(editingId ? '更新成功' : '创建成功');
|
||||
setModalVisible(false);
|
||||
fetchRecords();
|
||||
} else {
|
||||
message.error(result.error || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const addDetailItem = () => setDetailItems([...detailItems, { description: '', amount: 0, attachments: [] }]);
|
||||
const updateDetailItem = (index: number, field: keyof DetailItem, value: any) => {
|
||||
const newItems = [...detailItems];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
setDetailItems(newItems);
|
||||
};
|
||||
const convertToCNY = (amount: number, curr: string): number => {
|
||||
if (curr === "CNY") return amount;
|
||||
const rateKey = curr + "_CNY";
|
||||
const rate = exchangeRates[rateKey] || 1;
|
||||
return amount * rate;
|
||||
};
|
||||
|
||||
const removeDetailItem = (index: number) => setDetailItems(detailItems.filter((_, i) => i !== index));
|
||||
|
||||
const handleAdvanceSelect = (advanceCode: string) => {
|
||||
const advance = advances.find((a: any) => a.advance_code === advanceCode);
|
||||
if (advance) {
|
||||
form.setFieldsValue({
|
||||
advance_code: advance.advance_code,
|
||||
advance_amount: advance.amount,
|
||||
currency: advance.currency
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'processing', text: '待审批' },
|
||||
approved: { color: 'success', text: '已批准' },
|
||||
rejected: { color: 'error', text: '已退回' },
|
||||
withdrawn: { color: 'default', text: '已撤回' },
|
||||
completed: { color: 'blue', text: '已完成' },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number, currency: string = 'CNY') => {
|
||||
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
|
||||
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
// Format number with thousand separator for input display
|
||||
const formatNumberWithSeparator = (value: number | undefined, currency: string): string => {
|
||||
if (value === undefined || value === null) return '';
|
||||
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
|
||||
const symbol = symbols[currency] || '¥';
|
||||
return symbol + value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
// Parse formatted string back to number
|
||||
const parseFormattedNumber = (value: string): number => {
|
||||
// Remove currency symbols and thousand separators
|
||||
const cleaned = value.replace(/[¥$₭฿,]/g, '');
|
||||
return parseFloat(cleaned) || 0;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: '关联预支', dataIndex: 'advance_code', key: 'advance_code', width: 120 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
|
||||
<>
|
||||
<div>{formatAmount(v, r.currency)}</div>
|
||||
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}>≈ ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
|
||||
</>
|
||||
) },
|
||||
{ title: '核销日期', dataIndex: 'verification_date', key: 'verification_date', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
|
||||
{ title: '编号', dataIndex: 'verification_code', key: 'verification_code', width: 120 },
|
||||
{
|
||||
title: '操作', key: 'action', width: 250,
|
||||
render: (_: any, record: any) => (
|
||||
<Space wrap>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(record)}>详情</Button>
|
||||
{record.status === 'pending' && (
|
||||
<>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}>撤回</Button>
|
||||
</>
|
||||
)}
|
||||
{(record.status === 'rejected' || record.status === 'withdrawn') && (
|
||||
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑重提</Button>
|
||||
)}
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>删除</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const currency = Form.useWatch('currency', form);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ marginBottom: 8 }}>核销申请</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>预支款项核销</p>
|
||||
</div>
|
||||
|
||||
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建核销</Button>}>
|
||||
<Table dataSource={records} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
|
||||
</Card>
|
||||
|
||||
<Modal title={editingId ? '编辑核销' : '新建核销'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} width={900}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="applicant" label="申请人">
|
||||
<Input disabled style={{ color: 'rgba(0,0,0,0.85)', backgroundColor: '#f5f5f5' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="advance_code" label="关联预支单">
|
||||
<AutoComplete
|
||||
options={advances.map((a: any) => ({ value: a.advance_code, label: `${a.advance_code} - ${a.applicant} - ${formatAmount(a.amount, a.currency)}` }))}
|
||||
onSelect={handleAdvanceSelect}
|
||||
placeholder="选择或输入预支单编号"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="advance_amount" label="预支金额">
|
||||
<InputNumber disabled style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="verification_date" label="核销日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="currency" label="币种" rules={[{ required: true }]}>
|
||||
<Select style={{ width: 200 }}>
|
||||
<Option value="CNY">人民币 (CNY)</Option>
|
||||
<Option value="USD">美元 (USD)</Option>
|
||||
<Option value="LAK">老挝基普 (LAK)</Option>
|
||||
<Option value="THB">泰铢 (THB)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="reason" label="核销事由" rules={[{ required: true }]}>
|
||||
<TextArea rows={2} placeholder="核销原因说明" />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>核销明细</Divider>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button type="dashed" icon={<PlusCircleOutlined />} onClick={addDetailItem}>添加明细</Button>
|
||||
<span style={{ marginLeft: 16, color: '#888' }}>
|
||||
合计: {formatAmount(detailItems.reduce((sum, item) => sum + (item.amount || 0), 0), currency)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{detailItems.map((item, index) => (
|
||||
<Card key={index} size="small" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}>费用说明</label>
|
||||
<Input value={item.description} onChange={(e) => updateDetailItem(index, 'description', e.target.value)} placeholder="费用说明" />
|
||||
</div>
|
||||
<div style={{ width: 150 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}>金额</label>
|
||||
<InputNumber value={item.amount} onChange={(v) => updateDetailItem(index, 'amount', v)} min={0} precision={2} style={{ width: '100%' }} placeholder="金额" />
|
||||
</div>
|
||||
<div style={{ flex: 2, minWidth: 300 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}>凭证附件</label>
|
||||
<FileUpload value={item.attachments || []} onChange={(urls) => updateDetailItem(index, 'attachments', urls)} maxCount={3} accept="image/*" />
|
||||
</div>
|
||||
<Button type="text" danger icon={<MinusCircleOutlined />} onClick={() => removeDetailItem(index)} style={{ marginTop: 24 }} />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Divider>主附件</Divider>
|
||||
<Form.Item name="attachments" label="整体凭证附件">
|
||||
<FileUpload maxCount={9} accept="image/*" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="核销详情" open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={null} width={900}>
|
||||
{selectedRecord && (
|
||||
<>
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="核销编号">{selectedRecord.verification_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联预支">{selectedRecord.advance_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销日期">{selectedRecord.verification_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
|
||||
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>≈ ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{selectedRecord.detail_items && selectedRecord.detail_items.length > 0 && (
|
||||
<>
|
||||
<Divider>核销明细</Divider>
|
||||
<Table
|
||||
dataSource={selectedRecord.detail_items}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '费用说明', dataIndex: 'description', key: 'description' },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number) => formatAmount(v, selectedRecord.currency) },
|
||||
{ title: '附件', dataIndex: 'attachments', key: 'attachments', render: (v: string[]) => v?.length ? `${v.length}张` : '-' }
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedRecord.attachments && selectedRecord.attachments.length > 0 && (
|
||||
<>
|
||||
<Divider>整体凭证附件</Divider>
|
||||
<Image.PreviewGroup>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{selectedRecord.attachments.map((url: string, index: number) => (
|
||||
<Image key={index} src={url} width={100} height={100} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerificationPage;
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { Card, Typography, Descriptions, Tag, Row, Col, Progress, Divider } from 'antd';
|
||||
import {
|
||||
CloudServerOutlined,
|
||||
DatabaseOutlined,
|
||||
NodeIndexOutlined,
|
||||
CheckCircleOutlined,
|
||||
InfoCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
const AboutPage: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>关于系统</Title>
|
||||
<Paragraph type="secondary">系统信息与版本</Paragraph>
|
||||
</div>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={16}>
|
||||
<Card title={<><InfoCircleOutlined /> 系统信息</>}>
|
||||
<Descriptions bordered column={2}>
|
||||
<Descriptions.Item label="系统名称">轻远电力老挝ERP</Descriptions.Item>
|
||||
<Descriptions.Item label="系统版本">V1.0.0</Descriptions.Item>
|
||||
<Descriptions.Item label="开发团队">轻远电力信息技术部</Descriptions.Item>
|
||||
<Descriptions.Item label="上线日期">2026年3月</Descriptions.Item>
|
||||
<Descriptions.Item label="技术架构">
|
||||
<Tag color="blue">React 18</Tag>
|
||||
<Tag color="green">Ant Design 5</Tag>
|
||||
<Tag color="purple">Node.js</Tag>
|
||||
<Tag color="orange">PostgreSQL</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="部署环境">
|
||||
<Tag color="cyan">腾讯云服务器</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="前端框架">Vite + React + TypeScript</Descriptions.Item>
|
||||
<Descriptions.Item label="后端框架">Express.js + PostgreSQL</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title={<><CheckCircleOutlined /> 功能模块</>} style={{ marginTop: 24 }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Title level={5}>项目管理</Title>
|
||||
<Text type="secondary">项目创建、进度跟踪、合同管理</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Title level={5}>财务管理</Title>
|
||||
<Text type="secondary">预支、报销、付款申请、核销</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Title level={5}>采购管理</Title>
|
||||
<Text type="secondary">商品管理、供应商管理</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Title level={5}>合作伙伴</Title>
|
||||
<Text type="secondary">供应商、分包商、客户管理</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Title level={5}>施工管理</Title>
|
||||
<Text type="secondary">施工日志、里程碑管理</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Title level={5}>预算报价</Title>
|
||||
<Text type="secondary">项目预算、报价管理</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={8}>
|
||||
<Card title={<><CloudServerOutlined /> 服务器状态</>}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text type="secondary">CPU使用率</Text>
|
||||
<Progress percent={45} status="active" />
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text type="secondary">内存使用</Text>
|
||||
<Progress percent={60} strokeColor="#52c41a" />
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text type="secondary">磁盘空间</Text>
|
||||
<Progress percent={35} strokeColor="#1890ff" />
|
||||
</div>
|
||||
<Divider />
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="服务器IP">43.161.248.209</Descriptions.Item>
|
||||
<Descriptions.Item label="操作系统">OpenCloudOS 9</Descriptions.Item>
|
||||
<Descriptions.Item label="Node版本">v22.22.1</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title={<><DatabaseOutlined /> 数据库状态</>} style={{ marginTop: 24 }}>
|
||||
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||
<CheckCircleOutlined style={{ fontSize: 48, color: '#52c41a' }} />
|
||||
<Title level={4} style={{ margin: '16px 0 8px' }}>运行正常</Title>
|
||||
<Text type="secondary">PostgreSQL 15</Text>
|
||||
</div>
|
||||
<Divider />
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="数据库名">company_finance_db</Descriptions.Item>
|
||||
<Descriptions.Item label="连接状态">正常</Descriptions.Item>
|
||||
<Descriptions.Item label="最近备份">2026-03-19 00:00</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card style={{ marginTop: 24, background: '#f6ffed', borderColor: '#b7eb8f' }}>
|
||||
<Text>© 2026 轻远电力老挝ERP系统 - 版本 V1.0.0</Text>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutPage;
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, DatePicker, message, Row, Col, Progress } from 'antd';
|
||||
import { DownloadOutlined, UploadOutlined, DeleteOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
const BackupPage: React.FC = () => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [backuping, setBackuping] = React.useState(false);
|
||||
|
||||
const columns = [
|
||||
{ title: '备份名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '备份时间', dataIndex: 'time', key: 'time' },
|
||||
{ title: '文件大小', dataIndex: 'size', key: 'size' },
|
||||
{ title: '备份类型', dataIndex: 'type', key: 'type', render: (v: string) => <Tag color={v === 'auto' ? 'blue' : 'green'}>{v === 'auto' ? '自动' : '手动'}</Tag> },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', render: (v: string) => <Tag color={v === 'success' ? 'success' : 'error'}>{v === 'success' ? '成功' : '失败'}</Tag> },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: () => (
|
||||
<Space>
|
||||
<Button size="small" type="link" icon={<DownloadOutlined />}>下载</Button>
|
||||
<Button size="small" type="link" icon={<UploadOutlined />}>恢复</Button>
|
||||
<Button size="small" danger type="link" icon={<DeleteOutlined />}>删除</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const data = [
|
||||
{ key: '1', name: 'backup-20260319.sql', time: '2026-03-19 00:00', size: '15.2 MB', type: 'auto', status: 'success' },
|
||||
{ key: '2', name: 'backup-20260318.sql', time: '2026-03-18 00:00', size: '14.8 MB', type: 'auto', status: 'success' },
|
||||
{ key: '3', name: 'backup-manual-20260317.sql', time: '2026-03-17 15:30', size: '14.5 MB', type: 'manual', status: 'success' },
|
||||
];
|
||||
|
||||
const handleBackup = () => {
|
||||
setBackuping(true);
|
||||
setTimeout(() => {
|
||||
message.success('备份创建成功');
|
||||
setBackuping(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>数据备份</Title>
|
||||
<Paragraph type="secondary">管理系统数据备份与恢复</Paragraph>
|
||||
</div>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Text type="secondary">总备份数</Text>
|
||||
<Title level={2} style={{ margin: '8px 0 0' }}>3</Title>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Text type="secondary">总大小</Text>
|
||||
<Title level={2} style={{ margin: '8px 0 0' }}>44.5 MB</Title>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Text type="secondary">最近备份</Text>
|
||||
<Title level={4} style={{ margin: '8px 0 0' }}>2026-03-19 00:00</Title>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Text type="secondary">存储空间</Text>
|
||||
<Progress percent={30} size="small" style={{ marginTop: 8 }} />
|
||||
<Text type="secondary">300 MB / 1 GB</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title="备份列表"
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<ClockCircleOutlined />}>自动备份设置</Button>
|
||||
<Button type="primary" icon={<DownloadOutlined />} loading={backuping} onClick={handleBackup}>
|
||||
立即备份
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table columns={columns} dataSource={data} loading={loading} pagination={{ pageSize: 10 }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackupPage;
|
||||
@@ -0,0 +1,221 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Typography, Table, Button, Space, Modal, Form, Select, Input, message, Tag, Steps, Divider, Switch, Badge } from 'antd';
|
||||
import { EditOutlined, PlusOutlined, SettingOutlined, CheckCircleOutlined, ClockCircleOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
interface ProcessNode {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
roleName: string;
|
||||
order: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const ProcessManagement: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingNode, setEditingNode] = useState<ProcessNode | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 流程节点数据
|
||||
const [nodes, setNodes] = useState<ProcessNode[]>([
|
||||
{ id: '1', name: '发起申请', role: 'applicant', roleName: '申请人(任意角色)', order: 1, enabled: true },
|
||||
{ id: '2', name: '审批', role: 'admin', roleName: '管理员', order: 2, enabled: true },
|
||||
{ id: '3', name: '执行付款', role: 'admin', roleName: '管理员', order: 3, enabled: true },
|
||||
]);
|
||||
|
||||
// 角色选项
|
||||
const roleOptions = [
|
||||
{ value: 'applicant', label: '申请人(任意角色)' },
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'finance', label: '财务专员' },
|
||||
{ value: 'manager', label: '项目经理' },
|
||||
];
|
||||
|
||||
// 流程类型
|
||||
const processTypes = [
|
||||
{ key: 'advance', name: '预支申请', description: '员工预支款项申请流程' },
|
||||
{ key: 'reimbursement', name: '报销申请', description: '费用报销申请流程' },
|
||||
{ key: 'payment', name: '付款申请', description: '供应商付款申请流程' },
|
||||
{ key: 'verification', name: '核销申请', description: '单据核销申请流程' },
|
||||
];
|
||||
|
||||
const handleEdit = (node: ProcessNode) => {
|
||||
setEditingNode(node);
|
||||
form.setFieldsValue({
|
||||
role: node.role
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
form.validateFields().then(values => {
|
||||
if (editingNode) {
|
||||
const updatedNodes = nodes.map(n => {
|
||||
if (n.id === editingNode.id) {
|
||||
const roleOption = roleOptions.find(r => r.value === values.role);
|
||||
return { ...n, role: values.role, roleName: roleOption?.label || values.role };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
setNodes(updatedNodes);
|
||||
message.success('节点配置已保存');
|
||||
}
|
||||
setModalVisible(false);
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusTag = (enabled: boolean) => {
|
||||
return enabled ? <Tag color="success">已启用</Tag> : <Tag color="default">已禁用</Tag>;
|
||||
};
|
||||
|
||||
const getStepStatus = (order: number) => {
|
||||
if (order === 1) return 'finish';
|
||||
if (order === 2) return 'process';
|
||||
return 'wait';
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '顺序',
|
||||
dataIndex: 'order',
|
||||
key: 'order',
|
||||
width: 80,
|
||||
render: (v: number) => <Badge count={v} style={{ backgroundColor: '#1890ff' }} />
|
||||
},
|
||||
{ title: '节点名称', dataIndex: 'name', key: 'name', width: 150 },
|
||||
{
|
||||
title: '执行角色',
|
||||
dataIndex: 'roleName',
|
||||
key: 'roleName',
|
||||
render: (v: string, r: ProcessNode) => (
|
||||
<Space>
|
||||
<Tag color={r.role === 'admin' ? 'blue' : r.role === 'finance' ? 'green' : 'default'}>
|
||||
{v}
|
||||
</Tag>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
width: 100,
|
||||
render: (v: boolean) => getStatusTag(v)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: (_: any, record: ProcessNode) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>流程管理</Title>
|
||||
<Paragraph type="secondary">
|
||||
配置财务申请的审批流程节点,支持自定义执行角色
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* 流程图示 */}
|
||||
<Card title="当前流程图" style={{ marginBottom: 24 }}>
|
||||
<Steps current={1} style={{ marginTop: 16 }}>
|
||||
{nodes.filter(n => n.enabled).map((node, index) => (
|
||||
<Steps.Step
|
||||
key={node.id}
|
||||
title={node.name}
|
||||
description={node.roleName}
|
||||
status={getStepStatus(node.order)}
|
||||
icon={
|
||||
node.order === 1 ? <PlusOutlined /> :
|
||||
node.order === 2 ? <CheckCircleOutlined /> :
|
||||
<SyncOutlined />
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Steps>
|
||||
<Divider />
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
<Text strong>说明:</Text>
|
||||
当前流程为「申请人 → 管理员审批 → 管理员执行」,后续可在下方修改执行角色为财务专员。
|
||||
</Paragraph>
|
||||
</Card>
|
||||
|
||||
{/* 节点配置表 */}
|
||||
<Card title="节点配置">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={nodes}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 流程类型说明 */}
|
||||
<Card title="适用流程" style={{ marginTop: 24 }}>
|
||||
<Table
|
||||
columns={[
|
||||
{ title: '流程类型', dataIndex: 'name', key: 'name', width: 150 },
|
||||
{ title: '说明', dataIndex: 'description', key: 'description' },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: () => <Tag color="success">已启用</Tag>
|
||||
}
|
||||
]}
|
||||
dataSource={processTypes}
|
||||
rowKey="key"
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 编辑节点弹窗 */}
|
||||
<Modal
|
||||
title={`编辑节点:${editingNode?.name}`}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
onOk={handleSave}
|
||||
width={500}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="节点名称">
|
||||
<Input value={editingNode?.name} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="role"
|
||||
label="执行角色"
|
||||
rules={[{ required: true, message: '请选择执行角色' }]}
|
||||
>
|
||||
<Select placeholder="选择执行角色">
|
||||
{roleOptions.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div style={{ padding: 12, background: '#fffbe6', borderRadius: 6, marginTop: 16 }}>
|
||||
<Text type="warning">
|
||||
⚠️ 修改执行角色会影响所有使用此流程的申请。建议在公司有财务专员后再将执行节点改为财务角色。
|
||||
</Text>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProcessManagement;
|
||||
@@ -0,0 +1,422 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Card, Space, Tag, Modal, Form, Input, InputNumber, Select, DatePicker, message, Descriptions, Image, Divider } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, UndoOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import FileUpload from '../../components/FileUpload';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const AdvancesPage: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const [advances, setAdvances] = useState<any[]>([]);
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [selectedRecord, setSelectedRecord] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [deleteForm] = Form.useForm();
|
||||
const [currentEditingStatus, setCurrentEditingStatus] = useState<string>('');
|
||||
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchAdvances();
|
||||
fetchProjects();
|
||||
fetchExchangeRates();
|
||||
}, []);
|
||||
|
||||
const fetchAdvances = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('http://localhost:3005/api/advances');
|
||||
const data = await res.json();
|
||||
if (data.success) setAdvances(data.data);
|
||||
} catch (error) {
|
||||
message.error('获取预支列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const res = await fetch('http://localhost:3005/api/projects');
|
||||
const data = await res.json();
|
||||
if (data.success) setProjects(data.data);
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
const fetchExchangeRates = async () => {
|
||||
try {
|
||||
const res = await fetch('http://localhost:3005/api/exchange-rates/latest');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const rates: Record<string, number> = {};
|
||||
Object.keys(data.data).forEach(key => {
|
||||
rates[key] = parseFloat(data.data[key]) || 1;
|
||||
});
|
||||
setExchangeRates(rates);
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingId(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
advance_date: dayjs(),
|
||||
currency: 'CNY',
|
||||
applicant: user?.name || user?.username || '当前用户',
|
||||
attachments: []
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: any) => {
|
||||
setEditingId(record.id);
|
||||
setCurrentEditingStatus(record.status);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
advance_date: record.advance_date ? dayjs(record.advance_date) : null,
|
||||
attachments: record.attachments || []
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleView = async (record: any) => {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:3005/api/advances/${record.id}`);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setSelectedRecord(data.data);
|
||||
setDetailModalVisible(true);
|
||||
} else {
|
||||
message.error('获取详情失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
// 重置删除表单
|
||||
deleteForm.resetFields();
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: (
|
||||
<Form form={deleteForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="请输入密码确认删除"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password placeholder="输入密码" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
onOk: async () => {
|
||||
try {
|
||||
const values = await deleteForm.validateFields();
|
||||
// 这里可以添加密码验证逻辑,暂时直接删除
|
||||
await fetch('http://localhost:3005/api/advances/' + id, { method: 'DELETE' });
|
||||
message.success('删除成功');
|
||||
fetchAdvances();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleWithdraw = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认撤回',
|
||||
content: '撤回后可重新编辑提交,确认撤回吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await fetch('http://localhost:3005/api/advances/' + id + '/withdraw', { method: 'POST' });
|
||||
message.success('已撤回,可重新编辑');
|
||||
fetchAdvances();
|
||||
} catch (error) {
|
||||
message.error('撤回失败');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 保存操作:只保存信息,不改变状态
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
// 保存时使用编辑时的状态
|
||||
const saveStatus = currentEditingStatus || 'pending_edit';
|
||||
console.log('保存操作 - 状态:', saveStatus);
|
||||
console.log('currentEditingStatus:', currentEditingStatus);
|
||||
const data = {
|
||||
...values,
|
||||
advance_date: values.advance_date?.format('YYYY-MM-DD'),
|
||||
amount_cny: values.currency === 'CNY' ? values.amount : convertToCNY(values.amount, values.currency),
|
||||
applicant: user?.name || user?.username,
|
||||
status: saveStatus
|
||||
};
|
||||
console.log('保存操作 - 提交的数据:', data);
|
||||
const url = editingId ? 'http://localhost:3005/api/advances/' + editingId : 'http://localhost:3005/api/advances';
|
||||
const method = editingId ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await res.json();
|
||||
console.log('保存操作 - 响应:', result);
|
||||
if (result.success) {
|
||||
message.success(editingId ? '保存成功' : '创建成功');
|
||||
setModalVisible(false);
|
||||
fetchAdvances();
|
||||
} else {
|
||||
message.error(result.error || '保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存操作 - 错误:', error);
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 提交操作:提交到待审批状态
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
// 提交时使用pending状态
|
||||
const saveStatus = 'pending';
|
||||
console.log('提交操作 - 状态:', saveStatus);
|
||||
const data = {
|
||||
...values,
|
||||
advance_date: values.advance_date?.format('YYYY-MM-DD'),
|
||||
amount_cny: values.currency === 'CNY' ? values.amount : convertToCNY(values.amount, values.currency),
|
||||
applicant: user?.name || user?.username,
|
||||
status: saveStatus
|
||||
};
|
||||
console.log('提交操作 - 提交的数据:', data);
|
||||
const url = editingId ? 'http://localhost:3005/api/advances/' + editingId : 'http://localhost:3005/api/advances';
|
||||
const method = editingId ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await res.json();
|
||||
console.log('提交操作 - 响应:', result);
|
||||
if (result.success) {
|
||||
message.success(editingId ? '提交成功' : '创建成功');
|
||||
setModalVisible(false);
|
||||
fetchAdvances();
|
||||
} else {
|
||||
message.error(result.error || '提交失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交操作 - 错误:', error);
|
||||
message.error('提交失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitAndSubmit = async () => {
|
||||
await handleSubmit();
|
||||
};
|
||||
|
||||
const convertToCNY = (amount: number, currency: string): number => {
|
||||
if (currency === 'CNY') return amount;
|
||||
const rateKey = 'CNY_' + currency;
|
||||
const rate = exchangeRates[rateKey] || 1;
|
||||
return amount / rate;
|
||||
};
|
||||
|
||||
const amount = Form.useWatch('amount', form);
|
||||
const currency = Form.useWatch('currency', form);
|
||||
const amountCNY = React.useMemo(() => {
|
||||
return amount && currency ? convertToCNY(amount, currency) : 0;
|
||||
}, [amount, currency, exchangeRates]);
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'processing', text: '待审批' },
|
||||
approved: { color: 'success', text: '已批准' },
|
||||
rejected: { color: 'error', text: '已退回' },
|
||||
withdrawn: { color: 'default', text: '已撤回' },
|
||||
settled: { color: 'blue', text: '已核销' },
|
||||
pending_edit: { color: 'warning', text: '待编辑' },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number, currency: string = 'CNY') => {
|
||||
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
|
||||
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
// Format number with thousand separator for input display
|
||||
const formatNumberWithSeparator = (value: number | undefined, currency: string): string => {
|
||||
if (value === undefined || value === null) return '';
|
||||
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
|
||||
const symbol = symbols[currency] || '¥';
|
||||
return symbol + value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
// Parse formatted string back to number
|
||||
const parseFormattedNumber = (value: string): number => {
|
||||
// Remove currency symbols and thousand separators
|
||||
const cleaned = value.replace(/[¥$₭฿,]/g, '');
|
||||
return parseFloat(cleaned) || 0;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
|
||||
<>
|
||||
<div>{formatAmount(v, r.currency)}</div>
|
||||
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}>≈ ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
|
||||
</>
|
||||
) },
|
||||
{ title: '预支日期', dataIndex: 'advance_date', key: 'advance_date', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
|
||||
{ title: '编号', dataIndex: 'advance_code', key: 'advance_code', width: 120 },
|
||||
{
|
||||
title: '操作', key: 'action', width: 250,
|
||||
render: (_: any, record: any) => (
|
||||
<Space wrap>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(record)}>详情</Button>
|
||||
{record.status === 'pending' && (
|
||||
<>
|
||||
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}>撤回</Button>
|
||||
</>
|
||||
)}
|
||||
{(record.status === 'rejected' || record.status === 'withdrawn' || record.status === 'pending_edit') && (
|
||||
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑重提</Button>
|
||||
)}
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>删除</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ marginBottom: 8 }}>预支申请</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>管理员工预支申请</p>
|
||||
</div>
|
||||
|
||||
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建预支</Button>}>
|
||||
<Table dataSource={advances} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
|
||||
</Card>
|
||||
|
||||
{/* 新建/编辑弹窗 */}
|
||||
<Modal
|
||||
title={editingId ? '编辑预支' : '新建预支'}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setModalVisible(false)}>取消</Button>,
|
||||
<Button key="save" onClick={handleSave}>保存</Button>,
|
||||
<Button key="submit" type="primary" onClick={handleSubmitAndSubmit}>提交</Button>
|
||||
]}
|
||||
width={700}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="applicant" label="申请人">
|
||||
<Input disabled style={{ color: 'rgba(0,0,0,0.85)', backgroundColor: '#f5f5f5' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="advance_date" label="预支日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
|
||||
|
||||
<Form.Item label="金额" required>
|
||||
<Space>
|
||||
<Form.Item name="currency" noStyle initialValue="CNY">
|
||||
<Select style={{ width: 140 }}>
|
||||
<Option value="CNY">人民币 (CNY)</Option>
|
||||
<Option value="USD">美元 (USD)</Option>
|
||||
<Option value="LAK">老挝基普 (LAK)</Option>
|
||||
<Option value="THB">泰铢 (THB)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" noStyle rules={[{ required: true, message: '请输入金额' }]}>
|
||||
<InputNumber
|
||||
style={{ width: 200 }}
|
||||
min={0}
|
||||
precision={2}
|
||||
placeholder="输入金额"
|
||||
formatter={(value) => formatNumberWithSeparator(value as number, currency || 'CNY')}
|
||||
parser={(value) => parseFormattedNumber(value || '0')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
{amountCNY > 0 && (
|
||||
<div style={{ marginTop: 8, color: '#888', fontSize: 13 }}>
|
||||
等价人民币:¥ {amountCNY.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="reason" label="事由" rules={[{ required: true }]}>
|
||||
<TextArea rows={3} placeholder="请输入预支事由" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="attachments" label="凭证附件">
|
||||
<FileUpload
|
||||
value={form.getFieldValue('attachments')}
|
||||
onChange={(urls) => form.setFieldsValue({ attachments: urls })}
|
||||
maxCount={9}
|
||||
accept="image/*"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal title="预支详情" open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={null} width={800}>
|
||||
{selectedRecord && (
|
||||
<>
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="预支编号">{selectedRecord.advance_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label="预支日期">{selectedRecord.advance_date}</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="金额">
|
||||
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
|
||||
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>≈ ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{Array.isArray(selectedRecord.attachments) && selectedRecord.attachments.length > 0 && (
|
||||
<>
|
||||
<Divider>凭证附件</Divider>
|
||||
<Image.PreviewGroup>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{selectedRecord.attachments.map((url: string, index: number) => (
|
||||
<Image key={index} src={url} width={100} height={100} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancesPage;
|
||||
@@ -0,0 +1,651 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Table, Tag, Button, Space, Modal, Form, Input, message, Tabs, Badge, Descriptions, Divider, List, Upload } from 'antd';
|
||||
import { CheckOutlined, CloseOutlined, EyeOutlined, EditOutlined, UndoOutlined, FileImageOutlined } from '@ant-design/icons';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
// 项目支出分类
|
||||
const PROJECT_EXPENSE_CATEGORIES = [
|
||||
{ value: 'material_purchase', label: '材料采购' },
|
||||
{ value: 'equipment_purchase', label: '设备采购' },
|
||||
{ value: 'pole_crossarm', label: '电杆横担支出' },
|
||||
{ value: 'freight', label: '运费支出' },
|
||||
{ value: 'construction', label: '施工费支出' },
|
||||
{ value: 'other', label: '其他支出' }
|
||||
];
|
||||
|
||||
// 公司支出分类
|
||||
const COMPANY_EXPENSE_CATEGORIES = [
|
||||
{ value: 'office_operations', label: '通用运营(房租/耗材)' },
|
||||
{ value: 'transportation', label: '交通通勤' },
|
||||
{ value: 'marketing', label: '业扩营销' },
|
||||
{ value: 'power_system', label: '电力系统关系' },
|
||||
{ value: 'employee_welfare', label: '员工福利' },
|
||||
{ value: 'logistics', label: '快递物流' },
|
||||
{ value: 'other', label: '其他支出' }
|
||||
];
|
||||
|
||||
const ApprovalManagement: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [historyModalVisible, setHistoryModalVisible] = useState(false);
|
||||
const [selectedRecord, setSelectedRecord] = useState<any>(null);
|
||||
const [approvalType, setApprovalType] = useState<'approve' | 'reject'>('approve');
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
|
||||
// 审批记录
|
||||
const [approvalHistory, setApprovalHistory] = useState<any[]>([]);
|
||||
|
||||
// 待审批数据
|
||||
const [pendingData, setPendingData] = useState<any[]>([]);
|
||||
|
||||
// 已审批数据
|
||||
const [approvedData, setApprovedData] = useState<any[]>([]);
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
fetchPendingData();
|
||||
}, []);
|
||||
|
||||
// 获取待审批数据
|
||||
const fetchPendingData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log('开始获取待审批数据');
|
||||
// 获取预支申请
|
||||
const advancesRes = await fetch('http://localhost:3005/api/advances');
|
||||
console.log('Advances response status:', advancesRes.status);
|
||||
const advancesData = await advancesRes.json();
|
||||
console.log('Advances data:', advancesData);
|
||||
|
||||
// 获取报销申请
|
||||
const reimbursementsRes = await fetch('http://localhost:3005/api/reimbursements');
|
||||
console.log('Reimbursements response status:', reimbursementsRes.status);
|
||||
const reimbursementsData = await reimbursementsRes.json();
|
||||
console.log('Reimbursements data:', reimbursementsData);
|
||||
|
||||
// 获取付款申请
|
||||
const paymentsRes = await fetch('http://localhost:3005/api/payment-requests');
|
||||
console.log('Payments response status:', paymentsRes.status);
|
||||
const paymentsData = await paymentsRes.json();
|
||||
console.log('Payments data:', paymentsData);
|
||||
|
||||
// 获取核销申请
|
||||
const verificationsRes = await fetch('http://localhost:3005/api/verifications');
|
||||
console.log('Verifications response status:', verificationsRes.status);
|
||||
const verificationsData = await verificationsRes.json();
|
||||
console.log('Verifications data:', verificationsData);
|
||||
|
||||
// 合并数据
|
||||
const allPendingData = [];
|
||||
|
||||
// 添加预支申请
|
||||
if (advancesData.success && advancesData.data) {
|
||||
console.log('Advances data length:', advancesData.data.length);
|
||||
advancesData.data.forEach((item: any) => {
|
||||
console.log('Advance item:', item);
|
||||
if (item.status === 'pending') {
|
||||
allPendingData.push({
|
||||
key: `adv-${item.id}`,
|
||||
id: item.id,
|
||||
type: '预支申请',
|
||||
code: item.advance_code,
|
||||
applicant: item.applicant,
|
||||
amount: item.amount,
|
||||
currency: item.currency,
|
||||
date: item.advance_date,
|
||||
reason: item.reason,
|
||||
status: item.status,
|
||||
rawData: item
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 添加报销申请
|
||||
if (reimbursementsData.success && reimbursementsData.data) {
|
||||
console.log('Reimbursements data length:', reimbursementsData.data.length);
|
||||
reimbursementsData.data.forEach((item: any) => {
|
||||
console.log('Reimbursement item:', item);
|
||||
if (item.status === 'pending') {
|
||||
allPendingData.push({
|
||||
key: `reimb-${item.id}`,
|
||||
id: item.id,
|
||||
type: '报销申请',
|
||||
code: item.reimbursement_code,
|
||||
applicant: item.applicant,
|
||||
amount: item.amount,
|
||||
currency: item.currency,
|
||||
date: item.reimbursement_date,
|
||||
reason: item.reason,
|
||||
status: item.status,
|
||||
rawData: item
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 添加付款申请
|
||||
if (paymentsData.success && paymentsData.data) {
|
||||
console.log('Payments data length:', paymentsData.data.length);
|
||||
paymentsData.data.forEach((item: any) => {
|
||||
console.log('Payment item:', item);
|
||||
if (item.status === 'pending') {
|
||||
allPendingData.push({
|
||||
key: `pay-${item.id}`,
|
||||
id: item.id,
|
||||
type: '付款申请',
|
||||
code: item.request_code,
|
||||
applicant: item.applicant,
|
||||
amount: item.amount,
|
||||
currency: item.currency,
|
||||
date: item.payment_date,
|
||||
reason: item.reason,
|
||||
status: item.status,
|
||||
rawData: item
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 添加核销申请
|
||||
if (verificationsData.success && verificationsData.data) {
|
||||
console.log('Verifications data length:', verificationsData.data.length);
|
||||
verificationsData.data.forEach((item: any) => {
|
||||
console.log('Verification item:', item);
|
||||
if (item.status === 'pending') {
|
||||
allPendingData.push({
|
||||
key: `ver-${item.id}`,
|
||||
id: item.id,
|
||||
type: '核销申请',
|
||||
code: item.verification_code,
|
||||
applicant: item.applicant,
|
||||
amount: item.amount,
|
||||
currency: item.currency,
|
||||
date: item.verification_date,
|
||||
reason: item.reason,
|
||||
status: item.status,
|
||||
rawData: item
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Final pending data:', allPendingData);
|
||||
setPendingData(allPendingData);
|
||||
} catch (error) {
|
||||
console.error('获取待审批数据失败:', error);
|
||||
message.error('获取待审批数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化金额
|
||||
const formatAmount = (amount: number, currency: string = 'CNY') => {
|
||||
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
|
||||
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
// 获取类型标签
|
||||
const getTypeTag = (type: string) => {
|
||||
const colors: Record<string, string> = { '预支申请': 'blue', '报销申请': 'green', '付款申请': 'orange', '核销申请': 'purple' };
|
||||
return <Tag color={colors[type] || 'default'}>{type}</Tag>;
|
||||
};
|
||||
|
||||
// 获取状态标签
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'processing', text: '待审批' },
|
||||
approved: { color: 'success', text: '已通过' },
|
||||
rejected: { color: 'error', text: '已退回' },
|
||||
withdrawn: { color: 'default', text: '已撤回' }
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = (record: any) => {
|
||||
setSelectedRecord(record);
|
||||
setApprovalType('approve');
|
||||
form.resetFields();
|
||||
setDetailModalVisible(true);
|
||||
};
|
||||
|
||||
// 处理审批通过
|
||||
const handleApprove = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
// 构建API请求URL
|
||||
const isAdvance = selectedRecord.key.startsWith('adv-');
|
||||
const isReimbursement = selectedRecord.key.startsWith('reimb-');
|
||||
const isPayment = selectedRecord.key.startsWith('pay-');
|
||||
const isVerification = selectedRecord.key.startsWith('ver-');
|
||||
const id = selectedRecord.id;
|
||||
|
||||
let url = '';
|
||||
if (isAdvance) url = `http://localhost:3005/api/advances/${id}/approve`;
|
||||
else if (isReimbursement) url = `http://localhost:3005/api/reimbursements/${id}/approve`;
|
||||
else if (isPayment) url = `http://localhost:3005/api/payment-requests/${id}/approve`;
|
||||
else if (isVerification) url = `http://localhost:3005/api/verifications/${id}/approve`;
|
||||
|
||||
// 发送API请求
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(values)
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
// 从待审批列表中移除该申请
|
||||
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
|
||||
message.success(`审批通过:${selectedRecord.code}`);
|
||||
setDetailModalVisible(false);
|
||||
} else {
|
||||
message.error(result.message || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('审批操作失败:', error);
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理审批退回
|
||||
const handleReject = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
// 构建API请求URL
|
||||
const isAdvance = selectedRecord.key.startsWith('adv-');
|
||||
const isReimbursement = selectedRecord.key.startsWith('reimb-');
|
||||
const isPayment = selectedRecord.key.startsWith('pay-');
|
||||
const isVerification = selectedRecord.key.startsWith('ver-');
|
||||
const id = selectedRecord.id;
|
||||
|
||||
let url = '';
|
||||
if (isAdvance) url = `http://localhost:3005/api/advances/${id}/reject`;
|
||||
else if (isReimbursement) url = `http://localhost:3005/api/reimbursements/${id}/reject`;
|
||||
else if (isPayment) url = `http://localhost:3005/api/payment-requests/${id}/reject`;
|
||||
else if (isVerification) url = `http://localhost:3005/api/verifications/${id}/reject`;
|
||||
|
||||
// 发送API请求
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(values)
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
// 从待审批列表中移除该申请
|
||||
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
|
||||
message.success(`已退回:${selectedRecord.code}`);
|
||||
setDetailModalVisible(false);
|
||||
} else {
|
||||
message.error(result.message || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('审批操作失败:', error);
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理撤回申请
|
||||
const handleWithdraw = (record: any) => {
|
||||
Modal.confirm({
|
||||
title: '撤回申请',
|
||||
content: `确认撤回申请 ${record.code} 吗?`,
|
||||
okText: '确认撤回',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
setPendingData(pendingData.filter(item => item.key !== record.key));
|
||||
message.success('申请已撤回');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 处理编辑申请
|
||||
const handleEdit = (record: any) => {
|
||||
setSelectedRecord(record);
|
||||
editForm.setFieldsValue({ amount: record.amount, reason: record.reason });
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
// 处理编辑提交
|
||||
const handleEditSubmit = () => {
|
||||
editForm.validateFields().then(values => {
|
||||
message.success('修改成功,已重新提交审批');
|
||||
setEditModalVisible(false);
|
||||
});
|
||||
};
|
||||
|
||||
// 获取申请类型对应的API端点
|
||||
const getApiEndpoint = (key: string) => {
|
||||
if (key.startsWith('adv-')) return 'advances';
|
||||
if (key.startsWith('reimb-')) return 'reimbursements';
|
||||
if (key.startsWith('pay-')) return 'payment-requests';
|
||||
if (key.startsWith('ver-')) return 'verifications';
|
||||
return '';
|
||||
};
|
||||
|
||||
// 渲染附件列表
|
||||
const renderAttachments = (attachments: any) => {
|
||||
// 处理字符串类型的 attachments(JSON字符串)
|
||||
let attachmentList = attachments;
|
||||
if (typeof attachments === 'string') {
|
||||
try {
|
||||
attachmentList = JSON.parse(attachments);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保是数组
|
||||
if (!Array.isArray(attachmentList) || attachmentList.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{attachmentList.map((url: string, index: number) => (
|
||||
<div key={index} style={{ position: 'relative' }}>
|
||||
{url && url.match(/\.(jpg|jpeg|png|gif|webp)$/i) ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={`附件${index + 1}`}
|
||||
style={{ width: 120, height: 120, objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0', cursor: 'pointer' }}
|
||||
onClick={() => window.open(url, '_blank')}
|
||||
/>
|
||||
) : (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
<div style={{ width: 120, height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid #f0f0f0', borderRadius: 4, background: '#f5f5f5' }}>
|
||||
<FileImageOutlined style={{ fontSize: 32, color: '#999' }} />
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染明细清单
|
||||
const renderDetailItems = (detailItems: any) => {
|
||||
// 处理字符串类型的 detailItems(JSON字符串)
|
||||
let itemsList = detailItems;
|
||||
if (typeof detailItems === 'string') {
|
||||
try {
|
||||
itemsList = JSON.parse(detailItems);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保是数组
|
||||
if (!Array.isArray(itemsList) || itemsList.length === 0) return null;
|
||||
|
||||
return (
|
||||
<List
|
||||
size="small"
|
||||
bordered
|
||||
dataSource={itemsList}
|
||||
renderItem={(item: any, index: number) => (
|
||||
<List.Item>
|
||||
<div style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span><strong>明细 {index + 1}:</strong> {item.description || item.category || '-'}</span>
|
||||
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>{formatAmount(item.amount, item.currency)}</span>
|
||||
</div>
|
||||
{item.attachments && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<span style={{ color: '#666', fontSize: 12 }}>明细附件:</span>
|
||||
{renderAttachments(item.attachments)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 待审批列
|
||||
const pendingColumns = [
|
||||
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v}</a> },
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => formatAmount(v, r.currency) },
|
||||
{ title: '申请日期', dataIndex: 'date', key: 'date', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) },
|
||||
{ title: '编号', dataIndex: 'code', key: 'code', width: 140 },
|
||||
{
|
||||
title: '操作', key: 'action', width: 200,
|
||||
render: (_: any, record: any) => (
|
||||
<Space wrap>
|
||||
<Button size="small" type="primary" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}>审批</Button>
|
||||
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record)}>撤回</Button>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
// 已审批列
|
||||
const approvedColumns = [
|
||||
{ title: '申请类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
|
||||
{ title: '申请编号', dataIndex: 'code', key: 'code', width: 140 },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => formatAmount(v, r.currency) },
|
||||
{ title: '审批时间', dataIndex: 'approveTime', key: 'approveTime', width: 140 },
|
||||
{ title: '审批人', dataIndex: 'approver', key: 'approver', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) },
|
||||
{
|
||||
title: '操作', key: 'action', width: 100,
|
||||
render: (_: any, record: any) => (
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}>记录</Button>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
// 审批记录列
|
||||
const historyColumns = [
|
||||
{ title: '时间', dataIndex: 'timestamp', key: 'timestamp', width: 140 },
|
||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 100 },
|
||||
{ title: '申请编号', dataIndex: 'applyCode', key: 'applyCode', width: 140 },
|
||||
{ title: '类型', dataIndex: 'applyType', key: 'applyType', width: 100, render: (v: string) => getTypeTag(v) },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => formatAmount(v, r.currency) },
|
||||
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 100 },
|
||||
{ title: '备注/原因', dataIndex: 'remark', key: 'remark', ellipsis: true }
|
||||
];
|
||||
|
||||
const tabItems = [
|
||||
{ key: 'pending', label: <span>待审批 <Badge count={pendingData.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={pendingColumns} dataSource={pendingData} rowKey="key" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1200 }} /> },
|
||||
{ key: 'approved', label: '已审批', children: <Table columns={approvedColumns} dataSource={approvedData} loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1200 }} /> },
|
||||
{ key: 'history', label: <span>审批记录 <Badge count={approvalHistory.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={historyColumns} dataSource={approvalHistory} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1400 }} /> },
|
||||
];
|
||||
|
||||
// 获取完整的申请详情
|
||||
const getFullDetail = () => {
|
||||
if (!selectedRecord || !selectedRecord.rawData) return null;
|
||||
return selectedRecord.rawData;
|
||||
};
|
||||
|
||||
const fullDetail = getFullDetail();
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<h2>审批管理</h2>
|
||||
<Button type="primary" onClick={fetchPendingData} loading={loading}>
|
||||
刷新数据
|
||||
</Button>
|
||||
</div>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>审批预支、报销、付款等申请</p>
|
||||
</div>
|
||||
<Card><Tabs items={tabItems} /></Card>
|
||||
|
||||
{/* 详情模态框 */}
|
||||
<Modal
|
||||
title={`${selectedRecord?.type}详情:${selectedRecord?.code}`}
|
||||
open={detailModalVisible}
|
||||
onCancel={() => setDetailModalVisible(false)}
|
||||
width={900}
|
||||
footer={
|
||||
selectedRecord?.status === 'pending' ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button onClick={() => setDetailModalVisible(false)}>取消</Button>
|
||||
<Button danger icon={<CloseOutlined />} onClick={handleReject}>退回</Button>
|
||||
<Button type="primary" icon={<CheckOutlined />} onClick={handleApprove}>通过</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => setDetailModalVisible(false)}>关闭</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{fullDetail && (
|
||||
<>
|
||||
{/* 基本信息 */}
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="申请类型">{getTypeTag(selectedRecord.type)}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请编号">{selectedRecord.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请日期">{selectedRecord.date}</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
|
||||
{selectedRecord.currency !== 'CNY' && fullDetail.amount_cny > 0 && (
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>≈ ¥{fullDetail.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
|
||||
{/* 付款申请特有字段 */}
|
||||
{selectedRecord.type === '付款申请' && (
|
||||
<>
|
||||
<Descriptions.Item label="收款单位类型">
|
||||
{fullDetail.payee_type === 'subcontractor' ? '分包商' :
|
||||
fullDetail.payee_type === 'supplier' ? '供应商' :
|
||||
fullDetail.payee_type === 'customer' ? '客户' : '其他'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="收款方">{fullDetail.payee || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="银行名称">{fullDetail.bank_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="银行账号">{fullDetail.bank_account || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="支出类型">
|
||||
{fullDetail.expense_type === 'company' ? '公司支出' : '项目支出'}
|
||||
</Descriptions.Item>
|
||||
{fullDetail.expense_type === 'project' && fullDetail.project_id && (
|
||||
<Descriptions.Item label="关联项目">项目ID: {fullDetail.project_id}</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="支出分类">
|
||||
{fullDetail.expense_type === 'project'
|
||||
? (PROJECT_EXPENSE_CATEGORIES.find(c => c.value === fullDetail.expense_category)?.label || fullDetail.expense_category)
|
||||
: (COMPANY_EXPENSE_CATEGORIES.find(c => c.value === fullDetail.expense_category)?.label || fullDetail.expense_category)
|
||||
}
|
||||
</Descriptions.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 报销申请特有字段 */}
|
||||
{selectedRecord.type === '报销申请' && fullDetail.expense_type && (
|
||||
<>
|
||||
<Descriptions.Item label="支出类型">
|
||||
{fullDetail.expense_type === 'company' ? '公司支出' : '项目支出'}
|
||||
</Descriptions.Item>
|
||||
{fullDetail.project_id && (
|
||||
<Descriptions.Item label="关联项目">项目ID: {fullDetail.project_id}</Descriptions.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 核销申请特有字段 */}
|
||||
{selectedRecord.type === '核销申请' && fullDetail.advance_code && (
|
||||
<>
|
||||
<Descriptions.Item label="关联预支单">{fullDetail.advance_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="预支金额">{formatAmount(fullDetail.advance_amount, fullDetail.currency)}</Descriptions.Item>
|
||||
</>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
{/* 明细清单 */}
|
||||
{fullDetail.detail_items && fullDetail.detail_items.length > 0 && (
|
||||
<>
|
||||
<Divider>明细清单</Divider>
|
||||
{renderDetailItems(fullDetail.detail_items)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 凭证附件 */}
|
||||
{fullDetail.attachments && fullDetail.attachments.length > 0 && (
|
||||
<>
|
||||
<Divider>凭证附件</Divider>
|
||||
{renderAttachments(fullDetail.attachments)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 审批备注表单 */}
|
||||
{selectedRecord.status === 'pending' && (
|
||||
<>
|
||||
<Divider>审批意见</Divider>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="remark" label="审批备注">
|
||||
<TextArea rows={3} placeholder="可选:填写审批备注" />
|
||||
</Form.Item>
|
||||
<Form.Item name="rejectReason" label="退回原因" style={{ display: 'none' }}>
|
||||
<TextArea rows={3} placeholder="请填写退回原因" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal title={`编辑申请:${selectedRecord?.code}`} open={editModalVisible} onCancel={() => setEditModalVisible(false)} onOk={handleEditSubmit} width={600}>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item label="申请类型"><Input value={selectedRecord?.type} disabled /></Form.Item>
|
||||
<Form.Item label="申请人"><Input value={selectedRecord?.applicant} disabled /></Form.Item>
|
||||
<Form.Item name="amount" label="金额" rules={[{ required: true }]}><Input type="number" style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="reason" label="事由" rules={[{ required: true }]}><TextArea rows={3} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title={`${selectedRecord?.advance_code ? '预支申请' : '报销申请'}详情:${selectedRecord?.advance_code || selectedRecord?.reimbursement_code}`} open={historyModalVisible} onCancel={() => setHistoryModalVisible(false)} footer={null} width={800}>
|
||||
{selectedRecord && (
|
||||
<>
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="申请编号">{selectedRecord.advance_code || selectedRecord.reimbursement_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请日期">{selectedRecord.advance_date || selectedRecord.reimbursement_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
|
||||
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>≈ ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{Array.isArray(selectedRecord.attachments) && selectedRecord.attachments.length > 0 && (
|
||||
<>
|
||||
<Divider>凭证附件</Divider>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{selectedRecord.attachments.map((url: string, index: number) => (
|
||||
<img key={index} src={url} width={100} height={100} style={{ objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0' }} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApprovalManagement;
|
||||
@@ -0,0 +1,640 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Table, Tag, Button, Space, Modal, Form, Input, Select, DatePicker, message, Tabs, Badge, Descriptions, Divider, List, Upload } from 'antd';
|
||||
import { CheckOutlined, CloseOutlined, EyeOutlined, DollarOutlined, EditOutlined, UndoOutlined, ClockCircleOutlined, FileImageOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
// 项目支出分类
|
||||
const PROJECT_EXPENSE_CATEGORIES = [
|
||||
{ value: 'material_purchase', label: '材料采购' },
|
||||
{ value: 'equipment_purchase', label: '设备采购' },
|
||||
{ value: 'pole_crossarm', label: '电杆横担支出' },
|
||||
{ value: 'freight', label: '运费支出' },
|
||||
{ value: 'construction', label: '施工费支出' },
|
||||
{ value: 'other', label: '其他支出' }
|
||||
];
|
||||
|
||||
// 公司支出分类
|
||||
const COMPANY_EXPENSE_CATEGORIES = [
|
||||
{ value: 'office_operations', label: '通用运营(房租/耗材)' },
|
||||
{ value: 'transportation', label: '交通通勤' },
|
||||
{ value: 'marketing', label: '业扩营销' },
|
||||
{ value: 'power_system', label: '电力系统关系' },
|
||||
{ value: 'employee_welfare', label: '员工福利' },
|
||||
{ value: 'logistics', label: '快递物流' },
|
||||
{ value: 'other', label: '其他支出' }
|
||||
];
|
||||
|
||||
// 执行记录类型
|
||||
interface ExecutionRecord {
|
||||
id: string;
|
||||
applyCode: string;
|
||||
applyType: string;
|
||||
applicant: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
action: 'execute' | 'reject';
|
||||
operator: string;
|
||||
operatorRole: string;
|
||||
timestamp: string;
|
||||
executeMethod?: string;
|
||||
voucherNo?: string;
|
||||
rejectReason?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
const ExecutionManagement: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [historyModalVisible, setHistoryModalVisible] = useState(false);
|
||||
const [selectedRecord, setSelectedRecord] = useState<any>(null);
|
||||
const [executionType, setExecutionType] = useState<'execute' | 'reject'>('execute');
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [voucherFiles, setVoucherFiles] = useState<any[]>([]);
|
||||
|
||||
// 执行记录
|
||||
const [executionHistory, setExecutionHistory] = useState<ExecutionRecord[]>([]);
|
||||
|
||||
// 待执行数据
|
||||
const [pendingData, setPendingData] = useState([]);
|
||||
|
||||
// 已执行数据
|
||||
const [executedData, setExecutedData] = useState([]);
|
||||
|
||||
// 从后端获取待执行数据
|
||||
useEffect(() => {
|
||||
const fetchPendingData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('http://localhost:3005/api/executions/pending');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && Array.isArray(data.data)) {
|
||||
setPendingData(data.data.map((item: any, index: number) => ({
|
||||
...item,
|
||||
key: item.id || index,
|
||||
rawData: item
|
||||
})));
|
||||
} else {
|
||||
message.error('获取待执行数据失败:数据格式错误');
|
||||
}
|
||||
} else {
|
||||
message.error('获取待执行数据失败:' + response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取待执行数据错误:', error);
|
||||
message.error('网络错误,获取待执行数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPendingData();
|
||||
}, []);
|
||||
|
||||
// 从后端获取已执行数据
|
||||
useEffect(() => {
|
||||
const fetchExecutedData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('http://localhost:3005/api/executions/executed');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && Array.isArray(data.data)) {
|
||||
setExecutedData(data.data.map((item: any, index: number) => ({
|
||||
...item,
|
||||
key: item.id || index,
|
||||
rawData: item
|
||||
})));
|
||||
} else {
|
||||
message.error('获取已执行数据失败:数据格式错误');
|
||||
}
|
||||
} else {
|
||||
message.error('获取已执行数据失败:' + response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取已执行数据错误:', error);
|
||||
message.error('网络错误,获取已执行数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchExecutedData();
|
||||
}, []);
|
||||
|
||||
const formatAmount = (amount: number, currency: string = 'CNY') => {
|
||||
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
|
||||
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const formatNumberWithSeparator = (value: number | undefined, currency: string): string => {
|
||||
if (value === undefined || value === null) return '';
|
||||
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
|
||||
return (symbols[currency] || '¥') + value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const parseFormattedNumber = (value: string): number => {
|
||||
const cleaned = value.replace(/[¥$₭฿,]/g, '');
|
||||
return parseFloat(cleaned) || 0;
|
||||
};
|
||||
|
||||
const getTypeTag = (type: string) => {
|
||||
const colors: Record<string, string> = { '预支申请': 'blue', '报销申请': 'green', '付款申请': 'orange', '核销申请': 'purple' };
|
||||
return <Tag color={colors[type] || 'default'}>{type}</Tag>;
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'processing', text: '待执行' },
|
||||
executed: { color: 'success', text: '已执行' },
|
||||
rejected: { color: 'error', text: '已退回' },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
// 添加执行记录
|
||||
const addExecutionRecord = (record: any, action: 'execute' | 'reject', operator: string, operatorRole: string, data?: any) => {
|
||||
const newRecord: ExecutionRecord = {
|
||||
id: Date.now().toString(),
|
||||
applyCode: record.code,
|
||||
applyType: record.type,
|
||||
applicant: record.applicant,
|
||||
amount: record.amount,
|
||||
currency: record.currency,
|
||||
action,
|
||||
operator,
|
||||
operatorRole,
|
||||
timestamp: dayjs().format('YYYY-MM-DD HH:mm'),
|
||||
executeMethod: data?.executeMethod,
|
||||
voucherNo: data?.voucherNo,
|
||||
rejectReason: data?.rejectReason,
|
||||
remark: data?.remark
|
||||
};
|
||||
setExecutionHistory([newRecord, ...executionHistory]);
|
||||
};
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = (record: any) => {
|
||||
setSelectedRecord(record);
|
||||
setExecutionType('execute');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ execute_date: dayjs(), execute_method: 'bank' });
|
||||
setVoucherFiles([]);
|
||||
setDetailModalVisible(true);
|
||||
};
|
||||
|
||||
// 处理执行
|
||||
const handleExecute = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
// 检查是否上传了付款凭证
|
||||
if (!voucherFiles || voucherFiles.length === 0) {
|
||||
message.error('请上传付款凭证');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// 获取已上传文件的URL列表
|
||||
const voucherFileUrls = voucherFiles
|
||||
.map(f => f.url || f.response?.data?.url || f.response?.url)
|
||||
.filter(url => url); // 过滤掉空值
|
||||
|
||||
console.log('上传的凭证文件:', voucherFiles);
|
||||
console.log('凭证文件URL列表:', voucherFileUrls);
|
||||
|
||||
// 调用执行API
|
||||
const executeResponse = await fetch('http://localhost:3005/api/executions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
apply_id: selectedRecord.id,
|
||||
apply_type: selectedRecord.type === '预支申请' ? 'advance' : selectedRecord.type === '报销申请' ? 'reimbursement' : selectedRecord.type === '付款申请' ? 'payment' : 'verification',
|
||||
action: 'execute',
|
||||
execute_method: values.execute_method,
|
||||
voucher_files: voucherFileUrls,
|
||||
remark: values.remark
|
||||
})
|
||||
});
|
||||
|
||||
if (executeResponse.ok) {
|
||||
addExecutionRecord(selectedRecord, 'execute', '系统管理员', '管理员', {
|
||||
executeMethod: values.execute_method === 'bank' ? '银行转账' : values.execute_method === 'cash' ? '现金' : '其他',
|
||||
remark: values.remark
|
||||
});
|
||||
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
|
||||
message.success(`执行成功:${selectedRecord.code}`);
|
||||
setDetailModalVisible(false);
|
||||
} else {
|
||||
message.error('执行操作失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('执行操作失败:', error);
|
||||
message.error('网络错误,操作失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理退回
|
||||
const handleReject = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// 调用退回API
|
||||
const rejectResponse = await fetch('http://localhost:3005/api/executions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
apply_id: selectedRecord.id,
|
||||
apply_type: selectedRecord.type === '预支申请' ? 'advance' : selectedRecord.type === '报销申请' ? 'reimbursement' : selectedRecord.type === '付款申请' ? 'payment' : 'verification',
|
||||
action: 'reject',
|
||||
reject_reason: values.rejectReason
|
||||
})
|
||||
});
|
||||
|
||||
if (rejectResponse.ok) {
|
||||
addExecutionRecord(selectedRecord, 'reject', '系统管理员', '管理员', { rejectReason: values.rejectReason });
|
||||
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
|
||||
message.success(`已退回:${selectedRecord.code},申请人可编辑后重新提交`);
|
||||
setDetailModalVisible(false);
|
||||
} else {
|
||||
message.error('退回操作失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('退回操作失败:', error);
|
||||
message.error('网络错误,操作失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewHistory = (record: any) => {
|
||||
setSelectedRecord(record);
|
||||
setHistoryModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: any) => {
|
||||
setSelectedRecord(record);
|
||||
editForm.setFieldsValue({ amount: record.amount, reason: record.reason });
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEditSubmit = () => {
|
||||
editForm.validateFields().then(values => {
|
||||
message.success('修改成功,已重新提交审批');
|
||||
setEditModalVisible(false);
|
||||
});
|
||||
};
|
||||
|
||||
// 渲染附件列表
|
||||
const renderAttachments = (attachments: any) => {
|
||||
// 处理字符串类型的 attachments(JSON字符串)
|
||||
let attachmentList = attachments;
|
||||
if (typeof attachments === 'string') {
|
||||
try {
|
||||
attachmentList = JSON.parse(attachments);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保是数组
|
||||
if (!Array.isArray(attachmentList) || attachmentList.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{attachmentList.map((url: string, index: number) => (
|
||||
<div key={index} style={{ position: 'relative' }}>
|
||||
{url && url.match(/\.(jpg|jpeg|png|gif|webp)$/i) ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={`附件${index + 1}`}
|
||||
style={{ width: 120, height: 120, objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0', cursor: 'pointer' }}
|
||||
onClick={() => window.open(url, '_blank')}
|
||||
/>
|
||||
) : (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
<div style={{ width: 120, height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid #f0f0f0', borderRadius: 4, background: '#f5f5f5' }}>
|
||||
<FileImageOutlined style={{ fontSize: 32, color: '#999' }} />
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染明细清单
|
||||
const renderDetailItems = (detailItems: any) => {
|
||||
// 处理字符串类型的 detailItems(JSON字符串)
|
||||
let itemsList = detailItems;
|
||||
if (typeof detailItems === 'string') {
|
||||
try {
|
||||
itemsList = JSON.parse(detailItems);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保是数组
|
||||
if (!Array.isArray(itemsList) || itemsList.length === 0) return null;
|
||||
|
||||
return (
|
||||
<List
|
||||
size="small"
|
||||
bordered
|
||||
dataSource={itemsList}
|
||||
renderItem={(item: any, index: number) => (
|
||||
<List.Item>
|
||||
<div style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span><strong>明细 {index + 1}:</strong> {item.description || item.category || '-'}</span>
|
||||
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>{formatAmount(item.amount, item.currency)}</span>
|
||||
</div>
|
||||
{item.attachments && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<span style={{ color: '#666', fontSize: 12 }}>明细附件:</span>
|
||||
{renderAttachments(item.attachments)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 执行记录列
|
||||
const historyColumns = [
|
||||
{ title: '时间', dataIndex: 'timestamp', key: 'timestamp', width: 140 },
|
||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 100, render: (v: string) => {
|
||||
const map: Record<string, { color: string; icon: any; text: string }> = {
|
||||
execute: { color: 'green', icon: <CheckOutlined />, text: '执行' },
|
||||
reject: { color: 'red', icon: <CloseOutlined />, text: '退回' }
|
||||
};
|
||||
const m = map[v] || { color: 'default', icon: null, text: v };
|
||||
return <Tag color={m.color} icon={m.icon}>{m.text}</Tag>;
|
||||
}},
|
||||
{ title: '申请编号', dataIndex: 'applyCode', key: 'applyCode', width: 140 },
|
||||
{ title: '类型', dataIndex: 'applyType', key: 'applyType', width: 100, render: (v: string) => getTypeTag(v) },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: ExecutionRecord) => (
|
||||
<>
|
||||
<div>{formatAmount(v, r.currency)}</div>
|
||||
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}>≈ ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
|
||||
</>
|
||||
) },
|
||||
{ title: '执行方式', dataIndex: 'executeMethod', key: 'executeMethod', width: 100 },
|
||||
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 100 },
|
||||
{ title: '角色', dataIndex: 'operatorRole', key: 'operatorRole', width: 80 },
|
||||
{ title: '退回原因', dataIndex: 'rejectReason', key: 'rejectReason', ellipsis: true },
|
||||
];
|
||||
|
||||
const pendingColumns = [
|
||||
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v}</a> },
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 120, render: (v: number, r: any) => <span style={{ fontWeight: 'bold', color: '#1890ff' }}>{formatAmount(v, r.currency)}</span> },
|
||||
{ title: '收款方', dataIndex: 'payee', key: 'payee', ellipsis: true, render: (v: string, r: any) => v || r.applicant },
|
||||
{ title: '审批日期', dataIndex: 'approveDate', key: 'approveDate', width: 100 },
|
||||
{ title: '编号', dataIndex: 'code', key: 'code', width: 140 },
|
||||
{
|
||||
title: '操作', key: 'action', width: 200,
|
||||
render: (_: any, record: any) => (
|
||||
<Space wrap>
|
||||
<Button size="small" type="primary" icon={<DollarOutlined />} onClick={() => handleViewDetail(record)}>执行</Button>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const executedColumns = [
|
||||
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v || '-'}</a> },
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
|
||||
<>
|
||||
<div>{formatAmount(v, r.currency)}</div>
|
||||
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}>≈ ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
|
||||
</>
|
||||
) },
|
||||
{ title: '执行日期', dataIndex: 'executeDate', key: 'executeDate', width: 100 },
|
||||
{ title: '执行方式', dataIndex: 'executeMethod', key: 'executeMethod', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) },
|
||||
{ title: '编号', dataIndex: 'code', key: 'code', width: 140 },
|
||||
{
|
||||
title: '操作', key: 'action', width: 100,
|
||||
render: (_: any, record: any) => (
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleViewHistory(record)}>记录</Button>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const tabItems = [
|
||||
{ key: 'pending', label: <span>待执行 <Badge count={pendingData.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={pendingColumns} dataSource={pendingData} loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1300 }} /> },
|
||||
{ key: 'executed', label: '已执行', children: <Table columns={executedColumns} dataSource={executedData} loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1400 }} /> },
|
||||
{ key: 'history', label: <span>执行记录 <Badge count={executionHistory.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={historyColumns} dataSource={executionHistory} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1500 }} /> },
|
||||
];
|
||||
|
||||
// 获取完整的申请详情
|
||||
const getFullDetail = () => {
|
||||
if (!selectedRecord || !selectedRecord.rawData) return selectedRecord;
|
||||
return selectedRecord.rawData;
|
||||
};
|
||||
|
||||
const fullDetail = getFullDetail();
|
||||
|
||||
// 上传配置
|
||||
const uploadProps = {
|
||||
name: 'file',
|
||||
action: 'http://localhost:3005/api/upload/single',
|
||||
headers: {
|
||||
authorization: 'authorization-text',
|
||||
},
|
||||
onChange(info: any) {
|
||||
// 更新文件列表状态
|
||||
setVoucherFiles(info.fileList);
|
||||
|
||||
if (info.file.status === 'done') {
|
||||
message.success(`${info.file.name} 上传成功`);
|
||||
// 如果上传成功,将返回的URL添加到文件对象中
|
||||
const updatedFileList = info.fileList.map((file: any) => {
|
||||
if (file.uid === info.file.uid && file.response) {
|
||||
return {
|
||||
...file,
|
||||
url: file.response.data?.url || file.response.url || file.response
|
||||
};
|
||||
}
|
||||
return file;
|
||||
});
|
||||
setVoucherFiles(updatedFileList);
|
||||
} else if (info.file.status === 'error') {
|
||||
message.error(`${info.file.name} 上传失败`);
|
||||
}
|
||||
},
|
||||
fileList: voucherFiles,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}><h2 style={{ marginBottom: 8 }}>执行管理</h2><p style={{ color: '#888', marginBottom: 0 }}>执行已审批通过的付款申请</p></div>
|
||||
<Card><Tabs items={tabItems} /></Card>
|
||||
|
||||
{/* 详情模态框 */}
|
||||
<Modal
|
||||
title={`${selectedRecord?.type}详情:${selectedRecord?.code}`}
|
||||
open={detailModalVisible}
|
||||
onCancel={() => setDetailModalVisible(false)}
|
||||
width={900}
|
||||
footer={
|
||||
selectedRecord?.status === 'approved' || selectedRecord?.status === 'pending' ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button onClick={() => setDetailModalVisible(false)}>取消</Button>
|
||||
<Button danger icon={<CloseOutlined />} onClick={handleReject}>退回</Button>
|
||||
<Button type="primary" icon={<CheckOutlined />} onClick={handleExecute}>执行</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => setDetailModalVisible(false)}>关闭</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{fullDetail && (
|
||||
<>
|
||||
{/* 基本信息 */}
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="申请类型">{getTypeTag(selectedRecord.type)}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请编号">{selectedRecord.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请日期">{selectedRecord.date || fullDetail.advance_date || fullDetail.reimbursement_date || fullDetail.payment_date || fullDetail.verification_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
|
||||
{selectedRecord.currency !== 'CNY' && fullDetail.amount_cny > 0 && (
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>≈ ¥{fullDetail.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
|
||||
{/* 付款申请特有字段 */}
|
||||
{selectedRecord.type === '付款申请' && (
|
||||
<>
|
||||
<Descriptions.Item label="收款单位类型">
|
||||
{fullDetail.payee_type === 'subcontractor' ? '分包商' :
|
||||
fullDetail.payee_type === 'supplier' ? '供应商' :
|
||||
fullDetail.payee_type === 'customer' ? '客户' : '其他'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="收款方">{fullDetail.payee || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="银行名称">{fullDetail.bank_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="银行账号">{fullDetail.bank_account || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="支出类型">
|
||||
{fullDetail.expense_type === 'company' ? '公司支出' : '项目支出'}
|
||||
</Descriptions.Item>
|
||||
{fullDetail.expense_type === 'project' && fullDetail.project_id && (
|
||||
<Descriptions.Item label="关联项目">项目ID: {fullDetail.project_id}</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="支出分类">
|
||||
{fullDetail.expense_type === 'project'
|
||||
? (PROJECT_EXPENSE_CATEGORIES.find(c => c.value === fullDetail.expense_category)?.label || fullDetail.expense_category)
|
||||
: (COMPANY_EXPENSE_CATEGORIES.find(c => c.value === fullDetail.expense_category)?.label || fullDetail.expense_category)
|
||||
}
|
||||
</Descriptions.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 报销申请特有字段 */}
|
||||
{selectedRecord.type === '报销申请' && fullDetail.expense_type && (
|
||||
<>
|
||||
<Descriptions.Item label="支出类型">
|
||||
{fullDetail.expense_type === 'company' ? '公司支出' : '项目支出'}
|
||||
</Descriptions.Item>
|
||||
{fullDetail.project_id && (
|
||||
<Descriptions.Item label="关联项目">项目ID: {fullDetail.project_id}</Descriptions.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 核销申请特有字段 */}
|
||||
{selectedRecord.type === '核销申请' && fullDetail.advance_code && (
|
||||
<>
|
||||
<Descriptions.Item label="关联预支单">{fullDetail.advance_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="预支金额">{formatAmount(fullDetail.advance_amount, fullDetail.currency)}</Descriptions.Item>
|
||||
</>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
{/* 明细清单 */}
|
||||
{fullDetail.detail_items && fullDetail.detail_items.length > 0 && (
|
||||
<>
|
||||
<Divider>明细清单</Divider>
|
||||
{renderDetailItems(fullDetail.detail_items)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 凭证附件 */}
|
||||
{fullDetail.attachments && fullDetail.attachments.length > 0 && (
|
||||
<>
|
||||
<Divider>申请凭证附件</Divider>
|
||||
{renderAttachments(fullDetail.attachments)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 执行表单 */}
|
||||
{(selectedRecord.status === 'approved' || selectedRecord.status === 'pending') && (
|
||||
<>
|
||||
<Divider>执行信息</Divider>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="execute_date" label="执行日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="execute_method" label="执行方式" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: 'bank', label: '银行转账' }, { value: 'cash', label: '现金' }, { value: 'check', label: '支票' }, { value: 'other', label: '其他' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item label="付款凭证" required>
|
||||
<Upload {...uploadProps}>
|
||||
<Button icon={<UploadOutlined />}>上传付款凭证</Button>
|
||||
</Upload>
|
||||
<div style={{ marginTop: 8, color: '#666', fontSize: 12 }}>
|
||||
请上传付款凭证(银行转账回单、现金收据等),支持图片和PDF格式
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<TextArea rows={2} placeholder="可选:填写执行备注" />
|
||||
</Form.Item>
|
||||
<Form.Item name="rejectReason" label="退回原因" style={{ display: 'none' }}>
|
||||
<TextArea rows={3} placeholder="请填写退回原因" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal title={`编辑申请:${selectedRecord?.code}`} open={editModalVisible} onCancel={() => setEditModalVisible(false)} onOk={handleEditSubmit} width={600}>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item label="申请类型"><Input value={selectedRecord?.type} disabled /></Form.Item>
|
||||
<Form.Item label="申请人"><Input value={selectedRecord?.applicant} disabled /></Form.Item>
|
||||
<Form.Item name="amount" label="金额" rules={[{ required: true }]}><Input type="number" style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="reason" label="事由" rules={[{ required: true }]}><TextArea rows={3} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title={`执行记录:${selectedRecord?.code}`} open={historyModalVisible} onCancel={() => setHistoryModalVisible(false)} footer={null} width={1000}>
|
||||
<Table columns={historyColumns} dataSource={executionHistory.filter(r => r.applyCode === selectedRecord?.code)} rowKey="id" pagination={false} size="small" />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExecutionManagement;
|
||||
@@ -0,0 +1,220 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Typography,
|
||||
Space,
|
||||
Alert,
|
||||
Flex,
|
||||
Divider
|
||||
} from 'antd'
|
||||
import {
|
||||
UserOutlined,
|
||||
LockOutlined,
|
||||
DashboardOutlined,
|
||||
DollarOutlined,
|
||||
ProjectOutlined,
|
||||
TeamOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useLanguageStore } from '../../store/languageStore'
|
||||
import LanguageSelector from '../../components/common/LanguageSelector'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const { login } = useAuthStore()
|
||||
const { t } = useLanguageStore()
|
||||
|
||||
const handleSubmit = async (values: { username: string; password: string }) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await login(values.username, values.password)
|
||||
navigate('/dashboard')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('login.loginFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试账户
|
||||
const testAccounts = [
|
||||
{ username: 'admin', password: 'X123c321@', role: t('user.admin') },
|
||||
{ username: 'finance', password: 'X123c321@', role: t('user.finance') },
|
||||
{ username: 'manager', password: 'X123c321@', role: t('user.manager') },
|
||||
{ username: 'employee', password: 'X123c321@', role: t('user.employee') }
|
||||
]
|
||||
|
||||
const handleTestLogin = (username: string, password: string) => {
|
||||
form.setFieldsValue({ username, password })
|
||||
form.submit()
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '20px'
|
||||
}}>
|
||||
<Card
|
||||
className="login-card"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 480,
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)'
|
||||
}}
|
||||
styles={{ body: { padding: 40 } }}
|
||||
>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{/* 标题 */}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Title level={2} style={{ marginBottom: 8 }}>
|
||||
<DashboardOutlined style={{ marginRight: 12, color: '#1890ff' }} />
|
||||
{t('login.title')}
|
||||
</Title>
|
||||
<Text type="secondary">{t('login.subtitle')}</Text>
|
||||
</div>
|
||||
|
||||
{/* 语言选择器 V2.0 */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px',
|
||||
background: '#f0f2f5',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid #1890ff'
|
||||
}}>
|
||||
<div style={{ marginBottom: 8, color: '#1890ff', fontWeight: 'bold' }}>
|
||||
🌍 选择语言 / Select Language
|
||||
</div>
|
||||
<LanguageSelector size="large" style={{ width: '200px' }} />
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<Alert
|
||||
message={error}
|
||||
type="error"
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 登录表单 */}
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label={t('login.username')}
|
||||
rules={[
|
||||
{ required: true, message: t('login.usernameRequired') },
|
||||
{ min: 3, message: t('login.usernameMin') }
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder={t('login.usernamePlaceholder')}
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={t('login.password')}
|
||||
rules={[
|
||||
{ required: true, message: t('login.passwordRequired') },
|
||||
{ min: 6, message: t('login.passwordMin') }
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
size="large"
|
||||
block
|
||||
>
|
||||
{t('login.loginButton')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Divider>{t('login.testAccounts')}</Divider>
|
||||
|
||||
{/* 测试账户 */}
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{testAccounts.map((account, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
size="small"
|
||||
hoverable
|
||||
onClick={() => handleTestLogin(account.username, account.password)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Space>
|
||||
{account.role === t('user.admin') && <DashboardOutlined style={{ color: '#1890ff' }} />}
|
||||
{account.role === t('user.finance') && <DollarOutlined style={{ color: '#52c41a' }} />}
|
||||
{account.role === t('user.manager') && <ProjectOutlined style={{ color: '#fa8c16' }} />}
|
||||
{account.role === t('user.employee') && <TeamOutlined style={{ color: '#722ed1' }} />}
|
||||
<Text strong>{account.role}</Text>
|
||||
</Space>
|
||||
<Text type="secondary">
|
||||
{t('login.username')}: {account.username} / {t('login.password')}: {account.password}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
{/* 功能说明 */}
|
||||
<Card size="small" type="inner">
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Text strong>{t('menu.dashboard')}:</Text>
|
||||
<Text type="secondary">• {t('features.projectManage')}</Text>
|
||||
<Text type="secondary">• {t('features.advanceManage')}</Text>
|
||||
<Text type="secondary">• {t('features.reimburseManage')}</Text>
|
||||
<Text type="secondary">• {t('features.financeReport')}</Text>
|
||||
<Text type="secondary">• {t('features.mobileSupport')}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 技术支持 */}
|
||||
<div style={{ textAlign: 'center', marginTop: 20 }}>
|
||||
<Text type="secondary">
|
||||
{t('login.techSupport')}
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
||||
@@ -0,0 +1,295 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Typography, Button, Form, Input, Select, DatePicker, InputNumber, Radio, Space, message, Divider, Row, Col } from 'antd';
|
||||
import { SaveOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import FileUpload from '../../components/FileUpload';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface Customer {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
department?: string;
|
||||
}
|
||||
|
||||
const BudgetProjectCreate: React.FC = () => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [form] = Form.useForm();
|
||||
const [attachments, setAttachments] = useState<string[]>([]);
|
||||
const [surveyPhotos, setSurveyPhotos] = useState<string[]>([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const isAdmin = currentUser?.role === 'admin';
|
||||
|
||||
// 检查权限,如果不是管理员,重定向到列表页面
|
||||
useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
message.error('您没有权限访问此页面');
|
||||
navigate('/budget-projects');
|
||||
}
|
||||
}, [isAdmin, navigate]);
|
||||
|
||||
// const { user: currentUser } = useAuthStore();
|
||||
|
||||
// 表单监听值
|
||||
const intermediaryFeeType = Form.useWatch('intermediary_fee_type', form);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers();
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchCustomers = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/customers');
|
||||
if (res.data.success) setCustomers(res.data.data);
|
||||
} catch (error) {
|
||||
console.error('获取客户列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/users');
|
||||
if (res.data.success) setUsers(res.data.data);
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
const projectData = {
|
||||
...values,
|
||||
attachments,
|
||||
survey_photos: surveyPhotos,
|
||||
survey_date: values.survey_date?.format('YYYY-MM-DD'),
|
||||
status: 'negotiating',
|
||||
};
|
||||
|
||||
const res = await axios.post('/api/budget-projects', projectData, {
|
||||
headers: {
|
||||
'x-user-role': 'admin' // 创建预算项目需要管理员权限
|
||||
}
|
||||
});
|
||||
if (res.data.success) {
|
||||
message.success('创建成功');
|
||||
navigate('/budget-projects');
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response?.data?.error) {
|
||||
message.error(error.response.data.error);
|
||||
} else {
|
||||
message.error('创建失败');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 8 }}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/budget-projects')}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 0 }}>新建商谈项目</Title>
|
||||
</div>
|
||||
<Paragraph type="secondary">创建新的商谈项目,添加项目基本信息</Paragraph>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
intermediary_fee_type: 'fixed',
|
||||
survey_date: dayjs(), // 勘察日期默认为当天
|
||||
attachments: [],
|
||||
survey_photos: []
|
||||
}}
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<Divider orientation="left">基本信息</Divider>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="项目名称"
|
||||
rules={[{ required: true, message: '请输入项目名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入项目名称" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="customer_id"
|
||||
label="客户"
|
||||
rules={[{ required: true, message: '请选择客户' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择客户"
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
size="large"
|
||||
>
|
||||
{customers.map((c) => (
|
||||
<Option key={c.id} value={c.id}>{c.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="manager_id"
|
||||
label="业务经理"
|
||||
rules={[{ required: true, message: '请选择业务经理' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择业务经理"
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
size="large"
|
||||
>
|
||||
{users.map((u) => (
|
||||
<Option key={u.id} value={u.id}>{u.name} ({u.department || '未知部门'})</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="location" label="项目地点">
|
||||
<Input placeholder="请输入项目地点" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="survey_date" label="勘察日期">
|
||||
<DatePicker style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 居间人信息 */}
|
||||
<Divider orientation="left">居间人信息</Divider>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="intermediary" label="居间人">
|
||||
<Input placeholder="请输入居间人姓名" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="intermediary_fee_type" label="居间费类型">
|
||||
<Radio.Group>
|
||||
<Radio value="fixed">固定金额</Radio>
|
||||
<Radio value="percentage">百分比</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item
|
||||
name="intermediary_fee_value"
|
||||
label={intermediaryFeeType === 'percentage' ? '居间费比例(%)' : '居间费金额'}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
size="large"
|
||||
min={0}
|
||||
precision={intermediaryFeeType === 'percentage' ? 2 : 0}
|
||||
placeholder={intermediaryFeeType === 'percentage' ? '输入比例,如:5' : '输入金额'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 项目详情 */}
|
||||
<Divider orientation="left">项目详情</Divider>
|
||||
|
||||
<Form.Item name="customer_requirements" label="客户要求">
|
||||
<TextArea rows={4} placeholder="请输入客户的具体要求" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="project_overview" label="工程概况">
|
||||
<TextArea rows={4} placeholder="请输入工程概况描述" />
|
||||
</Form.Item>
|
||||
|
||||
{/* 附件上传 */}
|
||||
<Divider orientation="left">附件</Divider>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label="附件上传">
|
||||
<FileUpload
|
||||
value={attachments}
|
||||
onChange={setAttachments}
|
||||
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.xlsx,.xls"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label="勘察照片">
|
||||
<FileUpload
|
||||
value={surveyPhotos}
|
||||
onChange={setSurveyPhotos}
|
||||
accept="image/*"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div style={{ marginTop: 24, textAlign: 'right' }}>
|
||||
<Space>
|
||||
<Button onClick={() => navigate('/budget-projects')}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BudgetProjectCreate;
|
||||
@@ -0,0 +1,574 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Typography, Button, Space, Tag, message, Empty, Divider, Row, Col, List, Descriptions, Avatar, Badge, Modal, Input } from 'antd';
|
||||
import { ArrowLeftOutlined, EyeOutlined, FileAddOutlined, FileOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import QuotationCreateModal from './QuotationCreateModal';
|
||||
import ContractCreateModal from './ContractCreateModal';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
interface Quotation {
|
||||
id: number;
|
||||
version: number;
|
||||
quotation_date: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: 'draft' | 'sent' | 'approved' | 'rejected';
|
||||
file_url?: string;
|
||||
remark?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface BudgetProject {
|
||||
id: number;
|
||||
name: string;
|
||||
customer_id: number;
|
||||
customer_name: string;
|
||||
manager_id: number;
|
||||
manager_name: string;
|
||||
location?: string;
|
||||
survey_date?: string;
|
||||
intermediary?: string;
|
||||
intermediary_fee_type?: 'fixed' | 'percentage';
|
||||
intermediary_fee_value?: number;
|
||||
customer_requirements?: string;
|
||||
project_overview?: string;
|
||||
attachments?: string[];
|
||||
survey_photos?: string[];
|
||||
status: 'negotiating' | 'signed' | 'unsigned';
|
||||
days_in_status: number;
|
||||
created_at: string;
|
||||
quotations: Quotation[];
|
||||
}
|
||||
|
||||
const CURRENCIES: Record<string, { label: string; symbol: string }> = {
|
||||
CNY: { label: '人民币', symbol: '¥' },
|
||||
USD: { label: '美元', symbol: '$' },
|
||||
LAK: { label: '老挝基普', symbol: '₭' },
|
||||
THB: { label: '泰铢', symbol: '฿' },
|
||||
};
|
||||
|
||||
const BudgetProjectDetail: React.FC = () => {
|
||||
const [project, setProject] = useState<BudgetProject | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [quotationModalVisible, setQuotationModalVisible] = useState(false);
|
||||
const [contractModalVisible, setContractModalVisible] = useState(false);
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [deletePassword, setDeletePassword] = useState('');
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [quotationDeleteModalVisible, setQuotationDeleteModalVisible] = useState(false);
|
||||
const [quotationDeleteId, setQuotationDeleteId] = useState<number | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const isAdmin = currentUser?.role === 'admin' || false;
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchProjectDetail();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchProjectDetail = async () => {
|
||||
if (!id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get(`/api/budget-projects/${id}`);
|
||||
if (res.data.success) {
|
||||
const projectData = res.data.data;
|
||||
// 后端已经解析了数据,直接使用
|
||||
projectData.quotations = Array.isArray(projectData.quotations) ? projectData.quotations : [];
|
||||
projectData.attachments = Array.isArray(projectData.attachments) ? projectData.attachments : [];
|
||||
projectData.survey_photos = Array.isArray(projectData.survey_photos) ? projectData.survey_photos : [];
|
||||
setProject(projectData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目详情失败:', error);
|
||||
message.error('获取数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
negotiating: { color: 'processing', text: '商谈中' },
|
||||
signed: { color: 'success', text: '已签约' },
|
||||
unsigned: { color: 'error', text: '未签约' },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const getQuotationStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
draft: { color: 'default', text: '草稿' },
|
||||
sent: { color: 'processing', text: '已发送' },
|
||||
approved: { color: 'success', text: '已通过' },
|
||||
rejected: { color: 'error', text: '已拒绝' },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number, currency: string = 'CNY') => {
|
||||
const c = CURRENCIES[currency];
|
||||
const symbol = c?.symbol || '¥';
|
||||
return `${symbol}${amount.toLocaleString('zh-CN')}`;
|
||||
};
|
||||
|
||||
const handleSign = () => {
|
||||
if (!project) return;
|
||||
setContractModalVisible(true);
|
||||
};
|
||||
|
||||
const handleContractSuccess = () => {
|
||||
setContractModalVisible(false);
|
||||
fetchProjectDetail();
|
||||
};
|
||||
|
||||
const handleUnsigned = async () => {
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
const res = await axios.put(`/api/budget-projects/${project.id}/unsigned`, {}, {
|
||||
headers: {
|
||||
'x-user-role': currentUser?.role || 'employee'
|
||||
}
|
||||
});
|
||||
if (res.data.success) {
|
||||
message.success('标记未签约成功');
|
||||
fetchProjectDetail();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteQuotation = (quotationId: number) => {
|
||||
setQuotationDeleteId(quotationId);
|
||||
setDeletePassword('');
|
||||
setQuotationDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const handleQuotationDeleteConfirm = async () => {
|
||||
if (!project || !quotationDeleteId) return;
|
||||
|
||||
// 验证密码(这里简单验证,实际项目中应该使用更安全的验证方式)
|
||||
if (deletePassword !== 'X123c321@') {
|
||||
message.error('密码错误');
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
const res = await axios.delete(`/api/budget-projects/${project.id}/quotations/${quotationDeleteId}`, {
|
||||
headers: {
|
||||
'x-user-role': currentUser?.role || 'employee'
|
||||
}
|
||||
});
|
||||
if (res.data.success) {
|
||||
message.success('删除成功');
|
||||
setQuotationDeleteModalVisible(false);
|
||||
fetchProjectDetail();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openQuotationModal = () => {
|
||||
if (project) {
|
||||
setQuotationModalVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuotationSuccess = () => {
|
||||
setQuotationModalVisible(false);
|
||||
fetchProjectDetail();
|
||||
};
|
||||
|
||||
const goToProjectManagement = () => {
|
||||
if (project) {
|
||||
navigate(`/projects/${project.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = () => {
|
||||
if (!project) return;
|
||||
setDeletePassword('');
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const handleProjectDeleteConfirm = async () => {
|
||||
if (!project) return;
|
||||
|
||||
// 验证密码(这里简单验证,实际项目中应该使用更安全的验证方式)
|
||||
if (deletePassword !== 'X123c321@') {
|
||||
message.error('密码错误');
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
const res = await axios.delete(`/api/budget-projects/${project.id}`, {
|
||||
headers: {
|
||||
'x-user-role': currentUser?.role || 'employee'
|
||||
}
|
||||
});
|
||||
if (res.data.success) {
|
||||
message.success('删除成功');
|
||||
setDeleteModalVisible(false);
|
||||
navigate('/budget-projects');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card>
|
||||
<Empty description="项目不存在" />
|
||||
<Button type="primary" onClick={() => navigate('/budget-projects')} style={{ marginTop: 16 }}>
|
||||
返回列表
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 8 }}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/budget-projects')}
|
||||
>
|
||||
返回列表
|
||||
</Button>
|
||||
<Title level={2} style={{ marginBottom: 0 }}>预算项目详情</Title>
|
||||
</div>
|
||||
<Paragraph type="secondary">查看项目详细信息和报价版本</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* 项目基本信息 */}
|
||||
<Card style={{ marginBottom: 24 }}>
|
||||
<Title level={4}>项目信息</Title>
|
||||
<Divider />
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Descriptions column={1} bordered>
|
||||
<Descriptions.Item label="项目名称">{project.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="客户">{project.customer_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="业务经理">{project.manager_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="项目地点">{project.location || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="勘察日期">{project.survey_date || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(project.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{dayjs(project.created_at).format('YYYY-MM-DD HH:mm:ss')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={12}>
|
||||
<Descriptions column={1} bordered>
|
||||
<Descriptions.Item label="居间人">{project.intermediary || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="居间费类型">
|
||||
{project.intermediary_fee_type === 'fixed' ? '固定金额' : project.intermediary_fee_type === 'percentage' ? '百分比' : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="居间费">
|
||||
{project.intermediary_fee_value ?
|
||||
project.intermediary_fee_type === 'percentage' ?
|
||||
`${project.intermediary_fee_value}%` :
|
||||
formatAmount(project.intermediary_fee_value, 'CNY')
|
||||
: '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="客户要求">{project.customer_requirements || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="工程概况">{project.project_overview || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 附件和照片 */}
|
||||
<Card style={{ marginBottom: 24 }}>
|
||||
<Title level={4}>附件和照片</Title>
|
||||
<Divider />
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text strong>附件上传:</Text>
|
||||
{project.attachments && project.attachments.length > 0 ? (
|
||||
<List
|
||||
style={{ marginTop: 8 }}
|
||||
dataSource={project.attachments}
|
||||
renderItem={(url, index) => {
|
||||
const isOffice = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(url.split('.').pop()?.toLowerCase() || '');
|
||||
const handleView = () => {
|
||||
if (isOffice) {
|
||||
const previewUrl = `https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(url)}`;
|
||||
window.open(previewUrl, '_blank');
|
||||
} else {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<List.Item key={index}>
|
||||
<Space>
|
||||
<FileOutlined />
|
||||
<Text ellipsis>{url.split('/').pop() || `file-${index}`}</Text>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={handleView}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
</Space>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>暂无附件</Text>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text strong>勘察照片:</Text>
|
||||
{project.survey_photos && project.survey_photos.length > 0 ? (
|
||||
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{project.survey_photos.map((url, index) => (
|
||||
<div key={index} style={{ position: 'relative', width: 100, height: 100, border: '1px solid #f0f0f0', borderRadius: 4, overflow: 'hidden' }}>
|
||||
<img
|
||||
src={url}
|
||||
alt={`survey-${index}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onClick={() => window.open(url, '_blank')}
|
||||
/>
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(0, 0, 0, 0.5)', color: '#fff', padding: 4, fontSize: 12, textAlign: 'center' }}>
|
||||
照片 {index + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>暂无勘察照片</Text>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 报价版本列表 */}
|
||||
<Card style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Title level={4}>报价版本</Title>
|
||||
{isAdmin && project.status === 'negotiating' && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<FileAddOutlined />}
|
||||
onClick={openQuotationModal}
|
||||
>
|
||||
新增报价版本
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
{Array.isArray(project.quotations) && project.quotations.length > 0 ? (
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={project.quotations}
|
||||
renderItem={(quotation, index) => {
|
||||
const handleViewFile = () => {
|
||||
if (quotation.file_url) {
|
||||
const isOffice = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(quotation.file_url.split('.').pop()?.toLowerCase() || '');
|
||||
if (isOffice) {
|
||||
const previewUrl = `https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(quotation.file_url)}`;
|
||||
window.open(previewUrl, '_blank');
|
||||
} else {
|
||||
window.open(quotation.file_url, '_blank');
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<List.Item
|
||||
key={quotation.id}
|
||||
actions={[
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={handleViewFile}
|
||||
disabled={!quotation.file_url}
|
||||
>
|
||||
查看
|
||||
</Button>,
|
||||
isAdmin && (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => handleDeleteQuotation(quotation.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)
|
||||
].filter(Boolean)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar style={{ backgroundColor: '#1890ff' }}>V{quotation.version}</Avatar>}
|
||||
title={
|
||||
<Space>
|
||||
<Text strong>报价V{quotation.version}</Text>
|
||||
{getQuotationStatusTag(quotation.status)}
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical">
|
||||
<Text>报价日期: {dayjs(quotation.quotation_date).format('YYYY-MM-DD')}</Text>
|
||||
<Text>报价金额: {formatAmount(quotation.amount, quotation.currency)}</Text>
|
||||
{quotation.remark && <Text>备注: {quotation.remark}</Text>}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无报价版本" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Card>
|
||||
<Title level={4}>操作</Title>
|
||||
<Divider />
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{isAdmin && project.status === 'negotiating' && (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={handleSign}
|
||||
>
|
||||
标记签约
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<CloseCircleOutlined />}
|
||||
onClick={handleUnsigned}
|
||||
>
|
||||
标记未签约
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{project.status === 'signed' && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={goToProjectManagement}
|
||||
>
|
||||
进入项目管理
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<Button
|
||||
danger
|
||||
onClick={handleDeleteProject}
|
||||
>
|
||||
删除项目
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 新增报价版本弹窗 */}
|
||||
<QuotationCreateModal
|
||||
visible={quotationModalVisible}
|
||||
project={project}
|
||||
onCancel={() => setQuotationModalVisible(false)}
|
||||
onSuccess={handleQuotationSuccess}
|
||||
/>
|
||||
|
||||
{/* 合同信息录入弹窗 */}
|
||||
<ContractCreateModal
|
||||
visible={contractModalVisible}
|
||||
projectId={project?.id || 0}
|
||||
projectName={project?.name || ''}
|
||||
onCancel={() => setContractModalVisible(false)}
|
||||
onSuccess={handleContractSuccess}
|
||||
/>
|
||||
|
||||
{/* 删除项目确认模态框 */}
|
||||
<Modal
|
||||
title="删除确认"
|
||||
open={deleteModalVisible}
|
||||
onOk={handleProjectDeleteConfirm}
|
||||
onCancel={() => setDeleteModalVisible(false)}
|
||||
confirmLoading={deleteLoading}
|
||||
okText="确认删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<p>确定要删除这个预算项目吗?此操作不可恢复。</p>
|
||||
<p>请输入管理员密码确认删除操作:</p>
|
||||
</div>
|
||||
<Input.Password
|
||||
placeholder="请输入管理员密码"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
size="large"
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* 删除报价版本确认模态框 */}
|
||||
<Modal
|
||||
title="删除确认"
|
||||
open={quotationDeleteModalVisible}
|
||||
onOk={handleQuotationDeleteConfirm}
|
||||
onCancel={() => setQuotationDeleteModalVisible(false)}
|
||||
confirmLoading={deleteLoading}
|
||||
okText="确认删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<p>确定要删除这个报价版本吗?此操作不可恢复。</p>
|
||||
<p>请输入管理员密码确认删除操作:</p>
|
||||
</div>
|
||||
<Input.Password
|
||||
placeholder="请输入管理员密码"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
size="large"
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BudgetProjectDetail;
|
||||
@@ -0,0 +1,303 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Typography, Button, Space, Tag, message, Empty, Radio, Modal, Input } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
interface Quotation {
|
||||
id: number;
|
||||
version: number;
|
||||
quotation_date: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: 'draft' | 'sent' | 'approved' | 'rejected';
|
||||
file_url?: string;
|
||||
remark?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface BudgetProject {
|
||||
id: number;
|
||||
name: string;
|
||||
customer_id: number;
|
||||
customer_name: string;
|
||||
manager_id: number;
|
||||
manager_name: string;
|
||||
location?: string;
|
||||
survey_date?: string;
|
||||
intermediary?: string;
|
||||
intermediary_fee_type?: 'fixed' | 'percentage';
|
||||
intermediary_fee_value?: number;
|
||||
customer_requirements?: string;
|
||||
project_overview?: string;
|
||||
attachments?: string[];
|
||||
survey_photos?: string[];
|
||||
status: 'negotiating' | 'signed' | 'unsigned';
|
||||
days_in_status: number;
|
||||
created_at: string;
|
||||
quotations: Quotation[];
|
||||
}
|
||||
|
||||
type StatusFilter = 'all' | 'negotiating' | 'signed' | 'unsigned';
|
||||
|
||||
const CURRENCIES: Record<string, { label: string; symbol: string }> = {
|
||||
CNY: { label: '人民币', symbol: '¥' },
|
||||
USD: { label: '美元', symbol: '$' },
|
||||
LAK: { label: '老挝基普', symbol: '₭' },
|
||||
THB: { label: '泰铢', symbol: '฿' },
|
||||
};
|
||||
|
||||
const BudgetProjectList: React.FC = () => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [projects, setProjects] = useState<BudgetProject[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [deleteProjectId, setDeleteProjectId] = useState<number | null>(null);
|
||||
const [deletePassword, setDeletePassword] = useState('');
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const isAdmin = currentUser?.role === 'admin' || false;
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get('/api/budget-projects');
|
||||
if (res.data.success) {
|
||||
// 后端已经解析了数据,直接使用
|
||||
const projectsWithParsedData = res.data.data.map((project: any) => {
|
||||
return {
|
||||
...project,
|
||||
quotations: Array.isArray(project.quotations) ? project.quotations : [],
|
||||
attachments: Array.isArray(project.attachments) ? project.attachments : [],
|
||||
survey_photos: Array.isArray(project.survey_photos) ? project.survey_photos : []
|
||||
};
|
||||
});
|
||||
setProjects(projectsWithParsedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取预算项目失败:', error);
|
||||
message.error('获取数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredProjects = projects.filter(p =>
|
||||
statusFilter === 'all' || p.status === statusFilter
|
||||
);
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
negotiating: { color: 'processing', text: '商谈中' },
|
||||
signed: { color: 'success', text: '已签约' },
|
||||
unsigned: { color: 'error', text: '未签约' },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const getQuotationStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
draft: { color: 'default', text: '草稿' },
|
||||
sent: { color: 'processing', text: '已发送' },
|
||||
approved: { color: 'success', text: '已通过' },
|
||||
rejected: { color: 'error', text: '已拒绝' },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number, currency: string = 'CNY') => {
|
||||
const c = CURRENCIES[currency];
|
||||
const symbol = c?.symbol || '¥';
|
||||
return `${symbol}${amount.toLocaleString('zh-CN')}`;
|
||||
};
|
||||
|
||||
const handleDeleteProject = (projectId: number) => {
|
||||
setDeleteProjectId(projectId);
|
||||
setDeletePassword('');
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteProjectId) return;
|
||||
|
||||
// 验证密码(这里简单验证,实际项目中应该使用更安全的验证方式)
|
||||
if (deletePassword !== 'X123c321@') {
|
||||
message.error('密码错误');
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
const res = await axios.delete(`/api/budget-projects/${deleteProjectId}`, {
|
||||
headers: {
|
||||
'x-user-role': currentUser?.role || 'employee'
|
||||
}
|
||||
});
|
||||
if (res.data.success) {
|
||||
message.success('删除成功');
|
||||
setDeleteModalVisible(false);
|
||||
fetchProjects();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
||||
<div>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>预算报价管理</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>管理商谈项目及报价版本</Paragraph>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => navigate('/budget-projects/create')}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
新建商谈项目
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态筛选 */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Text strong>状态筛选:</Text>
|
||||
<Radio.Group
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="all">全部</Radio.Button>
|
||||
<Radio.Button value="negotiating">商谈中</Radio.Button>
|
||||
<Radio.Button value="signed">已签约</Radio.Button>
|
||||
<Radio.Button value="unsigned">未签约</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 项目列表 */}
|
||||
<Card loading={loading}>
|
||||
{filteredProjects.length === 0 ? (
|
||||
<Empty description="暂无数据" />
|
||||
) : (
|
||||
<div>
|
||||
{filteredProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
style={{
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 项目头部 */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
background: '#fafafa',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => navigate(`/budget-projects/${project.id}`)}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 12 }}>
|
||||
<Space size="middle">
|
||||
<Text strong style={{ fontSize: 16 }}>{project.name}</Text>
|
||||
</Space>
|
||||
<Space>
|
||||
{getStatusTag(project.status)}
|
||||
<Text type="secondary">{project.days_in_status}天</Text>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteProject(project.id);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text type="secondary">客户: {project.customer_name}</Text>
|
||||
<Text type="secondary">业务经理: {project.manager_name}</Text>
|
||||
{project.intermediary && (
|
||||
<Text type="secondary">
|
||||
居间人: {project.intermediary}
|
||||
{project.intermediary_fee_value && (
|
||||
<span> 居间费: {formatAmount(project.intermediary_fee_value, 'CNY')}</span>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 删除确认模态框 */}
|
||||
<Modal
|
||||
title="删除确认"
|
||||
open={deleteModalVisible}
|
||||
onOk={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteModalVisible(false)}
|
||||
confirmLoading={deleteLoading}
|
||||
okText="确认删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<p>确定要删除这个预算项目吗?此操作不可恢复。</p>
|
||||
<p>请输入管理员密码确认删除操作:</p>
|
||||
</div>
|
||||
<Input.Password
|
||||
placeholder="请输入管理员密码"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
size="large"
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BudgetProjectList;
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, DatePicker, InputNumber, Select, Space, message } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import axios from 'axios';
|
||||
|
||||
interface ContractCreateModalProps {
|
||||
visible: boolean;
|
||||
projectId: number;
|
||||
projectName: string;
|
||||
onCancel: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
|
||||
visible,
|
||||
projectId,
|
||||
projectName,
|
||||
onCancel,
|
||||
onSuccess
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [contractAmount, setContractAmount] = useState(0);
|
||||
|
||||
// 生成默认的合同编号(包含时间戳确保唯一性)
|
||||
const today = dayjs();
|
||||
const dateStr = today.format('YYYYMMDD');
|
||||
const timeStr = today.format('HHmmss');
|
||||
const defaultContractCode = `CONTRACT-${dateStr}-${timeStr}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
form.setFieldsValue({
|
||||
contract_code: defaultContractCode,
|
||||
project_name: projectName,
|
||||
contract_method: 'lump_sum',
|
||||
currency: 'CNY',
|
||||
contract_amount: 0,
|
||||
contract_period: 180
|
||||
});
|
||||
setContractAmount(0);
|
||||
}
|
||||
}, [visible, form, projectName]);
|
||||
|
||||
// 处理工期变化
|
||||
const handlePeriodChange = (value: number) => {
|
||||
// 只需要设置工期天数,不需要计算开始和结束日期
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async (values: any) => {
|
||||
// 构建提交数据(简化版)
|
||||
const submitData = {
|
||||
contract_code: values.contract_code,
|
||||
project_name: values.project_name,
|
||||
contract_method: values.contract_method || 'lump_sum',
|
||||
currency: values.currency || 'CNY',
|
||||
contract_amount: values.contract_amount || 0,
|
||||
contract_period: values.contract_period || 180,
|
||||
warranty_deposit_percentage: 5, // 默认5%
|
||||
warranty_period: 12, // 默认12个月
|
||||
// 其他字段留空,后续在项目管理中补充
|
||||
project_overview: '',
|
||||
other_requirements: '',
|
||||
contract_file: null,
|
||||
payment_nodes: [],
|
||||
unit_price_items: []
|
||||
};
|
||||
|
||||
console.log('提交的合同信息:', submitData);
|
||||
|
||||
try {
|
||||
const res = await axios.put(`/api/budget-projects/${projectId}/sign`, submitData, {
|
||||
headers: {
|
||||
'x-user-role': 'admin' // 签约操作需要管理员权限
|
||||
}
|
||||
});
|
||||
|
||||
console.log('API响应:', res);
|
||||
|
||||
if (res.data.success) {
|
||||
message.success('签约成功,项目已自动创建');
|
||||
onSuccess();
|
||||
onCancel();
|
||||
} else {
|
||||
message.error(res.data.message || '操作失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('签约失败:', error);
|
||||
console.error('错误响应:', error.response);
|
||||
const errorMessage = error.response?.data?.message || error.message || '操作失败';
|
||||
message.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="快速签约"
|
||||
open={visible}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={onCancel}
|
||||
width={600}
|
||||
okText="确认签约"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<Form.Item
|
||||
name="contract_code"
|
||||
label="合同编号"
|
||||
rules={[{ required: true, message: '请输入合同编号' }]}
|
||||
>
|
||||
<Input placeholder="请输入合同编号" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="project_name"
|
||||
label="项目名称"
|
||||
rules={[{ required: true, message: '请输入项目名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入项目名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="contract_method"
|
||||
label="承包方式"
|
||||
rules={[{ required: true, message: '请选择承包方式' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择承包方式"
|
||||
options={[
|
||||
{ value: 'lump_sum', label: '总价包干' },
|
||||
{ value: 'unit_price', label: '单价结算' }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="currency"
|
||||
label="币种"
|
||||
rules={[{ required: true, message: '请选择币种' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择币种"
|
||||
options={[
|
||||
{ value: 'CNY', label: '人民币' },
|
||||
{ value: 'USD', label: '美元' },
|
||||
{ value: 'LAK', label: '老挝基普' },
|
||||
{ value: 'THB', label: '泰铢' }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="contract_amount"
|
||||
label="总价"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入总价'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
placeholder="请输入总价"
|
||||
formatter={(value) => `¥ ${value}`}
|
||||
parser={(value) => value.replace(/¥\s?|(,*)/g, '')}
|
||||
onChange={(value) => setContractAmount(value || 0)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 工期 */}
|
||||
<Form.Item
|
||||
name="contract_period"
|
||||
label="工期(天)"
|
||||
rules={[{ required: true, message: '请输入工期' }]}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={1}
|
||||
placeholder="请输入工期(天)"
|
||||
onChange={handlePeriodChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ marginTop: 16, padding: 16, background: '#f5f5f5', borderRadius: 8 }}>
|
||||
<p style={{ margin: 0, fontSize: 14, color: '#666' }}>
|
||||
注:此为快速签约流程,仅录入基本信息。详细的合同信息可在项目管理中补充。
|
||||
</p>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContractCreateModal;
|
||||
@@ -0,0 +1,257 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, DatePicker, InputNumber, Select, Upload, Button, message } from 'antd';
|
||||
import { UploadOutlined, DeleteOutlined, FileOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import axios from 'axios';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface Quotation {
|
||||
id: number;
|
||||
version: number;
|
||||
quotation_date: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: 'draft' | 'sent' | 'approved' | 'rejected';
|
||||
file_url?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
interface BudgetProject {
|
||||
id: number;
|
||||
name: string;
|
||||
quotations: Quotation[];
|
||||
}
|
||||
|
||||
interface QuotationCreateModalProps {
|
||||
visible: boolean;
|
||||
project: BudgetProject | null;
|
||||
onCancel: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const CURRENCIES = [
|
||||
{ value: 'CNY', label: '人民币', symbol: '¥' },
|
||||
{ value: 'USD', label: '美元', symbol: '$' },
|
||||
{ value: 'LAK', label: '老挝基普', symbol: '₭' },
|
||||
{ value: 'THB', label: '泰铢', symbol: '฿' },
|
||||
];
|
||||
|
||||
const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
|
||||
visible,
|
||||
project,
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadedFile, setUploadedFile] = useState<{ url: string; name: string } | null>(null);
|
||||
|
||||
// 计算下一个版本号
|
||||
const nextVersion = project?.quotations && Array.isArray(project.quotations) && project.quotations.length > 0
|
||||
? Math.max(...project.quotations.map(q => q.version || 0)) + 1
|
||||
: 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
quotation_date: dayjs(),
|
||||
currency: 'CNY',
|
||||
version: nextVersion,
|
||||
});
|
||||
setUploadedFile(null);
|
||||
}
|
||||
}, [visible, nextVersion, form]);
|
||||
|
||||
const handleUpload = async (options: any) => {
|
||||
const { file, onSuccess: onUploadSuccess, onError } = options;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload/single', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
message.success('上传成功');
|
||||
setUploadedFile({ url: result.data.url, name: file.name });
|
||||
onUploadSuccess(result.data, file);
|
||||
} else {
|
||||
message.error(result.error || '上传失败');
|
||||
onError?.(new Error(result.error));
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error('上传失败');
|
||||
onError?.(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setUploadedFile(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
const quotationData = {
|
||||
...values,
|
||||
quotation_date: values.quotation_date.format('YYYY-MM-DD'),
|
||||
file_url: uploadedFile?.url,
|
||||
version: nextVersion,
|
||||
};
|
||||
|
||||
const res = await axios.post(`/api/budget-projects/${project.id}/quotations`, quotationData, {
|
||||
headers: {
|
||||
'x-user-role': 'admin' // 创建报价版本需要管理员权限
|
||||
}
|
||||
});
|
||||
if (res.data.success) {
|
||||
message.success('新增报价版本成功');
|
||||
onSuccess();
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response?.data?.error) {
|
||||
message.error(error.response.data.error);
|
||||
} else {
|
||||
message.error('创建失败');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileIcon = () => (
|
||||
<div
|
||||
style={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
<FileOutlined style={{ fontSize: 24, color: '#1890ff' }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="新增报价版本"
|
||||
open={visible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={onCancel}
|
||||
width={600}
|
||||
confirmLoading={loading}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{/* 项目信息展示 */}
|
||||
<div style={{
|
||||
padding: 16,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
marginBottom: 24
|
||||
}}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<span style={{ color: '#666' }}>项目名称: </span>
|
||||
<span style={{ fontWeight: 500 }}>{project?.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: '#666' }}>当前版本: </span>
|
||||
<span style={{ fontWeight: 500 }}>V{nextVersion - 1}</span>
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>
|
||||
(新创建将为 V{nextVersion})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="quotation_date"
|
||||
label="报价日期"
|
||||
rules={[{ required: true, message: '请选择报价日期' }]}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="amount"
|
||||
label="报价金额"
|
||||
rules={[{ required: true, message: '请输入报价金额' }]}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
precision={2}
|
||||
placeholder="请输入报价金额"
|
||||
addonAfter="元"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="currency"
|
||||
label="币种"
|
||||
rules={[{ required: true, message: '请选择币种' }]}
|
||||
>
|
||||
<Select placeholder="请选择币种">
|
||||
{CURRENCIES.map((c) => (
|
||||
<Option key={c.value} value={c.value}>
|
||||
{c.label} ({c.symbol})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="报价文件">
|
||||
{uploadedFile ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{getFileIcon()}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500 }}>{uploadedFile.name}</div>
|
||||
<a href={uploadedFile.url} target="_blank" rel="noopener noreferrer">
|
||||
查看文件
|
||||
</a>
|
||||
</div>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleRemoveFile}
|
||||
size="small"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Upload
|
||||
accept=".pdf,.doc,.docx,.xlsx,.xls,.jpg,.jpeg,.png"
|
||||
customRequest={handleUpload}
|
||||
showUploadList={false}
|
||||
maxCount={1}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>上传文件</Button>
|
||||
</Upload>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={3} placeholder="请输入备注信息" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotationCreateModal;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as BudgetProjectList } from "./BudgetProjectList";
|
||||
export { default as BudgetProjectCreate } from "./BudgetProjectCreate";
|
||||
export { default as QuotationCreateModal } from "./QuotationCreateModal";
|
||||
export { default } from "./BudgetProjectList";
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Typography, Button, Space, Tag, Progress, Empty, Spin, message, Row, Col, Divider } from 'antd';
|
||||
import { FileTextOutlined, CameraOutlined, ScheduleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
// 天气图标映射
|
||||
const WEATHER_ICONS: Record<string, string> = {
|
||||
sunny: '☀️ 晴',
|
||||
cloudy: '⛅ 多云',
|
||||
rainy: '🌧️ 雨',
|
||||
stormy: '⛈️ 雷暴',
|
||||
windy: '💨 大风',
|
||||
};
|
||||
|
||||
// 项目状态映射
|
||||
const STATUS_CONFIG: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'default', text: '待开始' },
|
||||
active: { color: 'processing', text: '施工中' },
|
||||
completed: { color: 'success', text: '完工' },
|
||||
suspended: { color: 'warning', text: '暂停' },
|
||||
cancelled: { color: 'error', text: '已取消' },
|
||||
};
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
project_code: string;
|
||||
name: string;
|
||||
customer_name: string;
|
||||
status: string;
|
||||
start_date: string;
|
||||
expected_end_date: string;
|
||||
contract_amount: number;
|
||||
currency: string;
|
||||
manager_name: string;
|
||||
progress_percentage: number;
|
||||
latest_log?: {
|
||||
id: number;
|
||||
log_date: string;
|
||||
weather: string;
|
||||
work_content: string;
|
||||
};
|
||||
}
|
||||
|
||||
const ConstructionList: React.FC = () => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get('/api/construction/my-projects');
|
||||
if (res.data.success) {
|
||||
setProjects(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目列表失败:', error);
|
||||
message.error('获取项目列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number, currency: string = 'CNY') => {
|
||||
const symbols: Record<string, string> = {
|
||||
CNY: '¥',
|
||||
USD: '$',
|
||||
LAK: '₭',
|
||||
THB: '฿',
|
||||
};
|
||||
const symbol = symbols[currency] || '¥';
|
||||
return `${symbol}${(amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 0 })}`;
|
||||
};
|
||||
|
||||
const isToday = (dateStr: string) => {
|
||||
return dayjs(dateStr).isSame(dayjs(), 'day');
|
||||
};
|
||||
|
||||
const renderProjectCard = (project: Project) => {
|
||||
const statusConfig = STATUS_CONFIG[project.status] || STATUS_CONFIG.pending;
|
||||
const hasTodayLog = project.latest_log && isToday(project.latest_log.log_date);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={project.id}
|
||||
style={{
|
||||
marginBottom: isMobile ? 12 : 16,
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
styles={{ body: { padding: isMobile ? 16 : 20 } }}
|
||||
>
|
||||
{/* 项目头部 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 20 }}>🎯</span>
|
||||
<Text strong style={{ fontSize: isMobile ? 15 : 16 }}>{project.name}</Text>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
客户: {project.customer_name || '未指定'}
|
||||
</Text>
|
||||
</div>
|
||||
<Tag color={statusConfig.color} style={{ marginLeft: 8 }}>
|
||||
{statusConfig.text}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>施工进度</Text>
|
||||
<Text strong style={{ fontSize: 12 }}>{Math.round((project.progress_percentage || 0))}%</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round((project.progress_percentage || 0))}
|
||||
showInfo={false}
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
trailColor="#f0f0f0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 最新日志状态 */}
|
||||
{project.status === 'active' && (
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
background: hasTodayLog ? '#f6ffed' : '#fff7e6',
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
}}>
|
||||
{hasTodayLog ? (
|
||||
<>
|
||||
<span>✅</span>
|
||||
<Text style={{ fontSize: 13 }}>
|
||||
今日日志: {project.latest_log?.work_content?.substring(0, 30)}...
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>⚠️</span>
|
||||
<Text type="warning" style={{ fontSize: 13 }}>今日日志: 未填写</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Button
|
||||
type={project.status === 'active' && !hasTodayLog ? 'primary' : 'default'}
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => navigate(`/construction/${project.id}/logs`)}
|
||||
block
|
||||
size={isMobile ? 'large' : 'middle'}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
{project.status === 'active' && !hasTodayLog ? '📝 写今日日志' : '📝 施工日志'}
|
||||
</Button>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Button
|
||||
icon={<CameraOutlined />}
|
||||
onClick={() => navigate(`/construction/${project.id}/logs`)}
|
||||
block
|
||||
size={isMobile ? 'large' : 'middle'}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
📷 上传照片
|
||||
</Button>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Button
|
||||
icon={<ScheduleOutlined />}
|
||||
onClick={() => navigate(`/construction/${project.id}/milestones`)}
|
||||
block
|
||||
size={isMobile ? 'large' : 'middle'}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
📋 节点进度
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: isMobile ? 12 : 24,
|
||||
maxWidth: 1200,
|
||||
margin: '0 auto'
|
||||
}}>
|
||||
{/* 页面标题 */}
|
||||
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title level={isMobile ? 4 : 3} style={{ marginBottom: 0 }}>施工管理</Title>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={fetchProjects}
|
||||
loading={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
查看和管理您的施工项目
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* 项目列表 */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
<Paragraph type="secondary" style={{ marginTop: 16 }}>加载中...</Paragraph>
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<Card style={{ borderRadius: 12 }}>
|
||||
<Empty
|
||||
description="暂无施工项目"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Text type="secondary">请联系管理员为您分配施工项目</Text>
|
||||
</Empty>
|
||||
</Card>
|
||||
) : (
|
||||
<div>
|
||||
<Text type="secondary" style={{ marginBottom: 12, display: 'block' }}>
|
||||
我的施工项目 ({projects.length})
|
||||
</Text>
|
||||
{projects.map(project => renderProjectCard(project))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConstructionList;
|
||||
@@ -0,0 +1,441 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card, Typography, Button, Space, Modal, Form, Input, DatePicker, Select,
|
||||
Upload, message, Spin, Empty, Image, Tag, Divider, Popconfirm, Row, Col
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined, ArrowLeftOutlined, DeleteOutlined,
|
||||
CameraOutlined, CalendarOutlined, CloudOutlined
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
// 天气选项
|
||||
const WEATHER_OPTIONS = [
|
||||
{ value: 'sunny', label: '☀️ 晴', icon: '☀️' },
|
||||
{ value: 'cloudy', label: '⛅ 多云', icon: '⛅' },
|
||||
{ value: 'rainy', label: '🌧️ 雨', icon: '🌧️' },
|
||||
{ value: 'stormy', label: '⛈️ 雷暴', icon: '⛈️' },
|
||||
{ value: 'windy', label: '💨 大风', icon: '💨' },
|
||||
];
|
||||
|
||||
interface Log {
|
||||
id: number;
|
||||
log_date: string;
|
||||
weather: string;
|
||||
work_content: string;
|
||||
next_plan: string;
|
||||
issues: string;
|
||||
recorder_name: string;
|
||||
photos: Photo[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Photo {
|
||||
id: number;
|
||||
photo_url: string;
|
||||
photo_name: string;
|
||||
photo_type: string;
|
||||
file_size: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const ConstructionLog: React.FC = () => {
|
||||
const { id: projectId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [logs, setLogs] = useState<Log[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [projectInfo, setProjectInfo] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetchLogs();
|
||||
fetchProjectInfo();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get(`/api/projects/${projectId}/construction-logs`);
|
||||
if (res.data.success) {
|
||||
setLogs(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取日志列表失败:', error);
|
||||
message.error('获取日志列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProjectInfo = async () => {
|
||||
try {
|
||||
const res = await axios.get(`/api/projects/${projectId}`);
|
||||
if (res.data.success) {
|
||||
setProjectInfo(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目信息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
|
||||
const res = await axios.post(`/api/projects/${projectId}/construction-logs`, {
|
||||
log_date: values.log_date.format('YYYY-MM-DD'),
|
||||
weather: values.weather,
|
||||
work_content: values.work_content,
|
||||
photos: '', // 暂时为空,后续添加照片上传功能
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
message.success('日志添加成功');
|
||||
setModalVisible(false);
|
||||
form.resetFields();
|
||||
fetchLogs();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加日志失败:', error);
|
||||
message.error('添加日志失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLog = async (logId: number) => {
|
||||
try {
|
||||
const res = await axios.delete(`/api/construction-logs/${logId}`);
|
||||
if (res.data.success) {
|
||||
message.success('日志删除成功');
|
||||
fetchLogs();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除日志失败:', error);
|
||||
message.error('删除日志失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 按日期分组
|
||||
const groupedLogs = logs.reduce((acc, log) => {
|
||||
const month = dayjs(log.log_date).format('YYYY年MM月');
|
||||
if (!acc[month]) {
|
||||
acc[month] = [];
|
||||
}
|
||||
acc[month].push(log);
|
||||
return acc;
|
||||
}, {} as Record<string, Log[]>);
|
||||
|
||||
const getWeatherLabel = (value: string) => {
|
||||
const option = WEATHER_OPTIONS.find(o => o.value === value);
|
||||
return option ? option.label : value;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
const renderLogCard = (log: Log) => (
|
||||
<Card
|
||||
key={log.id}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
styles={{ body: { padding: isMobile ? 16 : 20 } }}
|
||||
>
|
||||
{/* 日志头部 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<Space>
|
||||
<CalendarOutlined style={{ color: '#1890ff' }} />
|
||||
<Text strong style={{ fontSize: 15 }}>{dayjs(log.log_date).format('MM月DD日')}</Text>
|
||||
<Tag color="blue">{getWeatherLabel(log.weather)}</Tag>
|
||||
</Space>
|
||||
<Space>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>记录人: {log.recorder_name || '未知'}</Text>
|
||||
<Popconfirm
|
||||
title="确定删除此日志?"
|
||||
description="删除后无法恢复"
|
||||
onConfirm={() => handleDeleteLog(log.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger size="small" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 工作内容 */}
|
||||
{log.work_content && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>今日工作:</Text>
|
||||
<Paragraph style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>
|
||||
{log.work_content}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 明日计划 */}
|
||||
{log.next_plan && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>明日计划:</Text>
|
||||
<Paragraph style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>
|
||||
{log.next_plan}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 问题记录 */}
|
||||
{log.issues && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>问题记录:</Text>
|
||||
<Paragraph style={{ margin: '4px 0 0', color: '#fa8c16', whiteSpace: 'pre-wrap' }}>
|
||||
{log.issues}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 照片展示 */}
|
||||
{log.photos && log.photos.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: 'block' }}>
|
||||
施工照片 ({log.photos.length}张):
|
||||
</Text>
|
||||
<Image.PreviewGroup>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{log.photos.map(photo => (
|
||||
<Image
|
||||
key={photo.id}
|
||||
src={photo.photo_url}
|
||||
width={isMobile ? 80 : 100}
|
||||
height={isMobile ? 80 : 100}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
objectFit: 'cover',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
placeholder={
|
||||
<div style={{
|
||||
width: isMobile ? 80 : 100,
|
||||
height: isMobile ? 80 : 100,
|
||||
background: '#f0f0f0',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<CameraOutlined style={{ fontSize: 24, color: '#bfbfbf' }} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: isMobile ? 12 : 24,
|
||||
maxWidth: 800,
|
||||
margin: '0 auto'
|
||||
}}>
|
||||
{/* 页面头部 */}
|
||||
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/construction')}
|
||||
/>
|
||||
<div>
|
||||
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
|
||||
施工日志
|
||||
</Title>
|
||||
{projectInfo && (
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{projectInfo.name}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日志列表 */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<Card style={{ borderRadius: 12 }}>
|
||||
<Empty description="暂无施工日志">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
|
||||
添加第一条日志
|
||||
</Button>
|
||||
</Empty>
|
||||
</Card>
|
||||
) : (
|
||||
<div>
|
||||
{Object.entries(groupedLogs).map(([month, monthLogs]) => (
|
||||
<div key={month}>
|
||||
<Divider orientation="left" style={{ margin: '16px 0' }}>
|
||||
<Text strong style={{ fontSize: 14 }}>{month}</Text>
|
||||
</Divider>
|
||||
{monthLogs.map(log => renderLogCard(log))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部添加按钮 */}
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
zIndex: 100
|
||||
}}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
size="large"
|
||||
onClick={() => setModalVisible(true)}
|
||||
style={{
|
||||
borderRadius: 24,
|
||||
height: 48,
|
||||
paddingLeft: 24,
|
||||
paddingRight: 24,
|
||||
boxShadow: '0 4px 12px rgba(24, 144, 255, 0.4)'
|
||||
}}
|
||||
>
|
||||
新增日志
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 新增日志弹窗 */}
|
||||
<Modal
|
||||
title="新增施工日志"
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
confirmLoading={submitting}
|
||||
okText="提交"
|
||||
cancelText="取消"
|
||||
width={isMobile ? '95%' : 500}
|
||||
style={{ top: 20 }}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
log_date: dayjs(),
|
||||
weather: 'sunny'
|
||||
}}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="log_date"
|
||||
label="日期"
|
||||
rules={[{ required: true, message: '请选择日期' }]}
|
||||
>
|
||||
<DatePicker
|
||||
style={{ width: '100%' }}
|
||||
size="large"
|
||||
disabledDate={(current) => current && current > dayjs().endOf('day')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="weather"
|
||||
label="天气"
|
||||
rules={[{ required: true, message: '请选择天气' }]}
|
||||
>
|
||||
<Select size="large">
|
||||
{WEATHER_OPTIONS.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
name="work_content"
|
||||
label="今日工作"
|
||||
rules={[{ required: true, message: '请填写今日工作内容' }]}
|
||||
>
|
||||
<TextArea
|
||||
rows={3}
|
||||
placeholder="描述今日完成的施工工作..."
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="next_plan" label="明日计划">
|
||||
<TextArea
|
||||
rows={2}
|
||||
placeholder="明日工作计划..."
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="issues" label="问题记录">
|
||||
<TextArea
|
||||
rows={2}
|
||||
placeholder="遇到的问题或需要协调的事项..."
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="上传照片">
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
multiple
|
||||
maxCount={9}
|
||||
accept="image/*"
|
||||
beforeUpload={() => false}
|
||||
>
|
||||
<div>
|
||||
<CameraOutlined style={{ fontSize: 20 }} />
|
||||
<div style={{ marginTop: 4, fontSize: 12 }}>添加照片</div>
|
||||
</div>
|
||||
</Upload>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
支持上传多张照片,最多9张
|
||||
</Text>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConstructionLog;
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card, Typography, Button, Space, Tag, Spin, Empty, Timeline, Progress, Divider
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined, CheckCircleOutlined, ClockCircleOutlined,
|
||||
SyncOutlined, CloseCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
// 节点状态配置
|
||||
const STATUS_CONFIG: Record<string, {
|
||||
color: string;
|
||||
text: string;
|
||||
icon: React.ReactNode;
|
||||
timelineColor: string;
|
||||
}> = {
|
||||
pending: {
|
||||
color: 'default',
|
||||
text: '待开始',
|
||||
icon: <ClockCircleOutlined />,
|
||||
timelineColor: 'gray'
|
||||
},
|
||||
in_progress: {
|
||||
color: 'processing',
|
||||
text: '进行中',
|
||||
icon: <SyncOutlined spin />,
|
||||
timelineColor: 'blue'
|
||||
},
|
||||
completed: {
|
||||
color: 'success',
|
||||
text: '已完成',
|
||||
icon: <CheckCircleOutlined />,
|
||||
timelineColor: 'green'
|
||||
},
|
||||
cancelled: {
|
||||
color: 'error',
|
||||
text: '已取消',
|
||||
icon: <CloseCircleOutlined />,
|
||||
timelineColor: 'red'
|
||||
},
|
||||
};
|
||||
|
||||
interface Milestone {
|
||||
id: number;
|
||||
node_name: string;
|
||||
node_type: string;
|
||||
status: string;
|
||||
due_date: string;
|
||||
trigger_condition: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const ConstructionMilestones: React.FC = () => {
|
||||
const { id: projectId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
||||
const [projectInfo, setProjectInfo] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetchMilestones();
|
||||
fetchProjectInfo();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const fetchMilestones = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get(`/api/construction/projects/${projectId}/milestones`);
|
||||
if (res.data.success) {
|
||||
setMilestones(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取节点列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProjectInfo = async () => {
|
||||
try {
|
||||
const res = await axios.get(`/api/projects/${projectId}`);
|
||||
if (res.data.success) {
|
||||
setProjectInfo(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目信息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 计算进度
|
||||
const completedCount = milestones.filter(m => m.status === 'completed').length;
|
||||
const totalCount = milestones.length;
|
||||
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
|
||||
|
||||
const renderTimelineItem = (milestone: Milestone, index: number) => {
|
||||
const statusConfig = STATUS_CONFIG[milestone.status] || STATUS_CONFIG.pending;
|
||||
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={milestone.id}
|
||||
color={statusConfig.timelineColor}
|
||||
dot={
|
||||
<span style={{ fontSize: 16 }}>
|
||||
{statusConfig.icon}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
background: milestone.status === 'completed' ? '#f6ffed' : '#fff',
|
||||
}}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Text strong style={{ fontSize: 14 }}>{milestone.node_name}</Text>
|
||||
{milestone.trigger_condition && (
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
style={{ margin: '4px 0 0', fontSize: 12 }}
|
||||
>
|
||||
{milestone.trigger_condition}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<Tag color={statusConfig.color} icon={statusConfig.icon}>
|
||||
{statusConfig.text}
|
||||
</Tag>
|
||||
</div>
|
||||
{milestone.due_date && (
|
||||
<Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
||||
计划完成: {dayjs(milestone.due_date).format('YYYY-MM-DD')}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</Timeline.Item>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: isMobile ? 12 : 24,
|
||||
maxWidth: 800,
|
||||
margin: '0 auto'
|
||||
}}>
|
||||
{/* 页面头部 */}
|
||||
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/construction')}
|
||||
/>
|
||||
<div>
|
||||
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
|
||||
节点进度
|
||||
</Title>
|
||||
{projectInfo && (
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{projectInfo.name}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度概览 */}
|
||||
{!loading && milestones.length > 0 && (
|
||||
<Card style={{ marginBottom: 16, borderRadius: 12 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<Text type="secondary">整体进度</Text>
|
||||
<Title level={2} style={{ margin: '8px 0 0' }}>{progressPercent}%</Title>
|
||||
</div>
|
||||
<Progress
|
||||
percent={progressPercent}
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 24, marginTop: 16 }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text strong style={{ fontSize: 20 }}>{completedCount}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>已完成</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text strong style={{ fontSize: 20 }}>{totalCount - completedCount}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>进行中</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text strong style={{ fontSize: 20 }}>{totalCount}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>总节点</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 节点时间线 */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : milestones.length === 0 ? (
|
||||
<Card style={{ borderRadius: 12 }}>
|
||||
<Empty description="暂无施工节点">
|
||||
<Text type="secondary">节点由项目经理在项目设置中配置</Text>
|
||||
</Empty>
|
||||
</Card>
|
||||
) : (
|
||||
<Card style={{ borderRadius: 12 }}>
|
||||
<Timeline style={{ marginTop: 16 }}>
|
||||
{milestones.map((milestone, index) => renderTimelineItem(milestone, index))}
|
||||
</Timeline>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConstructionMilestones;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as ConstructionList } from "./ConstructionList";
|
||||
export { default as ConstructionLog } from "./ConstructionLog";
|
||||
export { default as ConstructionMilestones } from "./ConstructionMilestones";
|
||||
export { default } from "./ConstructionList";
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Col, Row, Statistic, Table, Typography, Tag } from 'antd';
|
||||
import {
|
||||
ProjectOutlined,
|
||||
DollarOutlined,
|
||||
FileTextOutlined,
|
||||
TeamOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
// 模拟数据
|
||||
const projectData = [
|
||||
{ key: '1', name: '项目 A', status: '进行中', budget: 500000, spent: 250000 },
|
||||
{ key: '2', name: '项目 B', status: '已完成', budget: 300000, spent: 280000 },
|
||||
{ key: '3', name: '项目 C', status: '规划中', budget: 800000, spent: 0 },
|
||||
];
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
// 桌面端表格列
|
||||
const desktopColumns = [
|
||||
{ title: '项目名称', dataIndex: 'name', key: 'name' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'进行中': 'blue',
|
||||
'已完成': 'green',
|
||||
'规划中': 'orange',
|
||||
};
|
||||
return <Tag color={colorMap[status] || 'default'}>{status}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '预算',
|
||||
dataIndex: 'budget',
|
||||
key: 'budget',
|
||||
render: (value: number) => `¥${value.toLocaleString()}`
|
||||
},
|
||||
{
|
||||
title: '已花费',
|
||||
dataIndex: 'spent',
|
||||
key: 'spent',
|
||||
render: (value: number) => `¥${value.toLocaleString()}`
|
||||
}
|
||||
];
|
||||
|
||||
// 移动端简化表格列
|
||||
const mobileColumns = [
|
||||
{ title: '项目', dataIndex: 'name', key: 'name', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={status === '已完成' ? 'green' : 'blue'} style={{ fontSize: 10 }}>
|
||||
{status}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '预算/花费',
|
||||
key: 'budget_spent',
|
||||
render: (_: any, record: any) => (
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<div>预算:¥{(record.budget / 10000).toFixed(0)}万</div>
|
||||
<div style={{ color: '#888' }}>已花:¥{(record.spent / 10000).toFixed(0)}万</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
📊 工作台
|
||||
</Title>
|
||||
|
||||
{/* 统计卡片 - 移动端优化 */}
|
||||
<Row gutter={[8, 8]} style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: 12 }}>进行中项目</span>}
|
||||
value={12}
|
||||
prefix={<ProjectOutlined />}
|
||||
valueStyle={{ color: '#1890ff', fontSize: isMobile ? 18 : undefined }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: 12 }}>本月报销</span>}
|
||||
value={85600}
|
||||
prefix={<DollarOutlined />}
|
||||
valueStyle={{ color: '#52c41a', fontSize: isMobile ? 18 : undefined }}
|
||||
suffix="元"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: 12 }}>待审批</span>}
|
||||
value={5}
|
||||
prefix={<FileTextOutlined />}
|
||||
valueStyle={{ color: '#faad14', fontSize: isMobile ? 18 : undefined }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: 12 }}>团队成员</span>}
|
||||
value={28}
|
||||
prefix={<TeamOutlined />}
|
||||
valueStyle={{ color: '#722ed1', fontSize: isMobile ? 18 : undefined }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 项目列表 */}
|
||||
<Card
|
||||
title="最近项目"
|
||||
size="small"
|
||||
styles={{ body: { padding: isMobile ? 8 : 24 } }}
|
||||
>
|
||||
<Table
|
||||
columns={isMobile ? mobileColumns : desktopColumns}
|
||||
dataSource={projectData}
|
||||
pagination={false}
|
||||
scroll={isMobile ? { x: 400 } : undefined}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -0,0 +1,227 @@
|
||||
import React from 'react';
|
||||
import { Card, Typography, Table, Statistic, Row, Col, Tag } from 'antd';
|
||||
import { DollarOutlined, FileTextOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
const FinancePage: React.FC = () => {
|
||||
// 统计数据
|
||||
const stats = [
|
||||
{
|
||||
title: '本月总收入',
|
||||
value: 125000,
|
||||
prefix: '¥',
|
||||
icon: <DollarOutlined />,
|
||||
trend: '+12%',
|
||||
color: '#3f8600',
|
||||
},
|
||||
{
|
||||
title: '本月总支出',
|
||||
value: 68000,
|
||||
prefix: '¥',
|
||||
icon: <FileTextOutlined />,
|
||||
trend: '-5%',
|
||||
color: '#cf1322',
|
||||
},
|
||||
{
|
||||
title: '待审批报销',
|
||||
value: 15000,
|
||||
prefix: '¥',
|
||||
icon: <CheckCircleOutlined />,
|
||||
trend: '+3%',
|
||||
color: '#1890ff',
|
||||
},
|
||||
];
|
||||
|
||||
// 财务记录数据
|
||||
const dataSource = [
|
||||
{
|
||||
key: '1',
|
||||
date: '2026-03-10',
|
||||
type: '收入',
|
||||
category: '项目回款',
|
||||
project: '项目 A',
|
||||
amount: 50000,
|
||||
status: '已入账',
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
date: '2026-03-09',
|
||||
type: '支出',
|
||||
category: '报销',
|
||||
project: '项目 B',
|
||||
amount: 8000,
|
||||
status: '已付款',
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
date: '2026-03-08',
|
||||
type: '支出',
|
||||
category: '预支',
|
||||
project: '项目 C',
|
||||
amount: 5000,
|
||||
status: '已付款',
|
||||
},
|
||||
{
|
||||
key: '4',
|
||||
date: '2026-03-07',
|
||||
type: '收入',
|
||||
category: '项目回款',
|
||||
project: '项目 D',
|
||||
amount: 75000,
|
||||
status: '已入账',
|
||||
},
|
||||
];
|
||||
|
||||
// 桌面端表格列
|
||||
const desktopColumns = [
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'date',
|
||||
key: 'date',
|
||||
sorter: (a: any, b: any) => a.date.localeCompare(b.date),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
render: (type: string) => (
|
||||
<span style={{ color: type === '收入' ? 'green' : 'red' }}>
|
||||
{type === '收入' ? '↑ 收入' : '↓ 支出'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类别',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
},
|
||||
{
|
||||
title: '项目',
|
||||
dataIndex: 'project',
|
||||
key: 'project',
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
render: (amount: number) => `¥${amount.toLocaleString()}`,
|
||||
sorter: (a: any, b: any) => a.amount - b.amount,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'已入账': 'green',
|
||||
'已付款': 'blue',
|
||||
'处理中': 'orange',
|
||||
};
|
||||
return <Tag color={colorMap[status] || 'default'}>{status}</Tag>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 移动端简化表格列
|
||||
const mobileColumns = [
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'date',
|
||||
key: 'date',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 60,
|
||||
render: (type: string) => (
|
||||
<span style={{ color: type === '收入' ? 'green' : 'red', fontSize: 12 }}>
|
||||
{type === '收入' ? '↑' : '↓'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
render: (amount: number) => (
|
||||
<div style={{ fontWeight: 'bold' }}>¥{amount.toLocaleString()}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={status === '已入账' ? 'green' : 'blue'} style={{ fontSize: 10 }}>
|
||||
{status}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const [isMobile, setIsMobile] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>财务管理</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
查看公司财务状况、收支明细
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 - 移动端优化 */}
|
||||
<Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
|
||||
{stats.map((stat, index) => (
|
||||
<Col xs={24} sm={8} key={index}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: 12 }}>{stat.title}</span>}
|
||||
value={stat.value}
|
||||
prefix={stat.prefix}
|
||||
suffix={stat.trend}
|
||||
valueStyle={{
|
||||
color: stat.color,
|
||||
fontSize: isMobile ? 18 : undefined
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* 财务明细表 */}
|
||||
<Card
|
||||
title="财务明细"
|
||||
size="small"
|
||||
styles={{ body: { padding: isMobile ? 8 : 24 } }}
|
||||
>
|
||||
<Table
|
||||
dataSource={dataSource}
|
||||
columns={isMobile ? mobileColumns : desktopColumns}
|
||||
pagination={{
|
||||
pageSize: 5,
|
||||
size: isMobile ? 'small' : 'default'
|
||||
}}
|
||||
scroll={isMobile ? { x: 500 } : undefined}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinancePage;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Typography, Button, Space, Table, Tag, message, Spin, Modal, Input } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
interface Project {
|
||||
id: number
|
||||
project_code: string
|
||||
name: string
|
||||
customer_id: number
|
||||
customer_name?: string
|
||||
status: string
|
||||
budget: string
|
||||
spent: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
description: string
|
||||
manager_name?: string
|
||||
progress?: number
|
||||
}
|
||||
|
||||
const ProjectsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [deleteProjectId, setDeleteProjectId] = useState<number | null>(null);
|
||||
const [deletePassword, setDeletePassword] = useState('');
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const isAdmin = currentUser?.role === 'admin' || false;
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/projects');
|
||||
if (response.data.success) {
|
||||
setProjects(response.data.data.map((p: Project) => ({
|
||||
...p,
|
||||
key: p.id.toString(),
|
||||
progress: Math.floor(Math.random() * 100), // 临时模拟进度
|
||||
manager_name: p.manager_name || '未分配'
|
||||
})));
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取项目列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理删除项目
|
||||
const handleDeleteProject = (projectId: number) => {
|
||||
setDeleteProjectId(projectId);
|
||||
setDeletePassword('');
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
// 确认删除项目
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteProjectId) return;
|
||||
|
||||
// 验证密码
|
||||
if (deletePassword !== 'X123c321@') {
|
||||
message.error('密码错误');
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
const response = await axios.delete(`/api/projects/${deleteProjectId}`, {
|
||||
headers: {
|
||||
'x-user-role': 'admin'
|
||||
}
|
||||
});
|
||||
if (response.data.success) {
|
||||
message.success('项目删除成功');
|
||||
setDeleteModalVisible(false);
|
||||
fetchProjects();
|
||||
} else {
|
||||
message.error(response.data.message || '删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除项目失败');
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 桌面端表格列
|
||||
const desktopColumns = [
|
||||
{
|
||||
title: '项目名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 250,
|
||||
ellipsis: true,
|
||||
render: (text: string, record: Project) => (
|
||||
<a onClick={() => navigate(`/projects/${record.id}`)} style={{ cursor: 'pointer' }}>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '项目经理',
|
||||
dataIndex: 'manager_name',
|
||||
key: 'manager_name',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '预算',
|
||||
dataIndex: 'budget',
|
||||
key: 'budget',
|
||||
width: 120,
|
||||
render: (amount: string) => {
|
||||
const val = parseFloat(amount || '0');
|
||||
return val > 0 ? `¥${(val / 10000).toFixed(1)}万` : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '进度',
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
width: 120,
|
||||
render: (progress: number) => (
|
||||
<div style={{ width: 100 }}>
|
||||
<div style={{ background: '#f0f0f0', borderRadius: 10, height: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
background: progress > 80 ? '#52c41a' : progress > 50 ? '#1890ff' : '#faad14',
|
||||
borderRadius: 10,
|
||||
height: 8,
|
||||
width: `${progress}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: '#888' }}>{progress}%</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
planning: { color: 'blue', text: '规划中' },
|
||||
in_progress: { color: 'processing', text: '进行中' },
|
||||
completed: { color: 'success', text: '已完成' },
|
||||
suspended: { color: 'warning', text: '已暂停' },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
render: (_: unknown, record: Project) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => navigate(`/projects/${record.id}`)}>查看</Button>
|
||||
<Button size="small">编辑</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDeleteProject(record.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 移动端简化表格列
|
||||
const mobileColumns = [
|
||||
{
|
||||
title: '项目',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
ellipsis: true,
|
||||
render: (text: string, record: Project) => (
|
||||
<a onClick={() => navigate(`/projects/${record.id}`)} style={{ cursor: 'pointer' }}>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '进度',
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
width: 80,
|
||||
render: (progress: number) => (
|
||||
<div style={{ width: 60 }}>
|
||||
<div style={{ background: '#f0f0f0', borderRadius: 4, height: 6 }}>
|
||||
<div
|
||||
style={{
|
||||
background: progress > 80 ? '#52c41a' : progress > 50 ? '#1890ff' : '#faad14',
|
||||
borderRadius: 4,
|
||||
height: 6,
|
||||
width: `${progress}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ fontSize: 10, color: '#888' }}>{progress}%</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 70,
|
||||
render: (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
planning: { color: 'blue', text: '规划' },
|
||||
in_progress: { color: 'processing', text: '进行中' },
|
||||
completed: { color: 'success', text: '完成' },
|
||||
suspended: { color: 'warning', text: '暂停' },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color} style={{ fontSize: 10 }}>{config.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 60,
|
||||
render: (_: unknown, record: Project) => (
|
||||
<Button size="small" onClick={() => navigate(`/projects/${record.id}`)}>查看</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>项目管理</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
项目由预算报价签约后自动创建,管理您的项目信息、进度和预算
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title="项目列表"
|
||||
size="small"
|
||||
styles={{ body: { padding: isMobile ? 8 : 24 } }}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
dataSource={projects}
|
||||
columns={isMobile ? mobileColumns : desktopColumns}
|
||||
pagination={{
|
||||
pageSize: isMobile ? 5 : 10,
|
||||
size: isMobile ? 'small' : 'default'
|
||||
}}
|
||||
scroll={isMobile ? { x: 350 } : undefined}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 删除确认模态框 */}
|
||||
<Modal
|
||||
title="删除确认"
|
||||
open={deleteModalVisible}
|
||||
onOk={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteModalVisible(false)}
|
||||
confirmLoading={deleteLoading}
|
||||
okText="确认删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<p>确定要删除这个项目吗?此操作不可恢复。</p>
|
||||
<p>请输入管理员密码确认删除操作:</p>
|
||||
</div>
|
||||
<Input.Password
|
||||
placeholder="请输入管理员密码"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
size="large"
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectsPage;
|
||||
@@ -0,0 +1,512 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Card, Space, Tag, Modal, Form, Input, InputNumber, Select, DatePicker, message, Descriptions, Image, Divider } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, UndoOutlined, PlusCircleOutlined, MinusCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import FileUpload from '../../components/FileUpload';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface DetailItem {
|
||||
id?: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
category: string;
|
||||
attachments?: string[];
|
||||
}
|
||||
|
||||
const ReimbursementsPage: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const [reimbursements, setReimbursements] = useState<any[]>([]);
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [selectedRecord, setSelectedRecord] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [deleteForm] = Form.useForm();
|
||||
const [currentEditingStatus, setCurrentEditingStatus] = useState<string>('');
|
||||
const [detailItems, setDetailItems] = useState<DetailItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReimbursements();
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
const fetchReimbursements = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/reimbursements');
|
||||
const data = await res.json();
|
||||
if (data.success) setReimbursements(data.data);
|
||||
} catch (error) {
|
||||
message.error('获取报销列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/projects');
|
||||
const data = await res.json();
|
||||
if (data.success) setProjects(data.data);
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingId(null);
|
||||
setDetailItems([]);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
reimbursement_date: dayjs(),
|
||||
currency: 'CNY',
|
||||
expense_type: 'company',
|
||||
applicant: user?.name || user?.username || '当前用户',
|
||||
attachments: []
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: any) => {
|
||||
setEditingId(record.id);
|
||||
setCurrentEditingStatus(record.status);
|
||||
setDetailItems(record.detail_items || []);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
reimbursement_date: record.reimbursement_date ? dayjs(record.reimbursement_date) : null,
|
||||
attachments: record.attachments || []
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleView = (record: any) => {
|
||||
setSelectedRecord(record);
|
||||
setDetailModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
// 重置删除表单
|
||||
deleteForm.resetFields();
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: (
|
||||
<Form form={deleteForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="请输入密码确认删除"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password placeholder="输入密码" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
onOk: async () => {
|
||||
try {
|
||||
const values = await deleteForm.validateFields();
|
||||
// 这里可以添加密码验证逻辑,暂时直接删除
|
||||
await fetch('/api/reimbursements/' + id, { method: 'DELETE' });
|
||||
message.success('删除成功');
|
||||
fetchReimbursements();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleWithdraw = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认撤回',
|
||||
content: '撤回后可重新编辑提交,确认撤回吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await fetch('/api/reimbursements/' + id + '/withdraw', { method: 'POST' });
|
||||
message.success('已撤回,可重新编辑');
|
||||
fetchReimbursements();
|
||||
} catch (error) {
|
||||
message.error('撤回失败');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 保存操作:只保存信息,不改变状态
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
// 保存时使用编辑时的状态
|
||||
const saveStatus = currentEditingStatus || 'pending_edit';
|
||||
const data = {
|
||||
...values,
|
||||
reimbursement_date: values.reimbursement_date?.format('YYYY-MM-DD'),
|
||||
detail_items: detailItems,
|
||||
amount: detailItems.reduce((sum, item) => sum + (item.amount || 0), 0),
|
||||
applicant: user?.name || user?.username,
|
||||
status: saveStatus
|
||||
};
|
||||
const url = editingId ? '/api/reimbursements/' + editingId : '/api/reimbursements';
|
||||
const method = editingId ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
message.success(editingId ? '保存成功' : '创建成功');
|
||||
setModalVisible(false);
|
||||
fetchReimbursements();
|
||||
} else {
|
||||
message.error(result.error || '保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 提交操作:提交到待审批状态
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
// 提交时使用pending状态
|
||||
const saveStatus = 'pending';
|
||||
const data = {
|
||||
...values,
|
||||
reimbursement_date: values.reimbursement_date?.format('YYYY-MM-DD'),
|
||||
detail_items: detailItems,
|
||||
amount: detailItems.reduce((sum, item) => sum + (item.amount || 0), 0),
|
||||
applicant: user?.name || user?.username,
|
||||
status: saveStatus
|
||||
};
|
||||
const url = editingId ? '/api/reimbursements/' + editingId : '/api/reimbursements';
|
||||
const method = editingId ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
message.success(editingId ? '提交成功' : '创建成功');
|
||||
setModalVisible(false);
|
||||
fetchReimbursements();
|
||||
} else {
|
||||
message.error(result.error || '提交失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('提交失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitAndSubmit = async () => {
|
||||
await handleSubmit();
|
||||
};
|
||||
|
||||
const addDetailItem = () => {
|
||||
setDetailItems([...detailItems, { description: '', amount: 0, category: '', attachments: [] }]);
|
||||
};
|
||||
|
||||
const updateDetailItem = (index: number, field: keyof DetailItem, value: any) => {
|
||||
const newItems = [...detailItems];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
setDetailItems(newItems);
|
||||
};
|
||||
|
||||
const removeDetailItem = (index: number) => {
|
||||
setDetailItems(detailItems.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'processing', text: '待审批' },
|
||||
approved: { color: 'success', text: '已批准' },
|
||||
rejected: { color: 'error', text: '已退回' },
|
||||
withdrawn: { color: 'default', text: '已撤回' },
|
||||
paid: { color: 'blue', text: '已付款' },
|
||||
pending_edit: { color: 'warning', text: '待编辑' },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number, currency: string = 'CNY') => {
|
||||
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
|
||||
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
|
||||
<>
|
||||
<div>{formatAmount(v, r.currency)}</div>
|
||||
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}>≈ ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
|
||||
</>
|
||||
) },
|
||||
{ title: '报销日期', dataIndex: 'reimbursement_date', key: 'reimbursement_date', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
|
||||
{ title: '编号', dataIndex: 'reimbursement_code', key: 'reimbursement_code', width: 120 },
|
||||
{
|
||||
title: '操作', key: 'action', width: 250,
|
||||
render: (_: any, record: any) => (
|
||||
<Space wrap>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(record)}>详情</Button>
|
||||
{record.status === 'pending' && (
|
||||
<>
|
||||
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}>撤回</Button>
|
||||
</>
|
||||
)}
|
||||
{(record.status === 'rejected' || record.status === 'withdrawn' || record.status === 'pending_edit') && (
|
||||
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑重提</Button>
|
||||
)}
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>删除</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const currency = Form.useWatch('currency', form);
|
||||
const expenseType = Form.useWatch('expense_type', form);
|
||||
const totalAmount = detailItems.reduce((sum, item) => sum + (item.amount || 0), 0);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ marginBottom: 8 }}>报销申请</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>管理费用报销申请</p>
|
||||
</div>
|
||||
|
||||
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建报销</Button>}>
|
||||
<Table dataSource={reimbursements} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1100 }} />
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingId ? '编辑报销' : '新建报销'}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setModalVisible(false)}>取消</Button>,
|
||||
<Button key="save" onClick={handleSave}>保存</Button>,
|
||||
<Button key="submit" type="primary" onClick={handleSubmitAndSubmit}>提交</Button>
|
||||
]}
|
||||
width={900}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="applicant" label="申请人">
|
||||
<Input disabled style={{ color: 'rgba(0,0,0,0.85)', backgroundColor: '#f5f5f5' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="reimbursement_date" label="报销日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="currency" label="币种" rules={[{ required: true }]}>
|
||||
<Select style={{ width: 200 }}>
|
||||
<Option value="CNY">人民币 (CNY)</Option>
|
||||
<Option value="USD">美元 (USD)</Option>
|
||||
<Option value="LAK">老挝基普 (LAK)</Option>
|
||||
<Option value="THB">泰铢 (THB)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="expense_type" label="支出类型" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择支出类型" onChange={() => form.setFieldsValue({ project_id: undefined })}>
|
||||
<Option value="company">公司支出</Option>
|
||||
<Option value="project">项目支出</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{expenseType === 'project' && (
|
||||
<Form.Item name="project_id" label="选择项目" rules={[{ required: true, message: '请选择项目' }]}>
|
||||
<Select placeholder="选择项目" showSearch optionFilterProp="children">
|
||||
{projects.map((p: any) => <Option key={p.id} value={p.id}>{p.name}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item name="reason" label="事由" rules={[{ required: true }]}>
|
||||
<TextArea rows={2} placeholder="请输入报销事由" />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>报销明细</Divider>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button type="dashed" icon={<PlusCircleOutlined />} onClick={addDetailItem}>添加明细</Button>
|
||||
<span style={{ marginLeft: 16, color: '#888' }}>
|
||||
合计: {formatAmount(totalAmount, currency)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{detailItems.map((item, index) => (
|
||||
<Card key={index} size="small" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}>费用说明</label>
|
||||
<Input
|
||||
value={item.description}
|
||||
onChange={(e) => updateDetailItem(index, 'description', e.target.value)}
|
||||
placeholder="费用说明"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 180 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}>支出分类</label>
|
||||
<Select
|
||||
value={item.category}
|
||||
onChange={(v) => updateDetailItem(index, 'category', v)}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="选择支出分类"
|
||||
>
|
||||
{expenseType === 'project' ? (
|
||||
<>
|
||||
<Option value="accommodation">住宿</Option>
|
||||
<Option value="food">餐饮</Option>
|
||||
<Option value="fuel">加油</Option>
|
||||
<Option value="materials">零散材料</Option>
|
||||
<Option value="customer_relations">客户关系</Option>
|
||||
<Option value="subcontract_relations">分包关系</Option>
|
||||
<Option value="edl_relations">EDL关系</Option>
|
||||
<Option value="extra_construction">额外施工</Option>
|
||||
<Option value="other">其他</Option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Option value="general_operations">通用运营(房租/耗材)</Option>
|
||||
<Option value="transportation">交通通勤</Option>
|
||||
<Option value="business_expansion">业扩营销</Option>
|
||||
<Option value="power_system_relations">电力系统关系</Option>
|
||||
<Option value="employee_benefits">员工福利</Option>
|
||||
<Option value="express_logistics">快递物流</Option>
|
||||
<Option value="other">其他</Option>
|
||||
</>
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
<div style={{ width: 150 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}>金额</label>
|
||||
<InputNumber
|
||||
value={item.amount}
|
||||
onChange={(v) => updateDetailItem(index, 'amount', v)}
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="金额"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 2, minWidth: 300 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, color: '#666' }}>凭证附件</label>
|
||||
<FileUpload
|
||||
value={item.attachments || []}
|
||||
onChange={(urls) => updateDetailItem(index, 'attachments', urls)}
|
||||
maxCount={3}
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
<Button type="text" danger icon={<MinusCircleOutlined />} onClick={() => removeDetailItem(index)} style={{ marginTop: 24 }} />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Divider>主附件</Divider>
|
||||
<Form.Item name="attachments" label="整体凭证附件">
|
||||
<FileUpload
|
||||
value={form.getFieldValue('attachments')}
|
||||
onChange={(urls) => form.setFieldsValue({ attachments: urls })}
|
||||
maxCount={9}
|
||||
accept="image/*"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="报销详情" open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={null} width={900}>
|
||||
{selectedRecord && (
|
||||
<>
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="报销编号">{selectedRecord.reimbursement_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label="报销日期">{selectedRecord.reimbursement_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="币种">{selectedRecord.currency}</Descriptions.Item>
|
||||
<Descriptions.Item label="支出类型">{selectedRecord.expense_type === 'project' ? '项目支出' : '公司支出'}</Descriptions.Item>
|
||||
{selectedRecord.project_id && (
|
||||
<Descriptions.Item label="关联项目" span={2}>
|
||||
{projects.find(p => p.id === selectedRecord.project_id)?.name || '未知项目'}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="金额">
|
||||
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
|
||||
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>≈ ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{Array.isArray(selectedRecord.detail_items) && selectedRecord.detail_items.length > 0 && (
|
||||
<>
|
||||
<Divider>报销明细</Divider>
|
||||
<Table
|
||||
dataSource={selectedRecord.detail_items}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '费用说明', dataIndex: 'description', key: 'description' },
|
||||
{
|
||||
title: '支出分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
render: (v: string) => {
|
||||
const categoryMap: Record<string, string> = {
|
||||
// Project expense categories
|
||||
accommodation: '住宿',
|
||||
food: '餐饮',
|
||||
fuel: '加油',
|
||||
materials: '零散材料',
|
||||
customer_relations: '客户关系',
|
||||
subcontract_relations: '分包关系',
|
||||
edl_relations: 'EDL关系',
|
||||
extra_construction: '额外施工',
|
||||
// Company expense categories
|
||||
general_operations: '通用运营(房租/耗材)',
|
||||
transportation: '交通通勤',
|
||||
business_expansion: '业扩营销',
|
||||
power_system_relations: '电力系统关系',
|
||||
employee_benefits: '员工福利',
|
||||
express_logistics: '快递物流',
|
||||
other: '其他'
|
||||
};
|
||||
return categoryMap[v] || v;
|
||||
}
|
||||
},
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number) => formatAmount(v, selectedRecord.currency) },
|
||||
{ title: '附件', dataIndex: 'attachments', key: 'attachments', render: (v: string[]) => v?.length ? `${v.length}张` : '-' }
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{Array.isArray(selectedRecord.attachments) && selectedRecord.attachments.length > 0 && (
|
||||
<>
|
||||
<Divider>整体凭证附件</Divider>
|
||||
<Image.PreviewGroup>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{selectedRecord.attachments.map((url: string, index: number) => (
|
||||
<Image key={index} src={url} width={100} height={100} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReimbursementsPage;
|
||||
@@ -0,0 +1,202 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Typography, Table, DatePicker, Button, Row, Col, Tag } from 'antd';
|
||||
import { DownloadOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
const ReportsPage: React.FC = () => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
// 报表数据
|
||||
const dataSource = [
|
||||
{
|
||||
key: '1',
|
||||
month: '2026-02',
|
||||
income: 250000,
|
||||
expense: 180000,
|
||||
profit: 70000,
|
||||
projects: 5,
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
month: '2026-01',
|
||||
income: 220000,
|
||||
expense: 165000,
|
||||
profit: 55000,
|
||||
projects: 4,
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
month: '2025-12',
|
||||
income: 280000,
|
||||
expense: 195000,
|
||||
profit: 85000,
|
||||
projects: 6,
|
||||
},
|
||||
];
|
||||
|
||||
// 桌面端表格列
|
||||
const desktopColumns = [
|
||||
{
|
||||
title: '月份',
|
||||
dataIndex: 'month',
|
||||
key: 'month',
|
||||
},
|
||||
{
|
||||
title: '总收入',
|
||||
dataIndex: 'income',
|
||||
key: 'income',
|
||||
render: (amount: number) => `¥${amount.toLocaleString()}`,
|
||||
},
|
||||
{
|
||||
title: '总支出',
|
||||
dataIndex: 'expense',
|
||||
key: 'expense',
|
||||
render: (amount: number) => `¥${amount.toLocaleString()}`,
|
||||
},
|
||||
{
|
||||
title: '净利润',
|
||||
dataIndex: 'profit',
|
||||
key: 'profit',
|
||||
render: (amount: number) => (
|
||||
<span style={{ color: amount > 0 ? 'green' : 'red' }}>
|
||||
¥{amount.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '项目数量',
|
||||
dataIndex: 'projects',
|
||||
key: 'projects',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: () => (
|
||||
<Button size="small" icon={<DownloadOutlined />}>导出</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 移动端简化表格列
|
||||
const mobileColumns = [
|
||||
{
|
||||
title: '月份',
|
||||
dataIndex: 'month',
|
||||
key: 'month',
|
||||
render: (month: string) => month.replace('-', '/'),
|
||||
},
|
||||
{
|
||||
title: '收入',
|
||||
dataIndex: 'income',
|
||||
key: 'income',
|
||||
render: (amount: number) => (
|
||||
<div style={{ color: 'green' }}>¥{(amount / 10000).toFixed(0)}万</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '支出',
|
||||
dataIndex: 'expense',
|
||||
key: 'expense',
|
||||
render: (amount: number) => (
|
||||
<div style={{ color: 'red' }}>¥{(amount / 10000).toFixed(0)}万</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '利润',
|
||||
dataIndex: 'profit',
|
||||
key: 'profit',
|
||||
render: (amount: number) => (
|
||||
<div style={{ fontWeight: 'bold', color: amount > 0 ? 'green' : 'red' }}>
|
||||
¥{(amount / 10000).toFixed(0)}万
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>统计报表</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
查看项目财务报表和统计分析数据
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title="月度财务报表"
|
||||
size="small"
|
||||
styles={{ body: { padding: isMobile ? 8 : 24 } }}
|
||||
extra={
|
||||
isMobile ? (
|
||||
<Button size="small" icon={<DownloadOutlined />} />
|
||||
) : (
|
||||
<DatePicker picker="month" style={{ marginRight: 8 }} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Table
|
||||
dataSource={dataSource}
|
||||
columns={isMobile ? mobileColumns : desktopColumns}
|
||||
pagination={false}
|
||||
scroll={isMobile ? { x: 350 } : undefined}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
summary={(pageData) => {
|
||||
let totalIncome = 0;
|
||||
let totalExpense = 0;
|
||||
let totalProfit = 0;
|
||||
let totalProjects = 0;
|
||||
|
||||
pageData.forEach(({ income, expense, profit, projects }) => {
|
||||
totalIncome += income;
|
||||
totalExpense += expense;
|
||||
totalProfit += profit;
|
||||
totalProjects += projects;
|
||||
});
|
||||
|
||||
return (
|
||||
<Table.Summary fixed>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0}>
|
||||
<strong>合计</strong>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1}>
|
||||
<strong>¥{(totalIncome / 10000).toFixed(0)}万</strong>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2}>
|
||||
<strong>¥{(totalExpense / 10000).toFixed(0)}万</strong>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={3}>
|
||||
<strong style={{ color: totalProfit > 0 ? 'green' : 'red' }}>
|
||||
¥{(totalProfit / 10000).toFixed(0)}万
|
||||
</strong>
|
||||
</Table.Summary.Cell>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Table.Summary.Cell index={4}>
|
||||
<strong>{totalProjects}</strong>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={5} />
|
||||
</>
|
||||
)}
|
||||
</Table.Summary.Row>
|
||||
</Table.Summary>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportsPage;
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Button, Table, message } from 'antd';
|
||||
|
||||
const TestPage: React.FC = () => {
|
||||
const [advances, setAdvances] = useState<any[]>([]);
|
||||
const [reimbursements, setReimbursements] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchAdvances = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('http://localhost:3005/api/advances');
|
||||
console.log('Advances response:', res);
|
||||
const data = await res.json();
|
||||
console.log('Advances data:', data);
|
||||
if (data.success) {
|
||||
setAdvances(data.data);
|
||||
message.success(`获取到 ${data.data.length} 条预支申请`);
|
||||
} else {
|
||||
message.error('获取预支申请失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching advances:', error);
|
||||
message.error('获取预支申请失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchReimbursements = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('http://localhost:3005/api/reimbursements');
|
||||
console.log('Reimbursements response:', res);
|
||||
const data = await res.json();
|
||||
console.log('Reimbursements data:', data);
|
||||
if (data.success) {
|
||||
setReimbursements(data.data);
|
||||
message.success(`获取到 ${data.data.length} 条报销申请`);
|
||||
} else {
|
||||
message.error('获取报销申请失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching reimbursements:', error);
|
||||
message.error('获取报销申请失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAdvances();
|
||||
fetchReimbursements();
|
||||
}, []);
|
||||
|
||||
const advanceColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '编号', dataIndex: 'advance_code', key: 'advance_code' },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant' },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount' },
|
||||
{ title: '币种', dataIndex: 'currency', key: 'currency' },
|
||||
{ title: '日期', dataIndex: 'advance_date', key: 'advance_date' },
|
||||
{ title: '事由', dataIndex: 'reason', key: 'reason' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
];
|
||||
|
||||
const reimbursementColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '编号', dataIndex: 'reimbursement_code', key: 'reimbursement_code' },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant' },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount' },
|
||||
{ title: '币种', dataIndex: 'currency', key: 'currency' },
|
||||
{ title: '日期', dataIndex: 'reimbursement_date', key: 'reimbursement_date' },
|
||||
{ title: '事由', dataIndex: 'reason', key: 'reason' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '支出类型', dataIndex: 'expense_type', key: 'expense_type' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2>测试API数据</h2>
|
||||
<p>此页面用于测试API是否正常返回数据</p>
|
||||
</div>
|
||||
|
||||
<Card title="预支申请" style={{ marginBottom: 24 }}>
|
||||
<Button type="primary" onClick={fetchAdvances} loading={loading} style={{ marginBottom: 16 }}>
|
||||
刷新预支申请
|
||||
</Button>
|
||||
<Table dataSource={advances} columns={advanceColumns} rowKey="id" />
|
||||
</Card>
|
||||
|
||||
<Card title="报销申请">
|
||||
<Button type="primary" onClick={fetchReimbursements} loading={loading} style={{ marginBottom: 16 }}>
|
||||
刷新报销申请
|
||||
</Button>
|
||||
<Table dataSource={reimbursements} columns={reimbursementColumns} rowKey="id" />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestPage;
|
||||
Reference in New Issue
Block a user