2026-04-19 19:15:01 +08:00
|
|
|
import React, { useState, useEffect } from 'react'
|
|
|
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
|
|
|
import {
|
|
|
|
|
Card, Descriptions, Tag, Spin, Empty, Row, Col, Table, Button, Tabs, Typography, Badge
|
|
|
|
|
} from 'antd'
|
|
|
|
|
import {
|
|
|
|
|
ArrowLeftOutlined, HomeOutlined, UserOutlined, PhoneOutlined,
|
|
|
|
|
DollarOutlined, FileTextOutlined
|
|
|
|
|
} from '@ant-design/icons'
|
2026-05-15 12:02:24 +08:00
|
|
|
import apiClient from '../utils/request'
|
2026-04-19 19:15:01 +08:00
|
|
|
import BusinessLedgerTab from '../components/BusinessLedgerTab'
|
2026-06-13 12:44:48 +08:00
|
|
|
import { useLanguageStore } from '../store/languageStore'
|
2026-04-19 19:15:01 +08:00
|
|
|
|
|
|
|
|
const { Title, Text } = Typography
|
|
|
|
|
|
|
|
|
|
interface Contact {
|
|
|
|
|
name: string
|
|
|
|
|
position: string
|
|
|
|
|
phone: string
|
|
|
|
|
is_primary?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface LedgerSummary {
|
|
|
|
|
item_count: number
|
|
|
|
|
total_contract_amount: number
|
|
|
|
|
total_received_amount: number
|
|
|
|
|
total_receivable_amount: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface LedgerItem {
|
|
|
|
|
id: number
|
|
|
|
|
type: string
|
|
|
|
|
code: string
|
|
|
|
|
name: string
|
|
|
|
|
contract_amount: number
|
|
|
|
|
received_amount: number
|
|
|
|
|
receivable_amount: number
|
|
|
|
|
status: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Customer {
|
|
|
|
|
id: number
|
|
|
|
|
code: string
|
|
|
|
|
name: string
|
|
|
|
|
address: string
|
|
|
|
|
contacts: Contact[]
|
|
|
|
|
remark: string
|
|
|
|
|
total_contract_amount: number
|
|
|
|
|
total_received: number
|
|
|
|
|
total_receivable: number
|
|
|
|
|
ledger?: {
|
|
|
|
|
summary: LedgerSummary
|
|
|
|
|
items: LedgerItem[]
|
|
|
|
|
}
|
|
|
|
|
created_at: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Quotation {
|
|
|
|
|
id: number
|
|
|
|
|
version: number
|
|
|
|
|
quotation_date: string
|
|
|
|
|
amount: number
|
|
|
|
|
currency: string
|
|
|
|
|
status: string
|
|
|
|
|
created_at: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface BudgetProject {
|
|
|
|
|
id: number
|
|
|
|
|
name: string
|
|
|
|
|
customer_id: number
|
|
|
|
|
manager_name: string
|
|
|
|
|
status: string
|
|
|
|
|
quotations: Quotation[]
|
|
|
|
|
created_at: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const CustomerDetail: React.FC = () => {
|
|
|
|
|
const { id } = useParams<{ id: string }>()
|
|
|
|
|
const navigate = useNavigate()
|
2026-06-13 12:44:48 +08:00
|
|
|
const { t, currentLanguage } = useLanguageStore()
|
2026-04-19 19:15:01 +08:00
|
|
|
const [customer, setCustomer] = useState<Customer | null>(null)
|
|
|
|
|
const [budgetProjects, setBudgetProjects] = useState<BudgetProject[]>([])
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
|
const [activeTab, setActiveTab] = useState('basic')
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchCustomerDetail()
|
|
|
|
|
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 fetchRelatedBudgetProjects = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await apiClient.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 }} />
|
2026-06-13 12:44:48 +08:00
|
|
|
if (!customer) return <Empty description={t('customer.notFound')} style={{ marginTop: 100 }} />
|
2026-04-19 19:15:01 +08:00
|
|
|
|
|
|
|
|
const budgetProjectColumns = [
|
2026-06-13 12:44:48 +08:00
|
|
|
{ title: t('customer.projectName'), dataIndex: 'name', key: 'name', render: (v: string, record: BudgetProject) => (
|
2026-04-19 19:15:01 +08:00
|
|
|
<Text strong onClick={() => navigate(`/budget-projects/${record.id}`)} style={{ cursor: 'pointer', color: '#1890ff' }}>{v}</Text>
|
|
|
|
|
) },
|
2026-06-13 12:44:48 +08:00
|
|
|
{ title: t('customer.businessManager'), dataIndex: 'manager_name', key: 'manager_name' },
|
|
|
|
|
{ title: t('common.status'), dataIndex: 'status', key: 'status', align: 'center' as const, render: (v: string) => {
|
2026-04-19 19:15:01 +08:00
|
|
|
const map: Record<string, { status: 'success' | 'processing' | 'error' | 'default'; text: string }> = {
|
2026-06-13 12:44:48 +08:00
|
|
|
negotiating: { status: 'processing', text: t('customer.inNegotiation') },
|
|
|
|
|
signed: { status: 'success', text: t('customer.signed') },
|
|
|
|
|
unsigned: { status: 'error', text: t('customer.unsigned') }
|
2026-04-19 19:15:01 +08:00
|
|
|
}
|
|
|
|
|
const c = map[v] || { status: 'default', text: v }
|
|
|
|
|
return <Badge status={c.status} text={c.text} />
|
|
|
|
|
} },
|
2026-06-13 12:44:48 +08:00
|
|
|
{ title: t('customer.quotationCount'), dataIndex: 'quotations', key: 'quotations', align: 'center' as const, render: (q: Quotation[]) => (q || []).length },
|
|
|
|
|
{ title: t('customer.createdAt'), dataIndex: 'created_at', key: 'created_at', render: (v: string) => v?.split('T')[0] || '-' }
|
2026-04-19 19:15:01 +08:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}>
|
|
|
|
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/customers')} style={{ marginBottom: 16 }} type="text">
|
2026-06-13 12:44:48 +08:00
|
|
|
{t('customer.returnToList')}
|
2026-04-19 19:15:01 +08:00
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<Title level={4} style={{ marginBottom: 24 }}>
|
|
|
|
|
<HomeOutlined style={{ marginRight: 8, color: '#52c41a' }} />
|
|
|
|
|
{customer.name}
|
|
|
|
|
</Title>
|
|
|
|
|
|
|
|
|
|
<Card style={{ borderRadius: 8 }}>
|
|
|
|
|
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
|
|
|
|
{/* TAB1: 基本信息 */}
|
2026-06-13 12:44:48 +08:00
|
|
|
<Tabs.TabPane tab={<span><UserOutlined /> {t('customer.basicInfo')}</span>} key="basic">
|
2026-04-19 19:15:01 +08:00
|
|
|
<Descriptions bordered column={{ xs: 1, sm: 2 }} size="small">
|
2026-06-13 12:44:48 +08:00
|
|
|
<Descriptions.Item label={t('customer.code')}>{customer.code}</Descriptions.Item>
|
|
|
|
|
<Descriptions.Item label={t('customer.address')}>{customer.address || '-'}</Descriptions.Item>
|
2026-04-19 19:15:01 +08:00
|
|
|
</Descriptions>
|
|
|
|
|
{customer.remark && (
|
|
|
|
|
<div style={{ marginTop: 16 }}>
|
2026-06-13 12:44:48 +08:00
|
|
|
<Text type="secondary">{t('customer.remarkLabel')}</Text>
|
2026-04-19 19:15:01 +08:00
|
|
|
<div style={{ padding: 12, background: '#f6ffed', borderRadius: 4, border: '1px solid #b7eb8f', marginTop: 8 }}>{customer.remark}</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Tabs.TabPane>
|
|
|
|
|
|
|
|
|
|
{/* TAB2: 联系人 */}
|
2026-06-13 12:44:48 +08:00
|
|
|
<Tabs.TabPane tab={<span><PhoneOutlined /> {t('customer.contact')}</span>} key="contacts">
|
2026-04-19 19:15:01 +08:00
|
|
|
<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 }}>
|
2026-06-13 12:44:48 +08:00
|
|
|
<Text strong>{contact.name || t('common.unnamed')}</Text>
|
|
|
|
|
{contact.is_primary && <Tag color="green">{t('customer.mainContactTag')}</Tag>}
|
2026-04-19 19:15:01 +08:00
|
|
|
</div>
|
|
|
|
|
<div style={{ color: '#666', fontSize: 13 }}>
|
2026-06-13 12:44:48 +08:00
|
|
|
{contact.position && <div>{t('customer.positionLabel')}{contact.position}</div>}
|
|
|
|
|
{contact.phone && <div>{t('customer.phoneLabel')}{contact.phone}</div>}
|
2026-04-19 19:15:01 +08:00
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
</Col>
|
|
|
|
|
))}
|
|
|
|
|
</Row>
|
2026-06-13 12:44:48 +08:00
|
|
|
{(customer.contacts || []).length === 0 && <Empty description={t('customer.noContact')} image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
2026-04-19 19:15:01 +08:00
|
|
|
</Tabs.TabPane>
|
|
|
|
|
|
|
|
|
|
{/* TAB3: 业务台账 */}
|
2026-06-13 12:44:48 +08:00
|
|
|
<Tabs.TabPane tab={<span><DollarOutlined /> {t('customer.ledger')}</span>} key="ledger">
|
2026-04-19 19:15:01 +08:00
|
|
|
<BusinessLedgerTab
|
|
|
|
|
partnerType="customer"
|
|
|
|
|
summary={customer.ledger?.summary || { item_count: 0, total_contract_amount: 0, total_received_amount: 0, total_receivable_amount: 0 }}
|
|
|
|
|
items={customer.ledger?.items || []}
|
|
|
|
|
/>
|
|
|
|
|
</Tabs.TabPane>
|
|
|
|
|
|
|
|
|
|
{/* TAB4: 关联预算 */}
|
2026-06-13 12:44:48 +08:00
|
|
|
<Tabs.TabPane tab={<span><FileTextOutlined /> {t('customer.relatedBudget')}</span>} key="budget">
|
2026-04-19 19:15:01 +08:00
|
|
|
{budgetProjects.length > 0 ? (
|
|
|
|
|
<Table columns={budgetProjectColumns} dataSource={budgetProjects} rowKey="id" size="small" pagination={{ pageSize: 10 }} bordered />
|
|
|
|
|
) : (
|
2026-06-13 12:44:48 +08:00
|
|
|
<Empty description={t('customer.noBudget')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
2026-04-19 19:15:01 +08:00
|
|
|
)}
|
|
|
|
|
</Tabs.TabPane>
|
|
|
|
|
</Tabs>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 12:44:48 +08:00
|
|
|
export default CustomerDetail
|