1
This commit is contained in:
parent
35f06746fe
commit
be2662d579
@ -56,6 +56,7 @@ interface DetailRow {
|
||||
hourly: number | null
|
||||
subtotal?: number | null
|
||||
finalFee?: number | null
|
||||
remark?: string
|
||||
actions?: unknown
|
||||
}
|
||||
|
||||
@ -180,6 +181,7 @@ const normalizeDetailRow = (row: Partial<DetailRow>): DetailRow => ({
|
||||
hourly: typeof row.hourly === 'number' ? row.hourly : null,
|
||||
subtotal: typeof row.subtotal === 'number' ? row.subtotal : null,
|
||||
finalFee: typeof row.finalFee === 'number' ? row.finalFee : null,
|
||||
remark: String(row.remark || ''),
|
||||
actions: row.actions
|
||||
})
|
||||
|
||||
@ -408,11 +410,11 @@ const createMethodColumn = (
|
||||
): ColDef<DetailRow> => ({
|
||||
headerName,
|
||||
field,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth,
|
||||
flex: 1.5,
|
||||
editable: false,
|
||||
cellClassRules: {
|
||||
'zxfw-number-cell': () => true,
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueGetter: params => {
|
||||
@ -666,11 +668,11 @@ const columnDefs: ColDef<DetailRow>[] = [
|
||||
{
|
||||
headerName: t('htZxFw.columns.subtotal'),
|
||||
field: 'subtotal',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
flex: 2,
|
||||
minWidth: 100,
|
||||
editable: false,
|
||||
cellClassRules: {
|
||||
'zxfw-number-cell': () => true,
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueGetter: params => {
|
||||
@ -682,12 +684,12 @@ const columnDefs: ColDef<DetailRow>[] = [
|
||||
{
|
||||
headerName: t('htZxFw.columns.finalFee'),
|
||||
field: 'finalFee',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
headerTooltip: t('htZxFw.columns.finalFeeTooltip'),
|
||||
flex: 2,
|
||||
minWidth: 110,
|
||||
editable: params => !isFixedRow(params.data),
|
||||
cellClassRules: {
|
||||
'zxfw-number-cell': () => true,
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueGetter: params => {
|
||||
@ -707,6 +709,32 @@ const columnDefs: ColDef<DetailRow>[] = [
|
||||
},
|
||||
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
|
||||
},
|
||||
{
|
||||
headerName: t('htZxFw.columns.remark'),
|
||||
field: 'remark',
|
||||
minWidth: 160,
|
||||
flex: 1.8,
|
||||
editable: params => !isFixedRow(params.data),
|
||||
cellEditor: 'agLargeTextCellEditor',
|
||||
cellEditorPopup: true,
|
||||
cellEditorParams: {
|
||||
maxLength: 500,
|
||||
rows: 8,
|
||||
cols: 48
|
||||
},
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||
valueGetter: params => {
|
||||
if (!params.data || isFixedRow(params.data)) return ''
|
||||
return String(params.data.remark || '')
|
||||
},
|
||||
valueFormatter: params => (params.value == null ? '' : String(params.value)),
|
||||
cellClass: params => (!isFixedRow(params.data) ? 'remark-wrap-cell' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => !isFixedRow(params.data) && (params.value == null || String(params.value).trim() === '')
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: t('htZxFw.columns.actions'),
|
||||
field: 'actions',
|
||||
@ -931,7 +959,8 @@ const applySelection = async (codes: string[]) => {
|
||||
hourly: nextValues.hourly,
|
||||
subtotal: typeof old?.subtotal === 'number' ? old.subtotal : null,
|
||||
|
||||
finalFee: typeof old?.finalFee === 'number' ? old.finalFee : null
|
||||
finalFee: typeof old?.finalFee === 'number' ? old.finalFee : null,
|
||||
remark: String(old?.remark || '')
|
||||
}
|
||||
})
|
||||
.filter((row): row is DetailRow => row !== null)
|
||||
@ -951,6 +980,7 @@ const applySelection = async (codes: string[]) => {
|
||||
hourly: typeof fixedOld?.hourly === 'number' ? fixedOld.hourly : null,
|
||||
subtotal: typeof fixedOld?.subtotal === 'number' ? fixedOld.subtotal : null,
|
||||
finalFee: typeof fixedOld?.finalFee === 'number' ? fixedOld.finalFee : null,
|
||||
remark: '',
|
||||
actions: null
|
||||
}
|
||||
|
||||
@ -1000,9 +1030,9 @@ const initializeContractState = async () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('initializeContractState failed:', error)
|
||||
await setCurrentContractState({
|
||||
selectedIds: [],
|
||||
detailRows: applyFixedRowTotals([{
|
||||
await setCurrentContractState({
|
||||
selectedIds: [],
|
||||
detailRows: applyFixedRowTotals([{
|
||||
id: fixedBudgetRow.id,
|
||||
code: fixedBudgetRow.code,
|
||||
name: fixedBudgetRow.name,
|
||||
@ -1011,11 +1041,12 @@ const initializeContractState = async () => {
|
||||
landScale: null,
|
||||
workload: null,
|
||||
hourly: null,
|
||||
subtotal: null,
|
||||
finalFee: null,
|
||||
actions: null
|
||||
}])
|
||||
})
|
||||
subtotal: null,
|
||||
finalFee: null,
|
||||
remark: '',
|
||||
actions: null
|
||||
}])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -1197,15 +1228,20 @@ const commitFinalFeeGridChanges = async () => {
|
||||
|
||||
const handleCellValueChanged = async (event: any) => {
|
||||
if (isBulkClipboardMutation) return
|
||||
if (event.colDef?.field !== 'finalFee') return
|
||||
const row = event.data as DetailRow | undefined
|
||||
if (!row || isFixedRow(row)) return
|
||||
const newValue = event.newValue != null ? roundTo(Number(event.newValue), 2) : null
|
||||
const field = String(event.colDef?.field || '')
|
||||
if (field !== 'finalFee' && field !== 'remark') return
|
||||
const currentState = getCurrentContractState()
|
||||
|
||||
const nextRows = currentState.detailRows.map(item =>
|
||||
item.id === row.id ? { ...item, finalFee: newValue } : item
|
||||
)
|
||||
const nextRows = currentState.detailRows.map(item => {
|
||||
if (item.id !== row.id) return item
|
||||
if (field === 'remark') {
|
||||
return { ...item, remark: String(event.newValue || '') }
|
||||
}
|
||||
const newValue = event.newValue != null ? roundTo(Number(event.newValue), 2) : null
|
||||
return { ...item, finalFee: newValue }
|
||||
})
|
||||
const finalRows = applyFixedRowSummary(nextRows)
|
||||
await setCurrentContractState({
|
||||
...currentState,
|
||||
@ -1256,7 +1292,7 @@ onActivated(async () => {
|
||||
</h3>
|
||||
|
||||
</div>
|
||||
<p class="text-[11px] text-muted-foreground leading-none leading-4 text-amber-700/90">{{ t('htZxFw.warning') }}</p>
|
||||
<p class="text-[11px] leading-none leading-4 !text-[brown]">{{ t('htZxFw.warning') }}</p>
|
||||
</div>
|
||||
|
||||
<div :class="agGridWrapClass">
|
||||
@ -1319,6 +1355,14 @@ onActivated(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.xmMx .ag-header-cell-label) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:deep(.xmMx .ag-header-cell-text) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(.zxfw-process-header .ag-header-cell-label) {
|
||||
justify-content: center;
|
||||
}
|
||||
@ -1332,6 +1376,11 @@ onActivated(async () => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.zxfw-number-cell) {
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
:deep(.zxfw-name-cell.ag-cell-auto-height) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -30,6 +30,9 @@ import {
|
||||
createScaleBudgetCellRendererToggleFactory,
|
||||
formatScaleEditableConditionalNumber,
|
||||
restoreScaleColumnDefaults,
|
||||
type ScaleBudgetCheckField,
|
||||
type ScaleBudgetHeaderCheckState,
|
||||
ScaleBudgetToggleHeader,
|
||||
} from '@/lib/pricingScaleGrid'
|
||||
import {
|
||||
getCheckedBenchmarkBudgetSplitByRow,
|
||||
@ -538,6 +541,32 @@ const createBudgetCellRendererWithCheck = createScaleBudgetCellRendererToggleFac
|
||||
() => handleCellValueChanged()
|
||||
)
|
||||
|
||||
const getBenchmarkBudgetHeaderCheckState = (field: ScaleBudgetCheckField): ScaleBudgetHeaderCheckState => {
|
||||
const targetRows = detailRows.value.filter(row => row.hasCost)
|
||||
if (!targetRows.length) return 'all'
|
||||
const checkedCount = targetRows.reduce((count, row) => (row[field] !== false ? count + 1 : count), 0)
|
||||
if (checkedCount === 0) return 'none'
|
||||
if (checkedCount === targetRows.length) return 'all'
|
||||
return 'partial'
|
||||
}
|
||||
|
||||
const toggleBenchmarkBudgetColumnChecked = (field: ScaleBudgetCheckField, checked: boolean) => {
|
||||
const targetRows = detailRows.value.filter(row => row.hasCost)
|
||||
if (!targetRows.length) return
|
||||
for (const row of targetRows) {
|
||||
row[field] = checked
|
||||
}
|
||||
gridApi.value?.refreshHeader()
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
handleCellValueChanged()
|
||||
}
|
||||
|
||||
const getBenchmarkBudgetHeaderParams = (field: ScaleBudgetCheckField) => ({
|
||||
field,
|
||||
getHeaderCheckState: getBenchmarkBudgetHeaderCheckState,
|
||||
onToggleAll: toggleBenchmarkBudgetColumnChecked
|
||||
})
|
||||
|
||||
const getBenchmarkBudgetSplitByAmount = (row?: Pick<DetailRow, 'amount'>) =>
|
||||
getCheckedBenchmarkBudgetSplitByRow(
|
||||
{
|
||||
@ -661,7 +690,9 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
|
||||
}),
|
||||
createScaleBenchmarkBudgetColumnGroup<DetailRow>({
|
||||
getCheckedSplit: getCheckedBenchmarkBudgetSplitByAmount,
|
||||
createBudgetCellRendererWithCheck
|
||||
createBudgetCellRendererWithCheck,
|
||||
getHeaderComponent: () => ScaleBudgetToggleHeader,
|
||||
getHeaderComponentParams: getBenchmarkBudgetHeaderParams
|
||||
}),
|
||||
createScaleBudgetFeeColumnGroup<DetailRow>({
|
||||
headerComponent: AgGridResetHeader,
|
||||
@ -877,7 +908,13 @@ const buildEmptyRows = (targetProjectCount: number) =>
|
||||
: buildDefaultRows(targetProjectCount)
|
||||
|
||||
const applyDetailRows = (rows: DetailRow[]) => {
|
||||
detailRows.value = rows
|
||||
detailRows.value = rows.map(row => ({
|
||||
...row,
|
||||
benchmarkBudgetBasicChecked:
|
||||
typeof row.benchmarkBudgetBasicChecked === 'boolean' ? row.benchmarkBudgetBasicChecked : true,
|
||||
benchmarkBudgetOptionalChecked:
|
||||
typeof row.benchmarkBudgetOptionalChecked === 'boolean' ? row.benchmarkBudgetOptionalChecked : true
|
||||
}))
|
||||
}
|
||||
|
||||
const relabelDetailRowsFromDict = async () => {
|
||||
@ -1039,9 +1076,11 @@ let isBulkClipboardMutation = false
|
||||
const commitGridChanges = async (source: string) => {
|
||||
await nextTick()
|
||||
syncComputedValuesToDetailRows()
|
||||
gridApi.value?.refreshHeader()
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
await saveToIndexedDB({ skipComputedSync: true })
|
||||
await nextTick()
|
||||
gridApi.value?.refreshHeader()
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
}
|
||||
|
||||
|
||||
@ -30,6 +30,9 @@ import {
|
||||
createScaleBudgetCellRendererToggleFactory,
|
||||
formatScaleEditableConditionalNumber,
|
||||
restoreScaleColumnDefaults,
|
||||
type ScaleBudgetCheckField,
|
||||
type ScaleBudgetHeaderCheckState,
|
||||
ScaleBudgetToggleHeader,
|
||||
} from '@/lib/pricingScaleGrid'
|
||||
import {
|
||||
getCheckedBenchmarkBudgetSplitByRow,
|
||||
@ -410,6 +413,32 @@ const createBudgetCellRendererWithCheck = createScaleBudgetCellRendererToggleFac
|
||||
() => handleCellValueChanged()
|
||||
)
|
||||
|
||||
const getBenchmarkBudgetHeaderCheckState = (field: ScaleBudgetCheckField): ScaleBudgetHeaderCheckState => {
|
||||
const targetRows = detailRows.value.filter(row => row.hasArea)
|
||||
if (!targetRows.length) return 'all'
|
||||
const checkedCount = targetRows.reduce((count, row) => (row[field] !== false ? count + 1 : count), 0)
|
||||
if (checkedCount === 0) return 'none'
|
||||
if (checkedCount === targetRows.length) return 'all'
|
||||
return 'partial'
|
||||
}
|
||||
|
||||
const toggleBenchmarkBudgetColumnChecked = (field: ScaleBudgetCheckField, checked: boolean) => {
|
||||
const targetRows = detailRows.value.filter(row => row.hasArea)
|
||||
if (!targetRows.length) return
|
||||
for (const row of targetRows) {
|
||||
row[field] = checked
|
||||
}
|
||||
gridApi.value?.refreshHeader()
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
handleCellValueChanged()
|
||||
}
|
||||
|
||||
const getBenchmarkBudgetHeaderParams = (field: ScaleBudgetCheckField) => ({
|
||||
field,
|
||||
getHeaderCheckState: getBenchmarkBudgetHeaderCheckState,
|
||||
onToggleAll: toggleBenchmarkBudgetColumnChecked
|
||||
})
|
||||
|
||||
const getBenchmarkBudgetSplitByLandArea = (row?: Pick<DetailRow, 'landArea'>) =>
|
||||
getCheckedBenchmarkBudgetSplitByRow(
|
||||
{
|
||||
@ -538,7 +567,9 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
|
||||
}),
|
||||
createScaleBenchmarkBudgetColumnGroup<DetailRow>({
|
||||
getCheckedSplit: getCheckedBenchmarkBudgetSplitByLandArea,
|
||||
createBudgetCellRendererWithCheck
|
||||
createBudgetCellRendererWithCheck,
|
||||
getHeaderComponent: () => ScaleBudgetToggleHeader,
|
||||
getHeaderComponentParams: getBenchmarkBudgetHeaderParams
|
||||
}),
|
||||
createScaleBudgetFeeColumnGroup<DetailRow>({
|
||||
headerComponent: AgGridResetHeader,
|
||||
@ -737,7 +768,13 @@ const buildRowsFromStoredState = (rows: DetailRow[]) =>
|
||||
const buildEmptyRows = (targetProjectCount: number) => buildDefaultRows(targetProjectCount)
|
||||
|
||||
const applyDetailRows = (rows: DetailRow[]) => {
|
||||
detailRows.value = rows
|
||||
detailRows.value = rows.map(row => ({
|
||||
...row,
|
||||
benchmarkBudgetBasicChecked:
|
||||
typeof row.benchmarkBudgetBasicChecked === 'boolean' ? row.benchmarkBudgetBasicChecked : true,
|
||||
benchmarkBudgetOptionalChecked:
|
||||
typeof row.benchmarkBudgetOptionalChecked === 'boolean' ? row.benchmarkBudgetOptionalChecked : true
|
||||
}))
|
||||
}
|
||||
|
||||
const relabelDetailRowsFromDict = async () => {
|
||||
@ -897,9 +934,11 @@ let isBulkClipboardMutation = false
|
||||
const commitGridChanges = async (source: string) => {
|
||||
await nextTick()
|
||||
syncComputedValuesToDetailRows()
|
||||
gridApi.value?.refreshHeader()
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
await saveToIndexedDB({ skipComputedSync: true })
|
||||
await nextTick()
|
||||
gridApi.value?.refreshHeader()
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
}
|
||||
|
||||
|
||||
@ -341,6 +341,7 @@ const handleCheckedToggle = (id: string, checked: boolean) => {
|
||||
if (!target || isAddTriggerRow(target)) return
|
||||
target.checked = checked
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
gridApi.value?.redrawRows()
|
||||
saveToStore()
|
||||
}
|
||||
|
||||
|
||||
@ -167,6 +167,8 @@ const columnDefs: ColDef<FactorRow>[] = [
|
||||
{
|
||||
headerName: t('xmFactorGrid.columns.standardFactor'),
|
||||
field: 'standardFactor',
|
||||
type: 'numericColumn',
|
||||
cellClass: 'ag-right-aligned-cell',
|
||||
minWidth: 86,
|
||||
maxWidth: 100,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
@ -493,3 +495,4 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@ -328,6 +328,7 @@ export const enUS = {
|
||||
subtotal: 'Subtotal',
|
||||
finalFee: 'Final Fee ✎',
|
||||
finalFeeTooltip: 'This column supports manual edits and will auto-sync to the fixed subtotal row.',
|
||||
remark: 'Remark',
|
||||
actions: 'Actions'
|
||||
},
|
||||
dialog: {
|
||||
|
||||
@ -328,6 +328,7 @@ export const zhCN = {
|
||||
subtotal: '小计',
|
||||
finalFee: '确认金额 ✎',
|
||||
finalFeeTooltip: '该列支持手动修改,修改后会自动汇总到固定小计行',
|
||||
remark: '备注',
|
||||
actions: '操作'
|
||||
},
|
||||
dialog: {
|
||||
|
||||
@ -1048,6 +1048,28 @@ const createRichTextCode = (...parts: string[]): unknown => ({
|
||||
.map(text => ({ text }))
|
||||
})
|
||||
|
||||
const resolveMethodEnabled = (value: unknown, fallback: boolean) =>
|
||||
typeof value === 'boolean' ? value : fallback
|
||||
|
||||
const getServiceMethodAvailability = (serviceIdText: string) => {
|
||||
const dict = getServiceDictItemById(serviceIdText) as {
|
||||
scale?: boolean | null
|
||||
onlyCostScale?: boolean | null
|
||||
amount?: boolean | null
|
||||
workDay?: boolean | null
|
||||
} | null | undefined
|
||||
const scale = resolveMethodEnabled(dict?.scale, true)
|
||||
const onlyCostScale = resolveMethodEnabled(dict?.onlyCostScale, false)
|
||||
const amount = resolveMethodEnabled(dict?.amount, true)
|
||||
const workDay = resolveMethodEnabled(dict?.workDay, true)
|
||||
return {
|
||||
investmentScale: scale,
|
||||
landScale: scale && !onlyCostScale,
|
||||
workload: amount,
|
||||
hourly: workDay
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeHtFeeMainRows = (rows: HtFeeMainRowLike[] | undefined) => {
|
||||
if (!Array.isArray(rows)) return [] as Array<{ id: string; name: string; subtotal?: unknown }>
|
||||
const normalized = rows.map(row => {
|
||||
@ -1148,11 +1170,20 @@ const buildReserveExport = async (contractId: string): Promise<ExportReserve | n
|
||||
const rows = normalizeHtFeeMainRows(mainState?.detailRows)
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const reserveCodeTemplate = getReserveListEntries(locale.value)?.[0]?.code
|
||||
?? {
|
||||
richText: [
|
||||
{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'Y' },
|
||||
{ font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'B' }
|
||||
]
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const methodPayload = await loadHtFeeMethodsByRow(storageKey, row.id)
|
||||
const rowSubtotal = getHtMainRowSubtotal(row)
|
||||
if (!methodPayload && rowSubtotal == null) continue
|
||||
const reserve: ExportReserve = {
|
||||
code: reserveCodeTemplate,
|
||||
name: row.name || t('htSummary.reservePrefix'),
|
||||
fee: toMoney(rowSubtotal ?? methodPayload?.fee ?? 0),
|
||||
tasks: []
|
||||
@ -1261,26 +1292,87 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
||||
const serviceId = toSafeInteger(serviceIdText)
|
||||
if (serviceId == null) return null
|
||||
const sourceRow = serviceRowMap.get(serviceIdText)
|
||||
const methodAvailability = getServiceMethodAvailability(serviceIdText)
|
||||
|
||||
const [method1State, method2State, method3State, method4State] = await Promise.all([
|
||||
zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'investScale'),
|
||||
zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'landScale'),
|
||||
zxFwPricingStore.loadServicePricingMethodState<WorkloadMethodRowLike>(contractId, serviceIdText, 'workload'),
|
||||
zxFwPricingStore.loadServicePricingMethodState<HourlyMethodRowLike>(contractId, serviceIdText, 'hourly')
|
||||
methodAvailability.investmentScale
|
||||
? zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'investScale')
|
||||
: Promise.resolve(null),
|
||||
methodAvailability.landScale
|
||||
? zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'landScale')
|
||||
: Promise.resolve(null),
|
||||
methodAvailability.workload
|
||||
? zxFwPricingStore.loadServicePricingMethodState<WorkloadMethodRowLike>(contractId, serviceIdText, 'workload')
|
||||
: Promise.resolve(null),
|
||||
methodAvailability.hourly
|
||||
? zxFwPricingStore.loadServicePricingMethodState<HourlyMethodRowLike>(contractId, serviceIdText, 'hourly')
|
||||
: Promise.resolve(null)
|
||||
])
|
||||
|
||||
const method1Raw = method1State ? { detailRows: method1State.detailRows } : null
|
||||
const method2Raw = method2State ? { detailRows: method2State.detailRows } : null
|
||||
const method3Raw = method3State ? { detailRows: method3State.detailRows } : null
|
||||
const method4Raw = method4State ? { detailRows: method4State.detailRows } : null
|
||||
const method1 = buildMethod1(method1Raw?.detailRows)
|
||||
const method2 = buildMethod2(method2Raw?.detailRows)
|
||||
const method3 = buildMethod3(method3Raw?.detailRows)
|
||||
const method4 = buildMethod4(method4Raw?.detailRows)
|
||||
const fee = buildServiceFee(sourceRow, method1, method2, method3, method4)
|
||||
const finalFee = buildServiceFinalFee(sourceRow, method1, method2, method3, method4)
|
||||
const method1 = methodAvailability.investmentScale ? buildMethod1(method1Raw?.detailRows) : null
|
||||
const method2 = methodAvailability.landScale ? buildMethod2(method2Raw?.detailRows) : null
|
||||
const method3 = methodAvailability.workload ? buildMethod3(method3Raw?.detailRows) : null
|
||||
const method4 = methodAvailability.hourly ? buildMethod4(method4Raw?.detailRows) : null
|
||||
const sanitizedSourceRow = sourceRow
|
||||
? {
|
||||
...sourceRow,
|
||||
investScale: methodAvailability.investmentScale ? sourceRow.investScale : null,
|
||||
landScale: methodAvailability.landScale ? sourceRow.landScale : null,
|
||||
workload: methodAvailability.workload ? sourceRow.workload : null,
|
||||
hourly: methodAvailability.hourly ? sourceRow.hourly : null,
|
||||
subtotal: sumNumbers([
|
||||
methodAvailability.investmentScale ? toFiniteNumber(sourceRow.investScale) : 0,
|
||||
methodAvailability.landScale ? toFiniteNumber(sourceRow.landScale) : 0,
|
||||
methodAvailability.workload ? toFiniteNumber(sourceRow.workload) : 0,
|
||||
methodAvailability.hourly ? toFiniteNumber(sourceRow.hourly) : 0
|
||||
])
|
||||
}
|
||||
: sourceRow
|
||||
const fee = buildServiceFee(sanitizedSourceRow, method1, method2, method3, method4)
|
||||
const finalFee = buildServiceFinalFee(sanitizedSourceRow, method1, method2, method3, method4)
|
||||
const tasks = await buildServiceTasks(contractId, serviceIdText)
|
||||
const process = Number(sourceRow?.process) === 1 ? 1 : 0
|
||||
|
||||
const disabledMethodLeak = {
|
||||
investScale: !methodAvailability.investmentScale && (toFiniteNumber(sourceRow?.investScale) != null || Boolean(method1Raw)),
|
||||
landScale: !methodAvailability.landScale && (toFiniteNumber(sourceRow?.landScale) != null || Boolean(method2Raw)),
|
||||
workload: !methodAvailability.workload && (toFiniteNumber(sourceRow?.workload) != null || Boolean(method3Raw)),
|
||||
hourly: !methodAvailability.hourly && (toFiniteNumber(sourceRow?.hourly) != null || Boolean(method4Raw))
|
||||
}
|
||||
if (disabledMethodLeak.investScale || disabledMethodLeak.landScale || disabledMethodLeak.workload || disabledMethodLeak.hourly) {
|
||||
console.warn('[export][method-disabled-leak-detected]', {
|
||||
contractId,
|
||||
serviceId: serviceIdText,
|
||||
methodAvailability,
|
||||
sourceRow,
|
||||
hasMethodPayload: {
|
||||
method1: Boolean(method1Raw),
|
||||
method2: Boolean(method2Raw),
|
||||
method3: Boolean(method3Raw),
|
||||
method4: Boolean(method4Raw)
|
||||
},
|
||||
disabledMethodLeak
|
||||
})
|
||||
}
|
||||
|
||||
console.log('[export][service-methods]', {
|
||||
contractId,
|
||||
serviceId: serviceIdText,
|
||||
methodAvailability,
|
||||
exported: {
|
||||
method1: Boolean(method1),
|
||||
method2: Boolean(method2),
|
||||
method3: Boolean(method3),
|
||||
method4: Boolean(method4)
|
||||
},
|
||||
fee,
|
||||
finalFee
|
||||
})
|
||||
|
||||
const service: ExportService = {
|
||||
id: serviceId,
|
||||
process,
|
||||
|
||||
@ -2,6 +2,107 @@ import type { ColDef, ColGroupDef } from 'ag-grid-community'
|
||||
|
||||
type AnyColDef<TRow> = ColDef<TRow> | ColGroupDef<TRow>
|
||||
|
||||
const numericFieldKeywords = [
|
||||
'amount',
|
||||
'area',
|
||||
'cost',
|
||||
'price',
|
||||
'fee',
|
||||
'budget',
|
||||
'subtotal',
|
||||
'total',
|
||||
'ratio',
|
||||
'rate',
|
||||
'quantity',
|
||||
'count',
|
||||
'num',
|
||||
'workday',
|
||||
'workload',
|
||||
'hourly',
|
||||
'scale',
|
||||
'value',
|
||||
'coe',
|
||||
'factor'
|
||||
]
|
||||
|
||||
const isFiniteNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value)
|
||||
|
||||
const isNumericLikeString = (value: unknown) => {
|
||||
if (typeof value !== 'string') return false
|
||||
const normalized = value.replace(/[,\s]/g, '').replace(/%$/, '')
|
||||
if (!normalized) return false
|
||||
const asNumber = Number(normalized)
|
||||
return Number.isFinite(asNumber)
|
||||
}
|
||||
|
||||
const looksNumericByColumnDef = <TRow>(col: ColDef<TRow>) => {
|
||||
const fieldLike = String(col.field ?? col.colId ?? '').toLowerCase()
|
||||
const hasNumericFieldHint = fieldLike
|
||||
? numericFieldKeywords.some(keyword => fieldLike.includes(keyword))
|
||||
: false
|
||||
const type = col.type
|
||||
const typeList = Array.isArray(type) ? type : type ? [type] : []
|
||||
const hasNumericType = typeList.some(item => {
|
||||
const normalized = String(item).toLowerCase()
|
||||
return normalized.includes('numeric') || normalized.includes('rightaligned')
|
||||
})
|
||||
return hasNumericFieldHint || col.cellDataType === 'number' || hasNumericType
|
||||
}
|
||||
|
||||
const hasRightAlignedHeaderClass = (value: unknown) => {
|
||||
if (typeof value === 'string') return value.includes('ag-right-aligned-header')
|
||||
if (Array.isArray(value)) return value.some(item => String(item).includes('ag-right-aligned-header'))
|
||||
return false
|
||||
}
|
||||
|
||||
const mergeHeaderClass = <TRow>(col: ColDef<TRow>, mustRightAlign: boolean): ColDef<TRow>['headerClass'] => {
|
||||
if (!mustRightAlign) return col.headerClass
|
||||
|
||||
const base = col.headerClass
|
||||
if (!base) return 'ag-right-aligned-header'
|
||||
if (hasRightAlignedHeaderClass(base)) return base
|
||||
|
||||
if (typeof base === 'function') {
|
||||
return params => {
|
||||
const result = base(params)
|
||||
if (!result) return 'ag-right-aligned-header'
|
||||
if (typeof result === 'string') {
|
||||
return result.includes('ag-right-aligned-header')
|
||||
? result
|
||||
: `${result} ag-right-aligned-header`
|
||||
}
|
||||
if (Array.isArray(result)) {
|
||||
return hasRightAlignedHeaderClass(result)
|
||||
? result
|
||||
: [...result, 'ag-right-aligned-header']
|
||||
}
|
||||
return 'ag-right-aligned-header'
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof base === 'string') return `${base} ag-right-aligned-header`
|
||||
if (Array.isArray(base)) return [...base, 'ag-right-aligned-header']
|
||||
return base
|
||||
}
|
||||
|
||||
const mergeNumericCellClassRules = <TRow>(
|
||||
col: ColDef<TRow>,
|
||||
mustRightAlign: boolean
|
||||
): ColDef<TRow>['cellClassRules'] => {
|
||||
if (!mustRightAlign) return col.cellClassRules
|
||||
|
||||
const baseRules = col.cellClassRules ? { ...col.cellClassRules } : {}
|
||||
const existing = baseRules['ag-right-aligned-cell']
|
||||
if (existing) return baseRules
|
||||
|
||||
baseRules['ag-right-aligned-cell'] = params =>
|
||||
looksNumericByColumnDef(params.colDef as ColDef<TRow>) ||
|
||||
isFiniteNumber(params.value) ||
|
||||
isNumericLikeString(params.value)
|
||||
|
||||
return baseRules
|
||||
}
|
||||
|
||||
const mergeCellStyle = (cellStyle: ColDef['cellStyle']): ColDef['cellStyle'] => {
|
||||
const baseStyle = { whiteSpace: 'normal', lineHeight: '1.4' }
|
||||
if (!cellStyle) return baseStyle
|
||||
@ -29,9 +130,18 @@ const mergeCellStyle = (cellStyle: ColDef['cellStyle']): ColDef['cellStyle'] =>
|
||||
const enhanceLeafColumn = <TRow>(col: ColDef<TRow>): ColDef<TRow> => {
|
||||
const editable = col.editable
|
||||
const isReadonlyColumn = editable == null || editable === false
|
||||
if (!isReadonlyColumn) return { ...col }
|
||||
const shouldRightAlign = looksNumericByColumnDef(col)
|
||||
if (!isReadonlyColumn) {
|
||||
return {
|
||||
...col,
|
||||
headerClass: mergeHeaderClass(col, shouldRightAlign),
|
||||
cellClassRules: mergeNumericCellClassRules(col, shouldRightAlign)
|
||||
}
|
||||
}
|
||||
return {
|
||||
...col,
|
||||
headerClass: mergeHeaderClass(col, shouldRightAlign),
|
||||
cellClassRules: mergeNumericCellClassRules(col, shouldRightAlign),
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellStyle: mergeCellStyle(col.cellStyle)
|
||||
|
||||
@ -36,6 +36,41 @@ export const agGridWrapClass = 'ag-theme-quartz h-full min-h-0 w-full flex-1'
|
||||
// AG Grid 组件通用 style(撑满容器 div)
|
||||
export const agGridStyle = { height: '100%' }
|
||||
|
||||
const numericFieldKeywords = [
|
||||
'amount',
|
||||
'area',
|
||||
'cost',
|
||||
'price',
|
||||
'fee',
|
||||
'budget',
|
||||
'subtotal',
|
||||
'total',
|
||||
'ratio',
|
||||
'rate',
|
||||
'quantity',
|
||||
'count',
|
||||
'num',
|
||||
'workday',
|
||||
'workload',
|
||||
'hourly',
|
||||
'investscale',
|
||||
'landscale',
|
||||
'scale',
|
||||
'finalfee',
|
||||
'value',
|
||||
'coe',
|
||||
'factor'
|
||||
]
|
||||
|
||||
const isLikelyNumericColumn = (params: any) => {
|
||||
const value = params?.value
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return true
|
||||
|
||||
const field = String(params?.colDef?.field || params?.column?.getColId?.() || '').toLowerCase()
|
||||
if (!field) return false
|
||||
return numericFieldKeywords.some(keyword => field.includes(keyword))
|
||||
}
|
||||
|
||||
const isPlainEnterKey = (event: KeyboardEvent) =>
|
||||
event.key === 'Enter' && !event.altKey && !event.ctrlKey && !event.metaKey
|
||||
|
||||
@ -112,7 +147,7 @@ export const agGridDefaultColDef: ColDef = {
|
||||
suppressKeyboardEvent: suppressExcelLikeEnter,
|
||||
// 默认把数值型单元格右对齐,减少每个列重复配置。
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': params => typeof params.value === 'number' && Number.isFinite(params.value)
|
||||
'ag-right-aligned-cell': params => isLikelyNumericColumn(params)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -52,6 +52,8 @@ export const createScaleValueColumn = <TRow>(options: {
|
||||
export const createScaleBenchmarkBudgetColumnGroup = <TRow>(options: {
|
||||
getCheckedSplit: (row: TRow | undefined) => { basic?: number | null; optional?: number | null; total?: number | null } | null
|
||||
createBudgetCellRendererWithCheck: (field: 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked') => any
|
||||
getHeaderComponent?: (field: 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked') => any
|
||||
getHeaderComponentParams?: (field: 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked') => Record<string, unknown>
|
||||
}) : ColGroupDef<TRow> => ({
|
||||
headerName: scaleT('columns.benchmarkBudget'),
|
||||
marryChildren: true,
|
||||
@ -61,6 +63,8 @@ export const createScaleBenchmarkBudgetColumnGroup = <TRow>(options: {
|
||||
field: 'benchmarkBudgetBasic' as any,
|
||||
colId: 'benchmarkBudgetBasic',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
headerComponent: options.getHeaderComponent?.('benchmarkBudgetBasicChecked'),
|
||||
headerComponentParams: options.getHeaderComponentParams?.('benchmarkBudgetBasicChecked'),
|
||||
minWidth: 130,
|
||||
flex: 1,
|
||||
cellClassRules: {
|
||||
@ -78,6 +82,8 @@ export const createScaleBenchmarkBudgetColumnGroup = <TRow>(options: {
|
||||
field: 'benchmarkBudgetOptional' as any,
|
||||
colId: 'benchmarkBudgetOptional',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
headerComponent: options.getHeaderComponent?.('benchmarkBudgetOptionalChecked'),
|
||||
headerComponentParams: options.getHeaderComponentParams?.('benchmarkBudgetOptionalChecked'),
|
||||
minWidth: 130,
|
||||
flex: 1,
|
||||
cellClassRules: {
|
||||
|
||||
@ -4,6 +4,7 @@ import type { GridApi } from 'ag-grid-community'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
export type ScaleBudgetCheckField = 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'
|
||||
export type ScaleBudgetHeaderCheckState = 'all' | 'none' | 'partial'
|
||||
|
||||
type BudgetCheckRow = {
|
||||
id: string
|
||||
@ -13,6 +14,71 @@ type BudgetCheckRow = {
|
||||
benchmarkBudgetOptional?: number | null
|
||||
}
|
||||
|
||||
export interface ScaleBudgetToggleHeaderParams {
|
||||
displayName?: string
|
||||
field: ScaleBudgetCheckField
|
||||
getHeaderCheckState?: (field: ScaleBudgetCheckField) => ScaleBudgetHeaderCheckState
|
||||
onToggleAll?: (field: ScaleBudgetCheckField, checked: boolean) => void
|
||||
}
|
||||
|
||||
export class ScaleBudgetToggleHeader {
|
||||
private params!: ScaleBudgetToggleHeaderParams
|
||||
private eGui!: HTMLDivElement
|
||||
private checkbox!: HTMLInputElement
|
||||
private label!: HTMLSpanElement
|
||||
|
||||
init(params: ScaleBudgetToggleHeaderParams) {
|
||||
this.params = params
|
||||
const root = document.createElement('div')
|
||||
root.style.display = 'inline-flex'
|
||||
root.style.alignItems = 'center'
|
||||
root.style.gap = '6px'
|
||||
root.style.width = '100%'
|
||||
|
||||
const checkbox = document.createElement('input')
|
||||
checkbox.type = 'checkbox'
|
||||
checkbox.className = 'cursor-pointer'
|
||||
checkbox.addEventListener('click', event => event.stopPropagation())
|
||||
checkbox.addEventListener('mousedown', event => event.stopPropagation())
|
||||
checkbox.addEventListener('change', event => {
|
||||
event.stopPropagation()
|
||||
this.params.onToggleAll?.(this.params.field, checkbox.checked)
|
||||
})
|
||||
|
||||
const label = document.createElement('span')
|
||||
label.textContent = String(params.displayName || '')
|
||||
label.style.userSelect = 'none'
|
||||
label.addEventListener('click', event => event.stopPropagation())
|
||||
|
||||
root.append(checkbox, label)
|
||||
this.eGui = root
|
||||
this.checkbox = checkbox
|
||||
this.label = label
|
||||
this.applyCheckState()
|
||||
}
|
||||
|
||||
getGui() {
|
||||
return this.eGui
|
||||
}
|
||||
|
||||
refresh(params: ScaleBudgetToggleHeaderParams) {
|
||||
this.params = params
|
||||
this.label.textContent = String(params.displayName || '')
|
||||
this.applyCheckState()
|
||||
return true
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// noop
|
||||
}
|
||||
|
||||
private applyCheckState() {
|
||||
const state = this.params.getHeaderCheckState?.(this.params.field) || 'none'
|
||||
this.checkbox.indeterminate = state === 'partial'
|
||||
this.checkbox.checked = state === 'all'
|
||||
}
|
||||
}
|
||||
|
||||
export const formatScaleEditableNumber = (params: any, precision = 3, emptyText = '请输入') => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return emptyText
|
||||
|
||||
@ -22,6 +22,7 @@ export interface ZxFwDetailRow {
|
||||
id: string
|
||||
code?: string
|
||||
name?: string
|
||||
remark?: string
|
||||
process?: number | null
|
||||
investScale: number | null
|
||||
landScale: number | null
|
||||
@ -126,6 +127,7 @@ const normalizeRows = (rows: unknown): ZxFwDetailRow[] =>
|
||||
id: rowId,
|
||||
code: typeof row.code === 'string' ? row.code : '',
|
||||
name: typeof row.name === 'string' ? row.name : '',
|
||||
remark: typeof row.remark === 'string' ? row.remark : '',
|
||||
process: normalizeProcessValue(row.process, rowId),
|
||||
investScale: toFiniteNumberOrNull(row.investScale),
|
||||
landScale: toFiniteNumberOrNull(row.landScale),
|
||||
@ -214,6 +216,7 @@ const isSameRows = (a: ZxFwDetailRow[] | undefined, b: ZxFwDetailRow[] | undefin
|
||||
if (l.id !== r.id) return false
|
||||
if ((l.code || '') !== (r.code || '')) return false
|
||||
if ((l.name || '') !== (r.name || '')) return false
|
||||
if ((l.remark || '') !== (r.remark || '')) return false
|
||||
if (normalizeProcessValue(l.process, l.id) !== normalizeProcessValue(r.process, r.id)) return false
|
||||
if (!isSameNullableNumber(l.investScale, r.investScale)) return false
|
||||
if (!isSameNullableNumber(l.landScale, r.landScale)) return false
|
||||
|
||||
@ -175,10 +175,23 @@ input[inputmode='numeric'] {
|
||||
}
|
||||
|
||||
.ag-theme-quartz .ag-cell.ag-right-aligned-cell .ag-cell-value {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Global AG Grid header alignment: center all header text. */
|
||||
.ag-theme-quartz .ag-header-cell-label,
|
||||
.ag-theme-quartz .ag-header-group-cell-label {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ag-theme-quartz .ag-header-cell-text,
|
||||
.ag-theme-quartz .ag-header-group-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-toolbar-btn {
|
||||
height: var(--app-toolbar-btn-h) !important;
|
||||
min-height: var(--app-toolbar-btn-h) !important;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user