@servicetitan/dte-pdf-editor 1.95.0 → 1.96.1
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 +87 -2
- package/dist/components/field-config-panel/advanced-settings.d.ts +2 -0
- package/dist/components/field-config-panel/advanced-settings.d.ts.map +1 -1
- package/dist/components/field-config-panel/advanced-settings.js +5 -4
- package/dist/components/field-config-panel/advanced-settings.js.map +1 -1
- package/dist/components/field-config-panel/e-sign-options-modal.d.ts +3 -0
- package/dist/components/field-config-panel/e-sign-options-modal.d.ts.map +1 -1
- package/dist/components/field-config-panel/e-sign-options-modal.js +6 -4
- package/dist/components/field-config-panel/e-sign-options-modal.js.map +1 -1
- package/dist/components/field-config-panel/field-config-panel-overlay.d.ts +14 -1
- package/dist/components/field-config-panel/field-config-panel-overlay.d.ts.map +1 -1
- package/dist/components/field-config-panel/field-config-panel-overlay.js +41 -2
- package/dist/components/field-config-panel/field-config-panel-overlay.js.map +1 -1
- package/dist/components/field-config-panel/field-config-panel.d.ts +6 -0
- package/dist/components/field-config-panel/field-config-panel.d.ts.map +1 -1
- package/dist/components/field-config-panel/field-config-panel.js +17 -3
- package/dist/components/field-config-panel/field-config-panel.js.map +1 -1
- package/dist/components/field-config-panel/formula-generator.d.ts +4 -0
- package/dist/components/field-config-panel/formula-generator.d.ts.map +1 -1
- package/dist/components/field-config-panel/formula-generator.js +2 -2
- package/dist/components/field-config-panel/formula-generator.js.map +1 -1
- package/dist/components/field-config-panel/formula-modal.d.ts +5 -0
- package/dist/components/field-config-panel/formula-modal.d.ts.map +1 -1
- package/dist/components/field-config-panel/formula-modal.js +13 -6
- package/dist/components/field-config-panel/formula-modal.js.map +1 -1
- package/dist/components/field-config-panel/formula-workspace.d.ts +2 -0
- package/dist/components/field-config-panel/formula-workspace.d.ts.map +1 -1
- package/dist/components/field-config-panel/formula-workspace.js +6 -4
- package/dist/components/field-config-panel/formula-workspace.js.map +1 -1
- package/dist/components/pdf-editor/pdf-editor.d.ts +52 -1
- package/dist/components/pdf-editor/pdf-editor.d.ts.map +1 -1
- package/dist/components/pdf-editor/pdf-editor.js +2 -2
- package/dist/components/pdf-editor/pdf-editor.js.map +1 -1
- package/dist/components/pdf-view/pdf-view-calculated.d.ts +3 -1
- package/dist/components/pdf-view/pdf-view-calculated.d.ts.map +1 -1
- package/dist/components/pdf-view/pdf-view-calculated.js +14 -4
- package/dist/components/pdf-view/pdf-view-calculated.js.map +1 -1
- package/dist/components/pdf-view/pdf-view.d.ts +18 -4
- 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 +6 -0
- package/dist/constants/calculated.constants.d.ts.map +1 -1
- package/dist/constants/calculated.constants.js +6 -0
- package/dist/constants/calculated.constants.js.map +1 -1
- package/dist/hooks/usePinchZoom.d.ts.map +1 -1
- package/dist/hooks/usePinchZoom.js +1 -1
- package/dist/hooks/usePinchZoom.js.map +1 -1
- package/dist/interface/types.d.ts +37 -1
- package/dist/interface/types.d.ts.map +1 -1
- package/dist/interface/types.js.map +1 -1
- package/dist/utils/formula/business-days.utils.d.ts +32 -0
- package/dist/utils/formula/business-days.utils.d.ts.map +1 -0
- package/dist/utils/formula/business-days.utils.js +148 -0
- package/dist/utils/formula/business-days.utils.js.map +1 -0
- package/dist/utils/formula/caret.utils.js +1 -1
- package/dist/utils/formula/caret.utils.js.map +1 -1
- package/dist/utils/formula/evaluate-formula.utils.d.ts +25 -6
- package/dist/utils/formula/evaluate-formula.utils.d.ts.map +1 -1
- package/dist/utils/formula/evaluate-formula.utils.js +61 -101
- package/dist/utils/formula/evaluate-formula.utils.js.map +1 -1
- package/dist/utils/formula/index.d.ts +1 -0
- package/dist/utils/formula/index.d.ts.map +1 -1
- package/dist/utils/formula/index.js +1 -0
- package/dist/utils/formula/index.js.map +1 -1
- package/dist/utils/formula/schedule-bindings.utils.d.ts +12 -0
- package/dist/utils/formula/schedule-bindings.utils.d.ts.map +1 -0
- package/dist/utils/formula/schedule-bindings.utils.js +17 -0
- package/dist/utils/formula/schedule-bindings.utils.js.map +1 -0
- package/dist/vendor/dte-shared/business-calendar-validation.utils.d.ts +22 -0
- package/dist/vendor/dte-shared/business-calendar-validation.utils.js +76 -0
- package/dist/vendor/dte-shared/components/business-calendar-section.d.ts +15 -0
- package/dist/vendor/dte-shared/components/business-calendar-section.js +13 -0
- package/dist/vendor/dte-shared/components/schedule-drawer.d.ts +16 -0
- package/dist/vendor/dte-shared/components/schedule-drawer.js +112 -0
- package/dist/vendor/dte-shared/components/schedule-picker.d.ts +8 -0
- package/dist/vendor/dte-shared/components/schedule-picker.js +88 -0
- package/dist/vendor/dte-shared/constants.d.ts +10 -0
- package/dist/vendor/dte-shared/constants.js +10 -0
- package/dist/vendor/dte-shared/index.d.ts +6 -0
- package/dist/vendor/dte-shared/index.js +5 -0
- package/dist/vendor/dte-shared/styles/schedule.css +19 -0
- package/dist/vendor/dte-shared/types.d.ts +67 -0
- package/dist/vendor/dte-shared/types.js +1 -0
- package/package.json +5 -2
- package/src/__tests__/field-types/date-fields.test.ts +29 -3
- package/src/__tests__/field-types/e-sign-fields.test.ts +2 -2
- package/src/__tests__/field-types/fillable-fields.test.ts +2 -2
- package/src/components/field-config-panel/advanced-settings.tsx +16 -3
- package/src/components/field-config-panel/e-sign-options-modal.tsx +13 -2
- package/src/components/field-config-panel/field-config-panel-overlay.tsx +97 -1
- package/src/components/field-config-panel/field-config-panel.tsx +28 -0
- package/src/components/field-config-panel/formula-generator.tsx +8 -1
- package/src/components/field-config-panel/formula-modal.tsx +19 -4
- package/src/components/field-config-panel/formula-workspace.tsx +31 -6
- package/src/components/pdf-editor/pdf-editor.tsx +82 -0
- package/src/components/pdf-view/pdf-view-calculated.tsx +28 -3
- package/src/components/pdf-view/pdf-view.tsx +23 -3
- package/src/constants/calculated.constants.ts +7 -0
- package/src/hooks/usePinchZoom.ts +1 -6
- package/src/interface/types.ts +47 -0
- package/src/utils/field/__tests__/field-background-color.utils.test.ts +8 -8
- package/src/utils/field/__tests__/get-field-bounds-style.utils.test.ts +16 -0
- package/src/utils/formula/__tests__/evaluate-formula.utils.test.ts +198 -16
- package/src/utils/formula/__tests__/schedule-bindings.utils.test.ts +73 -0
- package/src/utils/formula/business-days.utils.ts +188 -0
- package/src/utils/formula/caret.utils.ts +1 -1
- package/src/utils/formula/evaluate-formula.utils.ts +94 -114
- package/src/utils/formula/index.ts +1 -0
- package/src/utils/formula/schedule-bindings.utils.ts +27 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type FieldScheduleBinding } from '../../../interface/types';
|
|
2
|
+
import { setFieldSchedule, setFieldScheduleEnabled } from '../schedule-bindings.utils';
|
|
3
|
+
|
|
4
|
+
describe('setFieldScheduleEnabled', () => {
|
|
5
|
+
test('adds a default-schedule binding when enabling a field with no existing binding', () => {
|
|
6
|
+
expect(setFieldScheduleEnabled([], 'field-a', true)).toEqual([{ fieldKey: 'field-a' }]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('removing a field with no binding is a no-op', () => {
|
|
10
|
+
const bindings: FieldScheduleBinding[] = [{ fieldKey: 'field-b', scheduleId: 7 }];
|
|
11
|
+
expect(setFieldScheduleEnabled(bindings, 'field-a', false)).toEqual(bindings);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('disabling removes the field from the bindings', () => {
|
|
15
|
+
const bindings: FieldScheduleBinding[] = [
|
|
16
|
+
{ fieldKey: 'field-a', scheduleId: 3 },
|
|
17
|
+
{ fieldKey: 'field-b' },
|
|
18
|
+
];
|
|
19
|
+
expect(setFieldScheduleEnabled(bindings, 'field-a', false)).toEqual([
|
|
20
|
+
{ fieldKey: 'field-b' },
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('re-enabling after disabling resets to the default schedule, not the previous one', () => {
|
|
25
|
+
const bindings: FieldScheduleBinding[] = [{ fieldKey: 'field-a', scheduleId: 3 }];
|
|
26
|
+
const disabled = setFieldScheduleEnabled(bindings, 'field-a', false);
|
|
27
|
+
expect(setFieldScheduleEnabled(disabled, 'field-a', true)).toEqual([
|
|
28
|
+
{ fieldKey: 'field-a' },
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('leaves other fields untouched (field identity isolation)', () => {
|
|
33
|
+
const bindings: FieldScheduleBinding[] = [
|
|
34
|
+
{ fieldKey: 'field-a', scheduleId: 1 },
|
|
35
|
+
{ fieldKey: 'field-b', scheduleId: 2 },
|
|
36
|
+
];
|
|
37
|
+
const result = setFieldScheduleEnabled(bindings, 'field-a', false);
|
|
38
|
+
expect(result).toEqual([{ fieldKey: 'field-b', scheduleId: 2 }]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('setFieldSchedule', () => {
|
|
43
|
+
test('sets the default schedule (scheduleId undefined) for a new field', () => {
|
|
44
|
+
expect(setFieldSchedule([], 'field-a', undefined)).toEqual([
|
|
45
|
+
{ fieldKey: 'field-a', scheduleId: undefined },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('sets a named schedule id for a new field', () => {
|
|
50
|
+
expect(setFieldSchedule([], 'field-a', 42)).toEqual([
|
|
51
|
+
{ fieldKey: 'field-a', scheduleId: 42 },
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('replaces an existing binding for the same field', () => {
|
|
56
|
+
const bindings: FieldScheduleBinding[] = [{ fieldKey: 'field-a', scheduleId: 1 }];
|
|
57
|
+
expect(setFieldSchedule(bindings, 'field-a', 2)).toEqual([
|
|
58
|
+
{ fieldKey: 'field-a', scheduleId: 2 },
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('leaves other fields untouched (field identity isolation)', () => {
|
|
63
|
+
const bindings: FieldScheduleBinding[] = [
|
|
64
|
+
{ fieldKey: 'field-a', scheduleId: 1 },
|
|
65
|
+
{ fieldKey: 'field-b', scheduleId: 2 },
|
|
66
|
+
];
|
|
67
|
+
const result = setFieldSchedule(bindings, 'field-a', 9);
|
|
68
|
+
expect(result).toEqual([
|
|
69
|
+
{ fieldKey: 'field-b', scheduleId: 2 },
|
|
70
|
+
{ fieldKey: 'field-a', scheduleId: 9 },
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { DEFAULT_CALENDAR_YEARS, MAX_DATE_CALC_DAYS } from '../../constants';
|
|
2
|
+
import { type BusinessCalendar } from '../../interface/types';
|
|
3
|
+
|
|
4
|
+
/** Saturday and Sunday: the default calendar's weekend. */
|
|
5
|
+
const DEFAULT_WEEKEND_DAYS = [0, 6];
|
|
6
|
+
|
|
7
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
interface PreparedCalendar {
|
|
10
|
+
weekendDays: Set<number>;
|
|
11
|
+
holidays: Set<string>;
|
|
12
|
+
firstYear: number;
|
|
13
|
+
lastYear: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function toDateOnly(ms: number): Date {
|
|
17
|
+
const d = new Date(ms);
|
|
18
|
+
d.setUTCHours(0, 0, 0, 0);
|
|
19
|
+
return d;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function toDateKey(d: Date): string {
|
|
23
|
+
const y = d.getUTCFullYear();
|
|
24
|
+
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
25
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
26
|
+
return `${y}-${m}-${day}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildHolidaySet(holidays: string[] | undefined): Set<string> {
|
|
30
|
+
const set = new Set<string>();
|
|
31
|
+
for (const h of holidays ?? []) {
|
|
32
|
+
const d = toDateOnly(new Date(h).getTime());
|
|
33
|
+
if (!isNaN(d.getTime())) {
|
|
34
|
+
set.add(toDateKey(d));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return set;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Calendars are passed once and reused by every field, so each is prepared once.
|
|
41
|
+
const preparedCalendars = new WeakMap<BusinessCalendar, PreparedCalendar>();
|
|
42
|
+
|
|
43
|
+
function prepareCalendar(calendar: BusinessCalendar): PreparedCalendar {
|
|
44
|
+
const cached = preparedCalendars.get(calendar);
|
|
45
|
+
if (cached) {
|
|
46
|
+
return cached;
|
|
47
|
+
}
|
|
48
|
+
const prepared: PreparedCalendar = {
|
|
49
|
+
weekendDays: new Set(calendar.weekendDays ?? DEFAULT_WEEKEND_DAYS),
|
|
50
|
+
holidays: buildHolidaySet(calendar.holidays),
|
|
51
|
+
firstYear: calendar.firstYear,
|
|
52
|
+
lastYear: calendar.lastYear,
|
|
53
|
+
};
|
|
54
|
+
preparedCalendars.set(calendar, prepared);
|
|
55
|
+
return prepared;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The default calendar: Saturday and Sunday plus `holidays` (the host's, DTE's
|
|
60
|
+
* `GET api/holidays`), for the current year ± DEFAULT_CALENDAR_YEARS. Fields whose schedule toggle
|
|
61
|
+
* is off use it.
|
|
62
|
+
*/
|
|
63
|
+
export function defaultBusinessCalendar(holidays?: string[]): BusinessCalendar {
|
|
64
|
+
const year = new Date().getUTCFullYear();
|
|
65
|
+
return {
|
|
66
|
+
weekendDays: DEFAULT_WEEKEND_DAYS,
|
|
67
|
+
holidays: holidays ?? [],
|
|
68
|
+
firstYear: year - DEFAULT_CALENDAR_YEARS,
|
|
69
|
+
lastYear: year + DEFAULT_CALENDAR_YEARS,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isBusinessDay(d: Date, calendar: PreparedCalendar): boolean {
|
|
74
|
+
return !calendar.weekendDays.has(d.getUTCDay()) && !calendar.holidays.has(toDateKey(d));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isWithinYears(d: Date, calendar: PreparedCalendar): boolean {
|
|
78
|
+
const year = d.getUTCFullYear();
|
|
79
|
+
return year >= calendar.firstYear && year <= calendar.lastYear;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Walks `days` business days from a date (epoch ms; negative walks back), the start not counted.
|
|
84
|
+
* Mirrors DTE's `BusinessDayCalculator.AddBusinessDays`: null for a fractional offset, more than
|
|
85
|
+
* MAX_DATE_CALC_DAYS, an invalid start, or a start or walk outside the calendar's years.
|
|
86
|
+
*/
|
|
87
|
+
export function addBusinessDays(
|
|
88
|
+
startMs: number,
|
|
89
|
+
days: number,
|
|
90
|
+
calendar: BusinessCalendar,
|
|
91
|
+
): number | null {
|
|
92
|
+
const prepared = prepareCalendar(calendar);
|
|
93
|
+
const start = toDateOnly(startMs);
|
|
94
|
+
if (
|
|
95
|
+
!Number.isInteger(days) ||
|
|
96
|
+
Math.abs(days) > MAX_DATE_CALC_DAYS ||
|
|
97
|
+
!isFinite(start.getTime()) ||
|
|
98
|
+
!isWithinYears(start, prepared)
|
|
99
|
+
) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const direction = days >= 0 ? 1 : -1;
|
|
104
|
+
let remaining = Math.abs(days);
|
|
105
|
+
const current = new Date(start.getTime());
|
|
106
|
+
|
|
107
|
+
while (remaining > 0) {
|
|
108
|
+
current.setUTCDate(current.getUTCDate() + direction);
|
|
109
|
+
if (!isWithinYears(current, prepared)) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
if (isBusinessDay(current, prepared)) {
|
|
113
|
+
remaining--;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return current.getTime();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Counts the business days from `fromMs` to `toMs`, negative when `toMs` is earlier; the start is
|
|
122
|
+
* not counted, the end is. Mirrors DTE's `BusinessDayCalculator.CountBusinessDays`: null for an
|
|
123
|
+
* invalid date, a span over MAX_DATE_CALC_DAYS, or a date outside the calendar's years.
|
|
124
|
+
*/
|
|
125
|
+
export function countBusinessDays(
|
|
126
|
+
fromMs: number,
|
|
127
|
+
toMs: number,
|
|
128
|
+
calendar: BusinessCalendar,
|
|
129
|
+
): number | null {
|
|
130
|
+
const prepared = prepareCalendar(calendar);
|
|
131
|
+
const from = toDateOnly(fromMs);
|
|
132
|
+
const to = toDateOnly(toMs);
|
|
133
|
+
if (!isFinite(from.getTime()) || !isFinite(to.getTime())) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
if (
|
|
137
|
+
Math.abs(to.getTime() - from.getTime()) / DAY_MS > MAX_DATE_CALC_DAYS ||
|
|
138
|
+
!isWithinYears(from, prepared) ||
|
|
139
|
+
!isWithinYears(to, prepared)
|
|
140
|
+
) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const direction = to.getTime() >= from.getTime() ? 1 : -1;
|
|
145
|
+
const cursor = new Date(from.getTime());
|
|
146
|
+
let count = 0;
|
|
147
|
+
|
|
148
|
+
while (toDateKey(cursor) !== toDateKey(to)) {
|
|
149
|
+
cursor.setUTCDate(cursor.getUTCDate() + direction);
|
|
150
|
+
if (isBusinessDay(cursor, prepared)) {
|
|
151
|
+
count++;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return count * direction;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Adds `days` plain calendar days to a date (epoch ms; negative subtracts) — no weekend or holiday
|
|
160
|
+
* skipping. Used when a date calculated field's business-day exclusion toggle is off, matching the
|
|
161
|
+
* pre-DTE-4695 behavior. Null for a fractional offset, more than MAX_DATE_CALC_DAYS, or an invalid
|
|
162
|
+
* start.
|
|
163
|
+
*/
|
|
164
|
+
export function addCalendarDays(startMs: number, days: number): number | null {
|
|
165
|
+
const start = toDateOnly(startMs);
|
|
166
|
+
if (
|
|
167
|
+
!Number.isInteger(days) ||
|
|
168
|
+
Math.abs(days) > MAX_DATE_CALC_DAYS ||
|
|
169
|
+
!isFinite(start.getTime())
|
|
170
|
+
) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
return start.getTime() + days * DAY_MS;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Counts the plain calendar days from `fromMs` to `toMs`, negative when `toMs` is earlier — no
|
|
178
|
+
* weekend or holiday skipping. Null for an invalid date or a span over MAX_DATE_CALC_DAYS.
|
|
179
|
+
*/
|
|
180
|
+
export function countCalendarDays(fromMs: number, toMs: number): number | null {
|
|
181
|
+
const from = toDateOnly(fromMs);
|
|
182
|
+
const to = toDateOnly(toMs);
|
|
183
|
+
if (!isFinite(from.getTime()) || !isFinite(to.getTime())) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
const diffDays = Math.round((to.getTime() - from.getTime()) / DAY_MS);
|
|
187
|
+
return Math.abs(diffDays) > MAX_DATE_CALC_DAYS ? null : diffDays;
|
|
188
|
+
}
|
|
@@ -27,7 +27,7 @@ function getExpressionBeforeCaret(element: HTMLElement): string {
|
|
|
27
27
|
while (current && current.parentNode !== element) {
|
|
28
28
|
current = current.parentNode;
|
|
29
29
|
}
|
|
30
|
-
return current
|
|
30
|
+
return current?.parentNode === element ? (current as ChildNode) : null;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
if (endContainer === element) {
|
|
@@ -1,10 +1,50 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type BusinessCalendar,
|
|
2
3
|
type DataModelValues,
|
|
4
|
+
type FieldScheduleBinding,
|
|
3
5
|
type FormulaOperator,
|
|
4
6
|
type FormulaToken,
|
|
5
7
|
type StructuredFormula,
|
|
6
8
|
} from '../../interface/types';
|
|
7
9
|
import { resolvePdfDataValues } from '../data-model';
|
|
10
|
+
import {
|
|
11
|
+
addBusinessDays,
|
|
12
|
+
addCalendarDays,
|
|
13
|
+
countBusinessDays,
|
|
14
|
+
countCalendarDays,
|
|
15
|
+
defaultBusinessCalendar,
|
|
16
|
+
} from './business-days.utils';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Which calendar a date formula uses. `enabled` is the field's custom business-day exclusion
|
|
20
|
+
* toggle (DTE's schedule binding): off uses plain calendar-day arithmetic (no weekend/holiday
|
|
21
|
+
* skipping, the pre-DTE-4695 behavior); on uses the field's own calendar (`calendar`, a field
|
|
22
|
+
* bound to a schedule) or the default one, Saturday and Sunday plus `holidays`.
|
|
23
|
+
*/
|
|
24
|
+
export interface DateCalcOptions {
|
|
25
|
+
enabled?: boolean;
|
|
26
|
+
calendar?: BusinessCalendar;
|
|
27
|
+
holidays?: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A field's options: disabled (plain calendar-day math) unless it has a binding in
|
|
32
|
+
* `scheduleBindings` (DTE's `fieldKey` = `PdfField.id`), in which case it uses its own calendar
|
|
33
|
+
* when the host passed one for its id, else the default calendar.
|
|
34
|
+
*/
|
|
35
|
+
export function getFieldDateCalcOptions(
|
|
36
|
+
fieldId: string | undefined,
|
|
37
|
+
scheduleBindings: FieldScheduleBinding[] | undefined,
|
|
38
|
+
holidays: string[] | undefined,
|
|
39
|
+
fieldCalendars: Record<string, BusinessCalendar> | undefined,
|
|
40
|
+
): DateCalcOptions {
|
|
41
|
+
const enabled = !!fieldId && !!scheduleBindings?.some(b => b.fieldKey === fieldId);
|
|
42
|
+
if (!enabled) {
|
|
43
|
+
return { enabled: false };
|
|
44
|
+
}
|
|
45
|
+
const calendar = fieldCalendars?.[fieldId!];
|
|
46
|
+
return calendar ? { enabled: true, calendar } : { enabled: true, holidays };
|
|
47
|
+
}
|
|
8
48
|
|
|
9
49
|
/**
|
|
10
50
|
* Convert formula operand to number. Merge tags and fillable only ever give:
|
|
@@ -199,94 +239,9 @@ function parseDateValue(raw: string): number {
|
|
|
199
239
|
return isNaN(d.getTime()) ? NaN : d.getTime();
|
|
200
240
|
}
|
|
201
241
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
return d;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function toDateKey(d: Date): string {
|
|
209
|
-
const y = d.getUTCFullYear();
|
|
210
|
-
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
211
|
-
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
212
|
-
return `${y}-${m}-${day}`;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function isWeekend(d: Date): boolean {
|
|
216
|
-
const day = d.getUTCDay();
|
|
217
|
-
return day === 0 || day === 6;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function buildHolidaySet(holidays?: string[]): Set<string> {
|
|
221
|
-
if (!holidays?.length) {
|
|
222
|
-
return new Set();
|
|
223
|
-
}
|
|
224
|
-
const set = new Set<string>();
|
|
225
|
-
for (const h of holidays) {
|
|
226
|
-
const d = toDateOnly(new Date(h).getTime());
|
|
227
|
-
if (!isNaN(d.getTime())) {
|
|
228
|
-
set.add(toDateKey(d));
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
return set;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function isNonBusinessDay(d: Date, holidaySet: Set<string>): boolean {
|
|
235
|
-
return isWeekend(d) || (holidaySet.size > 0 && holidaySet.has(toDateKey(d)));
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Add business days to a date (epoch ms), skipping weekends and holidays.
|
|
240
|
-
* Returns the resulting date as epoch ms.
|
|
241
|
-
*/
|
|
242
|
-
function addBusinessDays(startMs: number, days: number, holidaySet: Set<string>): number {
|
|
243
|
-
const result = toDateOnly(startMs);
|
|
244
|
-
if (!isFinite(result.getTime())) {
|
|
245
|
-
return result.getTime();
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const intDays = Math.trunc(days);
|
|
249
|
-
if (intDays === 0) {
|
|
250
|
-
return result.getTime();
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const direction = intDays > 0 ? 1 : -1;
|
|
254
|
-
let remaining = Math.abs(intDays);
|
|
255
|
-
|
|
256
|
-
while (remaining > 0) {
|
|
257
|
-
result.setUTCDate(result.getUTCDate() + direction);
|
|
258
|
-
if (!isNonBusinessDay(result, holidaySet)) {
|
|
259
|
-
remaining--;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
return result.getTime();
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Count business days between two dates (epoch ms), skipping weekends and holidays.
|
|
268
|
-
* Positive when `toMs` is after `fromMs`, negative otherwise.
|
|
269
|
-
*/
|
|
270
|
-
function businessDayDiff(fromMs: number, toMs: number, holidaySet: Set<string>): number {
|
|
271
|
-
const start = toDateOnly(fromMs);
|
|
272
|
-
const end = toDateOnly(toMs);
|
|
273
|
-
|
|
274
|
-
if (!isFinite(start.getTime()) || !isFinite(end.getTime())) {
|
|
275
|
-
return 0;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const direction = end.getTime() >= start.getTime() ? 1 : -1;
|
|
279
|
-
const cursor = new Date(start.getTime());
|
|
280
|
-
let count = 0;
|
|
281
|
-
|
|
282
|
-
while (toDateKey(cursor) !== toDateKey(end)) {
|
|
283
|
-
cursor.setUTCDate(cursor.getUTCDate() + direction);
|
|
284
|
-
if (!isNonBusinessDay(cursor, holidaySet)) {
|
|
285
|
-
count++;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
return count * direction;
|
|
242
|
+
/** Blank date arithmetic is NaN, so it carries through to a null result like other invalid values. */
|
|
243
|
+
function orNaN(value: number | null): number {
|
|
244
|
+
return value ?? NaN;
|
|
290
245
|
}
|
|
291
246
|
|
|
292
247
|
function resolveDateAwareTokens(
|
|
@@ -297,8 +252,7 @@ function resolveDateAwareTokens(
|
|
|
297
252
|
if (t.type === 'field') {
|
|
298
253
|
const raw = resolvePdfDataValues(data, t.path);
|
|
299
254
|
if (t.fieldType === 'date') {
|
|
300
|
-
|
|
301
|
-
return { kind: 'date', value: isNaN(ms) ? 0 : ms };
|
|
255
|
+
return { kind: 'date', value: parseDateValue(raw) };
|
|
302
256
|
}
|
|
303
257
|
return { kind: 'number', value: valueToNumber(raw) };
|
|
304
258
|
}
|
|
@@ -309,38 +263,60 @@ function resolveDateAwareTokens(
|
|
|
309
263
|
});
|
|
310
264
|
}
|
|
311
265
|
|
|
266
|
+
/** Date add/diff behind the field's exclusion toggle: business-day-aware when on, plain when off. */
|
|
267
|
+
interface DateMath {
|
|
268
|
+
add(ms: number, days: number): number | null;
|
|
269
|
+
diff(fromMs: number, toMs: number): number | null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function businessDayMath(calendar: BusinessCalendar): DateMath {
|
|
273
|
+
return {
|
|
274
|
+
add: (ms, days) => addBusinessDays(ms, days, calendar),
|
|
275
|
+
diff: (fromMs, toMs) => countBusinessDays(fromMs, toMs, calendar),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function calendarDayMath(): DateMath {
|
|
280
|
+
return {
|
|
281
|
+
add: (ms, days) => addCalendarDays(ms, days),
|
|
282
|
+
diff: (fromMs, toMs) => countCalendarDays(fromMs, toMs),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
312
286
|
/**
|
|
313
|
-
* Apply an operator to two typed values with date-aware
|
|
314
|
-
* date + number → add
|
|
315
|
-
* number + date → add
|
|
316
|
-
* date - number → sub
|
|
317
|
-
* date - date →
|
|
318
|
-
* number ± number → normal
|
|
287
|
+
* Apply an operator to two typed values with date-aware semantics:
|
|
288
|
+
* date + number → add days → date
|
|
289
|
+
* number + date → add days → date
|
|
290
|
+
* date - number → sub days → date
|
|
291
|
+
* date - date → day diff → number (`D1 - D2` counts from D2 to D1, as DTE does)
|
|
292
|
+
* number ± number → normal → number
|
|
293
|
+
*
|
|
294
|
+
* Steps are applied one at a time, left to right, as DTE's `ByodFormulaEvaluator` does.
|
|
319
295
|
*/
|
|
320
296
|
function applyDateOp(
|
|
321
297
|
a: TypedValue,
|
|
322
298
|
op: FormulaOperator,
|
|
323
299
|
b: TypedValue,
|
|
324
|
-
|
|
300
|
+
dateMath: DateMath,
|
|
325
301
|
): TypedValue {
|
|
326
302
|
if (a.kind === 'date' && b.kind === 'number') {
|
|
327
303
|
if (op === '+') {
|
|
328
|
-
return { kind: 'date', value:
|
|
304
|
+
return { kind: 'date', value: orNaN(dateMath.add(a.value, b.value)) };
|
|
329
305
|
}
|
|
330
306
|
if (op === '-') {
|
|
331
|
-
return { kind: 'date', value:
|
|
307
|
+
return { kind: 'date', value: orNaN(dateMath.add(a.value, -b.value)) };
|
|
332
308
|
}
|
|
333
309
|
}
|
|
334
310
|
if (a.kind === 'number' && b.kind === 'date' && op === '+') {
|
|
335
|
-
return { kind: 'date', value:
|
|
311
|
+
return { kind: 'date', value: orNaN(dateMath.add(b.value, a.value)) };
|
|
336
312
|
}
|
|
337
313
|
if (a.kind === 'date' && b.kind === 'date' && op === '-') {
|
|
338
|
-
return { kind: 'number', value:
|
|
314
|
+
return { kind: 'number', value: orNaN(dateMath.diff(b.value, a.value)) };
|
|
339
315
|
}
|
|
340
316
|
return { kind: 'number', value: applyOp(a.value, op, b.value) };
|
|
341
317
|
}
|
|
342
318
|
|
|
343
|
-
function evalDateFlat(tokens: DateAwareToken[],
|
|
319
|
+
function evalDateFlat(tokens: DateAwareToken[], dateMath: DateMath): TypedValue {
|
|
344
320
|
const list = [...tokens];
|
|
345
321
|
|
|
346
322
|
for (let i = 1; i < list.length - 1; ) {
|
|
@@ -349,7 +325,7 @@ function evalDateFlat(tokens: DateAwareToken[], holidaySet: Set<string>): TypedV
|
|
|
349
325
|
const left = list[i - 1];
|
|
350
326
|
const right = list[i + 1];
|
|
351
327
|
if (isTypedValue(left) && isTypedValue(right)) {
|
|
352
|
-
const result = applyDateOp(left, t.value, right,
|
|
328
|
+
const result = applyDateOp(left, t.value, right, dateMath);
|
|
353
329
|
list.splice(i - 1, 3, result);
|
|
354
330
|
continue;
|
|
355
331
|
}
|
|
@@ -361,7 +337,7 @@ function evalDateFlat(tokens: DateAwareToken[], holidaySet: Set<string>): TypedV
|
|
|
361
337
|
return isTypedValue(single) ? single : { kind: 'number', value: 0 };
|
|
362
338
|
}
|
|
363
339
|
|
|
364
|
-
function evalDateAwareTokens(tokens: DateAwareToken[],
|
|
340
|
+
function evalDateAwareTokens(tokens: DateAwareToken[], dateMath: DateMath): TypedValue {
|
|
365
341
|
if (tokens.length === 0) {
|
|
366
342
|
return { kind: 'number', value: 0 };
|
|
367
343
|
}
|
|
@@ -379,14 +355,14 @@ function evalDateAwareTokens(tokens: DateAwareToken[], holidaySet: Set<string>):
|
|
|
379
355
|
break;
|
|
380
356
|
}
|
|
381
357
|
const inner = list.slice(idx + 1, close);
|
|
382
|
-
const result = evalDateAwareTokens(inner as DateAwareToken[],
|
|
358
|
+
const result = evalDateAwareTokens(inner as DateAwareToken[], dateMath);
|
|
383
359
|
list.splice(idx, close - idx + 1, result);
|
|
384
360
|
continue;
|
|
385
361
|
}
|
|
386
362
|
idx++;
|
|
387
363
|
}
|
|
388
364
|
|
|
389
|
-
return evalDateFlat(list as DateAwareToken[],
|
|
365
|
+
return evalDateFlat(list as DateAwareToken[], dateMath);
|
|
390
366
|
}
|
|
391
367
|
|
|
392
368
|
/*
|
|
@@ -406,14 +382,16 @@ function evalDateAwareTokens(tokens: DateAwareToken[], holidaySet: Set<string>):
|
|
|
406
382
|
* field is omitted rather than showing 0.
|
|
407
383
|
*
|
|
408
384
|
* Date fields are identified by `fieldType: 'date'` on field tokens. When date
|
|
409
|
-
* fields are present, arithmetic follows business-day
|
|
410
|
-
*
|
|
411
|
-
* business days
|
|
385
|
+
* fields are present and `options.enabled` is on, arithmetic follows business-day
|
|
386
|
+
* semantics — the calendar's weekend days and holidays are skipped when adding/subtracting
|
|
387
|
+
* days, and date diffs count only business days, with a result outside the calendar's years
|
|
388
|
+
* blank. Otherwise (the field's exclusion toggle is off) it's plain calendar-day arithmetic.
|
|
389
|
+
* Either way, as in DTE, a fractional offset or a difference over 365 days is `null` (blank).
|
|
412
390
|
*/
|
|
413
391
|
export function evaluateFormula(
|
|
414
392
|
formula: StructuredFormula | undefined | null,
|
|
415
393
|
data: DataModelValues | undefined,
|
|
416
|
-
|
|
394
|
+
options?: DateCalcOptions,
|
|
417
395
|
): number | null {
|
|
418
396
|
if (!formula?.tokens?.length) {
|
|
419
397
|
return null;
|
|
@@ -427,8 +405,10 @@ export function evaluateFormula(
|
|
|
427
405
|
|
|
428
406
|
if (dateFieldPaths.size) {
|
|
429
407
|
const resolved = resolveDateAwareTokens(formula, data);
|
|
430
|
-
const
|
|
431
|
-
|
|
408
|
+
const dateMath = options?.enabled
|
|
409
|
+
? businessDayMath(options.calendar ?? defaultBusinessCalendar(options.holidays))
|
|
410
|
+
: calendarDayMath();
|
|
411
|
+
const result = evalDateAwareTokens(resolved, dateMath);
|
|
432
412
|
const n = result.value;
|
|
433
413
|
return Number.isFinite(n) ? n : null;
|
|
434
414
|
}
|
|
@@ -445,7 +425,7 @@ export function evaluateFormula(
|
|
|
445
425
|
export function hasDivisionByZero(
|
|
446
426
|
formula: StructuredFormula | undefined | null,
|
|
447
427
|
data?: DataModelValues,
|
|
448
|
-
|
|
428
|
+
options?: DateCalcOptions,
|
|
449
429
|
) {
|
|
450
430
|
if (!formula?.tokens?.length) {
|
|
451
431
|
return false;
|
|
@@ -458,5 +438,5 @@ export function hasDivisionByZero(
|
|
|
458
438
|
if (!hasAllFieldValues(formula, data, dateFieldPaths)) {
|
|
459
439
|
return false;
|
|
460
440
|
}
|
|
461
|
-
return evaluateFormula(formula, data,
|
|
441
|
+
return evaluateFormula(formula, data, options) === null;
|
|
462
442
|
}
|
|
@@ -5,5 +5,6 @@ export * from './expression.utils';
|
|
|
5
5
|
export * from './format-calculated-result.utils';
|
|
6
6
|
export * from './formula-types';
|
|
7
7
|
export * from './render-formula.utils';
|
|
8
|
+
export * from './schedule-bindings.utils';
|
|
8
9
|
export * from './validate-formula.utils';
|
|
9
10
|
export * from './form-fields.utils';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type FieldScheduleBinding } from '../../interface/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Replaces `fieldKey`'s binding: enabling adds it (default schedule, `scheduleId` undefined);
|
|
5
|
+
* disabling removes it. Every other field's binding is returned unchanged.
|
|
6
|
+
*/
|
|
7
|
+
export function setFieldScheduleEnabled(
|
|
8
|
+
bindings: FieldScheduleBinding[],
|
|
9
|
+
fieldKey: string,
|
|
10
|
+
enabled: boolean,
|
|
11
|
+
): FieldScheduleBinding[] {
|
|
12
|
+
const withoutField = bindings.filter(b => b.fieldKey !== fieldKey);
|
|
13
|
+
return enabled ? [...withoutField, { fieldKey }] : withoutField;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Sets `fieldKey`'s selected schedule (`scheduleId` undefined for the default schedule),
|
|
18
|
+
* enabling its toggle if it wasn't already on. Every other field's binding is returned unchanged.
|
|
19
|
+
*/
|
|
20
|
+
export function setFieldSchedule(
|
|
21
|
+
bindings: FieldScheduleBinding[],
|
|
22
|
+
fieldKey: string,
|
|
23
|
+
scheduleId: number | undefined,
|
|
24
|
+
): FieldScheduleBinding[] {
|
|
25
|
+
const withoutField = bindings.filter(b => b.fieldKey !== fieldKey);
|
|
26
|
+
return [...withoutField, { fieldKey, scheduleId }];
|
|
27
|
+
}
|