550 lines
17 KiB
Vue
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>
|