@open-mercato/core 0.8.1-develop.7452.1.5e211edd51 → 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.
Files changed (158) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/customer_dictionary_entry/index.js +2 -0
  3. package/dist/generated/entities/customer_dictionary_entry/index.js.map +2 -2
  4. package/dist/generated/entities/customer_interaction/index.js +2 -0
  5. package/dist/generated/entities/customer_interaction/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +2 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/communication_channels/commands/deliver-outbound-message.js +5 -1
  9. package/dist/modules/communication_channels/commands/deliver-outbound-message.js.map +2 -2
  10. package/dist/modules/customers/api/activity-types/route.js +151 -0
  11. package/dist/modules/customers/api/activity-types/route.js.map +7 -0
  12. package/dist/modules/customers/api/dictionaries/[kind]/[id]/route.js +13 -5
  13. package/dist/modules/customers/api/dictionaries/[kind]/[id]/route.js.map +2 -2
  14. package/dist/modules/customers/api/dictionaries/[kind]/route.js +20 -5
  15. package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
  16. package/dist/modules/customers/api/interactions/route.js +3 -0
  17. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  18. package/dist/modules/customers/api/interactions/tasks/route.js +1 -0
  19. package/dist/modules/customers/api/interactions/tasks/route.js.map +2 -2
  20. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js +18 -0
  21. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js.map +2 -2
  22. package/dist/modules/customers/calendar-event-types.js +548 -0
  23. package/dist/modules/customers/calendar-event-types.js.map +7 -0
  24. package/dist/modules/customers/commands/dictionaries.js +70 -10
  25. package/dist/modules/customers/commands/dictionaries.js.map +2 -2
  26. package/dist/modules/customers/commands/interactions.js +187 -22
  27. package/dist/modules/customers/commands/interactions.js.map +3 -3
  28. package/dist/modules/customers/components/ActivityTypeEditor.js +288 -0
  29. package/dist/modules/customers/components/ActivityTypeEditor.js.map +7 -0
  30. package/dist/modules/customers/components/DictionarySettings.js +3 -1
  31. package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
  32. package/dist/modules/customers/components/calendar/CalendarEventEditor.js +391 -297
  33. package/dist/modules/customers/components/calendar/CalendarEventEditor.js.map +3 -3
  34. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js +9 -74
  35. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
  36. package/dist/modules/customers/components/calendar/TimeGrid.js +20 -16
  37. package/dist/modules/customers/components/calendar/TimeGrid.js.map +2 -2
  38. package/dist/modules/customers/components/calendar/editor/EventTypePanel.js +218 -0
  39. package/dist/modules/customers/components/calendar/editor/EventTypePanel.js.map +7 -0
  40. package/dist/modules/customers/components/calendar/editor/LocationField.js +7 -3
  41. package/dist/modules/customers/components/calendar/editor/LocationField.js.map +2 -2
  42. package/dist/modules/customers/components/calendar/editor/PeopleField.js +5 -2
  43. package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
  44. package/dist/modules/customers/components/calendar/editor/PriorityField.js +3 -1
  45. package/dist/modules/customers/components/calendar/editor/PriorityField.js.map +2 -2
  46. package/dist/modules/customers/components/calendar/editor/RepeatField.js +9 -1
  47. package/dist/modules/customers/components/calendar/editor/RepeatField.js.map +2 -2
  48. package/dist/modules/customers/components/calendar/editor/ResourcesField.js +4 -1
  49. package/dist/modules/customers/components/calendar/editor/ResourcesField.js.map +2 -2
  50. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js +15 -7
  51. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js.map +2 -2
  52. package/dist/modules/customers/components/calendar/editor/SegmentGroup.js +3 -1
  53. package/dist/modules/customers/components/calendar/editor/SegmentGroup.js.map +2 -2
  54. package/dist/modules/customers/components/calendar/editor/hooks.js +28 -13
  55. package/dist/modules/customers/components/calendar/editor/hooks.js.map +3 -3
  56. package/dist/modules/customers/components/calendar/editor/inputs.js +14 -6
  57. package/dist/modules/customers/components/calendar/editor/inputs.js.map +2 -2
  58. package/dist/modules/customers/components/calendar/editor/useCalendarCustomFields.js +54 -0
  59. package/dist/modules/customers/components/calendar/editor/useCalendarCustomFields.js.map +7 -0
  60. package/dist/modules/customers/components/calendar/editor/useEventTypeCatalog.js +82 -0
  61. package/dist/modules/customers/components/calendar/editor/useEventTypeCatalog.js.map +7 -0
  62. package/dist/modules/customers/components/calendar/types.js +1 -0
  63. package/dist/modules/customers/components/calendar/types.js.map +2 -2
  64. package/dist/modules/customers/data/entities.js +6 -0
  65. package/dist/modules/customers/data/entities.js.map +2 -2
  66. package/dist/modules/customers/data/validators.js +39 -10
  67. package/dist/modules/customers/data/validators.js.map +2 -2
  68. package/dist/modules/customers/di.js +20 -1
  69. package/dist/modules/customers/di.js.map +2 -2
  70. package/dist/modules/customers/extension-points.js +13 -0
  71. package/dist/modules/customers/extension-points.js.map +2 -2
  72. package/dist/modules/customers/lib/calendar/conflicts.js +59 -16
  73. package/dist/modules/customers/lib/calendar/conflicts.js.map +2 -2
  74. package/dist/modules/customers/lib/calendar/editorPayload.js +34 -16
  75. package/dist/modules/customers/lib/calendar/editorPayload.js.map +2 -2
  76. package/dist/modules/customers/lib/calendar/eventTypeResolver.js +197 -0
  77. package/dist/modules/customers/lib/calendar/eventTypeResolver.js.map +7 -0
  78. package/dist/modules/customers/lib/calendar/interactionApplicability.js +67 -0
  79. package/dist/modules/customers/lib/calendar/interactionApplicability.js.map +7 -0
  80. package/dist/modules/customers/lib/calendar/mapItem.js +5 -2
  81. package/dist/modules/customers/lib/calendar/mapItem.js.map +2 -2
  82. package/dist/modules/customers/lib/calendar/recurrence.js +62 -1
  83. package/dist/modules/customers/lib/calendar/recurrence.js.map +2 -2
  84. package/dist/modules/customers/lib/calendar/timezone.js +111 -0
  85. package/dist/modules/customers/lib/calendar/timezone.js.map +7 -0
  86. package/dist/modules/customers/lib/interactionReadModel.js +1 -0
  87. package/dist/modules/customers/lib/interactionReadModel.js.map +2 -2
  88. package/dist/modules/customers/lib/todoCompatibility.js +1 -0
  89. package/dist/modules/customers/lib/todoCompatibility.js.map +2 -2
  90. package/dist/modules/customers/migrations/Migration20260928175058_customers.js +17 -0
  91. package/dist/modules/customers/migrations/Migration20260928175058_customers.js.map +7 -0
  92. package/dist/modules/customers/migrations/Migration20260930110235_customers.js +17 -0
  93. package/dist/modules/customers/migrations/Migration20260930110235_customers.js.map +7 -0
  94. package/dist/modules/dictionaries/components/DictionaryTable.js +60 -51
  95. package/dist/modules/dictionaries/components/DictionaryTable.js.map +2 -2
  96. package/dist/modules/planner/lib/availabilityMerge.js +101 -38
  97. package/dist/modules/planner/lib/availabilityMerge.js.map +3 -3
  98. package/dist/modules/planner/services/plannerAvailabilityService.js.map +2 -2
  99. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +12 -7
  100. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
  101. package/generated/entities/customer_dictionary_entry/index.ts +1 -0
  102. package/generated/entities/customer_interaction/index.ts +1 -0
  103. package/generated/entity-fields-registry.ts +2 -0
  104. package/package.json +7 -7
  105. package/src/modules/communication_channels/commands/deliver-outbound-message.ts +6 -1
  106. package/src/modules/customers/api/activity-types/route.ts +154 -0
  107. package/src/modules/customers/api/dictionaries/[kind]/[id]/route.ts +9 -1
  108. package/src/modules/customers/api/dictionaries/[kind]/route.ts +18 -2
  109. package/src/modules/customers/api/interactions/route.ts +4 -0
  110. package/src/modules/customers/api/interactions/tasks/route.ts +1 -0
  111. package/src/modules/customers/backend/config/customers/pipeline-stages/page.meta.ts +20 -0
  112. package/src/modules/customers/calendar-event-types.ts +674 -0
  113. package/src/modules/customers/commands/dictionaries.ts +84 -8
  114. package/src/modules/customers/commands/interactions.ts +244 -21
  115. package/src/modules/customers/components/ActivityTypeEditor.tsx +239 -0
  116. package/src/modules/customers/components/DictionarySettings.tsx +5 -3
  117. package/src/modules/customers/components/calendar/CalendarEventEditor.tsx +347 -218
  118. package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +18 -96
  119. package/src/modules/customers/components/calendar/TimeGrid.tsx +20 -18
  120. package/src/modules/customers/components/calendar/editor/EventTypePanel.tsx +230 -0
  121. package/src/modules/customers/components/calendar/editor/LocationField.tsx +7 -1
  122. package/src/modules/customers/components/calendar/editor/PeopleField.tsx +4 -0
  123. package/src/modules/customers/components/calendar/editor/PriorityField.tsx +3 -0
  124. package/src/modules/customers/components/calendar/editor/RepeatField.tsx +10 -0
  125. package/src/modules/customers/components/calendar/editor/ResourcesField.tsx +4 -0
  126. package/src/modules/customers/components/calendar/editor/ScheduleSection.tsx +16 -4
  127. package/src/modules/customers/components/calendar/editor/SegmentGroup.tsx +3 -0
  128. package/src/modules/customers/components/calendar/editor/hooks.ts +29 -13
  129. package/src/modules/customers/components/calendar/editor/inputs.tsx +17 -5
  130. package/src/modules/customers/components/calendar/editor/useCalendarCustomFields.ts +49 -0
  131. package/src/modules/customers/components/calendar/editor/useEventTypeCatalog.ts +100 -0
  132. package/src/modules/customers/components/calendar/types.ts +1 -0
  133. package/src/modules/customers/components/detail/types.ts +1 -0
  134. package/src/modules/customers/data/entities.ts +8 -1
  135. package/src/modules/customers/data/validators.ts +42 -7
  136. package/src/modules/customers/di.ts +21 -0
  137. package/src/modules/customers/extension-points.ts +13 -0
  138. package/src/modules/customers/i18n/de.json +83 -0
  139. package/src/modules/customers/i18n/en.json +83 -0
  140. package/src/modules/customers/i18n/es.json +83 -0
  141. package/src/modules/customers/i18n/ko.json +83 -0
  142. package/src/modules/customers/i18n/pl.json +83 -0
  143. package/src/modules/customers/lib/calendar/conflicts.ts +106 -27
  144. package/src/modules/customers/lib/calendar/editorPayload.ts +39 -19
  145. package/src/modules/customers/lib/calendar/eventTypeResolver.ts +258 -0
  146. package/src/modules/customers/lib/calendar/interactionApplicability.ts +99 -0
  147. package/src/modules/customers/lib/calendar/mapItem.ts +6 -2
  148. package/src/modules/customers/lib/calendar/recurrence.ts +65 -1
  149. package/src/modules/customers/lib/calendar/timezone.ts +117 -0
  150. package/src/modules/customers/lib/interactionReadModel.ts +1 -0
  151. package/src/modules/customers/lib/todoCompatibility.ts +2 -0
  152. package/src/modules/customers/migrations/.snapshot-open-mercato.json +34 -0
  153. package/src/modules/customers/migrations/Migration20260928175058_customers.ts +15 -0
  154. package/src/modules/customers/migrations/Migration20260930110235_customers.ts +15 -0
  155. package/src/modules/dictionaries/components/DictionaryTable.tsx +63 -56
  156. package/src/modules/planner/lib/availabilityMerge.ts +106 -41
  157. package/src/modules/planner/services/plannerAvailabilityService.ts +4 -0
  158. 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
- <DataTable<DictionaryTableEntry>
147
- title={translations.title}
148
- titleHeadingLevel={2}
149
- actions={actions}
150
- columns={columns}
151
- data={paginated}
152
- embedded
153
- sortable
154
- sorting={sorting}
155
- onSortingChange={setSorting}
156
- searchValue={search}
157
- onSearchChange={(value) => {
158
- setSearch(value)
159
- setPage(1)
160
- }}
161
- searchPlaceholder={translations.searchPlaceholder}
162
- isLoading={loading}
163
- emptyState={<p className="py-10 text-center text-sm text-muted-foreground">{translations.emptyLabel}</p>}
164
- pagination={{
165
- page,
166
- pageSize,
167
- total: filtered.length,
168
- totalPages,
169
- onPageChange: setPage,
170
- }}
171
- refreshButton={onRefresh ? {
172
- label: translations.refreshLabel,
173
- onRefresh,
174
- isRefreshing: loading,
175
- } : undefined}
176
- onRowClick={handleRowClick}
177
- rowActions={
178
- canManage
179
- ? (entry) => {
180
- if (!entry) return null
181
- if (entry.isInherited) return null
182
- const items: RowActionItem[] = []
183
- if (onEdit) {
184
- items.push({
185
- id: 'edit',
186
- label: translations.editLabel,
187
- onSelect: () => onEdit(entry),
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
- if (onDelete) {
191
- items.push({
192
- id: 'delete',
193
- label: translations.deleteLabel,
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.toISOString().slice(0, 10)
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 expandRule(rule: AvailabilityRuleLike, parsed: ParsedRule, range: AvailabilityRange): AvailabilityWindow[] {
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 start = startOfDay(startAt)
100
- const end = new Date(start.getTime() + DAY_MS)
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
- if (freq === 'DAILY') {
114
- let cursor = new Date(startAt)
115
- let remaining = count ?? Number.POSITIVE_INFINITY
116
- while (cursor < range.end && remaining > 0) {
117
- addWindow(new Date(cursor))
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() + 7 * DAY_MS)
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 parsedRules = params.rules
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 = toDayKey(parsed.startAt)
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, 'unavailability')
191
- } else if (!overrideDays.has(dayKey)) {
192
- overrideDays.set(dayKey, 'availability')
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 = expandRules(availabilityRules, params.range)
204
- .filter((window) => !overrideDays.has(toDayKey(window.start)))
205
- const unavailabilityWindows = expandRules(unavailabilityRules, params.range)
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 end = new Date(start.getTime() + DAY_MS)
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
- const [accessDenied, setAccessDenied] = React.useState(false)
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) setAccessDenied(true)
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) setLoading(false)
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
- if (loading) {
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>