JGJS2026/src/components/shared/HtFeeMethodGrid.vue
2026-03-17 16:24:25 +08:00

743 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { Pencil, Eraser } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { useTabStore } from '@/pinia/tab'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle
} from 'reka-ui'
interface FeeMethodRow {
id: string
name: string
rateFee: number | null
hourlyFee: number | null
quantityUnitPriceFee: number | null
subtotal?: number | null
actions?: unknown
}
interface FeeMethodState {
detailRows: FeeMethodRow[]
}
interface MethodRateState {
rate?: unknown
budgetFee?: unknown
}
interface MethodHourlyRowLike {
adoptedBudgetUnitPrice?: unknown
personnelCount?: unknown
workdayCount?: unknown
serviceBudget?: unknown
}
interface MethodHourlyState {
detailRows?: MethodHourlyRowLike[]
}
interface MethodQuantityRowLike {
id?: unknown
budgetFee?: unknown
quantity?: unknown
unitPrice?: unknown
}
interface MethodQuantityState {
detailRows?: MethodQuantityRowLike[]
}
interface LegacyFeeRow {
id?: string
feeItem?: string
budgetFee?: number | null
quantity?: number | null
unitPrice?: number | null
}
const props = defineProps<{
title: string
storageKey: string
contractId?: string
contractName?: string
fixedNames?: any[]
}>()
const tabStore = useTabStore()
const zxFwPricingStore = useZxFwPricingStore()
const createRowId = () => `fee-method-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
const createDefaultRow = (name = ''): FeeMethodRow => ({
id: createRowId(),
name,
rateFee: null,
hourlyFee: null,
quantityUnitPriceFee: null
})
const SUMMARY_ROW_ID = 'fee-method-summary'
const isSummaryRow = (row: FeeMethodRow | null | undefined) => row?.id === SUMMARY_ROW_ID
const toFinite = (value: number | null | undefined) =>
typeof value === 'number' && Number.isFinite(value) ? value : 0
const round3 = (value: number) => Number(value.toFixed(3))
const isReserveStorageKey = (key: string) => String(key || '').includes('-reserve')
const sumNullableField = (rows: FeeMethodRow[], pick: (row: FeeMethodRow) => number | null | undefined): number | null => {
let hasValid = false
let total = 0
for (const row of rows) {
const value = pick(row)
if (typeof value !== 'number' || !Number.isFinite(value)) continue
total += value
hasValid = true
}
return hasValid ? round3(total) : null
}
const getRowSubtotal = (row: FeeMethodRow | null | undefined) => {
if (!row) return null
const values = [row.rateFee, row.hourlyFee, row.quantityUnitPriceFee]
const hasValid = values.some(value => typeof value === 'number' && Number.isFinite(value))
if (!hasValid) return null
return round3(toFinite(row.rateFee) + toFinite(row.hourlyFee) + toFinite(row.quantityUnitPriceFee))
}
const toFiniteUnknown = (value: unknown): number | null => {
if (value == null || value === '') return null
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : null
}
const sumMainStateSubtotal = (rows: FeeMethodRow[] | undefined) => {
if (!Array.isArray(rows) || rows.length === 0) return null
return sumNullableField(rows, row => getRowSubtotal(row))
}
const loadAdditionalWorkFeeTotal = async (contractId: string): Promise<number | null> => {
try {
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
const additionalState = await zxFwPricingStore.loadHtFeeMainState<FeeMethodRow>(additionalStorageKey)
return sumMainStateSubtotal(additionalState?.detailRows)
} catch (error) {
console.error('loadAdditionalWorkFeeTotal failed:', error)
return null
}
}
const loadContractServiceFeeBase = async (): Promise<number | null> => {
const contractId = String(props.contractId || '').trim()
if (!contractId) return null
try {
await zxFwPricingStore.loadContract(contractId)
const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId)
if (!isReserveStorageKey(props.storageKey)) {
return serviceBase == null ? null : round3(serviceBase)
}
const additionalFeeTotal = await loadAdditionalWorkFeeTotal(contractId)
const hasAnyBase = serviceBase != null || additionalFeeTotal != null
if (!hasAnyBase) return null
return round3(toFinite(serviceBase) + toFinite(additionalFeeTotal))
} catch (error) {
console.error('loadContractServiceFeeBase failed:', error)
return null
}
}
const sumHourlyMethodFee = (state: MethodHourlyState | null): number | null => {
const rows=state?.detailRows?state?.detailRows?.filter(e=>e.serviceBudget!== null):[]
if (rows.length === 0) return null
let total = 0
let hasValid = false
for (const row of rows) {
const rowBudget = toFiniteUnknown(row?.serviceBudget)
if (rowBudget != null) {
total += rowBudget
hasValid = true
continue
}
const adopted = toFiniteUnknown(row?.adoptedBudgetUnitPrice)
const personnel = toFiniteUnknown(row?.personnelCount)
const workday = toFiniteUnknown(row?.workdayCount)
if (adopted == null || personnel == null || workday == null) continue
total += adopted * personnel * workday
hasValid = true
}
return hasValid ? round3(total) : null
}
const sumQuantityMethodFee = (state: MethodQuantityState | null): number | null => {
const rows=state?.detailRows?state?.detailRows?.filter(e=>e.budgetFee!== null):[]
if (rows.length === 0) return null
let total = 0
let hasValid = false
for (const row of rows) {
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
const budget = toFiniteUnknown(row?.budgetFee)
if (budget != null) {
total += budget
hasValid = true
continue
}
const quantity = toFiniteUnknown(row?.quantity)
const unitPrice = toFiniteUnknown(row?.unitPrice)
if (quantity == null || unitPrice == null) continue
total += quantity * unitPrice
hasValid = true
}
return hasValid ? round3(total) : null
}
const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMethodRow[]> => {
if (!Array.isArray(rows) || rows.length === 0) return rows
const contractBase = await loadContractServiceFeeBase()
const hydratedRows = await Promise.all(
rows.map(async row => {
if (!row?.id) return row
const [rateData, hourlyData, quantityData] = await Promise.all([
zxFwPricingStore.loadHtFeeMethodState<MethodRateState>(props.storageKey, row.id, 'rate-fee'),
zxFwPricingStore.loadHtFeeMethodState<MethodHourlyState>(props.storageKey, row.id, 'hourly-fee'),
zxFwPricingStore.loadHtFeeMethodState<MethodQuantityState>(props.storageKey, row.id, 'quantity-unit-price-fee')
])
const storedRateFee = toFiniteUnknown(rateData?.budgetFee)
const rateValue = toFiniteUnknown(rateData?.rate)
const rateFee =
contractBase != null && rateValue != null
? round3(contractBase * rateValue)
: storedRateFee != null
? round3(storedRateFee)
: null
const hourlyFee = sumHourlyMethodFee(hourlyData)
const quantityUnitPriceFee = sumQuantityMethodFee(quantityData)
return {
...row,
rateFee,
hourlyFee,
quantityUnitPriceFee
}
})
)
return hydratedRows
}
const fixedNames = computed(() =>
Array.isArray(props.fixedNames)
? props.fixedNames.map(item => ({name:item.name,id:item.id}))
: []
)
const hasFixedNames = computed(() => fixedNames.value.length > 0)
const detailRows = computed<FeeMethodRow[]>({
get: () => {
const rows = zxFwPricingStore.getHtFeeMainState<FeeMethodRow>(props.storageKey)?.detailRows
return Array.isArray(rows) ? rows : []
},
set: rows => {
zxFwPricingStore.setHtFeeMainState(props.storageKey, {
detailRows: rows
})
}
})
const summaryRow = computed<FeeMethodRow>(() => {
const rateFee = sumNullableField(detailRows.value, row => row.rateFee)
const hourlyFee = sumNullableField(detailRows.value, row => row.hourlyFee)
const quantityUnitPriceFee = sumNullableField(detailRows.value, row => row.quantityUnitPriceFee)
const result: FeeMethodRow = {
id: SUMMARY_ROW_ID,
name: '小计',
rateFee,
hourlyFee,
quantityUnitPriceFee
}
result.subtotal = getRowSubtotal(result)
return result
})
const displayRows = computed<FeeMethodRow[]>(() => [...detailRows.value, summaryRow.value])
const gridApi = ref<GridApi<FeeMethodRow> | null>(null)
const clearConfirmOpen = ref(false)
const pendingClearRowId = ref<string | null>(null)
const pendingClearRowName = ref('')
const lastSavedSnapshot = ref('')
const requestClearRow = (id: string, name?: string) => {
pendingClearRowId.value = id
pendingClearRowName.value = String(name || '').trim() || '当前行'
clearConfirmOpen.value = true
}
const handleClearConfirmOpenChange = (open: boolean) => {
clearConfirmOpen.value = open
}
const confirmClearRow = async () => {
const id = pendingClearRowId.value
if (!id) return
await clearRow(id)
clearConfirmOpen.value = false
pendingClearRowId.value = null
pendingClearRowName.value = ''
}
const formatEditableText = (params: any) => {
if (params.value == null || params.value === '') {
if (isSummaryRow(params.data)) return ''
return ''
}
return String(params.value)
}
const formatEditableNumber = (params: any) => {
if (params.value == null || params.value === '') {
if (isSummaryRow(params.data)) return ''
return ''
}
return formatThousandsFlexible(params.value, 3)
}
const numericParser = (newValue: any): number | null =>
parseNumberOrNull(newValue, { precision: 3 })
const toLegacyQuantityUnitPriceFee = (row: LegacyFeeRow) => {
if (typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee)) return row.budgetFee
if (
typeof row.quantity === 'number' &&
Number.isFinite(row.quantity) &&
typeof row.unitPrice === 'number' &&
Number.isFinite(row.unitPrice)
) {
return Number((row.quantity * row.unitPrice).toFixed(2))
}
return null
}
const mergeWithStoredRows = (rowsFromDb: unknown): FeeMethodRow[] => {
const sourceRows = (Array.isArray(rowsFromDb) ? rowsFromDb : []).filter(
item => (item as Partial<FeeMethodRow>)?.id !== SUMMARY_ROW_ID
)
const rows = sourceRows.map(item => {
const row = item as Partial<FeeMethodRow> & LegacyFeeRow
return {
id: typeof row.id === 'string' && row.id ? row.id : createRowId(),
name:
typeof row.name === 'string'
? row.name
: (typeof row.feeItem === 'string' ? row.feeItem : ''),
rateFee: typeof row.rateFee === 'number' ? row.rateFee : null,
hourlyFee: typeof row.hourlyFee === 'number' ? row.hourlyFee : null,
quantityUnitPriceFee:
typeof row.quantityUnitPriceFee === 'number'
? row.quantityUnitPriceFee
: toLegacyQuantityUnitPriceFee(row)
} as FeeMethodRow
})
if (hasFixedNames.value) {
const byName = new Map(rows.map(row => [row.name, row]))
return fixedNames.value.map((item, index) => {
const fromDb = byName.get(item.name)
return {
id: item?.id || `fee-method-fixed-${index}`,
name:item.name,
rateFee: fromDb?.rateFee ?? null,
hourlyFee: fromDb?.hourlyFee ?? null,
quantityUnitPriceFee: fromDb?.quantityUnitPriceFee ?? null
}
})
}
return rows.length > 0 ? rows : [createDefaultRow()]
}
const buildPersistDetailRows = () => detailRows.value.map(row => ({ ...row }))
const saveToIndexedDB = async (force = false) => {
try {
const payload: FeeMethodState = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
}
const snapshot = JSON.stringify(payload.detailRows)
if (!force && snapshot === lastSavedSnapshot.value) return
zxFwPricingStore.setHtFeeMainState(props.storageKey, payload, { force })
lastSavedSnapshot.value = snapshot
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
const data = await zxFwPricingStore.loadHtFeeMainState<FeeMethodRow>(props.storageKey)
const mergedRows = mergeWithStoredRows(data?.detailRows)
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
await saveToIndexedDB(true)
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
const mergedRows = mergeWithStoredRows([])
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
await saveToIndexedDB(true)
}
}
const addRow = () => {
detailRows.value = [...detailRows.value, createDefaultRow()]
void saveToIndexedDB()
}
const clearRow = async (id: string) => {
tabStore.removeTab(`ht-fee-edit-${props.storageKey}-${id}`)
await nextTick()
zxFwPricingStore.removeHtFeeMethodState(props.storageKey, id, 'rate-fee')
zxFwPricingStore.removeHtFeeMethodState(props.storageKey, id, 'hourly-fee')
zxFwPricingStore.removeHtFeeMethodState(props.storageKey, id, 'quantity-unit-price-fee')
detailRows.value = detailRows.value.map(row =>
row.id !== id
? row
: {
...row,
rateFee: null,
hourlyFee: null,
quantityUnitPriceFee: null
}
)
await saveToIndexedDB()
}
const editRow = (id: string) => {
const row = detailRows.value.find(item => item.id === id)
if (!row) return
console.log(id)
tabStore.openTab({
id: `ht-fee-edit-${props.storageKey}-${id}`,
title: `费用编辑-${row.name || '未命名'}`,
componentName: 'HtFeeMethodTypeLineView',
props: {
sourceTitle: props.title,
storageKey: props.storageKey,
rowId: id,
rowName: row.name || '',
contractId: props.contractId,
contractName: props.contractName
}
})
}
const ActionCellRenderer = defineComponent({
name: 'HtFeeMethodActionCellRenderer',
props: {
params: {
type: Object as PropType<ICellRendererParams<FeeMethodRow>>,
required: true
}
},
setup(props) {
return () => {
if (isSummaryRow(props.params.data as FeeMethodRow | undefined)) return null
const onActionClick = (action: 'edit' | 'clear') => (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const rowId = props.params.data?.id
if (!rowId) return
if (action === 'edit') {
props.params.context?.onActionEdit?.(rowId)
return
}
void props.params.context?.onActionRequestClear?.(rowId, String(props.params.data?.name || ''))
}
return h('div', { class: 'zxfw-action-wrap' }, [
h('div', { class: 'zxfw-action-group' }, [
h('button', {
class: 'zxfw-action-btn',
'data-action': 'edit',
type: 'button',
onClick: onActionClick('edit')
}, [
h(Pencil, { size: 13, 'aria-hidden': 'true' }),
h('span', '编辑')
]),
h('button', {
class: 'zxfw-action-btn zxfw-action-btn--danger',
'data-action': 'clear',
type: 'button',
onClick: onActionClick('clear')
}, [
h(Eraser, { size: 13, 'aria-hidden': 'true' }),
h('span', '清空')
])
])
])
}
}
})
const columnDefs: ColDef<FeeMethodRow>[] = [
{
headerName: '名字',
field: 'name',
minWidth: 180,
flex: 1.8,
editable: false,
valueFormatter: formatEditableText,
cellClass: params =>
params.context?.fixedNames === true || isSummaryRow(params.data)
? ''
: 'editable-cell-line',
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '费率计取',
field: 'rateFee',
minWidth: 130,
flex: 1.2,
editable: false,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueParser: params => numericParser(params.newValue),
valueFormatter: formatEditableNumber,
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '工时法',
field: 'hourlyFee',
minWidth: 130,
flex: 1.2,
editable: false,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueParser: params => numericParser(params.newValue),
valueFormatter: formatEditableNumber,
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '数量单价',
field: 'quantityUnitPriceFee',
minWidth: 130,
flex: 1.2,
editable: false,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueParser: params => numericParser(params.newValue),
valueFormatter: formatEditableNumber,
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '小计',
field: 'subtotal',
minWidth: 140,
flex: 1.2,
editable: false,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueGetter: params => getRowSubtotal(params.data),
valueFormatter: formatEditableNumber
},
{
headerName: '操作',
field: 'actions',
minWidth: 220,
flex: 1.6,
maxWidth: 260,
editable: false,
sortable: false,
filter: false,
suppressMovable: true,
cellRenderer: ActionCellRenderer
}
]
const detailGridOptions: GridOptions<FeeMethodRow> = {
...gridOptions,
treeData: false,
getDataPath: undefined,
context: {
fixedNames: hasFixedNames.value,
onActionEdit: editRow,
onActionClear: clearRow,
onActionRequestClear: requestClearRow
},
onCellClicked: params => {
if (params.colDef.field !== 'actions' || !params.data || isSummaryRow(params.data)) return
const target = params.event?.target as HTMLElement | null
const btn = target?.closest('button[data-action]') as HTMLButtonElement | null
const action = btn?.dataset.action
if (action === 'edit') {
editRow(params.data.id)
return
}
if (action === 'clear') {
requestClearRow(params.data.id, params.data.name)
return
}
}
}
let reloadTimer: ReturnType<typeof setTimeout> | null = null
const scheduleReloadFromStorage = () => {
if (reloadTimer) clearTimeout(reloadTimer)
reloadTimer = setTimeout(() => {
void loadFromIndexedDB()
}, 80)
}
const handleGridReady = (event: GridReadyEvent<FeeMethodRow>) => {
gridApi.value = event.api
}
const handleCellValueChanged = (event: CellValueChangedEvent<FeeMethodRow>) => {
if (isSummaryRow(event.data)) return
void saveToIndexedDB()
}
onMounted(async () => {
await loadFromIndexedDB()
})
onActivated(() => {
scheduleReloadFromStorage()
})
const storageKeyRef = computed(() => props.storageKey)
const reserveAdditionalWorkKeyVersion = computed(() => {
if (!isReserveStorageKey(props.storageKey)) return 0
const contractId = String(props.contractId || '').trim()
if (!contractId) return 0
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
return zxFwPricingStore.getKeyVersion(additionalStorageKey)
})
watch(storageKeyRef, () => {
scheduleReloadFromStorage()
})
watch(
() =>
detailRows.value
.map(row => {
const rateKey = zxFwPricingStore.getHtFeeMethodStorageKey(props.storageKey, row.id, 'rate-fee')
const hourlyKey = zxFwPricingStore.getHtFeeMethodStorageKey(props.storageKey, row.id, 'hourly-fee')
const quantityKey = zxFwPricingStore.getHtFeeMethodStorageKey(props.storageKey, row.id, 'quantity-unit-price-fee')
return `${row.id}:${zxFwPricingStore.getKeyVersion(rateKey)}:${zxFwPricingStore.getKeyVersion(hourlyKey)}:${zxFwPricingStore.getKeyVersion(quantityKey)}`
})
.join('|'),
(nextSignature, prevSignature) => {
if (!nextSignature && !prevSignature) return
if (nextSignature === prevSignature) return
scheduleReloadFromStorage()
}
)
watch(
() => {
const contractId = String(props.contractId || '').trim()
if (!contractId) return 0
return zxFwPricingStore.contractVersions[contractId] || 0
},
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion || nextVersion === 0) return
scheduleReloadFromStorage()
}
)
watch(
reserveAdditionalWorkKeyVersion,
(nextVersion, prevVersion) => {
if (nextVersion === prevVersion) return
scheduleReloadFromStorage()
}
)
watch([hasFixedNames], () => {
if (!detailGridOptions.context) return
detailGridOptions.context.fixedNames = hasFixedNames.value
detailGridOptions.context.onActionEdit = editRow
detailGridOptions.context.onActionClear = clearRow
detailGridOptions.context.onActionRequestClear = requestClearRow
gridApi.value?.refreshCells({ force: true })
})
onBeforeUnmount(() => {
if (reloadTimer) clearTimeout(reloadTimer)
gridApi.value = null
void saveToIndexedDB(true)
})
</script>
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
<Button v-if="!hasFixedNames" type="button" variant="outline" size="sm" @click="addRow">新增</Button>
</div>
<div :class="agGridWrapClass">
<AgGridVue
:style="agGridStyle"
:rowData="displayRows"
:columnDefs="columnDefs"
:gridOptions="detailGridOptions"
:theme="myTheme"
:treeData="false"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
@grid-ready="handleGridReady"
@cell-value-changed="handleCellValueChanged"
/>
</div>
</div>
</div>
<AlertDialogRoot :open="clearConfirmOpen" @update:open="handleClearConfirmOpenChange">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认清空</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将清空{{ pendingClearRowName }}及其编辑页面的可填和自动计算数据是否继续
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="confirmClearRow">确认清空</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</template>