diff --git a/apps/web-antd/src/api/finance/cost.ts b/apps/web-antd/src/api/finance/cost.ts new file mode 100644 index 0000000..e3cfcbd --- /dev/null +++ b/apps/web-antd/src/api/finance/cost.ts @@ -0,0 +1,143 @@ +/** + * 文件职责:财务中心成本管理 API 契约与请求封装。 + */ +import { requestClient } from '#/api/request'; + +/** 成本统计维度。 */ +export type FinanceCostDimension = 'store' | 'tenant'; + +/** 成本分类编码。 */ +export type FinanceCostCategoryCode = 'fixed' | 'food' | 'labor' | 'packaging'; + +/** 成本作用域查询参数。 */ +export interface FinanceCostScopeQuery { + dimension?: FinanceCostDimension; + month?: string; + storeId?: string; +} + +/** 成本明细项。 */ +export interface FinanceCostEntryDetailDto { + amount: number; + itemId?: string; + itemName: string; + quantity?: number; + sortOrder: number; + unitPrice?: number; +} + +/** 成本分类数据。 */ +export interface FinanceCostEntryCategoryDto { + category: FinanceCostCategoryCode; + categoryText: string; + items: FinanceCostEntryDetailDto[]; + percentage: number; + totalAmount: number; +} + +/** 成本录入数据。 */ +export interface FinanceCostEntryDto { + categories: FinanceCostEntryCategoryDto[]; + costRate: number; + dimension: FinanceCostDimension; + month: string; + monthRevenue: number; + storeId?: string; + totalCost: number; +} + +/** 保存成本明细项请求。 */ +export interface SaveFinanceCostDetailPayload { + amount: number; + itemId?: string; + itemName: string; + quantity?: number; + sortOrder: number; + unitPrice?: number; +} + +/** 保存成本分类请求。 */ +export interface SaveFinanceCostCategoryPayload { + category: FinanceCostCategoryCode; + items: SaveFinanceCostDetailPayload[]; + totalAmount: number; +} + +/** 保存成本录入请求。 */ +export interface SaveFinanceCostEntryPayload extends FinanceCostScopeQuery { + categories: SaveFinanceCostCategoryPayload[]; +} + +/** 成本分析统计卡。 */ +export interface FinanceCostAnalysisStatsDto { + averageCostPerPaidOrder: number; + foodCostRate: number; + monthOnMonthChangeRate: number; + paidOrderCount: number; + revenue: number; + totalCost: number; +} + +/** 成本趋势点。 */ +export interface FinanceCostTrendPointDto { + costRate: number; + month: string; + revenue: number; + totalCost: number; +} + +/** 成本构成项。 */ +export interface FinanceCostCompositionDto { + amount: number; + category: FinanceCostCategoryCode; + categoryText: string; + percentage: number; +} + +/** 成本明细表行。 */ +export interface FinanceCostMonthlyDetailRowDto { + costRate: number; + fixedAmount: number; + foodAmount: number; + laborAmount: number; + month: string; + packagingAmount: number; + totalCost: number; +} + +/** 成本分析数据。 */ +export interface FinanceCostAnalysisDto { + composition: FinanceCostCompositionDto[]; + detailRows: FinanceCostMonthlyDetailRowDto[]; + dimension: FinanceCostDimension; + month: string; + stats: FinanceCostAnalysisStatsDto; + storeId?: string; + trend: FinanceCostTrendPointDto[]; +} + +/** 查询成本录入数据。 */ +export async function getFinanceCostEntryApi(params: FinanceCostScopeQuery) { + return requestClient.get('/finance/cost/entry', { + params, + }); +} + +/** 保存成本录入数据。 */ +export async function saveFinanceCostEntryApi( + payload: SaveFinanceCostEntryPayload, +) { + return requestClient.post( + '/finance/cost/entry/save', + payload, + ); +} + +/** 查询成本分析数据。 */ +export async function getFinanceCostAnalysisApi( + params: FinanceCostScopeQuery & { trendMonthCount?: number }, +) { + return requestClient.get('/finance/cost/analysis', { + params, + }); +} diff --git a/apps/web-antd/src/views/finance/cost/components/CostAnalysisComposition.vue b/apps/web-antd/src/views/finance/cost/components/CostAnalysisComposition.vue new file mode 100644 index 0000000..2414101 --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/components/CostAnalysisComposition.vue @@ -0,0 +1,119 @@ + + + diff --git a/apps/web-antd/src/views/finance/cost/components/CostAnalysisDetailTable.vue b/apps/web-antd/src/views/finance/cost/components/CostAnalysisDetailTable.vue new file mode 100644 index 0000000..ed36d27 --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/components/CostAnalysisDetailTable.vue @@ -0,0 +1,87 @@ + + + diff --git a/apps/web-antd/src/views/finance/cost/components/CostAnalysisStatsBar.vue b/apps/web-antd/src/views/finance/cost/components/CostAnalysisStatsBar.vue new file mode 100644 index 0000000..ca42b92 --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/components/CostAnalysisStatsBar.vue @@ -0,0 +1,57 @@ + + + diff --git a/apps/web-antd/src/views/finance/cost/components/CostAnalysisTrendChart.vue b/apps/web-antd/src/views/finance/cost/components/CostAnalysisTrendChart.vue new file mode 100644 index 0000000..073215f --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/components/CostAnalysisTrendChart.vue @@ -0,0 +1,114 @@ + + + diff --git a/apps/web-antd/src/views/finance/cost/components/CostDetailDeleteModal.vue b/apps/web-antd/src/views/finance/cost/components/CostDetailDeleteModal.vue new file mode 100644 index 0000000..8d01ba4 --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/components/CostDetailDeleteModal.vue @@ -0,0 +1,36 @@ + + + diff --git a/apps/web-antd/src/views/finance/cost/components/CostEntryCategoryCard.vue b/apps/web-antd/src/views/finance/cost/components/CostEntryCategoryCard.vue new file mode 100644 index 0000000..72f7bbf --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/components/CostEntryCategoryCard.vue @@ -0,0 +1,157 @@ + + + diff --git a/apps/web-antd/src/views/finance/cost/components/CostEntrySummaryBar.vue b/apps/web-antd/src/views/finance/cost/components/CostEntrySummaryBar.vue new file mode 100644 index 0000000..a695907 --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/components/CostEntrySummaryBar.vue @@ -0,0 +1,37 @@ + + + diff --git a/apps/web-antd/src/views/finance/cost/components/CostItemEditorDrawer.vue b/apps/web-antd/src/views/finance/cost/components/CostItemEditorDrawer.vue new file mode 100644 index 0000000..736f55f --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/components/CostItemEditorDrawer.vue @@ -0,0 +1,191 @@ + + + diff --git a/apps/web-antd/src/views/finance/cost/components/CostPageToolbar.vue b/apps/web-antd/src/views/finance/cost/components/CostPageToolbar.vue new file mode 100644 index 0000000..76374d8 --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/components/CostPageToolbar.vue @@ -0,0 +1,107 @@ + + + diff --git a/apps/web-antd/src/views/finance/cost/composables/cost-page/constants.ts b/apps/web-antd/src/views/finance/cost/composables/cost-page/constants.ts new file mode 100644 index 0000000..51220e5 --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/composables/cost-page/constants.ts @@ -0,0 +1,96 @@ +import type { + FinanceCostAnalysisState, + FinanceCostCategoryViewModel, + FinanceCostEntryState, + FinanceCostFilterState, + FinanceCostTabKey, +} from '../../types'; + +/** + * 文件职责:成本管理页面常量与默认状态定义。 + */ +import type { FinanceCostDimension } from '#/api/finance/cost'; + +/** 成本管理查看权限。 */ +export const FINANCE_COST_VIEW_PERMISSION = 'tenant:finance:cost:view'; + +/** 成本管理维护权限。 */ +export const FINANCE_COST_MANAGE_PERMISSION = 'tenant:finance:cost:manage'; + +/** 页面 Tab 选项。 */ +export const COST_TAB_OPTIONS: Array<{ + label: string; + value: FinanceCostTabKey; +}> = [ + { label: '成本录入', value: 'entry' }, + { label: '成本分析', value: 'analysis' }, +]; + +/** 维度切换选项。 */ +export const COST_DIMENSION_OPTIONS: Array<{ + label: string; + value: FinanceCostDimension; +}> = [ + { label: '租户汇总', value: 'tenant' }, + { label: '门店视角', value: 'store' }, +]; + +/** 分类颜色映射。 */ +export const COST_CATEGORY_COLOR_MAP: Record = { + food: '#2563eb', + labor: '#16a34a', + fixed: '#ca8a04', + packaging: '#db2777', +}; + +/** 分类图标映射。 */ +export const COST_CATEGORY_ICON_MAP: Record = { + food: 'lucide:utensils-crossed', + labor: 'lucide:users', + fixed: 'lucide:building-2', + packaging: 'lucide:package', +}; + +/** 默认录入状态。 */ +export const DEFAULT_ENTRY_STATE: FinanceCostEntryState = { + monthRevenue: 0, + totalCost: 0, + costRate: 0, + categories: [], +}; + +/** 默认分析状态。 */ +export const DEFAULT_ANALYSIS_STATE: FinanceCostAnalysisState = { + stats: { + totalCost: 0, + foodCostRate: 0, + averageCostPerPaidOrder: 0, + monthOnMonthChangeRate: 0, + revenue: 0, + paidOrderCount: 0, + }, + trend: [], + composition: [], + detailRows: [], +}; + +/** 默认筛选状态。 */ +export const DEFAULT_FILTER_STATE: FinanceCostFilterState = { + dimension: 'tenant', + month: '', + storeId: '', +}; + +/** 趋势月数。 */ +export const TREND_MONTH_COUNT = 6; + +/** 复制分类列表并重置展开状态。 */ +export function cloneCategoriesWithExpandState( + categories: FinanceCostCategoryViewModel[], +) { + return categories.map((item) => ({ + ...item, + items: [...item.items], + expanded: Boolean(item.expanded), + })); +} diff --git a/apps/web-antd/src/views/finance/cost/composables/cost-page/data-actions.ts b/apps/web-antd/src/views/finance/cost/composables/cost-page/data-actions.ts new file mode 100644 index 0000000..2543318 --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/composables/cost-page/data-actions.ts @@ -0,0 +1,169 @@ +/** + * 文件职责:成本管理页面数据加载与保存动作。 + */ +import type { + FinanceCostAnalysisState, + FinanceCostCategoryViewModel, + FinanceCostEntryState, + FinanceCostFilterState, +} from '../../types'; + +import type { StoreListItemDto } from '#/api/store'; + +import { message } from 'ant-design-vue'; + +import { + getFinanceCostAnalysisApi, + getFinanceCostEntryApi, + saveFinanceCostEntryApi, +} from '#/api/finance/cost'; +import { getStoreListApi } from '#/api/store'; + +import { DEFAULT_ANALYSIS_STATE, DEFAULT_ENTRY_STATE } from './constants'; +import { + buildSaveCategoryPayload, + buildScopeQueryPayload, + mapCategoriesToViewModel, +} from './helpers'; + +interface DataActionOptions { + analysis: FinanceCostAnalysisState; + entry: FinanceCostEntryState; + filters: FinanceCostFilterState; + isAnalysisLoading: { value: boolean }; + isEntryLoading: { value: boolean }; + isSaving: { value: boolean }; + isStoreLoading: { value: boolean }; + stores: { value: StoreListItemDto[] }; +} + +/** 创建数据动作集合。 */ +export function createDataActions(options: DataActionOptions) { + async function loadStores() { + options.isStoreLoading.value = true; + try { + const result = await getStoreListApi({ page: 1, pageSize: 200 }); + options.stores.value = result.items ?? []; + } finally { + options.isStoreLoading.value = false; + } + } + + function clearEntry() { + options.entry.monthRevenue = DEFAULT_ENTRY_STATE.monthRevenue; + options.entry.totalCost = DEFAULT_ENTRY_STATE.totalCost; + options.entry.costRate = DEFAULT_ENTRY_STATE.costRate; + options.entry.categories = []; + } + + function clearAnalysis() { + options.analysis.stats = { ...DEFAULT_ANALYSIS_STATE.stats }; + options.analysis.trend = []; + options.analysis.composition = []; + options.analysis.detailRows = []; + } + + function clearAllData() { + clearEntry(); + clearAnalysis(); + } + + async function loadEntryData() { + if (options.filters.dimension === 'store' && !options.filters.storeId) { + clearEntry(); + return; + } + + options.isEntryLoading.value = true; + try { + const expandedMap = new Map( + (options.entry.categories ?? []).map((item) => [ + item.category, + Boolean(item.expanded), + ]), + ); + + const result = await getFinanceCostEntryApi( + buildScopeQueryPayload(options.filters), + ); + + options.entry.monthRevenue = result.monthRevenue; + options.entry.totalCost = result.totalCost; + options.entry.costRate = result.costRate; + options.entry.categories = mapCategoriesToViewModel( + result.categories ?? [], + expandedMap, + ); + } finally { + options.isEntryLoading.value = false; + } + } + + async function loadAnalysisData() { + if (options.filters.dimension === 'store' && !options.filters.storeId) { + clearAnalysis(); + return; + } + + options.isAnalysisLoading.value = true; + try { + const result = await getFinanceCostAnalysisApi({ + ...buildScopeQueryPayload(options.filters), + trendMonthCount: 6, + }); + + options.analysis.stats = { ...result.stats }; + options.analysis.trend = [...(result.trend ?? [])]; + options.analysis.composition = [...(result.composition ?? [])]; + options.analysis.detailRows = [...(result.detailRows ?? [])]; + } finally { + options.isAnalysisLoading.value = false; + } + } + + async function saveEntryData() { + if (options.filters.dimension === 'store' && !options.filters.storeId) { + message.warning('请先选择门店'); + return; + } + + options.isSaving.value = true; + try { + const result = await saveFinanceCostEntryApi({ + ...buildScopeQueryPayload(options.filters), + categories: buildSaveCategoryPayload( + options.entry.categories as FinanceCostCategoryViewModel[], + ), + }); + + const expandedMap = new Map( + (options.entry.categories ?? []).map((item) => [ + item.category, + Boolean(item.expanded), + ]), + ); + + options.entry.monthRevenue = result.monthRevenue; + options.entry.totalCost = result.totalCost; + options.entry.costRate = result.costRate; + options.entry.categories = mapCategoriesToViewModel( + result.categories ?? [], + expandedMap, + ); + + message.success('成本数据保存成功'); + } finally { + options.isSaving.value = false; + } + } + + return { + clearAllData, + clearAnalysis, + clearEntry, + loadAnalysisData, + loadEntryData, + loadStores, + saveEntryData, + }; +} diff --git a/apps/web-antd/src/views/finance/cost/composables/cost-page/drawer-actions.ts b/apps/web-antd/src/views/finance/cost/composables/cost-page/drawer-actions.ts new file mode 100644 index 0000000..06897a1 --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/composables/cost-page/drawer-actions.ts @@ -0,0 +1,89 @@ +import type { CostDeleteModalState, CostDetailDrawerState } from '../../types'; + +/** + * 文件职责:成本明细抽屉与删除弹窗动作。 + */ +import type { + FinanceCostCategoryCode, + FinanceCostEntryDetailDto, +} from '#/api/finance/cost'; + +interface DrawerActionOptions { + deleteModalState: CostDeleteModalState; + drawerState: CostDetailDrawerState; + removeDetailItem: ( + category: FinanceCostCategoryCode, + itemId: string | undefined, + ) => void; + upsertDetailItem: ( + category: FinanceCostCategoryCode, + detail: FinanceCostEntryDetailDto, + mode: 'create' | 'edit', + ) => void; +} + +/** 创建抽屉与弹窗动作。 */ +export function createDrawerActions(options: DrawerActionOptions) { + function openCreateDrawer(category: FinanceCostCategoryCode) { + options.drawerState.open = true; + options.drawerState.mode = 'create'; + options.drawerState.category = category; + options.drawerState.sourceItem = undefined; + } + + function openEditDrawer( + category: FinanceCostCategoryCode, + item: FinanceCostEntryDetailDto, + ) { + options.drawerState.open = true; + options.drawerState.mode = 'edit'; + options.drawerState.category = category; + options.drawerState.sourceItem = { ...item }; + } + + function closeDrawer() { + options.drawerState.open = false; + options.drawerState.sourceItem = undefined; + } + + function submitDrawer(detail: FinanceCostEntryDetailDto) { + options.upsertDetailItem( + options.drawerState.category, + detail, + options.drawerState.mode, + ); + closeDrawer(); + } + + function openDeleteModal( + category: FinanceCostCategoryCode, + item: FinanceCostEntryDetailDto, + ) { + options.deleteModalState.open = true; + options.deleteModalState.category = category; + options.deleteModalState.item = item; + } + + function closeDeleteModal() { + options.deleteModalState.open = false; + options.deleteModalState.item = undefined; + } + + function confirmDelete() { + options.removeDetailItem( + options.deleteModalState.category, + options.deleteModalState.item?.itemId, + ); + closeDeleteModal(); + } + + return { + closeDeleteModal, + closeDrawer, + confirmDelete, + openCreateDrawer, + openDeleteModal, + openEditDrawer, + submitDrawer, + }; +} diff --git a/apps/web-antd/src/views/finance/cost/composables/cost-page/entry-actions.ts b/apps/web-antd/src/views/finance/cost/composables/cost-page/entry-actions.ts new file mode 100644 index 0000000..6ed0445 --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/composables/cost-page/entry-actions.ts @@ -0,0 +1,122 @@ +import type { FinanceCostEntryState } from '../../types'; + +/** + * 文件职责:成本录入分类与明细本地编辑动作。 + */ +import type { + FinanceCostCategoryCode, + FinanceCostEntryDetailDto, +} from '#/api/finance/cost'; + +import { roundAmount, sumAllCategoryTotal, sumCategoryItems } from './helpers'; + +interface EntryActionOptions { + entry: FinanceCostEntryState; +} + +/** 创建录入区编辑动作。 */ +export function createEntryActions(options: EntryActionOptions) { + function toggleCategoryExpanded(category: FinanceCostCategoryCode) { + const target = options.entry.categories.find( + (item) => item.category === category, + ); + if (!target) return; + target.expanded = !target.expanded; + } + + function setCategoryTotal(category: FinanceCostCategoryCode, value: number) { + const target = options.entry.categories.find( + (item) => item.category === category, + ); + if (!target) return; + target.totalAmount = Math.max(0, roundAmount(value)); + syncEntrySummary(); + } + + function upsertDetailItem( + category: FinanceCostCategoryCode, + detail: FinanceCostEntryDetailDto, + mode: 'create' | 'edit', + ) { + const target = options.entry.categories.find( + (item) => item.category === category, + ); + if (!target) return; + + const nextItem: FinanceCostEntryDetailDto = { + ...detail, + itemId: detail.itemId || createTempItemId(), + amount: resolveDetailAmount(category, detail), + quantity: + detail.quantity === undefined + ? undefined + : roundAmount(detail.quantity), + unitPrice: + detail.unitPrice === undefined + ? undefined + : roundAmount(detail.unitPrice), + sortOrder: detail.sortOrder > 0 ? detail.sortOrder : 1, + }; + + const index = target.items.findIndex( + (item) => item.itemId === detail.itemId, + ); + if (mode === 'edit' && index !== -1) { + target.items.splice(index, 1, nextItem); + } else { + target.items.push(nextItem); + } + + target.items.sort((left, right) => left.sortOrder - right.sortOrder); + target.totalAmount = sumCategoryItems(target); + syncEntrySummary(); + } + + function removeDetailItem( + category: FinanceCostCategoryCode, + itemId: string | undefined, + ) { + const target = options.entry.categories.find( + (item) => item.category === category, + ); + if (!target || !itemId) return; + + target.items = target.items.filter((item) => item.itemId !== itemId); + target.totalAmount = sumCategoryItems(target); + syncEntrySummary(); + } + + function syncEntrySummary() { + const totalCost = sumAllCategoryTotal(options.entry.categories); + options.entry.totalCost = totalCost; + options.entry.costRate = + options.entry.monthRevenue > 0 + ? roundAmount((totalCost / options.entry.monthRevenue) * 100) + : 0; + } + + return { + removeDetailItem, + setCategoryTotal, + syncEntrySummary, + toggleCategoryExpanded, + upsertDetailItem, + }; +} + +function resolveDetailAmount( + category: FinanceCostCategoryCode, + detail: FinanceCostEntryDetailDto, +) { + if (category !== 'labor') { + return Math.max(0, roundAmount(detail.amount)); + } + + const quantity = roundAmount(detail.quantity ?? 0); + const unitPrice = roundAmount(detail.unitPrice ?? 0); + return Math.max(0, roundAmount(quantity * unitPrice)); +} + +function createTempItemId() { + return `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} diff --git a/apps/web-antd/src/views/finance/cost/composables/cost-page/filter-actions.ts b/apps/web-antd/src/views/finance/cost/composables/cost-page/filter-actions.ts new file mode 100644 index 0000000..8666163 --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/composables/cost-page/filter-actions.ts @@ -0,0 +1,53 @@ +import type { FinanceCostFilterState, FinanceCostTabKey } from '../../types'; + +/** + * 文件职责:成本管理页面筛选项与月份切换动作。 + */ +import type { FinanceCostDimension } from '#/api/finance/cost'; + +import { getCurrentMonthString, shiftMonth } from './helpers'; + +interface FilterActionOptions { + activeTab: { value: FinanceCostTabKey }; + filters: FinanceCostFilterState; +} + +/** 创建筛选动作。 */ +export function createFilterActions(options: FilterActionOptions) { + function setActiveTab(value: FinanceCostTabKey) { + options.activeTab.value = value; + } + + function setDimension(value: FinanceCostDimension) { + options.filters.dimension = value; + } + + function setStoreId(value: string) { + options.filters.storeId = value; + } + + function setMonth(value: string) { + options.filters.month = value || getCurrentMonthString(); + } + + function shiftCurrentMonth(offset: number) { + options.filters.month = shiftMonth(options.filters.month, offset); + } + + function setPreviousMonth() { + shiftCurrentMonth(-1); + } + + function setNextMonth() { + shiftCurrentMonth(1); + } + + return { + setActiveTab, + setDimension, + setMonth, + setNextMonth, + setPreviousMonth, + setStoreId, + }; +} diff --git a/apps/web-antd/src/views/finance/cost/composables/cost-page/helpers.ts b/apps/web-antd/src/views/finance/cost/composables/cost-page/helpers.ts new file mode 100644 index 0000000..b5cb16f --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/composables/cost-page/helpers.ts @@ -0,0 +1,154 @@ +import type { FinanceCostFilterState } from '../../types'; + +/** + * 文件职责:成本管理页面纯函数与格式化工具。 + */ +import type { + FinanceCostCategoryCode, + FinanceCostEntryCategoryDto, + SaveFinanceCostCategoryPayload, +} from '#/api/finance/cost'; + +/** 解析为有限数字。 */ +export function toFiniteNumber(value: unknown, fallback = 0) { + const normalized = Number(value); + return Number.isFinite(normalized) ? normalized : fallback; +} + +/** 金额保留两位小数。 */ +export function roundAmount(value: unknown) { + const normalized = toFiniteNumber(value, 0); + return Math.round(normalized * 100) / 100; +} + +/** 货币格式化。 */ +export function formatCurrency(value: unknown) { + return new Intl.NumberFormat('zh-CN', { + style: 'currency', + currency: 'CNY', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(roundAmount(value)); +} + +/** 百分比格式化。 */ +export function formatPercent(value: unknown) { + return `${roundAmount(value).toFixed(2)}%`; +} + +/** 获取当前月份(yyyy-MM)。 */ +export function getCurrentMonthString() { + const now = new Date(); + const month = `${now.getMonth() + 1}`.padStart(2, '0'); + return `${now.getFullYear()}-${month}`; +} + +/** 月份字符串转标题文本。 */ +export function formatMonthTitle(month: string) { + const normalized = month.trim(); + const [year, monthValue] = normalized.split('-'); + if (!year || !monthValue) { + return normalized || '--'; + } + return `${year}年${Number(monthValue)}月`; +} + +/** 月份位移。 */ +export function shiftMonth(month: string, offset: number) { + const parsed = parseMonth(month); + if (!parsed) { + return getCurrentMonthString(); + } + parsed.setMonth(parsed.getMonth() + offset); + return formatMonth(parsed); +} + +/** 构建查询作用域参数。 */ +export function buildScopeQueryPayload(filters: FinanceCostFilterState) { + return { + dimension: filters.dimension, + month: filters.month || undefined, + storeId: + filters.dimension === 'store' ? filters.storeId || undefined : undefined, + }; +} + +/** 将 API 分类数据映射为页面视图模型。 */ +export function mapCategoriesToViewModel( + categories: FinanceCostEntryCategoryDto[], + expandedMap: Map, +) { + return (categories ?? []).map((item) => ({ + ...item, + items: [...(item.items ?? [])], + expanded: expandedMap.get(item.category) ?? false, + })); +} + +/** 计算分类总金额。 */ +export function sumCategoryItems( + category: Pick, +) { + let total = 0; + for (const item of category.items ?? []) { + if (category.category === 'labor') { + const quantity = toFiniteNumber(item.quantity, 0); + const unitPrice = toFiniteNumber(item.unitPrice, 0); + total += roundAmount(quantity * unitPrice); + continue; + } + total += roundAmount(item.amount); + } + return roundAmount(total); +} + +/** 计算全部分类总成本。 */ +export function sumAllCategoryTotal(categories: FinanceCostEntryCategoryDto[]) { + let total = 0; + for (const category of categories ?? []) { + total += roundAmount(category.totalAmount); + } + return roundAmount(total); +} + +/** 构建保存请求分类数组。 */ +export function buildSaveCategoryPayload( + categories: FinanceCostEntryCategoryDto[], +): SaveFinanceCostCategoryPayload[] { + return (categories ?? []).map((category) => ({ + category: category.category, + totalAmount: roundAmount(category.totalAmount), + items: (category.items ?? []).map((item, index) => ({ + itemId: item.itemId, + itemName: item.itemName.trim(), + amount: roundAmount(item.amount), + quantity: + item.quantity === undefined ? undefined : roundAmount(item.quantity), + unitPrice: + item.unitPrice === undefined ? undefined : roundAmount(item.unitPrice), + sortOrder: item.sortOrder > 0 ? item.sortOrder : index + 1, + })), + })); +} + +function parseMonth(value: string) { + const normalized = value.trim(); + const [year, month] = normalized.split('-'); + const yearValue = Number(year); + const monthValue = Number(month); + if ( + !Number.isInteger(yearValue) || + !Number.isInteger(monthValue) || + monthValue < 1 || + monthValue > 12 + ) { + return null; + } + return new Date(yearValue, monthValue - 1, 1); +} + +function formatMonth(date: Date) { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + return `${year}-${month}`; +} diff --git a/apps/web-antd/src/views/finance/cost/composables/useFinanceCostPage.ts b/apps/web-antd/src/views/finance/cost/composables/useFinanceCostPage.ts new file mode 100644 index 0000000..0d8914c --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/composables/useFinanceCostPage.ts @@ -0,0 +1,313 @@ +import type { + CostDeleteModalState, + CostDetailDrawerState, + FinanceCostAnalysisState, + FinanceCostEntryState, + FinanceCostFilterState, + FinanceCostTabKey, + OptionItem, +} from '../types'; + +/** + * 文件职责:成本管理页面状态与动作编排。 + */ +import type { FinanceCostCategoryCode } from '#/api/finance/cost'; +import type { StoreListItemDto } from '#/api/store'; + +import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'; + +import { useAccessStore } from '@vben/stores'; + +import { message } from 'ant-design-vue'; + +import { + COST_DIMENSION_OPTIONS, + COST_TAB_OPTIONS, + DEFAULT_ANALYSIS_STATE, + DEFAULT_ENTRY_STATE, + DEFAULT_FILTER_STATE, + FINANCE_COST_MANAGE_PERMISSION, + FINANCE_COST_VIEW_PERMISSION, +} from './cost-page/constants'; +import { createDataActions } from './cost-page/data-actions'; +import { createDrawerActions } from './cost-page/drawer-actions'; +import { createEntryActions } from './cost-page/entry-actions'; +import { createFilterActions } from './cost-page/filter-actions'; +import { formatMonthTitle, getCurrentMonthString } from './cost-page/helpers'; + +/** 创建成本管理页面组合状态。 */ +export function useFinanceCostPage() { + const accessStore = useAccessStore(); + + const stores = ref([]); + const activeTab = ref('entry'); + const filters = reactive({ + ...DEFAULT_FILTER_STATE, + month: getCurrentMonthString(), + }); + + const entry = reactive({ + ...DEFAULT_ENTRY_STATE, + }); + const analysis = reactive({ + ...DEFAULT_ANALYSIS_STATE, + }); + + const isStoreLoading = ref(false); + const isEntryLoading = ref(false); + const isAnalysisLoading = ref(false); + const isSaving = ref(false); + + const drawerState = reactive({ + open: false, + mode: 'create', + category: 'food', + }); + const deleteModalState = reactive({ + open: false, + category: 'food', + }); + + const accessCodeSet = computed( + () => new Set((accessStore.accessCodes ?? []).map(String)), + ); + const canManage = computed(() => + accessCodeSet.value.has(FINANCE_COST_MANAGE_PERMISSION), + ); + const canView = computed( + () => + accessCodeSet.value.has(FINANCE_COST_VIEW_PERMISSION) || + accessCodeSet.value.has(FINANCE_COST_MANAGE_PERMISSION), + ); + + const storeOptions = computed(() => + stores.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ); + + const selectedStoreName = computed( + () => + storeOptions.value.find((item) => item.value === filters.storeId) + ?.label ?? '--', + ); + + const monthTitle = computed(() => formatMonthTitle(filters.month)); + const showStoreSelect = computed(() => filters.dimension === 'store'); + const hasStore = computed(() => stores.value.length > 0); + const canQueryCurrentScope = computed( + () => filters.dimension === 'tenant' || Boolean(filters.storeId), + ); + const drawerCategoryText = computed(() => { + const matched = entry.categories.find( + (item) => item.category === drawerState.category, + ); + return matched?.categoryText || '明细'; + }); + const tabOptions = computed(() => COST_TAB_OPTIONS); + const dimensionOptions = computed(() => COST_DIMENSION_OPTIONS); + + const dataActions = createDataActions({ + stores, + filters, + entry, + analysis, + isStoreLoading, + isEntryLoading, + isAnalysisLoading, + isSaving, + }); + const entryActions = createEntryActions({ entry }); + const filterActions = createFilterActions({ activeTab, filters }); + const drawerActions = createDrawerActions({ + drawerState, + deleteModalState, + upsertDetailItem: entryActions.upsertDetailItem, + removeDetailItem: entryActions.removeDetailItem, + }); + + async function loadByCurrentTab() { + if (!canView.value) return; + if (!canQueryCurrentScope.value) { + if (activeTab.value === 'entry') { + dataActions.clearEntry(); + } else { + dataActions.clearAnalysis(); + } + return; + } + + if (activeTab.value === 'entry') { + await dataActions.loadEntryData(); + return; + } + + await dataActions.loadAnalysisData(); + } + + async function loadAllPanels() { + if (!canView.value || !canQueryCurrentScope.value) return; + await Promise.all([ + dataActions.loadEntryData(), + dataActions.loadAnalysisData(), + ]); + } + + function ensureStoreSelection() { + if (filters.dimension !== 'store') return; + if (filters.storeId) return; + if (stores.value.length === 0) return; + filters.storeId = stores.value[0]?.id ?? ''; + } + + function clearByPermission() { + stores.value = []; + filters.storeId = ''; + dataActions.clearAllData(); + drawerActions.closeDrawer(); + drawerActions.closeDeleteModal(); + } + + async function initPageData() { + if (!canView.value) { + clearByPermission(); + return; + } + + await dataActions.loadStores(); + ensureStoreSelection(); + await loadByCurrentTab(); + } + + async function saveEntry() { + if (!canManage.value) { + message.warning('当前账号没有维护权限'); + return; + } + + await dataActions.saveEntryData(); + await dataActions.loadAnalysisData(); + } + + function openAddDetail(category: FinanceCostCategoryCode) { + if (!canManage.value) { + message.warning('当前账号没有维护权限'); + return; + } + drawerActions.openCreateDrawer(category); + } + + function openEditDetail(category: FinanceCostCategoryCode, itemId: string) { + if (!canManage.value) { + message.warning('当前账号没有维护权限'); + return; + } + + const targetCategory = entry.categories.find( + (current) => current.category === category, + ); + const targetItem = targetCategory?.items.find( + (current) => current.itemId === itemId, + ); + if (!targetItem) return; + drawerActions.openEditDrawer(category, targetItem); + } + + function openDeleteDetail(category: FinanceCostCategoryCode, itemId: string) { + if (!canManage.value) { + message.warning('当前账号没有维护权限'); + return; + } + + const targetCategory = entry.categories.find( + (current) => current.category === category, + ); + const targetItem = targetCategory?.items.find( + (current) => current.itemId === itemId, + ); + if (!targetItem) return; + drawerActions.openDeleteModal(category, targetItem); + } + + watch( + () => activeTab.value, + async () => { + await loadByCurrentTab(); + }, + ); + + watch( + () => [filters.dimension, filters.storeId, filters.month], + async () => { + if (filters.dimension === 'store') { + ensureStoreSelection(); + } + await loadByCurrentTab(); + }, + ); + + watch( + () => canView.value, + async (value, oldValue) => { + if (value === oldValue) return; + if (!value) { + clearByPermission(); + return; + } + await initPageData(); + }, + ); + + onMounted(async () => { + await initPageData(); + }); + + onActivated(() => { + if (!canView.value) return; + if (stores.value.length === 0) { + void initPageData(); + } + }); + + return { + activeTab, + analysis, + canManage, + canQueryCurrentScope, + canView, + deleteModalState, + dimensionOptions, + drawerCategoryText, + drawerState, + entry, + filters, + hasStore, + isAnalysisLoading, + isEntryLoading, + isSaving, + isStoreLoading, + loadAllPanels, + monthTitle, + openAddDetail, + openDeleteDetail, + openEditDetail, + saveEntry, + selectedStoreName, + setActiveTab: filterActions.setActiveTab, + setCategoryTotal: entryActions.setCategoryTotal, + setDimension: filterActions.setDimension, + setMonth: filterActions.setMonth, + setNextMonth: filterActions.setNextMonth, + setPreviousMonth: filterActions.setPreviousMonth, + setStoreId: filterActions.setStoreId, + showStoreSelect, + storeOptions, + submitDetailFromDrawer: drawerActions.submitDrawer, + tabOptions, + toggleCategoryExpanded: entryActions.toggleCategoryExpanded, + closeDetailDrawer: drawerActions.closeDrawer, + closeDeleteModal: drawerActions.closeDeleteModal, + confirmDeleteDetail: drawerActions.confirmDelete, + }; +} diff --git a/apps/web-antd/src/views/finance/cost/index.vue b/apps/web-antd/src/views/finance/cost/index.vue new file mode 100644 index 0000000..f5416dc --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/index.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/apps/web-antd/src/views/finance/cost/styles/analysis.less b/apps/web-antd/src/views/finance/cost/styles/analysis.less new file mode 100644 index 0000000..8e47862 --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/styles/analysis.less @@ -0,0 +1,146 @@ +/** + * 文件职责:成本分析区域样式。 + */ +.fc-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.fc-stat-card { + padding: 16px; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 5%); +} + +.fc-stat-label { + margin-bottom: 8px; + font-size: 12px; + color: rgb(0 0 0 / 45%); +} + +.fc-stat-value { + font-size: 22px; + font-weight: 800; + color: rgb(0 0 0 / 88%); + + &.is-up { + color: #ef4444; + } + + &.is-down { + color: #16a34a; + } + + &.is-flat { + color: rgb(0 0 0 / 65%); + } +} + +.fc-section-title { + padding-left: 10px; + margin-bottom: 14px; + font-size: 15px; + font-weight: 600; + color: rgb(0 0 0 / 88%); + border-left: 3px solid #1677ff; +} + +.fc-chart-card, +.fc-composition-card, +.fc-table-card { + padding: 18px; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + box-shadow: 0 2px 8px rgb(15 23 42 / 5%); +} + +.fc-trend-chart { + height: 260px; +} + +.fc-composition-body { + display: flex; + gap: 20px; + align-items: center; +} + +.fc-composition-chart-wrap { + position: relative; + flex-shrink: 0; + width: 220px; + height: 220px; +} + +.fc-composition-chart { + width: 100%; + height: 100%; +} + +.fc-composition-center { + position: absolute; + inset: 71px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + pointer-events: none; +} + +.fc-composition-center-value { + font-size: 14px; + font-weight: 700; + color: rgb(0 0 0 / 88%); +} + +.fc-composition-center-label { + font-size: 11px; + color: rgb(0 0 0 / 45%); +} + +.fc-composition-legend { + display: flex; + flex: 1; + flex-direction: column; + gap: 10px; +} + +.fc-composition-legend-item { + display: grid; + grid-template-columns: 10px 1fr auto auto; + gap: 8px; + align-items: center; + font-size: 13px; +} + +.fc-composition-dot { + width: 10px; + height: 10px; + border-radius: 2px; +} + +.fc-composition-name { + color: rgb(0 0 0 / 88%); +} + +.fc-composition-amount { + min-width: 88px; + color: rgb(0 0 0 / 65%); + text-align: right; +} + +.fc-composition-percent { + min-width: 54px; + font-weight: 600; + color: rgb(0 0 0 / 88%); + text-align: right; +} + +.fc-total-amount { + font-weight: 700; + color: #1677ff; +} diff --git a/apps/web-antd/src/views/finance/cost/styles/base.less b/apps/web-antd/src/views/finance/cost/styles/base.less new file mode 100644 index 0000000..10b7d1b --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/styles/base.less @@ -0,0 +1,22 @@ +/** + * 文件职责:成本管理页面基础容器样式。 + */ +.page-finance-cost { + .ant-card { + border: 1px solid #f0f0f0; + border-radius: 10px; + } +} + +.fc-page { + display: flex; + flex-direction: column; + gap: 14px; +} + +.fc-empty { + padding: 36px 0; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; +} diff --git a/apps/web-antd/src/views/finance/cost/styles/drawer.less b/apps/web-antd/src/views/finance/cost/styles/drawer.less new file mode 100644 index 0000000..2d3bd70 --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/styles/drawer.less @@ -0,0 +1,24 @@ +/** + * 文件职责:成本明细抽屉与删除弹窗样式。 + */ +.fc-full-input { + width: 100%; +} + +.fc-drawer-footer { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.fc-delete-tip { + margin: 4px 0 0; + font-size: 13px; + line-height: 1.8; + color: rgb(0 0 0 / 65%); + + strong { + margin: 0 4px; + color: rgb(0 0 0 / 88%); + } +} diff --git a/apps/web-antd/src/views/finance/cost/styles/entry.less b/apps/web-antd/src/views/finance/cost/styles/entry.less new file mode 100644 index 0000000..1539773 --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/styles/entry.less @@ -0,0 +1,153 @@ +/** + * 文件职责:成本录入区域样式。 + */ +.fc-entry-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.fc-entry-card { + .ant-card-body { + padding: 0; + } +} + +.fc-entry-head { + display: flex; + gap: 12px; + align-items: center; + padding: 16px 18px; +} + +.fc-entry-icon { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + font-size: 20px; + background: rgb(22 119 255 / 8%); + border-radius: 10px; +} + +.fc-entry-meta { + min-width: 160px; +} + +.fc-entry-name { + font-size: 14px; + font-weight: 600; + color: rgb(0 0 0 / 88%); +} + +.fc-entry-ratio { + margin-left: 8px; + font-size: 12px; + font-weight: 400; + color: rgb(0 0 0 / 45%); +} + +.fc-entry-amount { + display: flex; + gap: 6px; + align-items: center; + margin-left: auto; +} + +.fc-entry-currency { + font-size: 13px; + color: rgb(0 0 0 / 45%); +} + +.fc-entry-input { + width: 130px; +} + +.fc-entry-toggle { + padding: 0; +} + +.fc-entry-detail { + padding: 0 18px 16px; + border-top: 1px solid #f3f4f6; +} + +.fc-entry-detail-row { + display: flex; + gap: 10px; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid #f6f7f9; +} + +.fc-entry-item-name { + flex: 1; + min-width: 120px; + font-size: 13px; + color: rgb(0 0 0 / 88%); +} + +.fc-entry-item-value { + display: inline-flex; + gap: 6px; + align-items: center; + justify-content: flex-end; + min-width: 240px; +} + +.fc-entry-item-amount { + font-size: 13px; + font-weight: 600; + color: rgb(0 0 0 / 88%); +} + +.fc-entry-mul, +.fc-entry-equal { + font-size: 12px; + color: rgb(0 0 0 / 45%); +} + +.fc-entry-item-actions { + display: inline-flex; + justify-content: flex-end; + min-width: 104px; +} + +.fc-entry-add { + padding: 0; + margin-top: 6px; +} + +.fc-entry-empty { + padding: 10px 0 2px; +} + +.fc-summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + background: #f8f9fb; + border: 1px solid #eef0f4; + border-radius: 10px; +} + +.fc-summary-label { + font-size: 16px; + font-weight: 700; + color: rgb(0 0 0 / 88%); +} + +.fc-summary-right { + display: inline-flex; + gap: 14px; + align-items: center; +} + +.fc-summary-value { + font-size: 24px; + font-weight: 800; + color: #1677ff; +} diff --git a/apps/web-antd/src/views/finance/cost/styles/index.less b/apps/web-antd/src/views/finance/cost/styles/index.less new file mode 100644 index 0000000..97e8617 --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/styles/index.less @@ -0,0 +1,9 @@ +/** + * 文件职责:成本管理页面样式聚合入口。 + */ +@import './base.less'; +@import './layout.less'; +@import './entry.less'; +@import './analysis.less'; +@import './drawer.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/finance/cost/styles/layout.less b/apps/web-antd/src/views/finance/cost/styles/layout.less new file mode 100644 index 0000000..335cef3 --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/styles/layout.less @@ -0,0 +1,86 @@ +/** + * 文件职责:成本管理页面布局与顶部工具条样式。 + */ +.fc-toolbar { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; +} + +.fc-toolbar-right { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: flex-end; +} + +.fc-tab-segmented { + .ant-segmented-item { + min-width: 108px; + text-align: center; + } +} + +.fc-dimension-segmented { + .ant-segmented-item { + min-width: 92px; + text-align: center; + } +} + +.fc-store-select { + width: 230px; +} + +.fc-month-picker { + display: flex; + gap: 8px; + align-items: center; +} + +.fc-month-arrow { + width: 32px; + min-width: 32px; + height: 32px; + color: rgb(0 0 0 / 65%); +} + +.fc-month-title { + min-width: 100px; + font-size: 15px; + font-weight: 700; + color: rgb(0 0 0 / 88%); + text-align: center; +} + +.fc-month-input { + width: 128px; +} + +.fc-entry-revenue { + padding: 10px 12px; + font-size: 13px; + color: rgb(0 0 0 / 65%); + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + + strong { + margin-left: 6px; + font-size: 15px; + color: #1677ff; + } +} + +.fc-entry-panel, +.fc-analysis-panel { + display: flex; + flex-direction: column; + gap: 12px; +} diff --git a/apps/web-antd/src/views/finance/cost/styles/responsive.less b/apps/web-antd/src/views/finance/cost/styles/responsive.less new file mode 100644 index 0000000..1b29392 --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/styles/responsive.less @@ -0,0 +1,84 @@ +/** + * 文件职责:成本管理页面响应式适配样式。 + */ +@media (max-width: 1280px) { + .fc-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .fc-composition-body { + align-items: flex-start; + } +} + +@media (max-width: 992px) { + .fc-toolbar { + flex-direction: column; + align-items: stretch; + } + + .fc-toolbar-right { + justify-content: flex-start; + } + + .fc-store-select { + width: 100%; + } + + .fc-stats { + grid-template-columns: 1fr; + } + + .fc-composition-body { + flex-direction: column; + } + + .fc-composition-chart-wrap { + margin: 0 auto; + } +} + +@media (max-width: 768px) { + .fc-entry-head { + flex-wrap: wrap; + } + + .fc-entry-meta { + width: calc(100% - 56px); + min-width: unset; + } + + .fc-entry-amount { + width: 100%; + margin-left: 0; + } + + .fc-entry-input { + flex: 1; + } + + .fc-entry-toggle { + margin-left: auto; + } + + .fc-entry-detail-row { + flex-wrap: wrap; + } + + .fc-entry-item-value { + justify-content: flex-start; + width: 100%; + min-width: unset; + } + + .fc-entry-item-actions { + justify-content: flex-start; + width: 100%; + } + + .fc-summary { + flex-direction: column; + gap: 10px; + align-items: flex-start; + } +} diff --git a/apps/web-antd/src/views/finance/cost/types.ts b/apps/web-antd/src/views/finance/cost/types.ts new file mode 100644 index 0000000..1640def --- /dev/null +++ b/apps/web-antd/src/views/finance/cost/types.ts @@ -0,0 +1,67 @@ +/** + * 文件职责:成本管理页面本地类型定义。 + */ +import type { + FinanceCostAnalysisDto, + FinanceCostCategoryCode, + FinanceCostCompositionDto, + FinanceCostDimension, + FinanceCostEntryCategoryDto, + FinanceCostEntryDetailDto, + FinanceCostTrendPointDto, +} from '#/api/finance/cost'; + +/** 页面 Tab 键。 */ +export type FinanceCostTabKey = 'analysis' | 'entry'; + +/** 选项项。 */ +export interface OptionItem { + label: string; + value: string; +} + +/** 成本分类视图模型。 */ +export interface FinanceCostCategoryViewModel extends FinanceCostEntryCategoryDto { + expanded: boolean; +} + +/** 录入区域状态。 */ +export interface FinanceCostEntryState { + categories: FinanceCostCategoryViewModel[]; + costRate: number; + monthRevenue: number; + totalCost: number; +} + +/** 分析区域状态。 */ +export interface FinanceCostAnalysisState { + composition: FinanceCostCompositionDto[]; + detailRows: FinanceCostAnalysisDto['detailRows']; + stats: FinanceCostAnalysisDto['stats']; + trend: FinanceCostTrendPointDto[]; +} + +/** 明细抽屉模式。 */ +export type CostDetailDrawerMode = 'create' | 'edit'; + +/** 明细抽屉状态。 */ +export interface CostDetailDrawerState { + category: FinanceCostCategoryCode; + mode: CostDetailDrawerMode; + open: boolean; + sourceItem?: FinanceCostEntryDetailDto; +} + +/** 删除弹窗状态。 */ +export interface CostDeleteModalState { + category: FinanceCostCategoryCode; + item?: FinanceCostEntryDetailDto; + open: boolean; +} + +/** 页面筛选状态。 */ +export interface FinanceCostFilterState { + dimension: FinanceCostDimension; + month: string; + storeId: string; +}