@open-mercato/ui 0.4.2-canary-c02407ff85

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 (319) hide show
  1. package/build.mjs +62 -0
  2. package/dist/backend/AppShell.js +902 -0
  3. package/dist/backend/AppShell.js.map +7 -0
  4. package/dist/backend/ConfirmDialog.js +17 -0
  5. package/dist/backend/ConfirmDialog.js.map +7 -0
  6. package/dist/backend/ContextHelp.js +31 -0
  7. package/dist/backend/ContextHelp.js.map +7 -0
  8. package/dist/backend/CrudForm.js +2028 -0
  9. package/dist/backend/CrudForm.js.map +7 -0
  10. package/dist/backend/DataTable.js +1363 -0
  11. package/dist/backend/DataTable.js.map +7 -0
  12. package/dist/backend/EmptyState.js +52 -0
  13. package/dist/backend/EmptyState.js.map +7 -0
  14. package/dist/backend/FilterBar.js +140 -0
  15. package/dist/backend/FilterBar.js.map +7 -0
  16. package/dist/backend/FilterOverlay.js +279 -0
  17. package/dist/backend/FilterOverlay.js.map +7 -0
  18. package/dist/backend/FlashMessages.js +66 -0
  19. package/dist/backend/FlashMessages.js.map +7 -0
  20. package/dist/backend/JsonBuilder.js +322 -0
  21. package/dist/backend/JsonBuilder.js.map +7 -0
  22. package/dist/backend/JsonDisplay.js +203 -0
  23. package/dist/backend/JsonDisplay.js.map +7 -0
  24. package/dist/backend/Page.js +27 -0
  25. package/dist/backend/Page.js.map +7 -0
  26. package/dist/backend/PerspectiveSidebar.js +282 -0
  27. package/dist/backend/PerspectiveSidebar.js.map +7 -0
  28. package/dist/backend/RowActions.js +148 -0
  29. package/dist/backend/RowActions.js.map +7 -0
  30. package/dist/backend/TruncatedCell.js +92 -0
  31. package/dist/backend/TruncatedCell.js.map +7 -0
  32. package/dist/backend/UserMenu.js +107 -0
  33. package/dist/backend/UserMenu.js.map +7 -0
  34. package/dist/backend/ValueIcons.js +34 -0
  35. package/dist/backend/ValueIcons.js.map +7 -0
  36. package/dist/backend/custom-fields/FieldDefinitionsEditor.js +1264 -0
  37. package/dist/backend/custom-fields/FieldDefinitionsEditor.js.map +7 -0
  38. package/dist/backend/custom-fields/FieldDefinitionsManager.js +332 -0
  39. package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +7 -0
  40. package/dist/backend/dashboard/DashboardScreen.js +578 -0
  41. package/dist/backend/dashboard/DashboardScreen.js.map +7 -0
  42. package/dist/backend/dashboard/index.js +5 -0
  43. package/dist/backend/dashboard/index.js.map +7 -0
  44. package/dist/backend/dashboard/widgetRegistry.js +55 -0
  45. package/dist/backend/dashboard/widgetRegistry.js.map +7 -0
  46. package/dist/backend/detail/ActivitiesSection.js +962 -0
  47. package/dist/backend/detail/ActivitiesSection.js.map +7 -0
  48. package/dist/backend/detail/AddressEditor.js +413 -0
  49. package/dist/backend/detail/AddressEditor.js.map +7 -0
  50. package/dist/backend/detail/AddressTiles.js +437 -0
  51. package/dist/backend/detail/AddressTiles.js.map +7 -0
  52. package/dist/backend/detail/AddressesSection.js +264 -0
  53. package/dist/backend/detail/AddressesSection.js.map +7 -0
  54. package/dist/backend/detail/AttachmentDeleteDialog.js +41 -0
  55. package/dist/backend/detail/AttachmentDeleteDialog.js.map +7 -0
  56. package/dist/backend/detail/AttachmentMetadataDialog.js +517 -0
  57. package/dist/backend/detail/AttachmentMetadataDialog.js.map +7 -0
  58. package/dist/backend/detail/AttachmentsSection.js +367 -0
  59. package/dist/backend/detail/AttachmentsSection.js.map +7 -0
  60. package/dist/backend/detail/CustomDataSection.js +433 -0
  61. package/dist/backend/detail/CustomDataSection.js.map +7 -0
  62. package/dist/backend/detail/DetailFieldsSection.js +75 -0
  63. package/dist/backend/detail/DetailFieldsSection.js.map +7 -0
  64. package/dist/backend/detail/ErrorMessage.js +28 -0
  65. package/dist/backend/detail/ErrorMessage.js.map +7 -0
  66. package/dist/backend/detail/InlineEditors.js +681 -0
  67. package/dist/backend/detail/InlineEditors.js.map +7 -0
  68. package/dist/backend/detail/LoadingMessage.js +14 -0
  69. package/dist/backend/detail/LoadingMessage.js.map +7 -0
  70. package/dist/backend/detail/NotesSection.js +1032 -0
  71. package/dist/backend/detail/NotesSection.js.map +7 -0
  72. package/dist/backend/detail/TabEmptyState.js +25 -0
  73. package/dist/backend/detail/TabEmptyState.js.map +7 -0
  74. package/dist/backend/detail/TagsSection.js +254 -0
  75. package/dist/backend/detail/TagsSection.js.map +7 -0
  76. package/dist/backend/detail/addressFormat.js +77 -0
  77. package/dist/backend/detail/addressFormat.js.map +7 -0
  78. package/dist/backend/detail/index.js +34 -0
  79. package/dist/backend/detail/index.js.map +7 -0
  80. package/dist/backend/fields/registry.generated.js +8 -0
  81. package/dist/backend/fields/registry.generated.js.map +7 -0
  82. package/dist/backend/fields/registry.js +29 -0
  83. package/dist/backend/fields/registry.js.map +7 -0
  84. package/dist/backend/indexes/PartialIndexBanner.js +58 -0
  85. package/dist/backend/indexes/PartialIndexBanner.js.map +7 -0
  86. package/dist/backend/indexes/store.js +62 -0
  87. package/dist/backend/indexes/store.js.map +7 -0
  88. package/dist/backend/injection/InjectionSpot.js +179 -0
  89. package/dist/backend/injection/InjectionSpot.js.map +7 -0
  90. package/dist/backend/injection/PageInjectionBoundary.js +26 -0
  91. package/dist/backend/injection/PageInjectionBoundary.js.map +7 -0
  92. package/dist/backend/injection/helpers.js +26 -0
  93. package/dist/backend/injection/helpers.js.map +7 -0
  94. package/dist/backend/injection/widgetRegistry.js +55 -0
  95. package/dist/backend/injection/widgetRegistry.js.map +7 -0
  96. package/dist/backend/inputs/ComboboxInput.js +225 -0
  97. package/dist/backend/inputs/ComboboxInput.js.map +7 -0
  98. package/dist/backend/inputs/LookupSelect.js +191 -0
  99. package/dist/backend/inputs/LookupSelect.js.map +7 -0
  100. package/dist/backend/inputs/PhoneNumberField.js +100 -0
  101. package/dist/backend/inputs/PhoneNumberField.js.map +7 -0
  102. package/dist/backend/inputs/SwitchableMarkdownInput.js +92 -0
  103. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +7 -0
  104. package/dist/backend/inputs/TagsInput.js +222 -0
  105. package/dist/backend/inputs/TagsInput.js.map +7 -0
  106. package/dist/backend/inputs/index.js +6 -0
  107. package/dist/backend/inputs/index.js.map +7 -0
  108. package/dist/backend/operations/LastOperationBanner.js +80 -0
  109. package/dist/backend/operations/LastOperationBanner.js.map +7 -0
  110. package/dist/backend/operations/store.js +183 -0
  111. package/dist/backend/operations/store.js.map +7 -0
  112. package/dist/backend/schedule/ScheduleAgenda.js +107 -0
  113. package/dist/backend/schedule/ScheduleAgenda.js.map +7 -0
  114. package/dist/backend/schedule/ScheduleGrid.js +107 -0
  115. package/dist/backend/schedule/ScheduleGrid.js.map +7 -0
  116. package/dist/backend/schedule/ScheduleToolbar.js +166 -0
  117. package/dist/backend/schedule/ScheduleToolbar.js.map +7 -0
  118. package/dist/backend/schedule/ScheduleView.js +165 -0
  119. package/dist/backend/schedule/ScheduleView.js.map +7 -0
  120. package/dist/backend/schedule/index.js +6 -0
  121. package/dist/backend/schedule/index.js.map +7 -0
  122. package/dist/backend/schedule/recurrence.js +83 -0
  123. package/dist/backend/schedule/recurrence.js.map +7 -0
  124. package/dist/backend/schedule/types.js +1 -0
  125. package/dist/backend/schedule/types.js.map +7 -0
  126. package/dist/backend/upgrades/UpgradeActionBanner.js +91 -0
  127. package/dist/backend/upgrades/UpgradeActionBanner.js.map +7 -0
  128. package/dist/backend/utils/api.js +127 -0
  129. package/dist/backend/utils/api.js.map +7 -0
  130. package/dist/backend/utils/apiCall.js +48 -0
  131. package/dist/backend/utils/apiCall.js.map +7 -0
  132. package/dist/backend/utils/crud.js +126 -0
  133. package/dist/backend/utils/crud.js.map +7 -0
  134. package/dist/backend/utils/customFieldColumns.js +56 -0
  135. package/dist/backend/utils/customFieldColumns.js.map +7 -0
  136. package/dist/backend/utils/customFieldDefs.js +143 -0
  137. package/dist/backend/utils/customFieldDefs.js.map +7 -0
  138. package/dist/backend/utils/customFieldFilters.js +126 -0
  139. package/dist/backend/utils/customFieldFilters.js.map +7 -0
  140. package/dist/backend/utils/customFieldForms.js +162 -0
  141. package/dist/backend/utils/customFieldForms.js.map +7 -0
  142. package/dist/backend/utils/customFieldValues.js +26 -0
  143. package/dist/backend/utils/customFieldValues.js.map +7 -0
  144. package/dist/backend/utils/flash.js +16 -0
  145. package/dist/backend/utils/flash.js.map +7 -0
  146. package/dist/backend/utils/nav.js +185 -0
  147. package/dist/backend/utils/nav.js.map +7 -0
  148. package/dist/backend/utils/serverErrors.js +230 -0
  149. package/dist/backend/utils/serverErrors.js.map +7 -0
  150. package/dist/frontend/AuthFooter.js +23 -0
  151. package/dist/frontend/AuthFooter.js.map +7 -0
  152. package/dist/frontend/LanguageSwitcher.js +57 -0
  153. package/dist/frontend/LanguageSwitcher.js.map +7 -0
  154. package/dist/frontend/Layout.js +14 -0
  155. package/dist/frontend/Layout.js.map +7 -0
  156. package/dist/index.js +32 -0
  157. package/dist/index.js.map +7 -0
  158. package/dist/primitives/DataLoader.js +67 -0
  159. package/dist/primitives/DataLoader.js.map +7 -0
  160. package/dist/primitives/ErrorNotice.js +20 -0
  161. package/dist/primitives/ErrorNotice.js.map +7 -0
  162. package/dist/primitives/alert.js +38 -0
  163. package/dist/primitives/alert.js.map +7 -0
  164. package/dist/primitives/badge.js +28 -0
  165. package/dist/primitives/badge.js.map +7 -0
  166. package/dist/primitives/button.js +44 -0
  167. package/dist/primitives/button.js.map +7 -0
  168. package/dist/primitives/card.js +91 -0
  169. package/dist/primitives/card.js.map +7 -0
  170. package/dist/primitives/checkbox.js +28 -0
  171. package/dist/primitives/checkbox.js.map +7 -0
  172. package/dist/primitives/dialog.js +90 -0
  173. package/dist/primitives/dialog.js.map +7 -0
  174. package/dist/primitives/input.js +22 -0
  175. package/dist/primitives/input.js.map +7 -0
  176. package/dist/primitives/label.js +21 -0
  177. package/dist/primitives/label.js.map +7 -0
  178. package/dist/primitives/separator.js +9 -0
  179. package/dist/primitives/separator.js.map +7 -0
  180. package/dist/primitives/spinner.js +24 -0
  181. package/dist/primitives/spinner.js.map +7 -0
  182. package/dist/primitives/switch.js +80 -0
  183. package/dist/primitives/switch.js.map +7 -0
  184. package/dist/primitives/table.js +29 -0
  185. package/dist/primitives/table.js.map +7 -0
  186. package/dist/primitives/tabs.js +87 -0
  187. package/dist/primitives/tabs.js.map +7 -0
  188. package/dist/primitives/textarea.js +21 -0
  189. package/dist/primitives/textarea.js.map +7 -0
  190. package/dist/primitives/tooltip.js +60 -0
  191. package/dist/primitives/tooltip.js.map +7 -0
  192. package/dist/theme/QueryProvider.js +44 -0
  193. package/dist/theme/QueryProvider.js.map +7 -0
  194. package/dist/theme/ThemeProvider.js +95 -0
  195. package/dist/theme/ThemeProvider.js.map +7 -0
  196. package/dist/theme/ThemeToggle.js +88 -0
  197. package/dist/theme/ThemeToggle.js.map +7 -0
  198. package/dist/theme/index.js +10 -0
  199. package/dist/theme/index.js.map +7 -0
  200. package/dist/types/react-big-calendar.d.js +1 -0
  201. package/dist/types/react-big-calendar.d.js.map +7 -0
  202. package/jest.config.cjs +23 -0
  203. package/jest.setup.ts +55 -0
  204. package/package.json +105 -0
  205. package/src/backend/AppShell.tsx +1096 -0
  206. package/src/backend/ConfirmDialog.tsx +19 -0
  207. package/src/backend/ContextHelp.tsx +38 -0
  208. package/src/backend/CrudForm.tsx +2503 -0
  209. package/src/backend/DataTable.tsx +1730 -0
  210. package/src/backend/EmptyState.tsx +65 -0
  211. package/src/backend/FilterBar.tsx +161 -0
  212. package/src/backend/FilterOverlay.tsx +328 -0
  213. package/src/backend/FlashMessages.tsx +82 -0
  214. package/src/backend/JsonBuilder.tsx +362 -0
  215. package/src/backend/JsonDisplay.tsx +254 -0
  216. package/src/backend/Page.tsx +30 -0
  217. package/src/backend/PerspectiveSidebar.tsx +337 -0
  218. package/src/backend/RowActions.tsx +151 -0
  219. package/src/backend/TruncatedCell.tsx +133 -0
  220. package/src/backend/UserMenu.tsx +118 -0
  221. package/src/backend/ValueIcons.tsx +48 -0
  222. package/src/backend/__tests__/AppShell.test.tsx +115 -0
  223. package/src/backend/__tests__/CrudForm.render.test.tsx +30 -0
  224. package/src/backend/__tests__/DataTable.render.test.tsx +48 -0
  225. package/src/backend/__tests__/custom-field-filters.test.ts +72 -0
  226. package/src/backend/__tests__/custom-field-forms.test.ts +54 -0
  227. package/src/backend/__tests__/serverErrors.test.ts +83 -0
  228. package/src/backend/custom-fields/FieldDefinitionsEditor.tsx +1292 -0
  229. package/src/backend/custom-fields/FieldDefinitionsManager.tsx +381 -0
  230. package/src/backend/dashboard/DashboardScreen.tsx +684 -0
  231. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +112 -0
  232. package/src/backend/dashboard/index.ts +1 -0
  233. package/src/backend/dashboard/widgetRegistry.ts +68 -0
  234. package/src/backend/detail/ActivitiesSection.tsx +1284 -0
  235. package/src/backend/detail/AddressEditor.tsx +472 -0
  236. package/src/backend/detail/AddressTiles.tsx +587 -0
  237. package/src/backend/detail/AddressesSection.tsx +346 -0
  238. package/src/backend/detail/AttachmentDeleteDialog.tsx +56 -0
  239. package/src/backend/detail/AttachmentMetadataDialog.tsx +672 -0
  240. package/src/backend/detail/AttachmentsSection.tsx +414 -0
  241. package/src/backend/detail/CustomDataSection.tsx +530 -0
  242. package/src/backend/detail/DetailFieldsSection.tsx +147 -0
  243. package/src/backend/detail/ErrorMessage.tsx +32 -0
  244. package/src/backend/detail/InlineEditors.tsx +877 -0
  245. package/src/backend/detail/LoadingMessage.tsx +14 -0
  246. package/src/backend/detail/NotesSection.tsx +1275 -0
  247. package/src/backend/detail/TabEmptyState.tsx +48 -0
  248. package/src/backend/detail/TagsSection.tsx +314 -0
  249. package/src/backend/detail/addressFormat.tsx +121 -0
  250. package/src/backend/detail/index.ts +44 -0
  251. package/src/backend/fields/registry.generated.ts +8 -0
  252. package/src/backend/fields/registry.ts +38 -0
  253. package/src/backend/indexes/PartialIndexBanner.tsx +68 -0
  254. package/src/backend/indexes/store.ts +88 -0
  255. package/src/backend/injection/InjectionSpot.tsx +236 -0
  256. package/src/backend/injection/PageInjectionBoundary.tsx +31 -0
  257. package/src/backend/injection/helpers.ts +35 -0
  258. package/src/backend/injection/widgetRegistry.ts +68 -0
  259. package/src/backend/inputs/ComboboxInput.tsx +269 -0
  260. package/src/backend/inputs/LookupSelect.tsx +247 -0
  261. package/src/backend/inputs/PhoneNumberField.tsx +129 -0
  262. package/src/backend/inputs/SwitchableMarkdownInput.tsx +128 -0
  263. package/src/backend/inputs/TagsInput.tsx +259 -0
  264. package/src/backend/inputs/index.ts +5 -0
  265. package/src/backend/operations/LastOperationBanner.tsx +85 -0
  266. package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +99 -0
  267. package/src/backend/operations/store.ts +230 -0
  268. package/src/backend/schedule/ScheduleAgenda.tsx +136 -0
  269. package/src/backend/schedule/ScheduleGrid.tsx +136 -0
  270. package/src/backend/schedule/ScheduleToolbar.tsx +178 -0
  271. package/src/backend/schedule/ScheduleView.tsx +198 -0
  272. package/src/backend/schedule/index.ts +5 -0
  273. package/src/backend/schedule/recurrence.ts +99 -0
  274. package/src/backend/schedule/types.ts +26 -0
  275. package/src/backend/upgrades/UpgradeActionBanner.tsx +128 -0
  276. package/src/backend/utils/__tests__/apiCall.test.ts +109 -0
  277. package/src/backend/utils/__tests__/crud.test.ts +87 -0
  278. package/src/backend/utils/__tests__/customFieldDefs.test.ts +25 -0
  279. package/src/backend/utils/__tests__/customFieldValues.test.ts +35 -0
  280. package/src/backend/utils/api.ts +149 -0
  281. package/src/backend/utils/apiCall.ts +96 -0
  282. package/src/backend/utils/crud.ts +174 -0
  283. package/src/backend/utils/customFieldColumns.ts +71 -0
  284. package/src/backend/utils/customFieldDefs.ts +245 -0
  285. package/src/backend/utils/customFieldFilters.ts +145 -0
  286. package/src/backend/utils/customFieldForms.ts +196 -0
  287. package/src/backend/utils/customFieldValues.ts +41 -0
  288. package/src/backend/utils/flash.ts +17 -0
  289. package/src/backend/utils/nav.ts +238 -0
  290. package/src/backend/utils/serverErrors.ts +302 -0
  291. package/src/frontend/AuthFooter.tsx +29 -0
  292. package/src/frontend/LanguageSwitcher.tsx +66 -0
  293. package/src/frontend/Layout.tsx +13 -0
  294. package/src/index.ts +32 -0
  295. package/src/primitives/DataLoader.tsx +92 -0
  296. package/src/primitives/ErrorNotice.tsx +26 -0
  297. package/src/primitives/alert.tsx +52 -0
  298. package/src/primitives/badge.tsx +31 -0
  299. package/src/primitives/button.tsx +47 -0
  300. package/src/primitives/card.tsx +92 -0
  301. package/src/primitives/checkbox.tsx +28 -0
  302. package/src/primitives/dialog.tsx +110 -0
  303. package/src/primitives/input.tsx +20 -0
  304. package/src/primitives/label.tsx +18 -0
  305. package/src/primitives/separator.tsx +7 -0
  306. package/src/primitives/spinner.tsx +27 -0
  307. package/src/primitives/switch.tsx +86 -0
  308. package/src/primitives/table.tsx +27 -0
  309. package/src/primitives/tabs.tsx +128 -0
  310. package/src/primitives/textarea.tsx +20 -0
  311. package/src/primitives/tooltip.tsx +85 -0
  312. package/src/theme/QueryProvider.tsx +46 -0
  313. package/src/theme/ThemeProvider.tsx +120 -0
  314. package/src/theme/ThemeToggle.tsx +88 -0
  315. package/src/theme/index.ts +3 -0
  316. package/src/types/react-big-calendar.d.ts +16 -0
  317. package/tsconfig.build.json +11 -0
  318. package/tsconfig.json +9 -0
  319. package/watch.mjs +6 -0
@@ -0,0 +1,230 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import type { OperationMetadataPayload } from '@open-mercato/shared/lib/commands/operationMetadata'
4
+
5
+ export type OperationEntry = OperationMetadataPayload & {
6
+ receivedAt: number
7
+ }
8
+
9
+ export type UndoneEntry = OperationEntry & {
10
+ undoneAt: number
11
+ }
12
+
13
+ type OperationStoreState = {
14
+ stack: OperationEntry[]
15
+ undone: UndoneEntry[]
16
+ }
17
+
18
+ const DEFAULT_STATE: OperationStoreState = { stack: [], undone: [] }
19
+
20
+ const STORAGE_KEY = 'om:last-operations:v1'
21
+ const STACK_LIMIT = 20
22
+ const LAST_OPERATION_TTL_MS = 60_000
23
+ const STACK_RETENTION_MS = 10 * 60_000
24
+
25
+ let internalState: OperationStoreState = DEFAULT_STATE
26
+
27
+ if (typeof window !== 'undefined') {
28
+ internalState = loadState()
29
+ }
30
+
31
+ const emitter = new EventTarget()
32
+
33
+ function now() {
34
+ return typeof performance !== 'undefined' && performance.now
35
+ ? Math.round(performance.timeOrigin + performance.now())
36
+ : Date.now()
37
+ }
38
+
39
+ function loadState(): OperationStoreState {
40
+ try {
41
+ const raw = window.localStorage.getItem(STORAGE_KEY)
42
+ if (!raw) return DEFAULT_STATE
43
+ const parsed = JSON.parse(raw)
44
+ if (!parsed || typeof parsed !== 'object') return DEFAULT_STATE
45
+ const stack = Array.isArray(parsed.stack) ? parsed.stack.filter(isValidEntry).map(hydrateEntry) : []
46
+ const undone = Array.isArray(parsed.undone)
47
+ ? parsed.undone.filter(isValidEntry).map((raw: unknown) => {
48
+ const hydrated = hydrateEntry(raw)
49
+ const candidate = raw as { undoneAt?: unknown }
50
+ const undoneAt = typeof candidate.undoneAt === 'number' ? candidate.undoneAt : now()
51
+ return { ...hydrated, undoneAt }
52
+ })
53
+ : []
54
+ return pruneState({ stack, undone })
55
+ } catch {
56
+ return DEFAULT_STATE
57
+ }
58
+ }
59
+
60
+ function isValidEntry(entry: unknown): entry is OperationEntry {
61
+ if (entry == null || typeof entry !== 'object') return false
62
+ const candidate = entry as Record<string, unknown>
63
+ return (
64
+ typeof candidate.id === 'string'
65
+ && typeof candidate.undoToken === 'string'
66
+ && typeof candidate.commandId === 'string'
67
+ && typeof candidate.receivedAt === 'number'
68
+ && typeof candidate.executedAt === 'string'
69
+ )
70
+ }
71
+
72
+ function hydrateEntry(entry: unknown): OperationEntry {
73
+ const source = entry as Partial<OperationEntry> & Record<string, unknown>
74
+ return {
75
+ id: String(source.id),
76
+ undoToken: String(source.undoToken),
77
+ commandId: String(source.commandId),
78
+ actionLabel: typeof source.actionLabel === 'string' ? source.actionLabel : source.actionLabel === null ? null : null,
79
+ resourceKind: typeof source.resourceKind === 'string' ? source.resourceKind : null,
80
+ resourceId: typeof source.resourceId === 'string' ? source.resourceId : null,
81
+ executedAt: typeof source.executedAt === 'string' ? source.executedAt : new Date((source.receivedAt as number | undefined) || now()).toISOString(),
82
+ receivedAt: typeof source.receivedAt === 'number' ? source.receivedAt : now(),
83
+ }
84
+ }
85
+
86
+ function persist(state: OperationStoreState) {
87
+ if (typeof window === 'undefined') return
88
+ try {
89
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
90
+ } catch {
91
+ // ignore storage quota errors
92
+ }
93
+ }
94
+
95
+ function pruneState(state: OperationStoreState): OperationStoreState {
96
+ const timestamp = now()
97
+ const stack = state.stack
98
+ .filter((entry, index, arr) => {
99
+ // Deduplicate by id/undoToken keeping latest
100
+ const duplicateIndex = arr.findIndex((candidate) => candidate.id === entry.id || candidate.undoToken === entry.undoToken)
101
+ if (duplicateIndex !== index) return false
102
+ return timestamp - entry.receivedAt <= STACK_RETENTION_MS
103
+ })
104
+ .sort((a, b) => a.receivedAt - b.receivedAt)
105
+ .slice(-STACK_LIMIT)
106
+ const undone = state.undone
107
+ .filter((entry) => timestamp - entry.undoneAt <= STACK_RETENTION_MS)
108
+ .sort((a, b) => a.undoneAt - b.undoneAt)
109
+ .slice(-STACK_LIMIT)
110
+ const next = { stack, undone }
111
+ return next
112
+ }
113
+
114
+ function emit() {
115
+ emitter.dispatchEvent(new Event('change'))
116
+ }
117
+
118
+ function updateState(updater: (prev: OperationStoreState) => OperationStoreState) {
119
+ const next = pruneState(updater(internalState))
120
+ internalState = next
121
+ persist(next)
122
+ emit()
123
+ }
124
+
125
+ function subscribe(listener: () => void) {
126
+ const wrapped = () => listener()
127
+ emitter.addEventListener('change', wrapped)
128
+ return () => emitter.removeEventListener('change', wrapped)
129
+ }
130
+
131
+ function getClientSnapshot(): OperationStoreState {
132
+ internalState = pruneState(internalState)
133
+ return internalState
134
+ }
135
+
136
+ export function useOperationStore<T>(selector: (state: OperationStoreState) => T): T {
137
+ return React.useSyncExternalStore(
138
+ subscribe,
139
+ () => selector(getClientSnapshot()),
140
+ () => selector(DEFAULT_STATE),
141
+ )
142
+ }
143
+
144
+ export function pushOperation(meta: OperationMetadataPayload) {
145
+ if (typeof window === 'undefined') return
146
+ updateState((prev) => {
147
+ const entry: OperationEntry = {
148
+ ...meta,
149
+ receivedAt: now(),
150
+ }
151
+ const stack = prev.stack.filter((item) => item.id !== entry.id && item.undoToken !== entry.undoToken)
152
+ stack.push(entry)
153
+ return { stack, undone: [] }
154
+ })
155
+ }
156
+
157
+ export function markUndoSuccess(undoToken: string) {
158
+ if (typeof window === 'undefined') return
159
+ const removed: OperationEntry[] = []
160
+ updateState((prev) => {
161
+ const stack = prev.stack.filter((entry) => {
162
+ if (entry.undoToken === undoToken) {
163
+ removed.push(entry)
164
+ return false
165
+ }
166
+ return true
167
+ })
168
+ const undone = removed.length
169
+ ? [...prev.undone, ...removed.map((entry) => ({ ...entry, undoneAt: now() }))]
170
+ : prev.undone
171
+ return { stack, undone }
172
+ })
173
+ }
174
+
175
+ export function markRedoConsumed(logId: string) {
176
+ if (typeof window === 'undefined') return
177
+ updateState((prev) => ({
178
+ stack: prev.stack,
179
+ undone: prev.undone.filter((entry) => entry.id !== logId),
180
+ }))
181
+ }
182
+
183
+ export function getLastOperation(): OperationEntry | null {
184
+ const state = getClientSnapshot()
185
+ if (!state.stack.length) return null
186
+ const last = state.stack[state.stack.length - 1]
187
+ const lastExecuted = Date.parse(last.executedAt)
188
+ const cutoff = now() - LAST_OPERATION_TTL_MS
189
+ if (Number.isFinite(lastExecuted) && lastExecuted < cutoff) return null
190
+ if (!Number.isFinite(lastExecuted) && last.receivedAt < cutoff) return null
191
+ return last
192
+ }
193
+
194
+ export function useLastOperation(): OperationEntry | null {
195
+ return useOperationStore(getLastOperationFromState)
196
+ }
197
+
198
+ function getLastOperationFromState(state: OperationStoreState): OperationEntry | null {
199
+ if (!state.stack.length) return null
200
+ const last = state.stack[state.stack.length - 1]
201
+ const timestamp = now()
202
+ const executedAt = Date.parse(last.executedAt)
203
+ const cutoff = timestamp - LAST_OPERATION_TTL_MS
204
+ if (Number.isFinite(executedAt)) {
205
+ return executedAt >= cutoff ? last : null
206
+ }
207
+ return last.receivedAt >= cutoff ? last : null
208
+ }
209
+
210
+ export function useRedoCandidate(): UndoneEntry | null {
211
+ return useOperationStore((state) => (state.undone.length ? state.undone[state.undone.length - 1] : null))
212
+ }
213
+
214
+ export function hasRedoCandidate(logId: string): boolean {
215
+ const state = getClientSnapshot()
216
+ if (!state.undone.length) return false
217
+ const top = state.undone[state.undone.length - 1]
218
+ return top.id === logId
219
+ }
220
+
221
+ export function clearAllOperations() {
222
+ if (typeof window === 'undefined') return
223
+ internalState = DEFAULT_STATE
224
+ persist(internalState)
225
+ emit()
226
+ }
227
+
228
+ export const operationStackConstants = {
229
+ LAST_OPERATION_TTL_MS,
230
+ }
@@ -0,0 +1,136 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import type { ScheduleItem, ScheduleRange, ScheduleSlot } from './types'
5
+ import { cn } from '@open-mercato/shared/lib/utils'
6
+ import { Badge } from '../../primitives/badge'
7
+ import { Button } from '../../primitives/button'
8
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
9
+ import { expandRecurringItems } from './recurrence'
10
+
11
+ const DAY_MS = 24 * 60 * 60 * 1000
12
+
13
+ function startOfDay(value: Date): Date {
14
+ return new Date(value.getFullYear(), value.getMonth(), value.getDate())
15
+ }
16
+
17
+ function endOfDay(value: Date): Date {
18
+ return new Date(value.getFullYear(), value.getMonth(), value.getDate(), 23, 59, 59, 999)
19
+ }
20
+
21
+ function eachDay(start: Date, end: Date): Date[] {
22
+ const days: Date[] = []
23
+ let cursor = startOfDay(start)
24
+ const last = startOfDay(end)
25
+ while (cursor <= last) {
26
+ days.push(new Date(cursor))
27
+ cursor = new Date(cursor.getTime() + DAY_MS)
28
+ }
29
+ return days
30
+ }
31
+
32
+ function overlapsDay(item: ScheduleItem, day: Date): boolean {
33
+ const dayStart = startOfDay(day)
34
+ const dayEnd = endOfDay(day)
35
+ return item.startsAt <= dayEnd && item.endsAt >= dayStart
36
+ }
37
+
38
+ function formatDayLabel(day: Date): string {
39
+ return day.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' })
40
+ }
41
+
42
+ function formatTimeRange(item: ScheduleItem, timezone?: string): string {
43
+ const options: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' }
44
+ if (timezone) options.timeZone = timezone
45
+ const startLabel = item.startsAt.toLocaleTimeString(undefined, options)
46
+ const endLabel = item.endsAt.toLocaleTimeString(undefined, options)
47
+ return `${startLabel}-${endLabel}`
48
+ }
49
+
50
+ function getStatusLabel(status: ScheduleItem['status'], t: (key: string, fallback?: string) => string): string | null {
51
+ if (!status) return null
52
+ if (status === 'draft') return t('schedule.item.status.draft', 'Draft')
53
+ if (status === 'negotiation') return t('schedule.item.status.negotiation', 'Negotiation')
54
+ if (status === 'confirmed') return t('schedule.item.status.confirmed', 'Confirmed')
55
+ if (status === 'cancelled') return t('schedule.item.status.cancelled', 'Cancelled')
56
+ return null
57
+ }
58
+
59
+ function getKindStyles(kind: ScheduleItem['kind']): string {
60
+ if (kind === 'event') return 'border-blue-500/40 bg-blue-500/10 text-blue-950'
61
+ if (kind === 'exception') return 'border-amber-500/40 bg-amber-500/10 text-amber-950'
62
+ return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-950'
63
+ }
64
+
65
+ export type ScheduleAgendaProps = {
66
+ items: ScheduleItem[]
67
+ range: ScheduleRange
68
+ timezone?: string
69
+ onItemClick?: (item: ScheduleItem) => void
70
+ onSlotClick?: (slot: ScheduleSlot) => void
71
+ className?: string
72
+ }
73
+
74
+ export function ScheduleAgenda({ items, range, timezone, onItemClick, onSlotClick, className }: ScheduleAgendaProps) {
75
+ const t = useT()
76
+ const days = React.useMemo(() => eachDay(range.start, range.end), [range])
77
+ const expandedItems = React.useMemo(() => expandRecurringItems(items, range), [items, range])
78
+
79
+ return (
80
+ <div className={cn('space-y-4', className)}>
81
+ {days.map((day) => {
82
+ const dayItems = expandedItems.filter((item) => overlapsDay(item, day))
83
+ const slotStart = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 9, 0, 0)
84
+ const slotEnd = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 10, 0, 0)
85
+ return (
86
+ <div key={day.toISOString()} className="rounded-xl border bg-card p-4">
87
+ <div className="flex items-center justify-between gap-2">
88
+ <div className="text-sm font-semibold text-foreground">{formatDayLabel(day)}</div>
89
+ {onSlotClick ? (
90
+ <Button
91
+ type="button"
92
+ variant="outline"
93
+ size="sm"
94
+ onClick={() => onSlotClick({ start: slotStart, end: slotEnd })}
95
+ >
96
+ {t('schedule.actions.add', 'Add')}
97
+ </Button>
98
+ ) : null}
99
+ </div>
100
+ <div className="mt-3 space-y-2">
101
+ {dayItems.length === 0 ? (
102
+ <div className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
103
+ {t('schedule.emptyDay', 'No scheduled items')}
104
+ </div>
105
+ ) : (
106
+ dayItems.map((item) => {
107
+ const statusLabel = getStatusLabel(item.status, t)
108
+ return (
109
+ <button
110
+ key={item.id}
111
+ type="button"
112
+ className={cn(
113
+ 'flex w-full flex-col gap-2 rounded-lg border px-3 py-2 text-left text-xs transition hover:shadow-sm',
114
+ getKindStyles(item.kind)
115
+ )}
116
+ onClick={() => onItemClick?.(item)}
117
+ >
118
+ <div className="flex items-center justify-between gap-2">
119
+ <span className="font-semibold">{item.title}</span>
120
+ {statusLabel ? <Badge variant="secondary">{statusLabel}</Badge> : null}
121
+ </div>
122
+ <div className="flex items-center justify-between text-[11px] text-muted-foreground">
123
+ <span>{formatTimeRange(item, timezone)}</span>
124
+ <span className="capitalize">{item.kind}</span>
125
+ </div>
126
+ </button>
127
+ )
128
+ })
129
+ )}
130
+ </div>
131
+ </div>
132
+ )
133
+ })}
134
+ </div>
135
+ )
136
+ }
@@ -0,0 +1,136 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import type { ScheduleItem, ScheduleRange, ScheduleSlot } from './types'
5
+ import { cn } from '@open-mercato/shared/lib/utils'
6
+ import { Badge } from '../../primitives/badge'
7
+ import { Button } from '../../primitives/button'
8
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
9
+ import { expandRecurringItems } from './recurrence'
10
+
11
+ const DAY_MS = 24 * 60 * 60 * 1000
12
+
13
+ function startOfDay(value: Date): Date {
14
+ return new Date(value.getFullYear(), value.getMonth(), value.getDate())
15
+ }
16
+
17
+ function endOfDay(value: Date): Date {
18
+ return new Date(value.getFullYear(), value.getMonth(), value.getDate(), 23, 59, 59, 999)
19
+ }
20
+
21
+ function eachDay(start: Date, end: Date): Date[] {
22
+ const days: Date[] = []
23
+ let cursor = startOfDay(start)
24
+ const last = startOfDay(end)
25
+ while (cursor <= last) {
26
+ days.push(new Date(cursor))
27
+ cursor = new Date(cursor.getTime() + DAY_MS)
28
+ }
29
+ return days
30
+ }
31
+
32
+ function overlapsDay(item: ScheduleItem, day: Date): boolean {
33
+ const dayStart = startOfDay(day)
34
+ const dayEnd = endOfDay(day)
35
+ return item.startsAt <= dayEnd && item.endsAt >= dayStart
36
+ }
37
+
38
+ function formatDayLabel(day: Date): string {
39
+ return day.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
40
+ }
41
+
42
+ function formatTimeRange(item: ScheduleItem, timezone?: string): string {
43
+ const options: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' }
44
+ if (timezone) options.timeZone = timezone
45
+ const startLabel = item.startsAt.toLocaleTimeString(undefined, options)
46
+ const endLabel = item.endsAt.toLocaleTimeString(undefined, options)
47
+ return `${startLabel}-${endLabel}`
48
+ }
49
+
50
+ function getStatusLabel(status: ScheduleItem['status'], t: (key: string, fallback?: string) => string): string | null {
51
+ if (!status) return null
52
+ if (status === 'draft') return t('schedule.item.status.draft', 'Draft')
53
+ if (status === 'negotiation') return t('schedule.item.status.negotiation', 'Negotiation')
54
+ if (status === 'confirmed') return t('schedule.item.status.confirmed', 'Confirmed')
55
+ if (status === 'cancelled') return t('schedule.item.status.cancelled', 'Cancelled')
56
+ return null
57
+ }
58
+
59
+ function getKindStyles(kind: ScheduleItem['kind']): string {
60
+ if (kind === 'event') return 'border-blue-500/40 bg-blue-500/10 text-blue-950'
61
+ if (kind === 'exception') return 'border-amber-500/40 bg-amber-500/10 text-amber-950'
62
+ return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-950'
63
+ }
64
+
65
+ export type ScheduleGridProps = {
66
+ items: ScheduleItem[]
67
+ range: ScheduleRange
68
+ timezone?: string
69
+ onItemClick?: (item: ScheduleItem) => void
70
+ onSlotClick?: (slot: ScheduleSlot) => void
71
+ className?: string
72
+ }
73
+
74
+ export function ScheduleGrid({ items, range, timezone, onItemClick, onSlotClick, className }: ScheduleGridProps) {
75
+ const t = useT()
76
+ const days = React.useMemo(() => eachDay(range.start, range.end), [range])
77
+ const expandedItems = React.useMemo(() => expandRecurringItems(items, range), [items, range])
78
+
79
+ return (
80
+ <div className={cn('grid gap-4 md:grid-cols-2 xl:grid-cols-3', className)}>
81
+ {days.map((day) => {
82
+ const dayItems = expandedItems.filter((item) => overlapsDay(item, day))
83
+ const slotStart = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 9, 0, 0)
84
+ const slotEnd = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 10, 0, 0)
85
+ return (
86
+ <div key={day.toISOString()} className="rounded-xl border bg-card p-4 shadow-sm">
87
+ <div className="flex items-center justify-between gap-2">
88
+ <div className="text-sm font-semibold text-foreground">{formatDayLabel(day)}</div>
89
+ {onSlotClick ? (
90
+ <Button
91
+ type="button"
92
+ variant="outline"
93
+ size="sm"
94
+ onClick={() => onSlotClick({ start: slotStart, end: slotEnd })}
95
+ >
96
+ {t('schedule.actions.add', 'Add')}
97
+ </Button>
98
+ ) : null}
99
+ </div>
100
+ <div className="mt-3 space-y-2">
101
+ {dayItems.length === 0 ? (
102
+ <div className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
103
+ {t('schedule.emptyDay', 'No scheduled items')}
104
+ </div>
105
+ ) : (
106
+ dayItems.map((item) => {
107
+ const statusLabel = getStatusLabel(item.status, t)
108
+ return (
109
+ <button
110
+ key={item.id}
111
+ type="button"
112
+ className={cn(
113
+ 'flex w-full flex-col gap-2 rounded-lg border px-3 py-2 text-left text-xs transition hover:shadow-sm',
114
+ getKindStyles(item.kind)
115
+ )}
116
+ onClick={() => onItemClick?.(item)}
117
+ >
118
+ <div className="flex items-center justify-between gap-2">
119
+ <span className="font-semibold">{item.title}</span>
120
+ {statusLabel ? <Badge variant="secondary">{statusLabel}</Badge> : null}
121
+ </div>
122
+ <div className="flex items-center justify-between text-[11px] text-muted-foreground">
123
+ <span>{formatTimeRange(item, timezone)}</span>
124
+ <span className="capitalize">{item.kind}</span>
125
+ </div>
126
+ </button>
127
+ )
128
+ })
129
+ )}
130
+ </div>
131
+ </div>
132
+ )
133
+ })}
134
+ </div>
135
+ )
136
+ }
@@ -0,0 +1,178 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Button } from '../../primitives/button'
5
+ import { Input } from '../../primitives/input'
6
+ import type { ScheduleRange, ScheduleViewMode } from './types'
7
+ import { cn } from '@open-mercato/shared/lib/utils'
8
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
9
+ import { addDays, addMonths, addWeeks, differenceInCalendarDays, endOfDay, endOfMonth, endOfWeek, format, startOfDay, startOfMonth, startOfWeek } from 'date-fns'
10
+ import { enUS } from 'date-fns/locale/en-US'
11
+ import { ChevronLeft, ChevronRight } from 'lucide-react'
12
+
13
+ const VIEW_OPTIONS: Array<{ id: ScheduleViewMode; labelKey: string; fallback: string }> = [
14
+ { id: 'day', labelKey: 'schedule.view.day', fallback: 'Day' },
15
+ { id: 'week', labelKey: 'schedule.view.week', fallback: 'Week' },
16
+ { id: 'month', labelKey: 'schedule.view.month', fallback: 'Month' },
17
+ { id: 'agenda', labelKey: 'schedule.view.agenda', fallback: 'Agenda' },
18
+ ]
19
+
20
+ function formatDateInputValue(value: Date): string {
21
+ const year = value.getFullYear()
22
+ const month = String(value.getMonth() + 1).padStart(2, '0')
23
+ const day = String(value.getDate()).padStart(2, '0')
24
+ return `${year}-${month}-${day}`
25
+ }
26
+
27
+ function parseDateInputValue(value: string, fallback: Date): Date {
28
+ if (!value) return fallback
29
+ const next = new Date(`${value}T00:00:00`)
30
+ return Number.isNaN(next.getTime()) ? fallback : next
31
+ }
32
+
33
+ export type ScheduleToolbarProps = {
34
+ view: ScheduleViewMode
35
+ range: ScheduleRange
36
+ timezone?: string
37
+ onViewChange: (view: ScheduleViewMode) => void
38
+ onRangeChange: (range: ScheduleRange) => void
39
+ onTimezoneChange?: (timezone: string) => void
40
+ className?: string
41
+ }
42
+
43
+ export function ScheduleToolbar({
44
+ view,
45
+ range,
46
+ timezone,
47
+ onViewChange,
48
+ onRangeChange,
49
+ onTimezoneChange,
50
+ className,
51
+ }: ScheduleToolbarProps) {
52
+ const t = useT()
53
+ const rangeLength = React.useMemo(
54
+ () => Math.max(1, differenceInCalendarDays(range.end, range.start) + 1),
55
+ [range.end, range.start],
56
+ )
57
+ const deriveRangeForView = React.useCallback((base: Date, nextView: ScheduleViewMode): ScheduleRange => {
58
+ if (nextView === 'day') {
59
+ const start = startOfDay(base)
60
+ return { start, end: endOfDay(start) }
61
+ }
62
+ if (nextView === 'week') {
63
+ return { start: startOfWeek(base, { locale: enUS }), end: endOfWeek(base, { locale: enUS }) }
64
+ }
65
+ if (nextView === 'month') {
66
+ return { start: startOfMonth(base), end: endOfMonth(base) }
67
+ }
68
+ const start = startOfDay(base)
69
+ return { start, end: endOfDay(addDays(start, rangeLength - 1)) }
70
+ }, [rangeLength])
71
+ const rangeLabel = React.useMemo(() => {
72
+ if (view === 'day') {
73
+ return format(range.start, 'EEE, MMM d')
74
+ }
75
+ if (view === 'week') {
76
+ const startLabel = format(range.start, 'MMM d')
77
+ const endLabel = format(range.end, 'MMM d')
78
+ const yearLabel = format(range.start, 'yyyy')
79
+ return `${startLabel} - ${endLabel}, ${yearLabel}`
80
+ }
81
+ if (view === 'month') {
82
+ return format(range.start, 'MMMM yyyy')
83
+ }
84
+ const startLabel = format(range.start, 'MMM d')
85
+ const endLabel = format(range.end, 'MMM d, yyyy')
86
+ return `${startLabel} - ${endLabel}`
87
+ }, [range.end, range.start, view])
88
+
89
+ const shiftRange = React.useCallback((direction: 'prev' | 'next') => {
90
+ const multiplier = direction === 'prev' ? -1 : 1
91
+ if (view === 'day') {
92
+ const nextStart = startOfDay(addDays(range.start, multiplier))
93
+ onRangeChange({ start: nextStart, end: endOfDay(nextStart) })
94
+ return
95
+ }
96
+ if (view === 'week') {
97
+ const base = addWeeks(range.start, multiplier)
98
+ onRangeChange({
99
+ start: startOfWeek(base, { locale: enUS }),
100
+ end: endOfWeek(base, { locale: enUS }),
101
+ })
102
+ return
103
+ }
104
+ if (view === 'month') {
105
+ const base = addMonths(range.start, multiplier)
106
+ onRangeChange({ start: startOfMonth(base), end: endOfMonth(base) })
107
+ return
108
+ }
109
+ const nextStart = startOfDay(addDays(range.start, multiplier * rangeLength))
110
+ onRangeChange({ start: nextStart, end: endOfDay(addDays(nextStart, rangeLength - 1)) })
111
+ }, [onRangeChange, range.start, rangeLength, view])
112
+
113
+ return (
114
+ <div className={cn('flex flex-col gap-3 rounded-xl border bg-card p-4 md:flex-row md:items-center md:justify-between', className)}>
115
+ <div className="flex flex-wrap items-center gap-2">
116
+ {VIEW_OPTIONS.map((option) => (
117
+ <Button
118
+ key={option.id}
119
+ variant={view === option.id ? 'default' : 'outline'}
120
+ size="sm"
121
+ onClick={() => {
122
+ if (option.id === view) return
123
+ onViewChange(option.id)
124
+ onRangeChange(deriveRangeForView(new Date(), option.id))
125
+ }}
126
+ >
127
+ {t(option.labelKey, option.fallback)}
128
+ </Button>
129
+ ))}
130
+ </div>
131
+ <div className="flex flex-wrap items-center gap-2">
132
+ <Button type="button" variant="outline" size="sm" onClick={() => shiftRange('prev')} aria-label={t('schedule.range.prev', 'Previous')}>
133
+ <ChevronLeft className="size-4" aria-hidden />
134
+ </Button>
135
+ <div className="text-sm font-medium text-foreground">{rangeLabel}</div>
136
+ <Button type="button" variant="outline" size="sm" onClick={() => shiftRange('next')} aria-label={t('schedule.range.next', 'Next')}>
137
+ <ChevronRight className="size-4" aria-hidden />
138
+ </Button>
139
+ </div>
140
+ <div className="flex flex-wrap items-center gap-3">
141
+ <label className="flex items-center gap-2 text-xs text-muted-foreground">
142
+ <span>{t('schedule.range.start', 'Start')}</span>
143
+ <Input
144
+ type="date"
145
+ value={formatDateInputValue(range.start)}
146
+ onChange={(event) => {
147
+ const nextStart = parseDateInputValue(event.target.value, range.start)
148
+ onRangeChange({ start: nextStart, end: range.end })
149
+ }}
150
+ className="h-8 w-[140px]"
151
+ />
152
+ </label>
153
+ <label className="flex items-center gap-2 text-xs text-muted-foreground">
154
+ <span>{t('schedule.range.end', 'End')}</span>
155
+ <Input
156
+ type="date"
157
+ value={formatDateInputValue(range.end)}
158
+ onChange={(event) => {
159
+ const nextEnd = parseDateInputValue(event.target.value, range.end)
160
+ onRangeChange({ start: range.start, end: nextEnd })
161
+ }}
162
+ className="h-8 w-[140px]"
163
+ />
164
+ </label>
165
+ <label className="flex items-center gap-2 text-xs text-muted-foreground">
166
+ <span>{t('schedule.range.timezone', 'Timezone')}</span>
167
+ <Input
168
+ type="text"
169
+ value={timezone ?? ''}
170
+ onChange={(event) => onTimezoneChange?.(event.target.value)}
171
+ className="h-8 w-[180px]"
172
+ placeholder={t('schedule.range.timezone.placeholder', 'UTC')}
173
+ />
174
+ </label>
175
+ </div>
176
+ </div>
177
+ )
178
+ }