550 lines
17 KiB
Vue

<script setup lang="ts">
import { computed, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
import { useI18n } from 'vue-i18n'
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 { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
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 { t } = useI18n()
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: t('htFeeDetail.subtotal'),
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() || t('htFeeDetail.currentRow')
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 t('htFeeDetail.clickToInput')
return String(params.value)
}
const formatEditableQuantity = (params: any) => {
if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return t('htFeeDetail.clickToInput')
return formatThousandsFlexible(params.value, 3)
}
const formatEditableUnitPrice = (params: any) => {
if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return t('htFeeDetail.clickToInput')
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 = t('htFeeDetail.subtotal')
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: t('htFeeDetail.columns.no'),
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)
? t('htFeeDetail.subtotal')
: typeof params.node?.rowIndex === 'number'
? params.node.rowIndex + 1
: '',
cellClassRules: {
'ag-summary-label-cell': params => isSubtotalRow(params.data)
},
colSpan: params => (isSubtotalRow(params.data) ? 2 : 1)
},
{
headerName: t('htFeeDetail.columns.feeItem'),
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: t('htFeeDetail.columns.unit'),
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: t('htFeeDetail.columns.quantity'),
field: 'quantity',
minWidth: 100,
flex: 1,
headerClass: 'ag-right-aligned-header',
cellClass: 'editable-cell-line',
editable: params => !isSubtotalRow(params.data),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatEditableQuantity,
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: t('htFeeDetail.columns.unitPrice'),
field: 'unitPrice',
minWidth: 120,
flex: 1.1,
headerClass: 'ag-right-aligned-header',
cellClass: 'editable-cell-line',
editable: params => !isSubtotalRow(params.data),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableUnitPrice,
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: t('htFeeDetail.columns.budgetFee'),
field: 'budgetFee',
minWidth: 130,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
editable: false,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: formatReadonlyBudgetFee
},
{
headerName: t('htFeeDetail.columns.remark'),
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: t('htFeeDetail.columns.actions'),
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', t('common.delete'))]
)
}
}
})
}
]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const detailGridOptions: GridOptions<FeeRow> = {
...gridOptions,
treeData: false
}
const handleGridReady = (event: GridReadyEvent<FeeRow>) => {
gridApi.value = event.api
}
let isBulkClipboardMutation = false
const commitGridChanges = () => {
syncComputedValuesToRows()
gridApi.value?.refreshCells({ force: true })
void saveToIndexedDB()
}
const handleCellValueChanged = () => {
if (isBulkClipboardMutation) return
commitGridChanges()
}
const handleBulkMutationStart = () => {
isBulkClipboardMutation = true
}
const handleBulkMutationEnd = () => {
isBulkClipboardMutation = false
commitGridChanges()
}
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value)
}
return params.value
}
const processCellFromClipboard = (params: any) => {
const field = String(params.column?.getColDef?.().field || '')
if (field === 'quantity') {
return parseNumberOrNull(params.value, { precision: 3 })
}
if (field === 'unitPrice') {
return parseNumberOrNull(params.value, { precision: 2 })
}
try {
const parsed = JSON.parse(params.value)
if (Array.isArray(parsed)) return parsed
} catch (_error) {
return params.value
}
return params.value
}
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">{{ t('htFeeDetail.addRow') }}</Button>
</div>
<div :class="agGridWrapClass">
<AgGridVue
:style="agGridStyle"
:rowData="detailRows"
:columnDefs="gridColumnDefs"
: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"
:processCellForClipboard="processCellForClipboard"
:processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
@grid-ready="handleGridReady"
@cell-value-changed="handleCellValueChanged"
@paste-start="handleBulkMutationStart"
@paste-end="handleBulkMutationEnd"
@fill-start="handleBulkMutationStart"
@fill-end="handleBulkMutationEnd"
/>
</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">{{ t('htFeeDetail.dialog.deleteTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
{{ t('htFeeDetail.dialog.deleteDesc', { name: pendingDeleteRowName }) }}
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">{{ t('common.cancel') }}</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="confirmDeleteRow">{{ t('common.delete') }}</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</template>