calculator2026/src/lib/pricingScaleColumns.ts

305 lines
12 KiB
TypeScript

import type { ColDef, ColGroupDef } from 'ag-grid-community'
import {
formatScaleEditableNumber,
formatScaleReadonlyMoney,
getScaleMergeColSpanBeforeTotal
} from '@/lib/pricingScaleGrid'
import { AgGridResetHeader } from '@/lib/agGridResetHeader'
import { i18n } from '@/i18n'
type ScaleColumnField<TRow> = Extract<keyof TRow, string> | string
const scaleT = (key: string, params?: Record<string, unknown>) =>
params ? i18n.global.t(`pricingScale.${key}`, params) : i18n.global.t(`pricingScale.${key}`)
export const createScaleValueColumn = <TRow>(options: {
headerName: string
field: ScaleColumnField<TRow>
headerTooltip: string
onReset: () => Promise<void> | void
resetTitle: string
headerComponent: any
minWidth?: number
flex?: number
isEditable: (row: TRow | undefined) => boolean
emptyTextPredicate: (row: TRow | undefined, value: unknown) => boolean
valueParser: (params: any) => any
valueFormatter: (params: any) => string
}) : ColDef<TRow> => ({
headerName: options.headerName,
field: options.field as any,
headerTooltip: options.headerTooltip,
headerComponent: options.headerComponent,
headerComponentParams: {
onReset: options.onReset,
resetTitle: options.resetTitle
},
headerClass: 'ag-right-aligned-header',
minWidth: options.minWidth ?? 90,
flex: options.flex ?? 2,
editable: params => !params.node?.group && !params.node?.rowPinned && options.isEditable(params.data),
cellClass: params =>
!params.node?.group && !params.node?.rowPinned && options.isEditable(params.data)
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && options.emptyTextPredicate(params.data, params.value)
},
valueParser: options.valueParser,
valueFormatter: options.valueFormatter
})
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,
children: [
{
headerName: scaleT('columns.basicWork'),
field: 'benchmarkBudgetBasic' as any,
colId: 'benchmarkBudgetBasic',
headerClass: 'ag-right-aligned-header',
headerComponent: options.getHeaderComponent?.('benchmarkBudgetBasicChecked'),
headerComponentParams: options.getHeaderComponentParams?.('benchmarkBudgetBasicChecked'),
minWidth: 130,
flex: 1,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => (params.node?.rowPinned ? null : options.getCheckedSplit(params.data)?.basic ?? null),
cellRenderer: options.createBudgetCellRendererWithCheck('benchmarkBudgetBasicChecked'),
cellRendererParams: {
suppressMouseEventHandling: () => true
},
valueFormatter: formatScaleReadonlyMoney
},
{
headerName: scaleT('columns.optionalWork'),
field: 'benchmarkBudgetOptional' as any,
colId: 'benchmarkBudgetOptional',
headerClass: 'ag-right-aligned-header',
headerComponent: options.getHeaderComponent?.('benchmarkBudgetOptionalChecked'),
headerComponentParams: options.getHeaderComponentParams?.('benchmarkBudgetOptionalChecked'),
minWidth: 130,
flex: 1,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => (params.node?.rowPinned ? null : options.getCheckedSplit(params.data)?.optional ?? null),
cellRenderer: options.createBudgetCellRendererWithCheck('benchmarkBudgetOptionalChecked'),
cellRendererParams: {
suppressMouseEventHandling: () => true
},
valueFormatter: formatScaleReadonlyMoney
},
{
headerName: scaleT('columns.subtotal'),
field: 'benchmarkBudget' as any,
colId: 'benchmarkBudgetTotal',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => (params.node?.rowPinned ? null : options.getCheckedSplit(params.data)?.total ?? null),
valueFormatter: formatScaleReadonlyMoney
}
]
})
export const createScaleBudgetFeeColumnGroup = <TRow>(options: {
headerComponent: any
restoreConsultCategoryFactorColumnDefaults: () => Promise<void> | void
restoreMajorFactorColumnDefaults: () => Promise<void> | void
parseNumberOrNull: (value: any, options?: any) => any
getBudgetFee: (row: TRow | undefined) => number | null
aggFunc: any
}) : ColGroupDef<TRow> => ({
headerName: scaleT('columns.budgetFee'),
marryChildren: true,
children: [
{
headerName: scaleT('columns.consultCategoryFactor'),
field: 'consultCategoryFactor' as any,
colId: 'consultCategoryFactor',
headerTooltip: scaleT('tooltip.resetConsultCategoryFactor'),
headerComponent: options.headerComponent,
headerComponentParams: {
onReset: options.restoreConsultCategoryFactorColumnDefaults,
resetTitle: scaleT('tooltip.resetConsultCategoryFactor')
},
headerClass: 'ag-right-aligned-header',
minWidth: 80,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => options.parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: params => formatScaleEditableNumber(params)
},
{
headerName: scaleT('columns.majorFactor'),
field: 'majorFactor' as any,
colId: 'majorFactor',
headerTooltip: scaleT('tooltip.resetMajorFactor'),
headerComponent: options.headerComponent,
headerComponentParams: {
onReset: options.restoreMajorFactorColumnDefaults,
resetTitle: scaleT('tooltip.resetMajorFactor')
},
headerClass: 'ag-right-aligned-header',
minWidth: 80,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => options.parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: params => formatScaleEditableNumber(params)
},
{
headerName: scaleT('columns.workStageFactor'),
field: 'workStageFactor' as any,
colId: 'workStageFactor',
headerClass: 'ag-right-aligned-header',
minWidth: 80,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => options.parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: params => formatScaleEditableNumber(params)
},
{
headerName: scaleT('columns.workRatio'),
field: 'workRatio' as any,
colId: 'workRatio',
headerTooltip: scaleT('tooltip.workRatio'),
headerComponent: AgGridResetHeader,
headerClass: 'ag-right-aligned-header',
minWidth: 80,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => options.parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: params => formatScaleEditableNumber(params, 2)
},
{
headerName: scaleT('columns.total'),
field: 'budgetFee' as any,
colId: 'budgetFeeTotal',
headerClass: 'ag-right-aligned-header',
minWidth: 120,
flex: 1,
aggFunc: options.aggFunc,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => (params.node?.rowPinned ? (params.data as any)?.budgetFee ?? null : options.getBudgetFee(params.data)),
valueFormatter: formatScaleReadonlyMoney
}
]
})
export const createScaleRemarkColumn = <TRow>() : ColDef<TRow> => ({
headerName: scaleT('columns.remark'),
field: 'remark' as any,
minWidth: 100,
flex: 1.2,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: params => !params.node?.group && !params.node?.rowPinned,
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && !params.value) return scaleT('clickToInput')
return params.value || ''
},
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? ' remark-wrap-cell' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}
})
export const createScaleAutoGroupColumn = <TRow>(options: {
totalLabel: string
idLabelMap: Map<string, string>
parseProjectIndexFromPathKey: (key: string) => number | null
}) : ColDef<TRow> => ({
headerName: scaleT('columns.majorGroup'),
minWidth: 250,
flex: 2,
wrapText: true,
autoHeight: true,
cellClassRules: {
'ag-summary-label-cell': params => Boolean(params.node?.rowPinned)
},
cellStyle: {
whiteSpace: 'normal',
lineHeight: '1.4'
},
cellRendererParams: {
suppressCount: true
},
colSpan: getScaleMergeColSpanBeforeTotal,
valueFormatter: params => {
if (params.node?.rowPinned) {
return options.totalLabel
}
const rowData = params.data as any
if (!params.node?.group && rowData?.majorCode && rowData?.majorName) {
return `${rowData.majorCode} ${rowData.majorName}`
}
const nodeId = String(params.value || '')
const projectIndex = options.parseProjectIndexFromPathKey(nodeId)
if (projectIndex != null) return scaleT('projectLabel', { index: projectIndex })
return options.idLabelMap.get(nodeId) || nodeId
},
tooltipValueGetter: params => {
if (params.node?.rowPinned) return options.totalLabel
const rowData = params.data as any
if (!params.node?.group && rowData?.majorCode && rowData?.majorName) {
return `${rowData.majorCode} ${rowData.majorName}`
}
const nodeId = String(params.value || '')
const projectIndex = options.parseProjectIndexFromPathKey(nodeId)
if (projectIndex != null) return scaleT('projectLabel', { index: projectIndex })
return options.idLabelMap.get(nodeId) || nodeId
}
})