Files
yunhaifinance/frontend/src/pages/ProjectCostPage.tsx
T

410 lines
19 KiB
TypeScript
Raw Normal View History

2026-06-13 12:44:48 +08:00
import React, { useState, useEffect, useMemo } from 'react'
import {
2026-06-13 12:44:48 +08:00
Card, Row, Col, Statistic, Select, message, Spin, Progress, Typography, Tabs, Table, Modal, Tag, DatePicker, Space
} from 'antd'
2026-06-13 12:44:48 +08:00
import { RiseOutlined, FallOutlined, UnorderedListOutlined, BarChartOutlined } from '@ant-design/icons'
import { useLanguageStore } from '../store/languageStore'
import apiClient from '../utils/request'
import dayjs from 'dayjs'
2026-06-13 12:44:48 +08:00
const { Title } = Typography
const { RangePicker } = DatePicker
const ProjectCostPage: React.FC = () => {
2026-06-13 12:44:48 +08:00
const { t } = useLanguageStore()
const LEVEL2_LABELS = useMemo(() => ({
contract_payment: t('finance.projectRevenue'),
deposit_refund: t('finance.warrantyReturn'),
shareholder_investment: t('finance.shareholderInvestment'),
other_income: t('finance.otherIncome'),
customer_advance: t('cash.customerAdvance'),
bank_loan: t('cash.bankLoan'),
other_loan: t('cash.otherLoan'),
dividend_income: t('cash.dividendIncome'),
interest_income: t('cash.interestIncome'),
asset_disposal: t('cash.assetDisposal'),
tax_refund: t('cash.taxRefund'),
government_subsidy: t('cash.governmentSubsidy'),
material: t('finance.materialPurchase'),
equipment: t('finance.equipmentPurchase'),
subcontract: t('finance.constructionSubcontract'),
construction_subcontract: t('finance.constructionSubcontract'),
labor: t('finance.laborWage'),
travel: t('finance.travelTransport'),
accommodation: t('finance.accommodationFood'),
freight: t('finance.transportLogistics'),
transport_logistics: t('finance.transportLogistics'),
design: t('finance.surveyDesign'),
survey_design: t('finance.surveyDesign'),
tools: t('finance.smallTools'),
client_relations: t('finance.customerEDLRelation'),
customer_edl: t('finance.customerEDLRelation'),
other_project: t('finance.otherProjectExpense'),
salary: t('finance.salaryWelfare'),
rent: t('finance.rentProperty'),
office: t('finance.officeExpense'),
commute: t('finance.commute'),
vehicle_maintenance: t('finance.vehicleMaintenance'),
assets: t('finance.fixedAsset'),
marketing: t('finance.marketing'),
entertainment: t('finance.entertainment'),
welfare: t('finance.employeeBenefit'),
logistics: t('finance.expressLogistics'),
other_company: t('finance.otherCompanyExpense'),
loan_repayment: t('cash.loanRepayment'),
interest_expense: t('cash.interestExpense'),
dividend_payment: t('cash.dividendPayment'),
tax_payment: t('cash.taxPayment'),
deposit_payment: t('cash.depositPayment'),
owner_expense: t('cash.ownerExpense'),
other_finance: t('cash.otherFinance'),
}), [t])
const LEVEL1_LABELS = useMemo(() => ({
income: t('finance.incomeCategory'),
project: t('finance.projectExpense'),
company: t('finance.companyExpense'),
finance: t('cash.financeExpense'),
}), [t])
const COUNTERPARTY_LABELS = useMemo(() => ({
supplier: t('finance.counterpartySupplier'),
subcontractor: t('finance.counterpartySubcontractor'),
customer: t('finance.counterpartyCustomer'),
employee: t('finance.counterpartyEmployee'),
logistics: t('finance.counterpartyLogistics'),
shareholder: t('finance.counterpartyShareholder'),
bank: t('cash.counterpartyBank'),
other: t('finance.counterpartyOther'),
}), [t])
const SOURCE_LABELS = useMemo(() => ({
manual: t('finance.manual'),
cash_management: t('cash.sourceLabel'),
receipt: t('cash.receiptSource'),
advance: t('finance.advance'),
reimbursement: t('finance.reimbursement'),
material: t('finance.material'),
primary_freight: t('finance.freight'),
secondary_freight: t('finance.freight'),
}), [t])
const [projects, setProjects] = useState<any[]>([])
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(null)
2026-06-13 12:44:48 +08:00
const [costSummary, setCostSummary] = useState<any>(null)
const [loading, setLoading] = useState(false)
2026-06-13 12:44:48 +08:00
const [activeTab, setActiveTab] = useState('overview')
const [details, setDetails] = useState<any[]>([])
const [detailPagination, setDetailPagination] = useState({ page: 1, pageSize: 20, total: 0 })
const [detailLoading, setDetailLoading] = useState(false)
const [detailFilter, setDetailFilter] = useState<any>({})
const [detailDateRange, setDetailDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null)
const [modalVisible, setModalVisible] = useState(false)
const [modalTitle, setModalTitle] = useState('')
const [modalRecords, setModalRecords] = useState<any[]>([])
const [modalPagination, setModalPagination] = useState({ page: 1, pageSize: 20, total: 0 })
const [modalLoading, setModalLoading] = useState(false)
const [modalFilter, setModalFilter] = useState<any>({})
useEffect(() => {
2026-06-13 12:44:48 +08:00
apiClient.get('/projects', { params: { pageSize: 200 } }).then(res => {
if (res.data.success) setProjects(res.data.data || res.data.projects || [])
}).catch(() => {})
}, [])
2026-06-13 12:44:48 +08:00
useEffect(() => {
if (selectedProjectId) {
2026-06-13 12:44:48 +08:00
setLoading(true)
apiClient.get(`/projects/${selectedProjectId}/cost-summary`).then(res => {
if (res.data.success) setCostSummary(res.data.data)
else { message.error(t('projectCost.getDataFailed')); setCostSummary(null) }
}).catch(() => { message.error(t('projectCost.getDataFailed')); setCostSummary(null) })
.finally(() => setLoading(false))
} else {
setCostSummary(null)
}
}, [selectedProjectId])
2026-06-13 12:44:48 +08:00
const fetchDetails = async (page = 1, filters?: any) => {
if (!selectedProjectId) return
setDetailLoading(true)
try {
const params: any = { page, pageSize: detailPagination.pageSize, ...filters }
if (detailDateRange && detailDateRange[0]) {
params.date_from = detailDateRange[0].format('YYYY-MM-DD')
params.date_to = detailDateRange[1]?.format('YYYY-MM-DD')
}
const res = await apiClient.get(`/projects/${selectedProjectId}/financial-details`, { params })
if (res.data.success) {
setDetails(res.data.data)
setDetailPagination(res.data.pagination)
}
} catch (e) { console.error(e) }
setDetailLoading(false)
}
const fetchModalRecords = async (page = 1) => {
if (!selectedProjectId) return
setModalLoading(true)
try {
const params: any = { page, pageSize: modalPagination.pageSize, ...modalFilter }
const res = await apiClient.get(`/projects/${selectedProjectId}/financial-details`, { params })
if (res.data.success) {
setModalRecords(res.data.data)
setModalPagination(res.data.pagination)
}
} catch (e) { console.error(e) }
setModalLoading(false)
}
useEffect(() => {
if (activeTab === 'details' && selectedProjectId) {
fetchDetails(1, detailFilter)
}
}, [activeTab, selectedProjectId, detailDateRange])
const handleCategoryClick = (txnType: string, categoryLevel2: string) => {
const label = LEVEL2_LABELS[categoryLevel2] || categoryLevel2
setModalTitle(`${label} - ${txnType === 'income' ? t('finance.income') : t('finance.expense')}${t('projectCost.detail')}`)
const filter = { txn_type: txnType, category_level2: categoryLevel2 }
setModalFilter(filter)
setModalPagination(prev => ({ ...prev, page: 1, total: 0 }))
setModalVisible(true)
setModalLoading(true)
apiClient.get(`/projects/${selectedProjectId}/financial-details`, { params: { page: 1, pageSize: 20, ...filter } })
.then(res => {
if (res.data.success) {
setModalRecords(res.data.data)
setModalPagination(res.data.pagination)
}
})
.catch(() => message.error(t('projectCost.getDataFailed')))
.finally(() => setModalLoading(false))
}
const profitRate = costSummary && costSummary.income?.total > 0
? ((costSummary.profit / costSummary.income.total) * 100).toFixed(1) : '0.0'
const costRate = costSummary && costSummary.contract_amount > 0
? ((costSummary.total_cost / costSummary.contract_amount) * 100).toFixed(1) : '0.0'
const detailColumns = [
{ title: t('finance.date'), dataIndex: 'record_date', width: 100, render: (v: string) => v?.slice(0, 10) },
{ title: t('finance.incomeType'), dataIndex: 'txn_type', width: 60, render: (v: string) => <Tag color={v === 'income' ? 'green' : 'red'}>{v === 'income' ? t('finance.income') : t('finance.expense')}</Tag> },
{ title: t('finance.level2Category'), dataIndex: 'category_level2', width: 100, render: (v: string) => LEVEL2_LABELS[v] || v },
{ title: t('finance.amount'), dataIndex: 'amount_original', width: 100, render: (v: number) => v?.toLocaleString(), align: 'right' as const },
{ title: t('finance.currency'), dataIndex: 'currency', width: 50 },
{ title: t('finance.equivalentCNY'), dataIndex: 'amount_cny', width: 110, render: (v: number) => ${v?.toLocaleString()}`, align: 'right' as const },
{ title: t('finance.counterpartyName'), dataIndex: 'counterparty_name', width: 100, ellipsis: true },
{ title: t('finance.desc'), dataIndex: 'description', ellipsis: true },
{ title: t('finance.source'), dataIndex: 'source', width: 80, render: (v: string) => <Tag>{SOURCE_LABELS[v] || v}</Tag> },
]
const modalColumns = [
{ title: t('finance.date'), dataIndex: 'record_date', width: 100, render: (v: string) => v?.slice(0, 10) },
{ title: t('finance.amount'), dataIndex: 'amount_original', width: 110, render: (v: number, r: any) => `${v?.toLocaleString()} ${r.currency}` },
{ title: t('finance.equivalentCNY'), dataIndex: 'amount_cny', width: 110, render: (v: number) => <b>¥{v?.toLocaleString()}</b>, align: 'right' as const },
{ title: t('finance.counterpartyName'), dataIndex: 'counterparty_name', width: 100, ellipsis: true },
{ title: t('finance.desc'), dataIndex: 'description', ellipsis: true },
{ title: t('finance.source'), dataIndex: 'source', width: 80, render: (v: string) => <Tag>{SOURCE_LABELS[v] || v}</Tag> },
]
const renderOverview = () => {
if (loading) return <div style={{ textAlign: 'center', padding: 40 }}><Spin size="large" /></div>
if (!costSummary) return <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>{t('projectCost.selectProject')}</div>
return (
<>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title={t('projectCost.contractAmount')} value={costSummary.contract_amount} precision={2} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title={t('projectCost.totalIncome')} value={costSummary.income?.total} precision={2} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title={t('projectCost.totalExpense')} value={costSummary.total_cost} precision={2} prefix="¥" valueStyle={{ color: '#fa541c', fontSize: 20 }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title={t('projectCost.profit')} value={costSummary.profit} precision={2} prefix="¥" valueStyle={{ color: costSummary.profit >= 0 ? '#52c41a' : '#ff4d4f', fontSize: 20 }} />
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>{t('projectCost.profitRate')}: {profitRate}%</div>
</Card>
</Col>
</Row>
2026-06-13 12:44:48 +08:00
<Card size="small" title={t('projectCost.costProgress')} style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span>{t('projectCost.costRatio')}</span>
<span>{costRate}%</span>
</div>
2026-06-13 12:44:48 +08:00
<Progress percent={parseFloat(costRate)} strokeColor={parseFloat(costRate) > 80 ? '#ff4d4f' : parseFloat(costRate) > 60 ? '#faad14' : '#52c41a'} />
</Card>
<Row gutter={[16, 16]}>
<Col xs={24} md={12}>
<Card title={<span><RiseOutlined style={{ color: '#52c41a', marginRight: 8 }} />{t('projectCost.incomeBreakdown')}</span>} size="small">
{costSummary.income && Object.keys(costSummary.income.by_category).length > 0 ? (
Object.entries(costSummary.income.by_category).map(([category, amount]: [string, any]) => (
<div key={category} style={{ marginBottom: 12, cursor: 'pointer' }} onClick={() => handleCategoryClick('income', category)}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ color: '#1890ff', textDecoration: 'underline' }}>{LEVEL2_LABELS[category] || category}</span>
<span>¥{(amount || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
</div>
<Progress percent={costSummary.income.total > 0 ? (amount / costSummary.income.total) * 100 : 0} strokeColor="#52c41a" size="small" />
</div>
))
) : (
<div style={{ textAlign: 'center', padding: 20, color: '#999' }}>{t('projectCost.noData')}</div>
)}
</Card>
</Col>
<Col xs={24} md={12}>
<Card title={<span><FallOutlined style={{ color: '#fa541c', marginRight: 8 }} />{t('projectCost.expenseBreakdown')}</span>} size="small">
{costSummary.expense && Object.keys(costSummary.expense.by_category).length > 0 ? (
Object.entries(costSummary.expense.by_category).map(([category, amount]: [string, any]) => (
<div key={category} style={{ marginBottom: 12, cursor: 'pointer' }} onClick={() => handleCategoryClick('expense', category)}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ color: '#1890ff', textDecoration: 'underline' }}>{LEVEL2_LABELS[category] || category}</span>
<span>¥{(amount || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
</div>
<Progress percent={costSummary.expense.total > 0 ? (amount / costSummary.expense.total) * 100 : 0} strokeColor="#fa541c" size="small" />
</div>
))
) : (
<div style={{ textAlign: 'center', padding: 20, color: '#999' }}>{t('projectCost.noData')}</div>
)}
</Card>
</Col>
</Row>
{costSummary.expense && Object.keys(costSummary.expense.by_level1 || {}).length > 0 && (
<Card title={t('projectCost.expenseByLevel1')} size="small" style={{ marginTop: 16 }}>
<Row gutter={[16, 16]}>
{Object.entries(costSummary.expense.by_level1).map(([level1, amount]: [string, any]) => (
<Col xs={8} sm={8} key={level1}>
<Statistic title={LEVEL1_LABELS[level1] || level1} value={amount} precision={2} prefix="¥" valueStyle={{ fontSize: 16 }} />
</Col>
))}
</Row>
2026-06-13 12:44:48 +08:00
</Card>
)}
2026-06-13 12:44:48 +08:00
</>
)
}
const renderDetails = () => {
if (!selectedProjectId) return <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>{t('projectCost.selectProject')}</div>
return (
<>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<Space size="small" wrap>
<RangePicker size="small" onChange={(dates) => setDetailDateRange(dates as any)} />
<Select size="small" allowClear placeholder={t('finance.incomeType')} style={{ width: 100 }}
onChange={(v) => { const f = { ...detailFilter, txn_type: v }; if (!v) delete f.txn_type; setDetailFilter(f); fetchDetails(1, f); }}
options={[{ value: 'income', label: t('finance.income') }, { value: 'expense', label: t('finance.expense') }]}
/>
<Select size="small" allowClear placeholder={t('finance.level1Category')} style={{ width: 120 }}
onChange={(v) => { const f = { ...detailFilter, category_level1: v }; if (!v) delete f.category_level1; setDetailFilter(f); fetchDetails(1, f); }}
options={[
{ value: 'income', label: t('finance.incomeCategory') },
{ value: 'project', label: t('finance.projectExpense') },
{ value: 'company', label: t('finance.companyExpense') },
{ value: 'finance', label: t('cash.financeExpense') },
]}
/>
</Space>
</div>
<Card size="small" styles={{ body: { padding: 0 } }}>
<Spin spinning={detailLoading}>
<Table
dataSource={details}
columns={detailColumns}
rowKey="id"
size="small"
scroll={{ x: 900 }}
pagination={{
current: detailPagination.page,
pageSize: detailPagination.pageSize,
total: detailPagination.total,
onChange: (page) => fetchDetails(page, detailFilter),
showTotal: (total) => t('finance.totalRecords', { total })
}}
/>
</Spin>
</Card>
</>
)
}
const tabItems = [
{
key: 'overview',
label: <span><BarChartOutlined /> {t('projectCost.overviewTab')}</span>,
children: renderOverview(),
},
{
key: 'details',
label: <span><UnorderedListOutlined /> {t('projectCost.detailsTab')}</span>,
children: renderDetails(),
},
]
return (
<div style={{ padding: 24 }}>
<Title level={3} style={{ marginBottom: 24 }}>{t('projectCost.title')}</Title>
<Card style={{ marginBottom: 16 }}>
<Select
placeholder={t('projectCost.selectProject')}
style={{ width: '100%', maxWidth: 500 }}
showSearch optionFilterProp="label"
value={selectedProjectId}
onChange={setSelectedProjectId}
options={projects.map(p => ({ value: p.id, label: p.name }))}
/>
</Card>
2026-06-13 12:44:48 +08:00
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} type="card" />
<Modal
title={modalTitle}
open={modalVisible}
onCancel={() => setModalVisible(false)}
footer={null}
width={800}
destroyOnClose
>
<Spin spinning={modalLoading}>
<Table
dataSource={modalRecords}
columns={modalColumns}
rowKey="id"
size="small"
scroll={{ x: 700 }}
pagination={{
current: modalPagination.page,
pageSize: modalPagination.pageSize,
total: modalPagination.total,
onChange: (page) => fetchModalRecords(page),
showTotal: (total) => t('finance.totalRecords', { total })
}}
/>
</Spin>
</Modal>
</div>
)
}
export default ProjectCostPage