备份:PWA配置前的完整版本
包含所有最新开发的功能和修复,作为PWA正确配置前的备份点。
This commit is contained in:
@@ -1,249 +1,407 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Table, Button, Card, Row, Col, Statistic, Select, message, Spin, Progress
|
||||
Card, Row, Col, Statistic, Select, message, Spin, Progress, Typography, Tabs, Table, Modal, Tag, DatePicker, Space
|
||||
} from 'antd'
|
||||
import { BarChartOutlined, DollarOutlined, ShoppingOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { RiseOutlined, FallOutlined, UnorderedListOutlined, BarChartOutlined } from '@ant-design/icons'
|
||||
import { useLanguageStore } from '../store/languageStore'
|
||||
import apiClient from '../utils/request'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
interface Project {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
contract_amount: number
|
||||
}
|
||||
const { Title } = Typography
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
interface CostSummary {
|
||||
project_name: string
|
||||
contract_amount: number
|
||||
purchase_cost: {
|
||||
total: number
|
||||
by_category: Record<string, number>
|
||||
}
|
||||
payment_cost: number
|
||||
total_cost: number
|
||||
profit: number
|
||||
}
|
||||
|
||||
// ==================== 组件 ====================
|
||||
const ProjectCostPage: React.FC = () => {
|
||||
// 状态
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
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)
|
||||
const [costSummary, setCostSummary] = useState<CostSummary | null>(null)
|
||||
const [costSummary, setCostSummary] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// ==================== 数据加载 ====================
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/projects')
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setProjects(data.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCostSummary = async (projectId: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/cost-summary`)
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
setCostSummary(data.data)
|
||||
} else {
|
||||
message.error('获取成本统计失败')
|
||||
setCostSummary(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取成本统计失败:', error)
|
||||
message.error('获取成本统计失败')
|
||||
setCostSummary(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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(() => {
|
||||
fetchProjects()
|
||||
apiClient.get('/projects', { params: { pageSize: 200 } }).then(res => {
|
||||
if (res.data.success) setProjects(res.data.data || res.data.projects || [])
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProjectId) {
|
||||
fetchCostSummary(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])
|
||||
|
||||
// ==================== 渲染 ====================
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={12}>
|
||||
<Select
|
||||
placeholder="请选择项目查看成本统计"
|
||||
style={{ width: '100%' }}
|
||||
value={selectedProjectId}
|
||||
onChange={(value) => setSelectedProjectId(value)}
|
||||
>
|
||||
{projects.map(project => (
|
||||
<Select.Option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
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>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<Spin size="large" />
|
||||
|
||||
<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>
|
||||
) : costSummary ? (
|
||||
<>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="合同金额"
|
||||
value={costSummary.contract_amount}
|
||||
precision={2}
|
||||
prefix={<DollarOutlined />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="采购成本"
|
||||
value={costSummary.purchase_cost.total}
|
||||
precision={2}
|
||||
prefix={<ShoppingOutlined />}
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="付款支出"
|
||||
value={costSummary.payment_cost}
|
||||
precision={2}
|
||||
prefix={<DollarOutlined />}
|
||||
valueStyle={{ color: '#fa8c16' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="利润"
|
||||
value={costSummary.profit}
|
||||
precision={2}
|
||||
prefix={<BarChartOutlined />}
|
||||
valueStyle={{ color: costSummary.profit >= 0 ? '#52c41a' : '#ff4d4f' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<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>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card title="总成本构成" style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span>合同金额</span>
|
||||
<span>{(costSummary.contract_amount || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={100}
|
||||
status="active"
|
||||
strokeColor="#1890ff"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span>采购成本</span>
|
||||
<span>{(costSummary.purchase_cost.total || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={costSummary.contract_amount > 0 ? Math.min((costSummary.purchase_cost.total / costSummary.contract_amount) * 100, 100) : 0}
|
||||
status="normal"
|
||||
strokeColor="#faad14"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span>付款支出</span>
|
||||
<span>{(costSummary.payment_cost || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={costSummary.contract_amount > 0 ? Math.min((costSummary.payment_cost / costSummary.contract_amount) * 100, 100) : 0}
|
||||
status="normal"
|
||||
strokeColor="#fa8c16"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span>总成本</span>
|
||||
<span>{costSummary.total_cost.toFixed(2)}</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={costSummary.contract_amount > 0 ? Math.min((costSummary.total_cost / costSummary.contract_amount) * 100, 100) : 0}
|
||||
status="normal"
|
||||
strokeColor={costSummary.profit >= 0 ? '#52c41a' : '#ff4d4f'}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="采购成本分类">
|
||||
{Object.keys(costSummary.purchase_cost.by_category).length > 0 ? (
|
||||
<div>
|
||||
{Object.entries(costSummary.purchase_cost.by_category).map(([category, amount]) => {
|
||||
const categoryMap: Record<string, string> = {
|
||||
material: '材料',
|
||||
equipment: '设备',
|
||||
pole: '电杆',
|
||||
other: '其他'
|
||||
}
|
||||
return (
|
||||
<div key={category} style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span>{categoryMap[category] || category}</span>
|
||||
<span>{(amount || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={costSummary.purchase_cost.total > 0 ? (amount / costSummary.purchase_cost.total) * 100 : 0}
|
||||
status="normal"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 20, color: '#999' }}>
|
||||
暂无采购成本数据
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
|
||||
请选择项目查看成本统计
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user