JGJS2026/src/components/shared/HtFeeGrid.vue
2026-03-20 18:08:36 +08:00

493 lines
15 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, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { 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 { roundTo, toDecimal } from '@/lib/decimal'
import { Button } from '@/components/ui/button'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { Trash2 } from 'lucide-vue-next'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle
} from 'reka-ui'
interface FeeRow {
id: string
feeItem: string
unit: string
quantity: number | null
unitPrice: number | null
budgetFee: number | null
remark: string
actions?: unknown
}
interface FeeGridState {
detailRows: FeeRow[]
}
const props = defineProps<{
title: string
storageKey: string
htMainStorageKey?: string
htRowId?: string
htMethodType?: 'quantity-unit-price-fee'
}>()
const zxFwPricingStore = useZxFwPricingStore()
const createRowId = () => `fee-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
const SUBTOTAL_ROW_ID = 'fee-subtotal-fixed'
const createDefaultRow = (): FeeRow => ({
id: createRowId(),
feeItem: '',
unit: '',
quantity: null,
unitPrice: null,
budgetFee: null,
remark: ''
})
const createSubtotalRow = (): FeeRow => ({
id: SUBTOTAL_ROW_ID,
feeItem: '小计',
unit: '',
quantity: null,
unitPrice: null,
budgetFee: 0,
remark: ''
})
const isSubtotalRow = (row?: FeeRow | null) => row?.id === SUBTOTAL_ROW_ID
const ensureSubtotalRow = (rows: FeeRow[]) => {
const normalRows = rows.filter(row => !isSubtotalRow(row))
if (normalRows.length === 0) return []
return [...normalRows, createSubtotalRow()]
}
const fallbackDetailRows = ref<FeeRow[]>([])
const gridApi = ref<GridApi<FeeRow> | null>(null)
const deleteConfirmOpen = ref(false)
const pendingDeleteRowId = ref<string | null>(null)
const pendingDeleteRowName = ref('')
const useHtMethodState = computed(
() => Boolean(props.htMainStorageKey && props.htRowId && props.htMethodType)
)
const detailRows = computed<FeeRow[]>({
get: () => {
if (!useHtMethodState.value) return fallbackDetailRows.value
const state = zxFwPricingStore.getHtFeeMethodState<FeeGridState>(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!
)
const rows = state?.detailRows
return Array.isArray(rows) ? rows : []
},
set: rows => {
if (!useHtMethodState.value) {
fallbackDetailRows.value = rows
return
}
zxFwPricingStore.setHtFeeMethodState(props.htMainStorageKey!, props.htRowId!, props.htMethodType!, {
detailRows: rows
})
}
})
const addRow = () => {
const normalRows = detailRows.value.filter(row => !isSubtotalRow(row))
detailRows.value = ensureSubtotalRow([...normalRows, createDefaultRow()])
syncComputedValuesToRows()
void saveToIndexedDB()
}
const deleteRow = (id: string) => {
const normalRows = detailRows.value.filter(row => !isSubtotalRow(row) && row.id !== id)
detailRows.value = ensureSubtotalRow(normalRows)
syncComputedValuesToRows()
void saveToIndexedDB()
}
const requestDeleteRow = (id: string, name?: string) => {
pendingDeleteRowId.value = id
pendingDeleteRowName.value = String(name || '').trim() || '当前行'
deleteConfirmOpen.value = true
}
const handleDeleteConfirmOpenChange = (open: boolean) => {
deleteConfirmOpen.value = open
}
const confirmDeleteRow = () => {
const id = pendingDeleteRowId.value
if (!id) return
deleteRow(id)
deleteConfirmOpen.value = false
pendingDeleteRowId.value = null
pendingDeleteRowName.value = ''
}
const formatEditableText = (params: any) => {
if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return '点击输入'
return String(params.value)
}
const formatEditableQuantity = (params: any) => {
if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return '点击输入'
return formatThousandsFlexible(params.value, 3)
}
const formatEditableUnitPrice = (params: any) => {
if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return '点击输入'
return formatThousandsFlexible(params.value, 3)
}
const formatReadonlyBudgetFee = (params: any) => {
if (params.value == null || params.value === '') return ''
return formatThousandsFlexible(params.value, 3)
}
const syncComputedValuesToRows = () => {
let totalBudgetFee = 0
let hasValidRow = false
for (const row of detailRows.value) {
if (isSubtotalRow(row)) continue
const hasFeeItem = typeof row.feeItem === 'string' && row.feeItem.trim() !== ''
const hasUnit = typeof row.unit === 'string' && row.unit.trim() !== ''
const quantity = typeof row.quantity === 'number' && Number.isFinite(row.quantity) ? row.quantity : null
const unitPrice = typeof row.unitPrice === 'number' && Number.isFinite(row.unitPrice) ? row.unitPrice : null
if (!hasFeeItem || !hasUnit || quantity == null || unitPrice == null) {
row.budgetFee = null
continue
}
row.budgetFee = roundTo(toDecimal(quantity).mul(unitPrice), 2)
if (typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee)) {
hasValidRow = true
totalBudgetFee = roundTo(toDecimal(totalBudgetFee).add(row.budgetFee), 2)
}
}
const subtotalRow = detailRows.value.find(row => isSubtotalRow(row))
if (subtotalRow) {
subtotalRow.feeItem = '小计'
subtotalRow.unit = ''
subtotalRow.quantity = null
subtotalRow.unitPrice = null
subtotalRow.budgetFee = hasValidRow ? totalBudgetFee : null
subtotalRow.remark = ''
}
}
const mergeWithStoredRows = (rowsFromDb: unknown): FeeRow[] => {
if (!Array.isArray(rowsFromDb) || rowsFromDb.length === 0) {
return []
}
const rows: FeeRow[] = rowsFromDb.map(item => {
const row = item as Partial<FeeRow>
return {
id: typeof row.id === 'string' && row.id ? row.id : createRowId(),
feeItem: typeof row.feeItem === 'string' ? row.feeItem : '',
unit: typeof row.unit === 'string' ? row.unit : '',
quantity: typeof row.quantity === 'number' ? row.quantity : null,
unitPrice: typeof row.unitPrice === 'number' ? row.unitPrice : null,
budgetFee: typeof row.budgetFee === 'number' ? row.budgetFee : null,
remark: typeof row.remark === 'string' ? row.remark : ''
}
})
return ensureSubtotalRow(rows)
}
const buildPersistDetailRows = () => {
syncComputedValuesToRows()
return detailRows.value.map(row => ({ ...row }))
}
const saveToIndexedDB = async () => {
try {
const payload: FeeGridState = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
}
if (useHtMethodState.value) {
zxFwPricingStore.setHtFeeMethodState(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!,
payload,
{ force: true }
)
} else {
zxFwPricingStore.setKeyState(props.storageKey, payload)
}
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
const data = useHtMethodState.value
? await zxFwPricingStore.loadHtFeeMethodState<FeeGridState>(
props.htMainStorageKey!,
props.htRowId!,
props.htMethodType!
)
: await zxFwPricingStore.loadKeyState<FeeGridState>(props.storageKey)
detailRows.value = mergeWithStoredRows(data?.detailRows)
syncComputedValuesToRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = []
syncComputedValuesToRows()
}
}
const columnDefs: ColDef<FeeRow>[] = [
{
headerName: '序号',
colId: 'rowNo',
minWidth: 68,
maxWidth: 80,
flex: 0.6,
editable: false,
sortable: false,
filter: false,
cellStyle: { textAlign: 'center' },
valueGetter: params =>
params.node?.rowPinned
? ''
: isSubtotalRow(params.data)
? '小计'
: typeof params.node?.rowIndex === 'number'
? params.node.rowIndex + 1
: '',
colSpan: params => (isSubtotalRow(params.data) ? 2 : 1)
},
{
headerName: '费用项',
field: 'feeItem',
minWidth: 140,
flex: 1.4,
editable: params => !isSubtotalRow(params.data),
valueGetter: params => (isSubtotalRow(params.data) ? '' : (params.data?.feeItem ?? '')),
valueFormatter: formatEditableText,
cellClass: params => (isSubtotalRow(params.data) ? '' : 'editable-cell-line'),
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '单位',
field: 'unit',
minWidth: 90,
flex: 0.9,
editable: params => !isSubtotalRow(params.data),
valueFormatter: formatEditableText,
cellClass: 'editable-cell-line',
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '数量',
field: 'quantity',
minWidth: 100,
flex: 1,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell editable-cell-line',
editable: params => !isSubtotalRow(params.data),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatEditableQuantity,
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '单价(元)',
field: 'unitPrice',
minWidth: 120,
flex: 1.1,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell editable-cell-line',
editable: params => !isSubtotalRow(params.data),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableUnitPrice,
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '预算费用(元)',
field: 'budgetFee',
minWidth: 130,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
editable: false,
valueFormatter: formatReadonlyBudgetFee
},
{
headerName: '说明',
field: 'remark',
minWidth: 170,
flex: 2,
editable: params => !isSubtotalRow(params.data),
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
valueFormatter: formatEditableText,
cellClass: ' remark-wrap-cell',
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '操作',
field: 'actions',
minWidth: 92,
maxWidth: 110,
flex: 0.8,
editable: false,
sortable: false,
filter: false,
suppressMovable: true,
cellRenderer: defineComponent({
name: 'HtFeeGridActionCellRenderer',
props: {
params: {
type: Object as PropType<ICellRendererParams<FeeRow>>,
required: true
}
},
setup(rendererProps) {
return () => {
const row = rendererProps.params.data
if (!row || isSubtotalRow(row)) return null
const onDelete = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
requestDeleteRow(row.id, row.feeItem)
}
return h(
'button',
{
type: 'button',
class:
'inline-flex cursor-pointer items-center gap-1 rounded border border-red-200 px-2 py-1 text-xs text-red-600 hover:bg-red-50',
onClick: onDelete
},
[h(Trash2, { size: 12, 'aria-hidden': 'true' }), h('span', '删除')]
)
}
}
})
}
]
const detailGridOptions: GridOptions<FeeRow> = {
...gridOptions,
treeData: false
}
const handleGridReady = (event: GridReadyEvent<FeeRow>) => {
gridApi.value = event.api
}
const handleCellValueChanged = () => {
syncComputedValuesToRows()
gridApi.value?.refreshCells({ force: true })
void saveToIndexedDB()
}
onMounted(async () => {
await loadFromIndexedDB()
})
onActivated(() => {
void loadFromIndexedDB()
})
watch(
() => props.storageKey,
() => {
void loadFromIndexedDB()
}
)
onBeforeUnmount(() => {
gridApi.value?.stopEditing()
gridApi.value = null
void saveToIndexedDB()
})
</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 type="button" variant="outline" size="sm" @click="addRow">添加行</Button>
</div>
<div :class="agGridWrapClass">
<AgGridVue
:style="agGridStyle"
:rowData="detailRows"
:columnDefs="columnDefs"
:gridOptions="detailGridOptions"
:theme="myTheme"
:animateRows="true"
: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="deleteConfirmOpen" @update:open="handleDeleteConfirmOpenChange">
<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">
将删除{{ pendingDeleteRowName }}这条明细是否继续
</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="confirmDeleteRow">确认删除</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</template>