455 lines
14 KiB
Vue
455 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { 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 localforage from 'localforage'
|
||
import { myTheme, gridOptions } 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 { useHtFeeMethodReloadStore } from '@/pinia/htFeeMethodReload'
|
||
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
|
||
syncMainStorageKey?: string
|
||
syncRowId?: string
|
||
}>()
|
||
const htFeeMethodReloadStore = useHtFeeMethodReloadStore()
|
||
|
||
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 detailRows = ref<FeeRow[]>([])
|
||
const gridApi = ref<GridApi<FeeRow> | null>(null)
|
||
const deleteConfirmOpen = ref(false)
|
||
const pendingDeleteRowId = ref<string | null>(null)
|
||
const pendingDeleteRowName = ref('')
|
||
|
||
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
|
||
for (const row of detailRows.value) {
|
||
if (isSubtotalRow(row)) continue
|
||
if (row.quantity == null || row.unitPrice == null) {
|
||
row.budgetFee = null
|
||
continue
|
||
}
|
||
row.budgetFee = roundTo(toDecimal(row.quantity).mul(row.unitPrice), 2)
|
||
if (typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee)) {
|
||
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 = totalBudgetFee
|
||
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()))
|
||
}
|
||
|
||
await localforage.setItem(props.storageKey, payload)
|
||
if (props.syncMainStorageKey && props.syncRowId) {
|
||
htFeeMethodReloadStore.emit(props.syncMainStorageKey, props.syncRowId)
|
||
}
|
||
} catch (error) {
|
||
console.error('saveToIndexedDB failed:', error)
|
||
}
|
||
}
|
||
|
||
const loadFromIndexedDB = async () => {
|
||
try {
|
||
const data = await localforage.getItem<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: 'editable-cell-line 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
|
||
}
|
||
|
||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
const handleGridReady = (event: GridReadyEvent<FeeRow>) => {
|
||
gridApi.value = event.api
|
||
}
|
||
|
||
const handleCellValueChanged = () => {
|
||
syncComputedValuesToRows()
|
||
gridApi.value?.refreshCells({ force: true })
|
||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||
gridPersistTimer = setTimeout(() => {
|
||
void saveToIndexedDB()
|
||
}, 300)
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await loadFromIndexedDB()
|
||
})
|
||
|
||
onActivated(() => {
|
||
void loadFromIndexedDB()
|
||
})
|
||
|
||
watch(
|
||
() => props.storageKey,
|
||
() => {
|
||
void loadFromIndexedDB()
|
||
}
|
||
)
|
||
|
||
onBeforeUnmount(() => {
|
||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||
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="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||
<AgGridVue
|
||
:style="{ height: '100%' }"
|
||
:rowData="detailRows"
|
||
: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="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>
|