2026-06-13 12:44:48 +08:00
|
|
|
import React, { useState, useEffect, useMemo } from 'react'
|
2026-03-25 23:55:36 +07:00
|
|
|
import {
|
2026-06-13 12:44:48 +08:00
|
|
|
Card, Row, Col, Statistic, Select, message, Spin, Progress, Typography, Tabs, Table, Modal, Tag, DatePicker, Space
|
2026-03-25 23:55:36 +07:00
|
|
|
} 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-03-25 23:55:36 +07:00
|
|
|
|
2026-06-13 12:44:48 +08:00
|
|
|
const { Title } = Typography
|
|
|
|
|
const { RangePicker } = DatePicker
|
2026-03-25 23:55:36 +07:00
|
|
|
|
|
|
|
|
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[]>([])
|
2026-03-25 23:55:36 +07:00
|
|
|
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(null)
|
2026-06-13 12:44:48 +08:00
|
|
|
const [costSummary, setCostSummary] = useState<any>(null)
|
2026-03-25 23:55:36 +07:00
|
|
|
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>({})
|
|
|
|
|
|
2026-03-25 23:55:36 +07:00
|
|
|
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-03-25 23:55:36 +07:00
|
|
|
}, [])
|
2026-06-13 12:44:48 +08:00
|
|
|
|
2026-03-25 23:55:36 +07: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))
|
2026-03-25 23:55:36 +07:00
|
|
|
} 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>
|
2026-03-25 23:55:36 +07:00
|
|
|
</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>
|
2026-03-25 23:55:36 +07:00
|
|
|
</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>
|
|
|
|
|
))}
|
2026-03-25 23:55:36 +07:00
|
|
|
</Row>
|
2026-06-13 12:44:48 +08:00
|
|
|
</Card>
|
2026-03-25 23:55:36 +07:00
|
|
|
)}
|
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 }))}
|
|
|
|
|
/>
|
2026-03-25 23:55:36 +07:00
|
|
|
</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>
|
2026-03-25 23:55:36 +07:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default ProjectCostPage
|