@servicetitan/dte-pdf-editor 1.35.0 → 1.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -12
- package/dist/components/field-config-panel/advanced-settings.d.ts.map +1 -1
- package/dist/components/field-config-panel/advanced-settings.js +20 -10
- package/dist/components/field-config-panel/advanced-settings.js.map +1 -1
- package/dist/components/field-config-panel/formula-generator.d.ts.map +1 -1
- package/dist/components/field-config-panel/formula-generator.js +2 -1
- package/dist/components/field-config-panel/formula-generator.js.map +1 -1
- package/dist/components/field-config-panel/formula-modal.d.ts.map +1 -1
- package/dist/components/field-config-panel/formula-modal.js +48 -7
- package/dist/components/field-config-panel/formula-modal.js.map +1 -1
- package/dist/components/field-config-panel/formula-workspace.d.ts +1 -0
- package/dist/components/field-config-panel/formula-workspace.d.ts.map +1 -1
- package/dist/components/field-config-panel/formula-workspace.js +10 -4
- package/dist/components/field-config-panel/formula-workspace.js.map +1 -1
- package/dist/components/field-config-panel/result-type-selector.d.ts.map +1 -1
- package/dist/components/field-config-panel/result-type-selector.js +1 -0
- package/dist/components/field-config-panel/result-type-selector.js.map +1 -1
- package/dist/components/pdf-view/pdf-view-calculated.d.ts +1 -0
- package/dist/components/pdf-view/pdf-view-calculated.d.ts.map +1 -1
- package/dist/components/pdf-view/pdf-view-calculated.js +20 -3
- package/dist/components/pdf-view/pdf-view-calculated.js.map +1 -1
- package/dist/components/pdf-view/pdf-view-fillable.d.ts.map +1 -1
- package/dist/components/pdf-view/pdf-view-fillable.js +5 -2
- package/dist/components/pdf-view/pdf-view-fillable.js.map +1 -1
- package/dist/components/pdf-view/pdf-view.d.ts +6 -0
- package/dist/components/pdf-view/pdf-view.d.ts.map +1 -1
- package/dist/components/pdf-view/pdf-view.js +2 -2
- package/dist/components/pdf-view/pdf-view.js.map +1 -1
- package/dist/constants/calculated.constants.d.ts +2 -0
- package/dist/constants/calculated.constants.d.ts.map +1 -1
- package/dist/constants/calculated.constants.js +2 -0
- package/dist/constants/calculated.constants.js.map +1 -1
- package/dist/hooks/useFormulaEditor.d.ts +3 -0
- package/dist/hooks/useFormulaEditor.d.ts.map +1 -1
- package/dist/hooks/useFormulaEditor.js +24 -1
- package/dist/hooks/useFormulaEditor.js.map +1 -1
- package/dist/interface/types.d.ts +6 -2
- package/dist/interface/types.d.ts.map +1 -1
- package/dist/interface/types.js.map +1 -1
- package/dist/utils/data-model/extract-fields.utils.d.ts.map +1 -1
- package/dist/utils/data-model/extract-fields.utils.js +11 -1
- package/dist/utils/data-model/extract-fields.utils.js.map +1 -1
- package/dist/utils/formula/evaluate-formula.utils.d.ts +10 -2
- package/dist/utils/formula/evaluate-formula.utils.d.ts.map +1 -1
- package/dist/utils/formula/evaluate-formula.utils.js +205 -4
- package/dist/utils/formula/evaluate-formula.utils.js.map +1 -1
- package/dist/utils/formula/expression.utils.d.ts +3 -3
- package/dist/utils/formula/expression.utils.d.ts.map +1 -1
- package/dist/utils/formula/expression.utils.js +3 -5
- package/dist/utils/formula/expression.utils.js.map +1 -1
- package/dist/utils/formula/format-calculated-result.utils.d.ts +1 -1
- package/dist/utils/formula/format-calculated-result.utils.d.ts.map +1 -1
- package/dist/utils/formula/format-calculated-result.utils.js +29 -8
- package/dist/utils/formula/format-calculated-result.utils.js.map +1 -1
- package/dist/utils/formula/validate-formula.utils.d.ts +1 -1
- package/dist/utils/formula/validate-formula.utils.d.ts.map +1 -1
- package/dist/utils/formula/validate-formula.utils.js +18 -3
- package/dist/utils/formula/validate-formula.utils.js.map +1 -1
- package/dist/utils/shared/date.utils.d.ts +2 -0
- package/dist/utils/shared/date.utils.d.ts.map +1 -0
- package/dist/utils/shared/date.utils.js +6 -0
- package/dist/utils/shared/date.utils.js.map +1 -0
- package/dist/utils/shared/index.d.ts +1 -0
- package/dist/utils/shared/index.d.ts.map +1 -1
- package/dist/utils/shared/index.js +1 -0
- package/dist/utils/shared/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/field-config-panel/advanced-settings.tsx +103 -77
- package/src/components/field-config-panel/formula-generator.tsx +2 -1
- package/src/components/field-config-panel/formula-modal.tsx +48 -8
- package/src/components/field-config-panel/formula-workspace.tsx +21 -7
- package/src/components/field-config-panel/result-type-selector.tsx +1 -0
- package/src/components/pdf-view/pdf-view-calculated.tsx +20 -3
- package/src/components/pdf-view/pdf-view-fillable.tsx +6 -3
- package/src/components/pdf-view/pdf-view.tsx +8 -0
- package/src/constants/calculated.constants.ts +4 -0
- package/src/hooks/useFormulaEditor.ts +32 -1
- package/src/interface/types.ts +8 -2
- package/src/utils/data-model/extract-fields.utils.ts +13 -1
- package/src/utils/formula/evaluate-formula.utils.ts +271 -5
- package/src/utils/formula/expression.utils.ts +3 -8
- package/src/utils/formula/format-calculated-result.utils.ts +32 -10
- package/src/utils/formula/validate-formula.utils.ts +27 -1
- package/src/utils/shared/date.utils.ts +6 -0
- package/src/utils/shared/index.ts +1 -0
|
@@ -17,6 +17,7 @@ import { ResultTypeSelector } from './result-type-selector';
|
|
|
17
17
|
interface FormulaWorkspaceProps {
|
|
18
18
|
actions: ReactNode;
|
|
19
19
|
advancedOpen: boolean;
|
|
20
|
+
disabledOperators?: Set<string>;
|
|
20
21
|
editorRef: RefObject<HTMLDivElement | null>;
|
|
21
22
|
format: CalculatedFieldFormat;
|
|
22
23
|
isInvalid: boolean;
|
|
@@ -36,6 +37,7 @@ interface FormulaWorkspaceProps {
|
|
|
36
37
|
export const FormulaWorkspace: FC<FormulaWorkspaceProps> = ({
|
|
37
38
|
actions,
|
|
38
39
|
advancedOpen,
|
|
40
|
+
disabledOperators,
|
|
39
41
|
editorRef,
|
|
40
42
|
format,
|
|
41
43
|
isInvalid,
|
|
@@ -73,8 +75,11 @@ export const FormulaWorkspace: FC<FormulaWorkspaceProps> = ({
|
|
|
73
75
|
return (
|
|
74
76
|
<Flex direction="column" flex={1} gap={2} style={{ padding: '12px' }}>
|
|
75
77
|
<Text variant="body" size="small">
|
|
76
|
-
Click a field on the left to add it. Use +, -,
|
|
77
|
-
formulas.
|
|
78
|
+
Click a field on the left to add it. Use +, -,{' '}
|
|
79
|
+
{disabledOperators?.has('*') ? '' : '*, /, and '}parentheses to build formulas.
|
|
80
|
+
{disabledOperators?.has('*')
|
|
81
|
+
? ' Multiply and divide are not available with date fields.'
|
|
82
|
+
: ''}
|
|
78
83
|
</Text>
|
|
79
84
|
<Flex alignItems="center" justifyContent="space-between">
|
|
80
85
|
<Text variant="body" size="small">
|
|
@@ -101,11 +106,20 @@ export const FormulaWorkspace: FC<FormulaWorkspaceProps> = ({
|
|
|
101
106
|
/>
|
|
102
107
|
{isInvalid && validationError && <Alert status="danger" title={validationError} />}
|
|
103
108
|
<Flex gap={1}>
|
|
104
|
-
{CALCULATED_OPERATIONS.map(op =>
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
{CALCULATED_OPERATIONS.map(op => {
|
|
110
|
+
const isDisabled = disabledOperators?.has(op) ?? false;
|
|
111
|
+
return (
|
|
112
|
+
<Button
|
|
113
|
+
key={op}
|
|
114
|
+
onClick={() => onOperatorSelect(op)}
|
|
115
|
+
size="small"
|
|
116
|
+
disabled={isDisabled}
|
|
117
|
+
title={isDisabled ? 'Not available with date fields' : undefined}
|
|
118
|
+
>
|
|
119
|
+
{op}
|
|
120
|
+
</Button>
|
|
121
|
+
);
|
|
122
|
+
})}
|
|
109
123
|
</Flex>
|
|
110
124
|
<ResultTypeSelector format={format} onChange={onResultTypeChange} />
|
|
111
125
|
<LinkButton appearance="primary" onClick={onToggleAdvanced}>
|
|
@@ -11,6 +11,7 @@ const RESULT_TYPE_OPTIONS: { label: string; value: CalculatedFieldFormat['result
|
|
|
11
11
|
{ label: 'Number', value: 'number' },
|
|
12
12
|
{ label: 'Currency', value: 'currency' },
|
|
13
13
|
{ label: 'Percent', value: 'percent' },
|
|
14
|
+
{ label: 'Date', value: 'date' },
|
|
14
15
|
];
|
|
15
16
|
|
|
16
17
|
export const ResultTypeSelector: FC<ResultTypeSelectorProps> = ({ format, onChange }) => (
|
|
@@ -5,19 +5,36 @@ import { evaluateFormula, formatCalculatedResult, resolvePdfDataValues } from '.
|
|
|
5
5
|
interface PdfViewCalculatedProps {
|
|
6
6
|
field: PdfField;
|
|
7
7
|
data?: DataModelValues;
|
|
8
|
+
holidays?: string[];
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
export const PdfViewCalculated: FC<PdfViewCalculatedProps> = ({ data, field }) => {
|
|
11
|
+
export const PdfViewCalculated: FC<PdfViewCalculatedProps> = ({ data, field, holidays }) => {
|
|
12
|
+
const dateFieldPaths = useMemo(() => {
|
|
13
|
+
const paths = field.formulaFormat?.dateFieldPaths;
|
|
14
|
+
return paths?.length ? new Set(paths) : undefined;
|
|
15
|
+
}, [field.formulaFormat?.dateFieldPaths]);
|
|
16
|
+
|
|
11
17
|
const displayValue = useMemo(() => {
|
|
12
18
|
if (field.formula?.tokens?.length) {
|
|
13
|
-
const value = evaluateFormula(field.formula, data);
|
|
19
|
+
const value = evaluateFormula(field.formula, data, dateFieldPaths, holidays);
|
|
20
|
+
if (value === null) {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
14
23
|
return formatCalculatedResult(value, field.formulaFormat);
|
|
15
24
|
}
|
|
16
25
|
if (field.path) {
|
|
17
26
|
return resolvePdfDataValues(data, field.path) ?? '';
|
|
18
27
|
}
|
|
19
28
|
return field.label ?? '';
|
|
20
|
-
}, [
|
|
29
|
+
}, [
|
|
30
|
+
data,
|
|
31
|
+
dateFieldPaths,
|
|
32
|
+
field.formula,
|
|
33
|
+
field.formulaFormat,
|
|
34
|
+
field.label,
|
|
35
|
+
field.path,
|
|
36
|
+
holidays,
|
|
37
|
+
]);
|
|
21
38
|
|
|
22
39
|
return <div className="dte-pdf-field-value">{displayValue}</div>;
|
|
23
40
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { FC } from 'react';
|
|
2
2
|
import { DataChangePayload, DataModelValues, PdfField, PreviewMode } from '../../interface/types';
|
|
3
|
-
import { getFieldPlaceholderText } from '../../utils';
|
|
3
|
+
import { dateToUtcZero, getFieldPlaceholderText } from '../../utils';
|
|
4
4
|
|
|
5
5
|
interface PdfViewFillableProps {
|
|
6
6
|
field: PdfField;
|
|
@@ -103,8 +103,11 @@ export const PdfViewFillable: FC<PdfViewFillableProps> = ({
|
|
|
103
103
|
disabled={!isFillable}
|
|
104
104
|
id={field.path}
|
|
105
105
|
name={field.path}
|
|
106
|
-
value={resolvedValue
|
|
107
|
-
onChange={e =>
|
|
106
|
+
value={resolvedValue ? resolvedValue.slice(0, 10) : ''}
|
|
107
|
+
onChange={e => {
|
|
108
|
+
const dateStr = e.target.value;
|
|
109
|
+
handleDataChange(dateStr ? dateToUtcZero(dateStr) : '');
|
|
110
|
+
}}
|
|
108
111
|
placeholder={getFieldPlaceholderText(field, 'mm/dd/yyyy')}
|
|
109
112
|
/>
|
|
110
113
|
);
|
|
@@ -25,6 +25,12 @@ export interface PdfViewProps {
|
|
|
25
25
|
data?: DataModelValues;
|
|
26
26
|
pdfUrl: string;
|
|
27
27
|
loading?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* UTC date strings (e.g. `'2026-03-17T00:00:00Z'`) treated as non-business days.
|
|
30
|
+
* When provided, date-based calculated fields skip weekends and these holidays
|
|
31
|
+
* when adding/subtracting days or computing date differences.
|
|
32
|
+
*/
|
|
33
|
+
holidays?: string[];
|
|
28
34
|
/*
|
|
29
35
|
* fillingBy defines the list of recipient names
|
|
30
36
|
* who are allowed to fill this field.
|
|
@@ -45,6 +51,7 @@ export const PdfView: FC<PdfViewProps> = ({
|
|
|
45
51
|
errors = {},
|
|
46
52
|
fields,
|
|
47
53
|
fillingBy,
|
|
54
|
+
holidays,
|
|
48
55
|
loading = false,
|
|
49
56
|
loadingPlaceholder,
|
|
50
57
|
onDataChange,
|
|
@@ -106,6 +113,7 @@ export const PdfView: FC<PdfViewProps> = ({
|
|
|
106
113
|
<PdfViewCalculated
|
|
107
114
|
field={field}
|
|
108
115
|
data={previewMode === 'fillable' ? previewData : data}
|
|
116
|
+
holidays={holidays}
|
|
109
117
|
/>
|
|
110
118
|
)}
|
|
111
119
|
{field.type === FieldTypeEnum.fillable && (
|
|
@@ -18,10 +18,11 @@ import {
|
|
|
18
18
|
|
|
19
19
|
export const useFormulaEditor = (params: {
|
|
20
20
|
currentExpression: string;
|
|
21
|
+
knownDateFields?: Set<string>;
|
|
21
22
|
opened: boolean;
|
|
22
23
|
pathToLabel: Map<string, string>;
|
|
23
24
|
}) => {
|
|
24
|
-
const { currentExpression, opened, pathToLabel } = params;
|
|
25
|
+
const { currentExpression, knownDateFields, opened, pathToLabel } = params;
|
|
25
26
|
const [draftExpression, setDraftExpression] = useState(currentExpression);
|
|
26
27
|
const [history, setHistory] = useState<string[]>([currentExpression]);
|
|
27
28
|
const [historyIndex, setHistoryIndex] = useState(0);
|
|
@@ -32,6 +33,7 @@ export const useFormulaEditor = (params: {
|
|
|
32
33
|
const lastCaretRef = useRef(0);
|
|
33
34
|
const historyIndexRef = useRef(0);
|
|
34
35
|
const historyRef = useRef<string[]>([currentExpression]);
|
|
36
|
+
const knownDateFieldsRef = useRef(knownDateFields);
|
|
35
37
|
|
|
36
38
|
useEffect(() => {
|
|
37
39
|
if (opened) {
|
|
@@ -210,6 +212,10 @@ export const useFormulaEditor = (params: {
|
|
|
210
212
|
[draftExpression],
|
|
211
213
|
);
|
|
212
214
|
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
knownDateFieldsRef.current = knownDateFields;
|
|
217
|
+
}, [knownDateFields]);
|
|
218
|
+
|
|
213
219
|
const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
214
220
|
if (e.ctrlKey || e.metaKey) {
|
|
215
221
|
const key = e.key.toLowerCase();
|
|
@@ -240,6 +246,16 @@ export const useFormulaEditor = (params: {
|
|
|
240
246
|
return;
|
|
241
247
|
}
|
|
242
248
|
}
|
|
249
|
+
if (knownDateFieldsRef.current?.size && (e.key === '*' || e.key === '/')) {
|
|
250
|
+
const parts = tokenizeExpression(readExpressionFromEditor(editorRef.current!));
|
|
251
|
+
const hasDate = parts.some(
|
|
252
|
+
p => p.type === 'field' && knownDateFieldsRef.current!.has(p.value),
|
|
253
|
+
);
|
|
254
|
+
if (hasDate) {
|
|
255
|
+
e.preventDefault();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
243
259
|
const allowed = new Set([
|
|
244
260
|
'+',
|
|
245
261
|
'-',
|
|
@@ -331,9 +347,24 @@ export const useFormulaEditor = (params: {
|
|
|
331
347
|
return new Set(parts.filter(p => p.type === 'field').map(p => p.value));
|
|
332
348
|
}, [draftExpression]);
|
|
333
349
|
|
|
350
|
+
const expressionHasDateFields = useMemo(() => {
|
|
351
|
+
if (!knownDateFields?.size) {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
const parts = tokenizeExpression(draftExpression);
|
|
355
|
+
return parts.some(p => p.type === 'field' && knownDateFields.has(p.value));
|
|
356
|
+
}, [draftExpression, knownDateFields]);
|
|
357
|
+
|
|
358
|
+
const disabledOperators = useMemo(
|
|
359
|
+
() => (expressionHasDateFields ? new Set(['*', '/']) : undefined),
|
|
360
|
+
[expressionHasDateFields],
|
|
361
|
+
);
|
|
362
|
+
|
|
334
363
|
return {
|
|
335
364
|
draftExpression,
|
|
365
|
+
disabledOperators,
|
|
336
366
|
editorRef,
|
|
367
|
+
expressionHasDateFields,
|
|
337
368
|
isDirty,
|
|
338
369
|
canUndo: historyIndex > 0,
|
|
339
370
|
canRedo: historyIndex < history.length - 1,
|
package/src/interface/types.ts
CHANGED
|
@@ -80,11 +80,14 @@ export interface PdfField {
|
|
|
80
80
|
displayCondition?: DisplayConditionState | null;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
export type SchemaFieldType = 'number' | 'date';
|
|
84
|
+
|
|
83
85
|
export interface FieldTypeOption {
|
|
84
86
|
label: string;
|
|
85
87
|
type: FieldTypeEnum;
|
|
86
88
|
subType?: PdfFieldSubType;
|
|
87
89
|
path?: string;
|
|
90
|
+
fieldType?: SchemaFieldType;
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
export interface DataModelFieldGroup {
|
|
@@ -102,7 +105,7 @@ export type FormulaToken =
|
|
|
102
105
|
| { type: 'operator'; value: FormulaOperator }
|
|
103
106
|
| { type: 'lparen'; value: '(' }
|
|
104
107
|
| { type: 'rparen'; value: ')' }
|
|
105
|
-
| { type: 'field'; path: string
|
|
108
|
+
| { type: 'field'; path: string };
|
|
106
109
|
|
|
107
110
|
/** Structured formula representation (AST-like token list) for validation and safe editing */
|
|
108
111
|
export interface StructuredFormula {
|
|
@@ -111,7 +114,7 @@ export interface StructuredFormula {
|
|
|
111
114
|
|
|
112
115
|
/** Format options for a calculated field result (display and rounding) */
|
|
113
116
|
export interface CalculatedFieldFormat {
|
|
114
|
-
resultType: 'number' | 'currency' | 'percent';
|
|
117
|
+
resultType: 'number' | 'currency' | 'percent' | 'date';
|
|
115
118
|
thousandsSeparator: boolean;
|
|
116
119
|
decimals: number;
|
|
117
120
|
roundingMode: 'round' | 'floor' | 'ceil';
|
|
@@ -119,6 +122,9 @@ export interface CalculatedFieldFormat {
|
|
|
119
122
|
decimalSeparator: '.' | ',';
|
|
120
123
|
prefixText: string;
|
|
121
124
|
postfixText: string;
|
|
125
|
+
dateFormat?: string;
|
|
126
|
+
/** Paths of date-typed fields used in the formula, persisted for date-aware evaluation */
|
|
127
|
+
dateFieldPaths?: string[];
|
|
122
128
|
}
|
|
123
129
|
|
|
124
130
|
export interface SchemaFieldBaseOptions {
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
DataModelFieldGroup,
|
|
3
3
|
FieldTypeEnum,
|
|
4
4
|
FieldTypeOption,
|
|
5
|
+
SchemaFieldType,
|
|
5
6
|
SchemaNode,
|
|
6
7
|
SchemaObject,
|
|
7
8
|
SchemaSimpleDate,
|
|
@@ -16,6 +17,16 @@ function isSchemaSimpleDate(node: SchemaNode): node is SchemaSimpleDate {
|
|
|
16
17
|
return node.type === 'date';
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
function getSchemaFieldType(node: SchemaNode): SchemaFieldType | undefined {
|
|
21
|
+
if (isSchemaSimpleNumber(node)) {
|
|
22
|
+
return 'number';
|
|
23
|
+
}
|
|
24
|
+
if (isSchemaSimpleDate(node)) {
|
|
25
|
+
return 'date';
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
19
30
|
function useInCalculatedFields(node: SchemaNode): boolean {
|
|
20
31
|
return node.options?.useInCalculatedFields === true;
|
|
21
32
|
}
|
|
@@ -79,6 +90,7 @@ export const extractGroupedFieldsFromDataModel = (
|
|
|
79
90
|
label: property.title ?? key,
|
|
80
91
|
type: FieldTypeEnum.dataModel,
|
|
81
92
|
path: key,
|
|
93
|
+
fieldType: getSchemaFieldType(property),
|
|
82
94
|
});
|
|
83
95
|
}
|
|
84
96
|
}
|
|
@@ -114,12 +126,12 @@ const extractFieldsRecursive = (
|
|
|
114
126
|
rawType === 'boolean';
|
|
115
127
|
const includeField = !onlyUseInCalculatedFields || useInCalculatedFields(fieldProperty);
|
|
116
128
|
if (isLeaf && includeField) {
|
|
117
|
-
// Leaf property - add as a field
|
|
118
129
|
const label = fieldProperty.title ?? fieldKey;
|
|
119
130
|
fields.push({
|
|
120
131
|
label,
|
|
121
132
|
type: FieldTypeEnum.dataModel,
|
|
122
133
|
path: currentPath,
|
|
134
|
+
fieldType: getSchemaFieldType(fieldProperty),
|
|
123
135
|
});
|
|
124
136
|
} else if (fieldProperty.type === 'object' && fieldProperty.properties) {
|
|
125
137
|
extractFieldsRecursive(
|
|
@@ -28,6 +28,26 @@ export function valueToNumber(raw: unknown): number {
|
|
|
28
28
|
return Number.isNaN(n) ? 0 : n;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
function hasAllFieldValues(
|
|
32
|
+
formula: StructuredFormula,
|
|
33
|
+
data: DataModelValues | undefined,
|
|
34
|
+
dateFieldPaths?: Set<string>,
|
|
35
|
+
): boolean {
|
|
36
|
+
return formula.tokens.every((t: FormulaToken) => {
|
|
37
|
+
if (t.type !== 'field') {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
const raw = resolvePdfDataValues(data, t.path);
|
|
41
|
+
if (raw === '') {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (dateFieldPaths?.has(t.path)) {
|
|
45
|
+
return !isNaN(new Date(raw).getTime());
|
|
46
|
+
}
|
|
47
|
+
return !isNaN(Number(raw.trim()));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
type ResolvedToken =
|
|
32
52
|
| { type: 'number'; value: number }
|
|
33
53
|
| { type: 'operator'; value: FormulaOperator }
|
|
@@ -66,7 +86,7 @@ function applyOp(a: number, op: FormulaOperator, b: number): number {
|
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
/** Find the index of the matching closing paren for an open paren at openIndex. */
|
|
69
|
-
function findMatchingParen(tokens:
|
|
89
|
+
function findMatchingParen<T extends { type: string }>(tokens: T[], openIndex: number): number {
|
|
70
90
|
let balance = 1;
|
|
71
91
|
for (let i = openIndex + 1; i < tokens.length; i++) {
|
|
72
92
|
const t = tokens[i];
|
|
@@ -123,7 +143,6 @@ function evalResolvedTokens(tokens: ResolvedToken[]): number {
|
|
|
123
143
|
|
|
124
144
|
const list = [...tokens];
|
|
125
145
|
|
|
126
|
-
// Resolve innermost parentheses first
|
|
127
146
|
let idx = 0;
|
|
128
147
|
while (idx < list.length) {
|
|
129
148
|
if (list[idx].type === 'lparen') {
|
|
@@ -142,18 +161,265 @@ function evalResolvedTokens(tokens: ResolvedToken[]): number {
|
|
|
142
161
|
return evalFlat(list);
|
|
143
162
|
}
|
|
144
163
|
|
|
164
|
+
/*
|
|
165
|
+
* ---------------------------------------------------------------------------
|
|
166
|
+
* Date-aware evaluation (business-day logic: skips weekends & holidays)
|
|
167
|
+
* ---------------------------------------------------------------------------
|
|
168
|
+
*/
|
|
169
|
+
|
|
170
|
+
type ValueKind = 'number' | 'date';
|
|
171
|
+
|
|
172
|
+
interface TypedValue {
|
|
173
|
+
kind: ValueKind;
|
|
174
|
+
value: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
type DateAwareToken =
|
|
178
|
+
| TypedValue
|
|
179
|
+
| { type: 'operator'; value: FormulaOperator }
|
|
180
|
+
| { type: 'lparen'; value: '(' }
|
|
181
|
+
| { type: 'rparen'; value: ')' };
|
|
182
|
+
|
|
183
|
+
function isTypedValue(t: DateAwareToken): t is TypedValue {
|
|
184
|
+
return 'kind' in t;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseDateValue(raw: string): number {
|
|
188
|
+
const d = new Date(raw);
|
|
189
|
+
return isNaN(d.getTime()) ? NaN : d.getTime();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function toDateOnly(ms: number): Date {
|
|
193
|
+
const d = new Date(ms);
|
|
194
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
195
|
+
return d;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function toDateKey(d: Date): string {
|
|
199
|
+
const y = d.getUTCFullYear();
|
|
200
|
+
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
201
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
202
|
+
return `${y}-${m}-${day}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isWeekend(d: Date): boolean {
|
|
206
|
+
const day = d.getUTCDay();
|
|
207
|
+
return day === 0 || day === 6;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function buildHolidaySet(holidays?: string[]): Set<string> {
|
|
211
|
+
if (!holidays?.length) {
|
|
212
|
+
return new Set();
|
|
213
|
+
}
|
|
214
|
+
const set = new Set<string>();
|
|
215
|
+
for (const h of holidays) {
|
|
216
|
+
const d = toDateOnly(new Date(h).getTime());
|
|
217
|
+
if (!isNaN(d.getTime())) {
|
|
218
|
+
set.add(toDateKey(d));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return set;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isNonBusinessDay(d: Date, holidaySet: Set<string>): boolean {
|
|
225
|
+
return isWeekend(d) || (holidaySet.size > 0 && holidaySet.has(toDateKey(d)));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Add business days to a date (epoch ms), skipping weekends and holidays.
|
|
230
|
+
* Returns the resulting date as epoch ms.
|
|
231
|
+
*/
|
|
232
|
+
function addBusinessDays(startMs: number, days: number, holidaySet: Set<string>): number {
|
|
233
|
+
const result = toDateOnly(startMs);
|
|
234
|
+
if (!isFinite(result.getTime())) {
|
|
235
|
+
return result.getTime();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const intDays = Math.trunc(days);
|
|
239
|
+
if (intDays === 0) {
|
|
240
|
+
return result.getTime();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const direction = intDays > 0 ? 1 : -1;
|
|
244
|
+
let remaining = Math.abs(intDays);
|
|
245
|
+
|
|
246
|
+
while (remaining > 0) {
|
|
247
|
+
result.setUTCDate(result.getUTCDate() + direction);
|
|
248
|
+
if (!isNonBusinessDay(result, holidaySet)) {
|
|
249
|
+
remaining--;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return result.getTime();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Count business days between two dates (epoch ms), skipping weekends and holidays.
|
|
258
|
+
* Positive when `toMs` is after `fromMs`, negative otherwise.
|
|
259
|
+
*/
|
|
260
|
+
function businessDayDiff(fromMs: number, toMs: number, holidaySet: Set<string>): number {
|
|
261
|
+
const start = toDateOnly(fromMs);
|
|
262
|
+
const end = toDateOnly(toMs);
|
|
263
|
+
|
|
264
|
+
if (!isFinite(start.getTime()) || !isFinite(end.getTime())) {
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const direction = end.getTime() >= start.getTime() ? 1 : -1;
|
|
269
|
+
const cursor = new Date(start.getTime());
|
|
270
|
+
let count = 0;
|
|
271
|
+
|
|
272
|
+
while (toDateKey(cursor) !== toDateKey(end)) {
|
|
273
|
+
cursor.setUTCDate(cursor.getUTCDate() + direction);
|
|
274
|
+
if (!isNonBusinessDay(cursor, holidaySet)) {
|
|
275
|
+
count++;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return count * direction;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function resolveDateAwareTokens(
|
|
283
|
+
formula: StructuredFormula,
|
|
284
|
+
data: DataModelValues | undefined,
|
|
285
|
+
dateFieldPaths: Set<string>,
|
|
286
|
+
): DateAwareToken[] {
|
|
287
|
+
return formula.tokens.map((t: FormulaToken): DateAwareToken => {
|
|
288
|
+
if (t.type === 'field') {
|
|
289
|
+
const raw = resolvePdfDataValues(data, t.path);
|
|
290
|
+
if (dateFieldPaths.has(t.path)) {
|
|
291
|
+
const ms = parseDateValue(raw);
|
|
292
|
+
return { kind: 'date', value: isNaN(ms) ? 0 : ms };
|
|
293
|
+
}
|
|
294
|
+
return { kind: 'number', value: valueToNumber(raw) };
|
|
295
|
+
}
|
|
296
|
+
if (t.type === 'number') {
|
|
297
|
+
return { kind: 'number', value: valueToNumber(t.value) };
|
|
298
|
+
}
|
|
299
|
+
return t as DateAwareToken;
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Apply an operator to two typed values with date-aware, business-day semantics:
|
|
305
|
+
* date + number → add business days → date
|
|
306
|
+
* number + date → add business days → date
|
|
307
|
+
* date - number → sub business days → date
|
|
308
|
+
* date - date → business day diff → number
|
|
309
|
+
* number ± number → normal → number
|
|
310
|
+
*/
|
|
311
|
+
function applyDateOp(
|
|
312
|
+
a: TypedValue,
|
|
313
|
+
op: FormulaOperator,
|
|
314
|
+
b: TypedValue,
|
|
315
|
+
holidaySet: Set<string>,
|
|
316
|
+
): TypedValue {
|
|
317
|
+
if (a.kind === 'date' && b.kind === 'number') {
|
|
318
|
+
if (op === '+') {
|
|
319
|
+
return { kind: 'date', value: addBusinessDays(a.value, b.value, holidaySet) };
|
|
320
|
+
}
|
|
321
|
+
if (op === '-') {
|
|
322
|
+
return { kind: 'date', value: addBusinessDays(a.value, -b.value, holidaySet) };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (a.kind === 'number' && b.kind === 'date' && op === '+') {
|
|
326
|
+
return { kind: 'date', value: addBusinessDays(b.value, a.value, holidaySet) };
|
|
327
|
+
}
|
|
328
|
+
if (a.kind === 'date' && b.kind === 'date' && op === '-') {
|
|
329
|
+
return { kind: 'number', value: businessDayDiff(a.value, b.value, holidaySet) };
|
|
330
|
+
}
|
|
331
|
+
return { kind: 'number', value: applyOp(a.value, op, b.value) };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function evalDateFlat(tokens: DateAwareToken[], holidaySet: Set<string>): TypedValue {
|
|
335
|
+
const list = [...tokens];
|
|
336
|
+
|
|
337
|
+
for (let i = 1; i < list.length - 1; ) {
|
|
338
|
+
const t = list[i];
|
|
339
|
+
if ('type' in t && t.type === 'operator' && (t.value === '+' || t.value === '-')) {
|
|
340
|
+
const left = list[i - 1];
|
|
341
|
+
const right = list[i + 1];
|
|
342
|
+
if (isTypedValue(left) && isTypedValue(right)) {
|
|
343
|
+
const result = applyDateOp(left, t.value, right, holidaySet);
|
|
344
|
+
list.splice(i - 1, 3, result);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
i += 2;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const single = list[0];
|
|
352
|
+
return isTypedValue(single) ? single : { kind: 'number', value: 0 };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function evalDateAwareTokens(tokens: DateAwareToken[], holidaySet: Set<string>): TypedValue {
|
|
356
|
+
if (tokens.length === 0) {
|
|
357
|
+
return { kind: 'number', value: 0 };
|
|
358
|
+
}
|
|
359
|
+
if (tokens.length === 1 && isTypedValue(tokens[0])) {
|
|
360
|
+
return tokens[0];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const list = [...tokens];
|
|
364
|
+
|
|
365
|
+
let idx = 0;
|
|
366
|
+
while (idx < list.length) {
|
|
367
|
+
if ('type' in list[idx] && (list[idx] as { type: string }).type === 'lparen') {
|
|
368
|
+
const close = findMatchingParen(list as { type: string }[], idx);
|
|
369
|
+
if (close === -1) {
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
const inner = list.slice(idx + 1, close);
|
|
373
|
+
const result = evalDateAwareTokens(inner as DateAwareToken[], holidaySet);
|
|
374
|
+
list.splice(idx, close - idx + 1, result);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
idx++;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return evalDateFlat(list as DateAwareToken[], holidaySet);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/*
|
|
384
|
+
* ---------------------------------------------------------------------------
|
|
385
|
+
* Public API
|
|
386
|
+
* ---------------------------------------------------------------------------
|
|
387
|
+
*/
|
|
388
|
+
|
|
145
389
|
/**
|
|
146
390
|
* Evaluate a structured formula with the given data.
|
|
147
391
|
* Field tokens are resolved via resolvePdfDataValues; string values are converted to number.
|
|
148
|
-
* Supports +, -, *, / and parentheses.
|
|
392
|
+
* Supports +, -, *, / and parentheses.
|
|
393
|
+
*
|
|
394
|
+
* Returns `null` when the formula is empty **or** when any field token resolves
|
|
395
|
+
* to null / undefined / empty / non-numeric (non-date) — making the entire
|
|
396
|
+
* calculation blank instead of silently substituting 0.
|
|
397
|
+
*
|
|
398
|
+
* When `dateFieldPaths` is provided, date fields are resolved to epoch-ms and
|
|
399
|
+
* arithmetic follows business-day semantics — weekends and holidays are skipped
|
|
400
|
+
* when adding/subtracting days, and date diffs count only business days.
|
|
149
401
|
*/
|
|
150
402
|
export function evaluateFormula(
|
|
151
403
|
formula: StructuredFormula | undefined | null,
|
|
152
404
|
data: DataModelValues | undefined,
|
|
153
|
-
|
|
405
|
+
dateFieldPaths?: Set<string>,
|
|
406
|
+
holidays?: string[],
|
|
407
|
+
): number | null {
|
|
154
408
|
if (!formula?.tokens?.length) {
|
|
155
|
-
return
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (!hasAllFieldValues(formula, data, dateFieldPaths)) {
|
|
413
|
+
return null;
|
|
156
414
|
}
|
|
415
|
+
|
|
416
|
+
if (dateFieldPaths?.size) {
|
|
417
|
+
const resolved = resolveDateAwareTokens(formula, data, dateFieldPaths);
|
|
418
|
+
const holidaySet = buildHolidaySet(holidays);
|
|
419
|
+
const result = evalDateAwareTokens(resolved, holidaySet);
|
|
420
|
+
return result.value;
|
|
421
|
+
}
|
|
422
|
+
|
|
157
423
|
const resolved = resolveTokens(formula, data);
|
|
158
424
|
return evalResolvedTokens(resolved);
|
|
159
425
|
}
|
|
@@ -48,14 +48,10 @@ export function tokenizeExpression(expression: string): ExpressionPart[] {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* Parse an expression string into FormulaToken[] using valid paths
|
|
52
|
-
* Invalid or unknown identifiers are skipped
|
|
51
|
+
* Parse an expression string into FormulaToken[] using valid paths.
|
|
52
|
+
* Invalid or unknown identifiers are skipped; only known paths become field tokens.
|
|
53
53
|
*/
|
|
54
|
-
export function parseExpression(
|
|
55
|
-
expression: string,
|
|
56
|
-
validPaths: Set<string>,
|
|
57
|
-
pathToLabel: Map<string, string>,
|
|
58
|
-
): FormulaToken[] {
|
|
54
|
+
export function parseExpression(expression: string, validPaths: Set<string>): FormulaToken[] {
|
|
59
55
|
const parts = tokenizeExpression(expression).filter(
|
|
60
56
|
p => p.type !== 'text' || p.value.trim() !== '',
|
|
61
57
|
);
|
|
@@ -70,7 +66,6 @@ export function parseExpression(
|
|
|
70
66
|
tokens.push({
|
|
71
67
|
type: 'field',
|
|
72
68
|
path: part.value,
|
|
73
|
-
label: pathToLabel.get(part.value) ?? part.value,
|
|
74
69
|
});
|
|
75
70
|
}
|
|
76
71
|
continue;
|