@open-mercato/core 0.8.1-develop.7454.1.010095490f → 0.8.1-develop.7455.1.8f7e53badb
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/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/customer_dictionary_entry/index.js +2 -0
- package/dist/generated/entities/customer_dictionary_entry/index.js.map +2 -2
- package/dist/generated/entities/customer_interaction/index.js +2 -0
- package/dist/generated/entities/customer_interaction/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +2 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/customers/api/activity-types/route.js +151 -0
- package/dist/modules/customers/api/activity-types/route.js.map +7 -0
- package/dist/modules/customers/api/dictionaries/[kind]/[id]/route.js +13 -5
- package/dist/modules/customers/api/dictionaries/[kind]/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/dictionaries/[kind]/route.js +20 -5
- package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
- package/dist/modules/customers/api/interactions/route.js +3 -0
- package/dist/modules/customers/api/interactions/route.js.map +2 -2
- package/dist/modules/customers/api/interactions/tasks/route.js +1 -0
- package/dist/modules/customers/api/interactions/tasks/route.js.map +2 -2
- package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js +18 -0
- package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js.map +2 -2
- package/dist/modules/customers/calendar-event-types.js +548 -0
- package/dist/modules/customers/calendar-event-types.js.map +7 -0
- package/dist/modules/customers/commands/dictionaries.js +70 -10
- package/dist/modules/customers/commands/dictionaries.js.map +2 -2
- package/dist/modules/customers/commands/interactions.js +187 -22
- package/dist/modules/customers/commands/interactions.js.map +3 -3
- package/dist/modules/customers/components/ActivityTypeEditor.js +288 -0
- package/dist/modules/customers/components/ActivityTypeEditor.js.map +7 -0
- package/dist/modules/customers/components/DictionarySettings.js +3 -1
- package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
- package/dist/modules/customers/components/calendar/CalendarEventEditor.js +391 -297
- package/dist/modules/customers/components/calendar/CalendarEventEditor.js.map +3 -3
- package/dist/modules/customers/components/calendar/CalendarSettingsModal.js +9 -74
- package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
- package/dist/modules/customers/components/calendar/TimeGrid.js +20 -16
- package/dist/modules/customers/components/calendar/TimeGrid.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/EventTypePanel.js +218 -0
- package/dist/modules/customers/components/calendar/editor/EventTypePanel.js.map +7 -0
- package/dist/modules/customers/components/calendar/editor/LocationField.js +7 -3
- package/dist/modules/customers/components/calendar/editor/LocationField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/PeopleField.js +5 -2
- package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/PriorityField.js +3 -1
- package/dist/modules/customers/components/calendar/editor/PriorityField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/RepeatField.js +9 -1
- package/dist/modules/customers/components/calendar/editor/RepeatField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/ResourcesField.js +4 -1
- package/dist/modules/customers/components/calendar/editor/ResourcesField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/ScheduleSection.js +15 -7
- package/dist/modules/customers/components/calendar/editor/ScheduleSection.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/SegmentGroup.js +3 -1
- package/dist/modules/customers/components/calendar/editor/SegmentGroup.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/hooks.js +28 -13
- package/dist/modules/customers/components/calendar/editor/hooks.js.map +3 -3
- package/dist/modules/customers/components/calendar/editor/inputs.js +14 -6
- package/dist/modules/customers/components/calendar/editor/inputs.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/useCalendarCustomFields.js +54 -0
- package/dist/modules/customers/components/calendar/editor/useCalendarCustomFields.js.map +7 -0
- package/dist/modules/customers/components/calendar/editor/useEventTypeCatalog.js +82 -0
- package/dist/modules/customers/components/calendar/editor/useEventTypeCatalog.js.map +7 -0
- package/dist/modules/customers/components/calendar/types.js +1 -0
- package/dist/modules/customers/components/calendar/types.js.map +2 -2
- package/dist/modules/customers/data/entities.js +6 -0
- package/dist/modules/customers/data/entities.js.map +2 -2
- package/dist/modules/customers/data/validators.js +39 -10
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/di.js +20 -1
- package/dist/modules/customers/di.js.map +2 -2
- package/dist/modules/customers/extension-points.js +13 -0
- package/dist/modules/customers/extension-points.js.map +2 -2
- package/dist/modules/customers/lib/calendar/conflicts.js +59 -16
- package/dist/modules/customers/lib/calendar/conflicts.js.map +2 -2
- package/dist/modules/customers/lib/calendar/editorPayload.js +34 -16
- package/dist/modules/customers/lib/calendar/editorPayload.js.map +2 -2
- package/dist/modules/customers/lib/calendar/eventTypeResolver.js +197 -0
- package/dist/modules/customers/lib/calendar/eventTypeResolver.js.map +7 -0
- package/dist/modules/customers/lib/calendar/interactionApplicability.js +67 -0
- package/dist/modules/customers/lib/calendar/interactionApplicability.js.map +7 -0
- package/dist/modules/customers/lib/calendar/mapItem.js +5 -2
- package/dist/modules/customers/lib/calendar/mapItem.js.map +2 -2
- package/dist/modules/customers/lib/calendar/recurrence.js +62 -1
- package/dist/modules/customers/lib/calendar/recurrence.js.map +2 -2
- package/dist/modules/customers/lib/calendar/timezone.js +111 -0
- package/dist/modules/customers/lib/calendar/timezone.js.map +7 -0
- package/dist/modules/customers/lib/interactionReadModel.js +1 -0
- package/dist/modules/customers/lib/interactionReadModel.js.map +2 -2
- package/dist/modules/customers/lib/todoCompatibility.js +1 -0
- package/dist/modules/customers/lib/todoCompatibility.js.map +2 -2
- package/dist/modules/customers/migrations/Migration20260928175058_customers.js +17 -0
- package/dist/modules/customers/migrations/Migration20260928175058_customers.js.map +7 -0
- package/dist/modules/customers/migrations/Migration20260930110235_customers.js +17 -0
- package/dist/modules/customers/migrations/Migration20260930110235_customers.js.map +7 -0
- package/dist/modules/dictionaries/components/DictionaryTable.js +60 -51
- package/dist/modules/dictionaries/components/DictionaryTable.js.map +2 -2
- package/dist/modules/planner/lib/availabilityMerge.js +101 -38
- package/dist/modules/planner/lib/availabilityMerge.js.map +3 -3
- package/dist/modules/planner/services/plannerAvailabilityService.js.map +2 -2
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +12 -7
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
- package/generated/entities/customer_dictionary_entry/index.ts +1 -0
- package/generated/entities/customer_interaction/index.ts +1 -0
- package/generated/entity-fields-registry.ts +2 -0
- package/package.json +7 -7
- package/src/modules/customers/api/activity-types/route.ts +154 -0
- package/src/modules/customers/api/dictionaries/[kind]/[id]/route.ts +9 -1
- package/src/modules/customers/api/dictionaries/[kind]/route.ts +18 -2
- package/src/modules/customers/api/interactions/route.ts +4 -0
- package/src/modules/customers/api/interactions/tasks/route.ts +1 -0
- package/src/modules/customers/backend/config/customers/pipeline-stages/page.meta.ts +20 -0
- package/src/modules/customers/calendar-event-types.ts +674 -0
- package/src/modules/customers/commands/dictionaries.ts +84 -8
- package/src/modules/customers/commands/interactions.ts +244 -21
- package/src/modules/customers/components/ActivityTypeEditor.tsx +239 -0
- package/src/modules/customers/components/DictionarySettings.tsx +5 -3
- package/src/modules/customers/components/calendar/CalendarEventEditor.tsx +347 -218
- package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +18 -96
- package/src/modules/customers/components/calendar/TimeGrid.tsx +20 -18
- package/src/modules/customers/components/calendar/editor/EventTypePanel.tsx +230 -0
- package/src/modules/customers/components/calendar/editor/LocationField.tsx +7 -1
- package/src/modules/customers/components/calendar/editor/PeopleField.tsx +4 -0
- package/src/modules/customers/components/calendar/editor/PriorityField.tsx +3 -0
- package/src/modules/customers/components/calendar/editor/RepeatField.tsx +10 -0
- package/src/modules/customers/components/calendar/editor/ResourcesField.tsx +4 -0
- package/src/modules/customers/components/calendar/editor/ScheduleSection.tsx +16 -4
- package/src/modules/customers/components/calendar/editor/SegmentGroup.tsx +3 -0
- package/src/modules/customers/components/calendar/editor/hooks.ts +29 -13
- package/src/modules/customers/components/calendar/editor/inputs.tsx +17 -5
- package/src/modules/customers/components/calendar/editor/useCalendarCustomFields.ts +49 -0
- package/src/modules/customers/components/calendar/editor/useEventTypeCatalog.ts +100 -0
- package/src/modules/customers/components/calendar/types.ts +1 -0
- package/src/modules/customers/components/detail/types.ts +1 -0
- package/src/modules/customers/data/entities.ts +8 -1
- package/src/modules/customers/data/validators.ts +42 -7
- package/src/modules/customers/di.ts +21 -0
- package/src/modules/customers/extension-points.ts +13 -0
- package/src/modules/customers/i18n/de.json +83 -0
- package/src/modules/customers/i18n/en.json +83 -0
- package/src/modules/customers/i18n/es.json +83 -0
- package/src/modules/customers/i18n/ko.json +83 -0
- package/src/modules/customers/i18n/pl.json +83 -0
- package/src/modules/customers/lib/calendar/conflicts.ts +106 -27
- package/src/modules/customers/lib/calendar/editorPayload.ts +39 -19
- package/src/modules/customers/lib/calendar/eventTypeResolver.ts +258 -0
- package/src/modules/customers/lib/calendar/interactionApplicability.ts +99 -0
- package/src/modules/customers/lib/calendar/mapItem.ts +6 -2
- package/src/modules/customers/lib/calendar/recurrence.ts +65 -1
- package/src/modules/customers/lib/calendar/timezone.ts +117 -0
- package/src/modules/customers/lib/interactionReadModel.ts +1 -0
- package/src/modules/customers/lib/todoCompatibility.ts +2 -0
- package/src/modules/customers/migrations/.snapshot-open-mercato.json +34 -0
- package/src/modules/customers/migrations/Migration20260928175058_customers.ts +15 -0
- package/src/modules/customers/migrations/Migration20260930110235_customers.ts +15 -0
- package/src/modules/dictionaries/components/DictionaryTable.tsx +63 -56
- package/src/modules/planner/lib/availabilityMerge.ts +106 -41
- package/src/modules/planner/services/plannerAvailabilityService.ts +4 -0
- package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +25 -13
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
export class Migration20260930110235_customers extends Migration {
|
|
4
|
+
|
|
5
|
+
override name = 'Migration20260930110235_customers';
|
|
6
|
+
|
|
7
|
+
override up(): void | Promise<void> {
|
|
8
|
+
this.addSql(`alter table "customer_interactions" add "timezone" text null;`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
override down(): void | Promise<void> {
|
|
12
|
+
this.addSql(`alter table "customer_interactions" drop column "timezone";`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
}
|
|
@@ -143,62 +143,69 @@ export function DictionaryTable({
|
|
|
143
143
|
: undefined
|
|
144
144
|
|
|
145
145
|
return (
|
|
146
|
-
<
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
146
|
+
<div
|
|
147
|
+
role="region"
|
|
148
|
+
aria-label={translations.title}
|
|
149
|
+
tabIndex={0}
|
|
150
|
+
className="w-full min-w-0 max-w-full overflow-x-auto focus-visible:outline-none focus-visible:shadow-focus"
|
|
151
|
+
>
|
|
152
|
+
<DataTable<DictionaryTableEntry>
|
|
153
|
+
title={translations.title}
|
|
154
|
+
titleHeadingLevel={2}
|
|
155
|
+
actions={actions}
|
|
156
|
+
columns={columns}
|
|
157
|
+
data={paginated}
|
|
158
|
+
embedded
|
|
159
|
+
sortable
|
|
160
|
+
sorting={sorting}
|
|
161
|
+
onSortingChange={setSorting}
|
|
162
|
+
searchValue={search}
|
|
163
|
+
onSearchChange={(value) => {
|
|
164
|
+
setSearch(value)
|
|
165
|
+
setPage(1)
|
|
166
|
+
}}
|
|
167
|
+
searchPlaceholder={translations.searchPlaceholder}
|
|
168
|
+
isLoading={loading}
|
|
169
|
+
emptyState={<p className="py-10 text-center text-sm text-muted-foreground">{translations.emptyLabel}</p>}
|
|
170
|
+
pagination={{
|
|
171
|
+
page,
|
|
172
|
+
pageSize,
|
|
173
|
+
total: filtered.length,
|
|
174
|
+
totalPages,
|
|
175
|
+
onPageChange: setPage,
|
|
176
|
+
}}
|
|
177
|
+
refreshButton={onRefresh ? {
|
|
178
|
+
label: translations.refreshLabel,
|
|
179
|
+
onRefresh,
|
|
180
|
+
isRefreshing: loading,
|
|
181
|
+
} : undefined}
|
|
182
|
+
onRowClick={handleRowClick}
|
|
183
|
+
rowActions={
|
|
184
|
+
canManage
|
|
185
|
+
? (entry) => {
|
|
186
|
+
if (!entry) return null
|
|
187
|
+
if (entry.isInherited) return null
|
|
188
|
+
const items: RowActionItem[] = []
|
|
189
|
+
if (onEdit) {
|
|
190
|
+
items.push({
|
|
191
|
+
id: 'edit',
|
|
192
|
+
label: translations.editLabel,
|
|
193
|
+
onSelect: () => onEdit(entry),
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
if (onDelete) {
|
|
197
|
+
items.push({
|
|
198
|
+
id: 'delete',
|
|
199
|
+
label: translations.deleteLabel,
|
|
200
|
+
onSelect: () => onDelete(entry),
|
|
201
|
+
destructive: true,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
return items.length ? <RowActions items={items} /> : null
|
|
189
205
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
onSelect: () => onDelete(entry),
|
|
195
|
-
destructive: true,
|
|
196
|
-
})
|
|
197
|
-
}
|
|
198
|
-
return items.length ? <RowActions items={items} /> : null
|
|
199
|
-
}
|
|
200
|
-
: undefined
|
|
201
|
-
}
|
|
202
|
-
/>
|
|
206
|
+
: undefined
|
|
207
|
+
}
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
203
210
|
)
|
|
204
211
|
}
|
|
@@ -3,6 +3,7 @@ type AvailabilityKind = 'availability' | 'unavailability'
|
|
|
3
3
|
export type AvailabilityRuleLike = {
|
|
4
4
|
id?: string
|
|
5
5
|
rrule: string
|
|
6
|
+
timezone?: string
|
|
6
7
|
exdates?: string[]
|
|
7
8
|
kind?: AvailabilityKind
|
|
8
9
|
note?: string | null
|
|
@@ -28,6 +29,45 @@ type ParsedRule = {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
32
|
+
const timezoneFormatters = new Map<string, Intl.DateTimeFormat>()
|
|
33
|
+
|
|
34
|
+
function zonedParts(instant: Date, timezone: string): { year: number; month: number; day: number; hour: number; minute: number; second: number } {
|
|
35
|
+
let formatter = timezoneFormatters.get(timezone)
|
|
36
|
+
if (!formatter) {
|
|
37
|
+
formatter = new Intl.DateTimeFormat('en-US', {
|
|
38
|
+
timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit',
|
|
39
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
|
|
40
|
+
})
|
|
41
|
+
if (timezoneFormatters.size >= 64) timezoneFormatters.delete(timezoneFormatters.keys().next().value!)
|
|
42
|
+
timezoneFormatters.set(timezone, formatter)
|
|
43
|
+
}
|
|
44
|
+
const parts = formatter.formatToParts(instant)
|
|
45
|
+
const number = (kind: string) => Number(parts.find((part) => part.type === kind)?.value)
|
|
46
|
+
return { year: number('year'), month: number('month'), day: number('day'), hour: number('hour'), minute: number('minute'), second: number('second') }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function zonedWallToInstant(wall: Date, timezone: string): Date | null {
|
|
50
|
+
const target = wall.getTime()
|
|
51
|
+
let candidate = target
|
|
52
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
53
|
+
const parts = zonedParts(new Date(candidate), timezone)
|
|
54
|
+
const displayed = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second)
|
|
55
|
+
const next = candidate + target - displayed
|
|
56
|
+
if (next === candidate) return new Date(candidate)
|
|
57
|
+
candidate = next
|
|
58
|
+
}
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function zonedStartOfDay(dayKey: string, timezone: string): Date | null {
|
|
63
|
+
return zonedWallToInstant(new Date(`${dayKey}T00:00:00Z`), timezone)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function dayKeyForRule(instant: Date, timezone?: string): string {
|
|
67
|
+
if (!timezone || timezone === 'UTC' || timezone === 'Etc/UTC' || timezone === 'Etc/GMT') return toDayKey(instant)
|
|
68
|
+
const parts = zonedParts(instant, timezone)
|
|
69
|
+
return `${parts.year}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')}`
|
|
70
|
+
}
|
|
31
71
|
|
|
32
72
|
function parseRrule(rule: string): ParsedRule | null {
|
|
33
73
|
const dtStartMatch = rule.match(/DTSTART[:=](\d{8}T\d{6}Z?)/)
|
|
@@ -77,10 +117,10 @@ function buildExdateSets(exdates?: string[]) {
|
|
|
77
117
|
return { dateOnly, dateTime }
|
|
78
118
|
}
|
|
79
119
|
|
|
80
|
-
function shouldExcludeOccurrence(startAt: Date, exdates?: string[]): boolean {
|
|
120
|
+
function shouldExcludeOccurrence(startAt: Date, exdates?: string[], timezone?: string): boolean {
|
|
81
121
|
const { dateOnly, dateTime } = buildExdateSets(exdates)
|
|
82
122
|
if (dateTime.has(startAt.getTime())) return true
|
|
83
|
-
const dayKey = startAt
|
|
123
|
+
const dayKey = dayKeyForRule(startAt, timezone)
|
|
84
124
|
return dateOnly.has(dayKey)
|
|
85
125
|
}
|
|
86
126
|
|
|
@@ -92,15 +132,47 @@ function startOfDay(value: Date): Date {
|
|
|
92
132
|
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()))
|
|
93
133
|
}
|
|
94
134
|
|
|
95
|
-
function
|
|
135
|
+
function expandZonedRule(rule: AvailabilityRuleLike, parsed: ParsedRule, range: AvailabilityRange, weeklyScheduleTemplate: boolean): AvailabilityWindow[] {
|
|
136
|
+
const timezone = rule.timezone!
|
|
137
|
+
const anchor = zonedParts(parsed.startAt, timezone)
|
|
138
|
+
const anchorWall = Date.UTC(anchor.year, anchor.month - 1, anchor.day, anchor.hour, anchor.minute, anchor.second)
|
|
139
|
+
const step = parsed.freq === 'WEEKLY' ? 7 * DAY_MS : DAY_MS
|
|
140
|
+
const duration = parsed.durationMinutes * 60000
|
|
141
|
+
const windows: AvailabilityWindow[] = []
|
|
142
|
+
const rangeAnchor = zonedParts(range.start, timezone)
|
|
143
|
+
const rangeStartWall = Date.UTC(rangeAnchor.year, rangeAnchor.month - 1, rangeAnchor.day, rangeAnchor.hour, rangeAnchor.minute, rangeAnchor.second)
|
|
144
|
+
const rangeOffset = Math.floor((rangeStartWall - duration - DAY_MS - anchorWall) / step)
|
|
145
|
+
const skipped = weeklyScheduleTemplate && parsed.freq === 'WEEKLY' && parsed.count === undefined ? rangeOffset : Math.max(0, rangeOffset)
|
|
146
|
+
let wall = new Date(anchorWall + skipped * step)
|
|
147
|
+
let remaining = (parsed.count ?? Number.POSITIVE_INFINITY) - skipped
|
|
148
|
+
while (wall.getTime() <= range.end.getTime() + DAY_MS && remaining > 0) {
|
|
149
|
+
const start = zonedWallToInstant(wall, timezone)
|
|
150
|
+
if (start && !shouldExcludeOccurrence(start, rule.exdates, timezone)) {
|
|
151
|
+
const end = new Date(start.getTime() + duration)
|
|
152
|
+
if (end > range.start && start < range.end) windows.push({ start, end, ruleId: rule.id })
|
|
153
|
+
}
|
|
154
|
+
wall = new Date(wall.getTime() + step)
|
|
155
|
+
remaining -= 1
|
|
156
|
+
}
|
|
157
|
+
return windows
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function expandRule(rule: AvailabilityRuleLike, parsed: ParsedRule, range: AvailabilityRange, weeklyScheduleTemplate: boolean): AvailabilityWindow[] {
|
|
96
161
|
const { startAt, durationMinutes, freq, count, repeat } = parsed
|
|
97
162
|
if (repeat === 'once') {
|
|
98
|
-
if (shouldExcludeOccurrence(startAt, rule.exdates)) return []
|
|
99
|
-
const
|
|
100
|
-
const
|
|
163
|
+
if (shouldExcludeOccurrence(startAt, rule.exdates, rule.timezone)) return []
|
|
164
|
+
const dayKey = dayKeyForRule(startAt, rule.timezone)
|
|
165
|
+
const start = rule.timezone ? zonedStartOfDay(dayKey, rule.timezone) : startOfDay(startAt)
|
|
166
|
+
if (!start) return []
|
|
167
|
+
const nextDay = new Date(new Date(`${dayKey}T00:00:00Z`).getTime() + DAY_MS).toISOString().slice(0, 10)
|
|
168
|
+
const end = rule.timezone ? zonedStartOfDay(nextDay, rule.timezone) : new Date(start.getTime() + DAY_MS)
|
|
169
|
+
if (!end) return []
|
|
101
170
|
if (end <= range.start || start >= range.end) return []
|
|
102
171
|
return [{ start, end, ruleId: rule.id }]
|
|
103
172
|
}
|
|
173
|
+
if (rule.timezone && !['UTC', 'Etc/UTC', 'Etc/GMT'].includes(rule.timezone)) {
|
|
174
|
+
return expandZonedRule(rule, parsed, range, weeklyScheduleTemplate)
|
|
175
|
+
}
|
|
104
176
|
const durationMs = durationMinutes * 60000
|
|
105
177
|
const windows: AvailabilityWindow[] = []
|
|
106
178
|
const addWindow = (start: Date) => {
|
|
@@ -110,39 +182,24 @@ function expandRule(rule: AvailabilityRuleLike, parsed: ParsedRule, range: Avail
|
|
|
110
182
|
windows.push({ start, end, ruleId: rule.id })
|
|
111
183
|
}
|
|
112
184
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
cursor = new Date(cursor.getTime() + DAY_MS)
|
|
119
|
-
remaining -= 1
|
|
120
|
-
}
|
|
121
|
-
return windows
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
let cursor = new Date(startAt)
|
|
125
|
-
let remaining = count ?? Number.POSITIVE_INFINITY
|
|
126
|
-
if (cursor < range.start) {
|
|
127
|
-
const diffDays = Math.floor((range.start.getTime() - cursor.getTime()) / DAY_MS)
|
|
128
|
-
const weeksToAdd = Math.floor(diffDays / 7)
|
|
129
|
-
if (weeksToAdd > 0) {
|
|
130
|
-
cursor = new Date(cursor.getTime() + weeksToAdd * 7 * DAY_MS)
|
|
131
|
-
}
|
|
132
|
-
}
|
|
185
|
+
const step = freq === 'WEEKLY' ? 7 * DAY_MS : DAY_MS
|
|
186
|
+
const rangeOffset = Math.floor((range.start.getTime() - durationMs - startAt.getTime()) / step)
|
|
187
|
+
const skipped = weeklyScheduleTemplate && freq === 'WEEKLY' && count === undefined ? rangeOffset : Math.max(0, rangeOffset)
|
|
188
|
+
let cursor = new Date(startAt.getTime() + skipped * step)
|
|
189
|
+
let remaining = (count ?? Number.POSITIVE_INFINITY) - skipped
|
|
133
190
|
while (cursor < range.end && remaining > 0) {
|
|
134
191
|
addWindow(new Date(cursor))
|
|
135
|
-
cursor = new Date(cursor.getTime() +
|
|
192
|
+
cursor = new Date(cursor.getTime() + step)
|
|
136
193
|
remaining -= 1
|
|
137
194
|
}
|
|
138
195
|
return windows
|
|
139
196
|
}
|
|
140
197
|
|
|
141
|
-
function expandRules(rules: AvailabilityRuleLike[], range: AvailabilityRange): AvailabilityWindow[] {
|
|
198
|
+
function expandRules(rules: AvailabilityRuleLike[], range: AvailabilityRange, weeklyScheduleTemplate: boolean): AvailabilityWindow[] {
|
|
142
199
|
const expanded = rules.flatMap((rule) => {
|
|
143
200
|
const parsed = parseRrule(rule.rrule)
|
|
144
201
|
if (!parsed) return []
|
|
145
|
-
return expandRule(rule, parsed, range)
|
|
202
|
+
return expandRule(rule, parsed, range, weeklyScheduleTemplate)
|
|
146
203
|
})
|
|
147
204
|
return expanded.sort((a, b) => a.start.getTime() - b.start.getTime())
|
|
148
205
|
}
|
|
@@ -171,8 +228,11 @@ function subtractWindow(window: AvailabilityWindow, blockers: AvailabilityWindow
|
|
|
171
228
|
export function getMergedAvailabilityWindows(params: {
|
|
172
229
|
rules: AvailabilityRuleLike[]
|
|
173
230
|
range: AvailabilityRange
|
|
231
|
+
respectTimezone?: boolean
|
|
232
|
+
weeklyScheduleTemplate?: boolean
|
|
174
233
|
}): AvailabilityWindow[] {
|
|
175
|
-
const
|
|
234
|
+
const rules = params.respectTimezone ? params.rules : params.rules.map((rule) => ({ ...rule, timezone: undefined }))
|
|
235
|
+
const parsedRules = rules
|
|
176
236
|
.map((rule) => {
|
|
177
237
|
const parsed = parseRrule(rule.rrule)
|
|
178
238
|
if (!parsed) return null
|
|
@@ -180,16 +240,17 @@ export function getMergedAvailabilityWindows(params: {
|
|
|
180
240
|
})
|
|
181
241
|
.filter((entry): entry is { rule: AvailabilityRuleLike; parsed: ParsedRule } => entry !== null)
|
|
182
242
|
|
|
183
|
-
const overrideDays = new Map<string, AvailabilityKind>()
|
|
243
|
+
const overrideDays = new Map<string, { kind: AvailabilityKind; dayKey: string; timezone?: string }>()
|
|
184
244
|
parsedRules
|
|
185
245
|
.filter(({ parsed }) => parsed.repeat === 'once')
|
|
186
246
|
.forEach(({ rule, parsed }) => {
|
|
187
|
-
const dayKey =
|
|
247
|
+
const dayKey = dayKeyForRule(parsed.startAt, rule.timezone)
|
|
248
|
+
const overrideKey = `${rule.timezone ?? 'UTC'}|${dayKey}`
|
|
188
249
|
const kind = rule.kind === 'unavailability' ? 'unavailability' : 'availability'
|
|
189
250
|
if (kind === 'unavailability') {
|
|
190
|
-
overrideDays.set(dayKey,
|
|
191
|
-
} else if (!overrideDays.has(
|
|
192
|
-
overrideDays.set(dayKey,
|
|
251
|
+
overrideDays.set(overrideKey, { kind, dayKey, timezone: rule.timezone })
|
|
252
|
+
} else if (!overrideDays.has(overrideKey)) {
|
|
253
|
+
overrideDays.set(overrideKey, { kind, dayKey, timezone: rule.timezone })
|
|
193
254
|
}
|
|
194
255
|
})
|
|
195
256
|
|
|
@@ -200,20 +261,24 @@ export function getMergedAvailabilityWindows(params: {
|
|
|
200
261
|
.filter(({ parsed, rule }) => parsed.repeat !== 'once' && rule.kind === 'unavailability')
|
|
201
262
|
.map(({ rule }) => rule)
|
|
202
263
|
|
|
203
|
-
const availabilityWindows =
|
|
204
|
-
.filter((window) =>
|
|
205
|
-
|
|
264
|
+
const availabilityWindows = availabilityRules.flatMap((rule) =>
|
|
265
|
+
expandRules([rule], params.range, params.weeklyScheduleTemplate === true).filter((window) =>
|
|
266
|
+
!overrideDays.has(`${rule.timezone ?? 'UTC'}|${dayKeyForRule(window.start, rule.timezone)}`)))
|
|
267
|
+
const unavailabilityWindows = expandRules(unavailabilityRules, params.range, params.weeklyScheduleTemplate === true)
|
|
206
268
|
|
|
207
269
|
const merged = unavailabilityWindows.length === 0
|
|
208
270
|
? availabilityWindows
|
|
209
271
|
: availabilityWindows.flatMap((window) => subtractWindow(window, unavailabilityWindows))
|
|
210
272
|
|
|
211
273
|
const overrideWindows: AvailabilityWindow[] = []
|
|
212
|
-
overrideDays.forEach((kind, dayKey) => {
|
|
274
|
+
overrideDays.forEach(({ kind, dayKey, timezone }) => {
|
|
213
275
|
if (kind !== 'availability') return
|
|
214
|
-
const start = new Date(`${dayKey}T00:00:00Z`)
|
|
276
|
+
const start = timezone ? zonedStartOfDay(dayKey, timezone) : new Date(`${dayKey}T00:00:00Z`)
|
|
277
|
+
if (!start) return
|
|
215
278
|
if (Number.isNaN(start.getTime())) return
|
|
216
|
-
const
|
|
279
|
+
const nextDay = new Date(new Date(`${dayKey}T00:00:00Z`).getTime() + DAY_MS).toISOString().slice(0, 10)
|
|
280
|
+
const end = timezone ? zonedStartOfDay(nextDay, timezone) : new Date(start.getTime() + DAY_MS)
|
|
281
|
+
if (!end) return
|
|
217
282
|
if (end <= params.range.start || start >= params.range.end) return
|
|
218
283
|
overrideWindows.push({ start, end })
|
|
219
284
|
})
|
|
@@ -7,6 +7,8 @@ export interface PlannerAvailabilityService {
|
|
|
7
7
|
getMergedAvailabilityWindows(params: {
|
|
8
8
|
rules: AvailabilityRuleLike[]
|
|
9
9
|
range: AvailabilityRange
|
|
10
|
+
respectTimezone?: boolean
|
|
11
|
+
weeklyScheduleTemplate?: boolean
|
|
10
12
|
}): AvailabilityWindow[]
|
|
11
13
|
}
|
|
12
14
|
|
|
@@ -14,6 +16,8 @@ export class DefaultPlannerAvailabilityService implements PlannerAvailabilitySer
|
|
|
14
16
|
getMergedAvailabilityWindows(params: {
|
|
15
17
|
rules: AvailabilityRuleLike[]
|
|
16
18
|
range: AvailabilityRange
|
|
19
|
+
respectTimezone?: boolean
|
|
20
|
+
weeklyScheduleTemplate?: boolean
|
|
17
21
|
}): AvailabilityWindow[] {
|
|
18
22
|
return getMergedAvailabilityWindows(params)
|
|
19
23
|
}
|
|
@@ -177,7 +177,13 @@ export default function TimesheetProjectDetailPage({ params }: { params?: { id?:
|
|
|
177
177
|
const [loading, setLoading] = React.useState(true)
|
|
178
178
|
const [error, setError] = React.useState<string | null>(null)
|
|
179
179
|
const [isNotFound, setIsNotFound] = React.useState(false)
|
|
180
|
-
|
|
180
|
+
// Keyed by project id so a route change to another project never shows the
|
|
181
|
+
// previous project's guard (with a live request button) while the new one loads.
|
|
182
|
+
const [accessDeniedProjectId, setAccessDeniedProjectId] = React.useState<string | null>(null)
|
|
183
|
+
const accessDenied = accessDeniedProjectId !== null && accessDeniedProjectId === projectId
|
|
184
|
+
// The id the last finished load was for. Until it matches the route, the page
|
|
185
|
+
// is still loading — never the previous project's error or empty state.
|
|
186
|
+
const [settledProjectId, setSettledProjectId] = React.useState<string | null>(null)
|
|
181
187
|
|
|
182
188
|
const [employees, setEmployees] = React.useState<EmployeeAssignment[]>([])
|
|
183
189
|
const [employeesLoading, setEmployeesLoading] = React.useState(false)
|
|
@@ -387,7 +393,6 @@ export default function TimesheetProjectDetailPage({ params }: { params?: { id?:
|
|
|
387
393
|
setLoading(true)
|
|
388
394
|
setError(null)
|
|
389
395
|
setIsNotFound(false)
|
|
390
|
-
setAccessDenied(false)
|
|
391
396
|
try {
|
|
392
397
|
const queryParams = new URLSearchParams({ page: '1', pageSize: '1', ids: projectId! })
|
|
393
398
|
const call = await apiCall<ProjectResponse>(
|
|
@@ -396,9 +401,10 @@ export default function TimesheetProjectDetailPage({ params }: { params?: { id?:
|
|
|
396
401
|
// Screen 17: the route answers 404 with this discriminator for a project
|
|
397
402
|
// the caller is not a member of, without naming it.
|
|
398
403
|
if (call.status === 404 && readDenialReason(call.result) === NO_PROJECT_ACCESS_REASON) {
|
|
399
|
-
if (!cancelled)
|
|
404
|
+
if (!cancelled) setAccessDeniedProjectId(projectId ?? null)
|
|
400
405
|
return
|
|
401
406
|
}
|
|
407
|
+
if (!cancelled) setAccessDeniedProjectId(null)
|
|
402
408
|
if (!call.ok) {
|
|
403
409
|
throw new Error(t('staff.timesheets.projects.errors.load', 'Failed to load project.'))
|
|
404
410
|
}
|
|
@@ -414,7 +420,10 @@ export default function TimesheetProjectDetailPage({ params }: { params?: { id?:
|
|
|
414
420
|
setError(loadError instanceof Error ? loadError.message : t('staff.timesheets.projects.errors.load', 'Failed to load project.'))
|
|
415
421
|
}
|
|
416
422
|
} finally {
|
|
417
|
-
if (!cancelled)
|
|
423
|
+
if (!cancelled) {
|
|
424
|
+
setLoading(false)
|
|
425
|
+
setSettledProjectId(projectId ?? null)
|
|
426
|
+
}
|
|
418
427
|
}
|
|
419
428
|
}
|
|
420
429
|
loadProject()
|
|
@@ -649,7 +658,18 @@ export default function TimesheetProjectDetailPage({ params }: { params?: { id?:
|
|
|
649
658
|
}, [handleAddEmployee])
|
|
650
659
|
|
|
651
660
|
// --- Render ---
|
|
652
|
-
|
|
661
|
+
// Screen 17 — the caller may not open this project. The guard state names
|
|
662
|
+
// neither the customer nor the project (note 1), and the surrounding shell
|
|
663
|
+
// (sidebar, topbar) is untouched because the caller still has other projects
|
|
664
|
+
// (note 3). It is checked before `loading` and only cleared by a response that
|
|
665
|
+
// grants access, so a background reload (e.g. the organization scope
|
|
666
|
+
// resolving late) keeps the guard mounted instead of resetting a request the
|
|
667
|
+
// user has just sent.
|
|
668
|
+
if (accessDenied) {
|
|
669
|
+
return <NoProjectAccess timeProjectId={projectId} />
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (loading || settledProjectId !== (projectId ?? null)) {
|
|
653
673
|
return (
|
|
654
674
|
<Page>
|
|
655
675
|
<PageBody>
|
|
@@ -659,14 +679,6 @@ export default function TimesheetProjectDetailPage({ params }: { params?: { id?:
|
|
|
659
679
|
)
|
|
660
680
|
}
|
|
661
681
|
|
|
662
|
-
// Screen 17 — the caller may not open this project. The guard state names
|
|
663
|
-
// neither the customer nor the project (note 1), and the surrounding shell
|
|
664
|
-
// (sidebar, topbar) is untouched because the caller still has other projects
|
|
665
|
-
// (note 3).
|
|
666
|
-
if (accessDenied) {
|
|
667
|
-
return <NoProjectAccess timeProjectId={projectId} />
|
|
668
|
-
}
|
|
669
|
-
|
|
670
682
|
if (isNotFound) {
|
|
671
683
|
return (
|
|
672
684
|
<Page>
|