import React, { useState, useEffect, useMemo } from 'react' import { Card, Row, Col, Statistic, Select, message, Spin, Progress, Typography, Tabs, Table, Modal, Tag, DatePicker, Space } from 'antd' import { RiseOutlined, FallOutlined, UnorderedListOutlined, BarChartOutlined } from '@ant-design/icons' import { useLanguageStore } from '../store/languageStore' import apiClient from '../utils/request' import dayjs from 'dayjs' const { Title } = Typography const { RangePicker } = DatePicker const ProjectCostPage: React.FC = () => { 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([]) const [selectedProjectId, setSelectedProjectId] = useState(null) const [costSummary, setCostSummary] = useState(null) const [loading, setLoading] = useState(false) const [activeTab, setActiveTab] = useState('overview') const [details, setDetails] = useState([]) const [detailPagination, setDetailPagination] = useState({ page: 1, pageSize: 20, total: 0 }) const [detailLoading, setDetailLoading] = useState(false) const [detailFilter, setDetailFilter] = useState({}) 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([]) const [modalPagination, setModalPagination] = useState({ page: 1, pageSize: 20, total: 0 }) const [modalLoading, setModalLoading] = useState(false) const [modalFilter, setModalFilter] = useState({}) useEffect(() => { apiClient.get('/projects', { params: { pageSize: 200 } }).then(res => { if (res.data.success) setProjects(res.data.data || res.data.projects || []) }).catch(() => {}) }, []) useEffect(() => { if (selectedProjectId) { 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]) 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) => {v === 'income' ? t('finance.income') : t('finance.expense')} }, { 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) => {SOURCE_LABELS[v] || v} }, ] 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) => ¥{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) => {SOURCE_LABELS[v] || v} }, ] const renderOverview = () => { if (loading) return
if (!costSummary) return
{t('projectCost.selectProject')}
return ( <> = 0 ? '#52c41a' : '#ff4d4f', fontSize: 20 }} />
{t('projectCost.profitRate')}: {profitRate}%
{t('projectCost.costRatio')} {costRate}%
80 ? '#ff4d4f' : parseFloat(costRate) > 60 ? '#faad14' : '#52c41a'} />
{t('projectCost.incomeBreakdown')}} size="small"> {costSummary.income && Object.keys(costSummary.income.by_category).length > 0 ? ( Object.entries(costSummary.income.by_category).map(([category, amount]: [string, any]) => (
handleCategoryClick('income', category)}>
{LEVEL2_LABELS[category] || category} ¥{(amount || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
0 ? (amount / costSummary.income.total) * 100 : 0} strokeColor="#52c41a" size="small" />
)) ) : (
{t('projectCost.noData')}
)}
{t('projectCost.expenseBreakdown')}} size="small"> {costSummary.expense && Object.keys(costSummary.expense.by_category).length > 0 ? ( Object.entries(costSummary.expense.by_category).map(([category, amount]: [string, any]) => (
handleCategoryClick('expense', category)}>
{LEVEL2_LABELS[category] || category} ¥{(amount || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
0 ? (amount / costSummary.expense.total) * 100 : 0} strokeColor="#fa541c" size="small" />
)) ) : (
{t('projectCost.noData')}
)}
{costSummary.expense && Object.keys(costSummary.expense.by_level1 || {}).length > 0 && ( {Object.entries(costSummary.expense.by_level1).map(([level1, amount]: [string, any]) => ( ))} )} ) } const renderDetails = () => { if (!selectedProjectId) return
{t('projectCost.selectProject')}
return ( <>
setDetailDateRange(dates as any)} /> { 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') }, ]} />
fetchDetails(page, detailFilter), showTotal: (total) => t('finance.totalRecords', { total }) }} /> ) } const tabItems = [ { key: 'overview', label: {t('projectCost.overviewTab')}, children: renderOverview(), }, { key: 'details', label: {t('projectCost.detailsTab')}, children: renderDetails(), }, ] return (
{t('projectCost.title')}
fetchModalRecords(page), showTotal: (total) => t('finance.totalRecords', { total }) }} /> ) } export default ProjectCostPage