743 lines
23 KiB
Vue
743 lines
23 KiB
Vue
<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>
|