@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.
- package/build.mjs +62 -0
- package/dist/backend/AppShell.js +902 -0
- package/dist/backend/AppShell.js.map +7 -0
- package/dist/backend/ConfirmDialog.js +17 -0
- package/dist/backend/ConfirmDialog.js.map +7 -0
- package/dist/backend/ContextHelp.js +31 -0
- package/dist/backend/ContextHelp.js.map +7 -0
- package/dist/backend/CrudForm.js +2028 -0
- package/dist/backend/CrudForm.js.map +7 -0
- package/dist/backend/DataTable.js +1363 -0
- package/dist/backend/DataTable.js.map +7 -0
- package/dist/backend/EmptyState.js +52 -0
- package/dist/backend/EmptyState.js.map +7 -0
- package/dist/backend/FilterBar.js +140 -0
- package/dist/backend/FilterBar.js.map +7 -0
- package/dist/backend/FilterOverlay.js +279 -0
- package/dist/backend/FilterOverlay.js.map +7 -0
- package/dist/backend/FlashMessages.js +66 -0
- package/dist/backend/FlashMessages.js.map +7 -0
- package/dist/backend/JsonBuilder.js +322 -0
- package/dist/backend/JsonBuilder.js.map +7 -0
- package/dist/backend/JsonDisplay.js +203 -0
- package/dist/backend/JsonDisplay.js.map +7 -0
- package/dist/backend/Page.js +27 -0
- package/dist/backend/Page.js.map +7 -0
- package/dist/backend/PerspectiveSidebar.js +282 -0
- package/dist/backend/PerspectiveSidebar.js.map +7 -0
- package/dist/backend/RowActions.js +148 -0
- package/dist/backend/RowActions.js.map +7 -0
- package/dist/backend/TruncatedCell.js +92 -0
- package/dist/backend/TruncatedCell.js.map +7 -0
- package/dist/backend/UserMenu.js +107 -0
- package/dist/backend/UserMenu.js.map +7 -0
- package/dist/backend/ValueIcons.js +34 -0
- package/dist/backend/ValueIcons.js.map +7 -0
- package/dist/backend/custom-fields/FieldDefinitionsEditor.js +1264 -0
- package/dist/backend/custom-fields/FieldDefinitionsEditor.js.map +7 -0
- package/dist/backend/custom-fields/FieldDefinitionsManager.js +332 -0
- package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +7 -0
- package/dist/backend/dashboard/DashboardScreen.js +578 -0
- package/dist/backend/dashboard/DashboardScreen.js.map +7 -0
- package/dist/backend/dashboard/index.js +5 -0
- package/dist/backend/dashboard/index.js.map +7 -0
- package/dist/backend/dashboard/widgetRegistry.js +55 -0
- package/dist/backend/dashboard/widgetRegistry.js.map +7 -0
- package/dist/backend/detail/ActivitiesSection.js +962 -0
- package/dist/backend/detail/ActivitiesSection.js.map +7 -0
- package/dist/backend/detail/AddressEditor.js +413 -0
- package/dist/backend/detail/AddressEditor.js.map +7 -0
- package/dist/backend/detail/AddressTiles.js +437 -0
- package/dist/backend/detail/AddressTiles.js.map +7 -0
- package/dist/backend/detail/AddressesSection.js +264 -0
- package/dist/backend/detail/AddressesSection.js.map +7 -0
- package/dist/backend/detail/AttachmentDeleteDialog.js +41 -0
- package/dist/backend/detail/AttachmentDeleteDialog.js.map +7 -0
- package/dist/backend/detail/AttachmentMetadataDialog.js +517 -0
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +7 -0
- package/dist/backend/detail/AttachmentsSection.js +367 -0
- package/dist/backend/detail/AttachmentsSection.js.map +7 -0
- package/dist/backend/detail/CustomDataSection.js +433 -0
- package/dist/backend/detail/CustomDataSection.js.map +7 -0
- package/dist/backend/detail/DetailFieldsSection.js +75 -0
- package/dist/backend/detail/DetailFieldsSection.js.map +7 -0
- package/dist/backend/detail/ErrorMessage.js +28 -0
- package/dist/backend/detail/ErrorMessage.js.map +7 -0
- package/dist/backend/detail/InlineEditors.js +681 -0
- package/dist/backend/detail/InlineEditors.js.map +7 -0
- package/dist/backend/detail/LoadingMessage.js +14 -0
- package/dist/backend/detail/LoadingMessage.js.map +7 -0
- package/dist/backend/detail/NotesSection.js +1032 -0
- package/dist/backend/detail/NotesSection.js.map +7 -0
- package/dist/backend/detail/TabEmptyState.js +25 -0
- package/dist/backend/detail/TabEmptyState.js.map +7 -0
- package/dist/backend/detail/TagsSection.js +254 -0
- package/dist/backend/detail/TagsSection.js.map +7 -0
- package/dist/backend/detail/addressFormat.js +77 -0
- package/dist/backend/detail/addressFormat.js.map +7 -0
- package/dist/backend/detail/index.js +34 -0
- package/dist/backend/detail/index.js.map +7 -0
- package/dist/backend/fields/registry.generated.js +8 -0
- package/dist/backend/fields/registry.generated.js.map +7 -0
- package/dist/backend/fields/registry.js +29 -0
- package/dist/backend/fields/registry.js.map +7 -0
- package/dist/backend/indexes/PartialIndexBanner.js +58 -0
- package/dist/backend/indexes/PartialIndexBanner.js.map +7 -0
- package/dist/backend/indexes/store.js +62 -0
- package/dist/backend/indexes/store.js.map +7 -0
- package/dist/backend/injection/InjectionSpot.js +179 -0
- package/dist/backend/injection/InjectionSpot.js.map +7 -0
- package/dist/backend/injection/PageInjectionBoundary.js +26 -0
- package/dist/backend/injection/PageInjectionBoundary.js.map +7 -0
- package/dist/backend/injection/helpers.js +26 -0
- package/dist/backend/injection/helpers.js.map +7 -0
- package/dist/backend/injection/widgetRegistry.js +55 -0
- package/dist/backend/injection/widgetRegistry.js.map +7 -0
- package/dist/backend/inputs/ComboboxInput.js +225 -0
- package/dist/backend/inputs/ComboboxInput.js.map +7 -0
- package/dist/backend/inputs/LookupSelect.js +191 -0
- package/dist/backend/inputs/LookupSelect.js.map +7 -0
- package/dist/backend/inputs/PhoneNumberField.js +100 -0
- package/dist/backend/inputs/PhoneNumberField.js.map +7 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js +92 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +7 -0
- package/dist/backend/inputs/TagsInput.js +222 -0
- package/dist/backend/inputs/TagsInput.js.map +7 -0
- package/dist/backend/inputs/index.js +6 -0
- package/dist/backend/inputs/index.js.map +7 -0
- package/dist/backend/operations/LastOperationBanner.js +80 -0
- package/dist/backend/operations/LastOperationBanner.js.map +7 -0
- package/dist/backend/operations/store.js +183 -0
- package/dist/backend/operations/store.js.map +7 -0
- package/dist/backend/schedule/ScheduleAgenda.js +107 -0
- package/dist/backend/schedule/ScheduleAgenda.js.map +7 -0
- package/dist/backend/schedule/ScheduleGrid.js +107 -0
- package/dist/backend/schedule/ScheduleGrid.js.map +7 -0
- package/dist/backend/schedule/ScheduleToolbar.js +166 -0
- package/dist/backend/schedule/ScheduleToolbar.js.map +7 -0
- package/dist/backend/schedule/ScheduleView.js +165 -0
- package/dist/backend/schedule/ScheduleView.js.map +7 -0
- package/dist/backend/schedule/index.js +6 -0
- package/dist/backend/schedule/index.js.map +7 -0
- package/dist/backend/schedule/recurrence.js +83 -0
- package/dist/backend/schedule/recurrence.js.map +7 -0
- package/dist/backend/schedule/types.js +1 -0
- package/dist/backend/schedule/types.js.map +7 -0
- package/dist/backend/upgrades/UpgradeActionBanner.js +91 -0
- package/dist/backend/upgrades/UpgradeActionBanner.js.map +7 -0
- package/dist/backend/utils/api.js +127 -0
- package/dist/backend/utils/api.js.map +7 -0
- package/dist/backend/utils/apiCall.js +48 -0
- package/dist/backend/utils/apiCall.js.map +7 -0
- package/dist/backend/utils/crud.js +126 -0
- package/dist/backend/utils/crud.js.map +7 -0
- package/dist/backend/utils/customFieldColumns.js +56 -0
- package/dist/backend/utils/customFieldColumns.js.map +7 -0
- package/dist/backend/utils/customFieldDefs.js +143 -0
- package/dist/backend/utils/customFieldDefs.js.map +7 -0
- package/dist/backend/utils/customFieldFilters.js +126 -0
- package/dist/backend/utils/customFieldFilters.js.map +7 -0
- package/dist/backend/utils/customFieldForms.js +162 -0
- package/dist/backend/utils/customFieldForms.js.map +7 -0
- package/dist/backend/utils/customFieldValues.js +26 -0
- package/dist/backend/utils/customFieldValues.js.map +7 -0
- package/dist/backend/utils/flash.js +16 -0
- package/dist/backend/utils/flash.js.map +7 -0
- package/dist/backend/utils/nav.js +185 -0
- package/dist/backend/utils/nav.js.map +7 -0
- package/dist/backend/utils/serverErrors.js +230 -0
- package/dist/backend/utils/serverErrors.js.map +7 -0
- package/dist/frontend/AuthFooter.js +23 -0
- package/dist/frontend/AuthFooter.js.map +7 -0
- package/dist/frontend/LanguageSwitcher.js +57 -0
- package/dist/frontend/LanguageSwitcher.js.map +7 -0
- package/dist/frontend/Layout.js +14 -0
- package/dist/frontend/Layout.js.map +7 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +7 -0
- package/dist/primitives/DataLoader.js +67 -0
- package/dist/primitives/DataLoader.js.map +7 -0
- package/dist/primitives/ErrorNotice.js +20 -0
- package/dist/primitives/ErrorNotice.js.map +7 -0
- package/dist/primitives/alert.js +38 -0
- package/dist/primitives/alert.js.map +7 -0
- package/dist/primitives/badge.js +28 -0
- package/dist/primitives/badge.js.map +7 -0
- package/dist/primitives/button.js +44 -0
- package/dist/primitives/button.js.map +7 -0
- package/dist/primitives/card.js +91 -0
- package/dist/primitives/card.js.map +7 -0
- package/dist/primitives/checkbox.js +28 -0
- package/dist/primitives/checkbox.js.map +7 -0
- package/dist/primitives/dialog.js +90 -0
- package/dist/primitives/dialog.js.map +7 -0
- package/dist/primitives/input.js +22 -0
- package/dist/primitives/input.js.map +7 -0
- package/dist/primitives/label.js +21 -0
- package/dist/primitives/label.js.map +7 -0
- package/dist/primitives/separator.js +9 -0
- package/dist/primitives/separator.js.map +7 -0
- package/dist/primitives/spinner.js +24 -0
- package/dist/primitives/spinner.js.map +7 -0
- package/dist/primitives/switch.js +80 -0
- package/dist/primitives/switch.js.map +7 -0
- package/dist/primitives/table.js +29 -0
- package/dist/primitives/table.js.map +7 -0
- package/dist/primitives/tabs.js +87 -0
- package/dist/primitives/tabs.js.map +7 -0
- package/dist/primitives/textarea.js +21 -0
- package/dist/primitives/textarea.js.map +7 -0
- package/dist/primitives/tooltip.js +60 -0
- package/dist/primitives/tooltip.js.map +7 -0
- package/dist/theme/QueryProvider.js +44 -0
- package/dist/theme/QueryProvider.js.map +7 -0
- package/dist/theme/ThemeProvider.js +95 -0
- package/dist/theme/ThemeProvider.js.map +7 -0
- package/dist/theme/ThemeToggle.js +88 -0
- package/dist/theme/ThemeToggle.js.map +7 -0
- package/dist/theme/index.js +10 -0
- package/dist/theme/index.js.map +7 -0
- package/dist/types/react-big-calendar.d.js +1 -0
- package/dist/types/react-big-calendar.d.js.map +7 -0
- package/jest.config.cjs +23 -0
- package/jest.setup.ts +55 -0
- package/package.json +105 -0
- package/src/backend/AppShell.tsx +1096 -0
- package/src/backend/ConfirmDialog.tsx +19 -0
- package/src/backend/ContextHelp.tsx +38 -0
- package/src/backend/CrudForm.tsx +2503 -0
- package/src/backend/DataTable.tsx +1730 -0
- package/src/backend/EmptyState.tsx +65 -0
- package/src/backend/FilterBar.tsx +161 -0
- package/src/backend/FilterOverlay.tsx +328 -0
- package/src/backend/FlashMessages.tsx +82 -0
- package/src/backend/JsonBuilder.tsx +362 -0
- package/src/backend/JsonDisplay.tsx +254 -0
- package/src/backend/Page.tsx +30 -0
- package/src/backend/PerspectiveSidebar.tsx +337 -0
- package/src/backend/RowActions.tsx +151 -0
- package/src/backend/TruncatedCell.tsx +133 -0
- package/src/backend/UserMenu.tsx +118 -0
- package/src/backend/ValueIcons.tsx +48 -0
- package/src/backend/__tests__/AppShell.test.tsx +115 -0
- package/src/backend/__tests__/CrudForm.render.test.tsx +30 -0
- package/src/backend/__tests__/DataTable.render.test.tsx +48 -0
- package/src/backend/__tests__/custom-field-filters.test.ts +72 -0
- package/src/backend/__tests__/custom-field-forms.test.ts +54 -0
- package/src/backend/__tests__/serverErrors.test.ts +83 -0
- package/src/backend/custom-fields/FieldDefinitionsEditor.tsx +1292 -0
- package/src/backend/custom-fields/FieldDefinitionsManager.tsx +381 -0
- package/src/backend/dashboard/DashboardScreen.tsx +684 -0
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +112 -0
- package/src/backend/dashboard/index.ts +1 -0
- package/src/backend/dashboard/widgetRegistry.ts +68 -0
- package/src/backend/detail/ActivitiesSection.tsx +1284 -0
- package/src/backend/detail/AddressEditor.tsx +472 -0
- package/src/backend/detail/AddressTiles.tsx +587 -0
- package/src/backend/detail/AddressesSection.tsx +346 -0
- package/src/backend/detail/AttachmentDeleteDialog.tsx +56 -0
- package/src/backend/detail/AttachmentMetadataDialog.tsx +672 -0
- package/src/backend/detail/AttachmentsSection.tsx +414 -0
- package/src/backend/detail/CustomDataSection.tsx +530 -0
- package/src/backend/detail/DetailFieldsSection.tsx +147 -0
- package/src/backend/detail/ErrorMessage.tsx +32 -0
- package/src/backend/detail/InlineEditors.tsx +877 -0
- package/src/backend/detail/LoadingMessage.tsx +14 -0
- package/src/backend/detail/NotesSection.tsx +1275 -0
- package/src/backend/detail/TabEmptyState.tsx +48 -0
- package/src/backend/detail/TagsSection.tsx +314 -0
- package/src/backend/detail/addressFormat.tsx +121 -0
- package/src/backend/detail/index.ts +44 -0
- package/src/backend/fields/registry.generated.ts +8 -0
- package/src/backend/fields/registry.ts +38 -0
- package/src/backend/indexes/PartialIndexBanner.tsx +68 -0
- package/src/backend/indexes/store.ts +88 -0
- package/src/backend/injection/InjectionSpot.tsx +236 -0
- package/src/backend/injection/PageInjectionBoundary.tsx +31 -0
- package/src/backend/injection/helpers.ts +35 -0
- package/src/backend/injection/widgetRegistry.ts +68 -0
- package/src/backend/inputs/ComboboxInput.tsx +269 -0
- package/src/backend/inputs/LookupSelect.tsx +247 -0
- package/src/backend/inputs/PhoneNumberField.tsx +129 -0
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +128 -0
- package/src/backend/inputs/TagsInput.tsx +259 -0
- package/src/backend/inputs/index.ts +5 -0
- package/src/backend/operations/LastOperationBanner.tsx +85 -0
- package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +99 -0
- package/src/backend/operations/store.ts +230 -0
- package/src/backend/schedule/ScheduleAgenda.tsx +136 -0
- package/src/backend/schedule/ScheduleGrid.tsx +136 -0
- package/src/backend/schedule/ScheduleToolbar.tsx +178 -0
- package/src/backend/schedule/ScheduleView.tsx +198 -0
- package/src/backend/schedule/index.ts +5 -0
- package/src/backend/schedule/recurrence.ts +99 -0
- package/src/backend/schedule/types.ts +26 -0
- package/src/backend/upgrades/UpgradeActionBanner.tsx +128 -0
- package/src/backend/utils/__tests__/apiCall.test.ts +109 -0
- package/src/backend/utils/__tests__/crud.test.ts +87 -0
- package/src/backend/utils/__tests__/customFieldDefs.test.ts +25 -0
- package/src/backend/utils/__tests__/customFieldValues.test.ts +35 -0
- package/src/backend/utils/api.ts +149 -0
- package/src/backend/utils/apiCall.ts +96 -0
- package/src/backend/utils/crud.ts +174 -0
- package/src/backend/utils/customFieldColumns.ts +71 -0
- package/src/backend/utils/customFieldDefs.ts +245 -0
- package/src/backend/utils/customFieldFilters.ts +145 -0
- package/src/backend/utils/customFieldForms.ts +196 -0
- package/src/backend/utils/customFieldValues.ts +41 -0
- package/src/backend/utils/flash.ts +17 -0
- package/src/backend/utils/nav.ts +238 -0
- package/src/backend/utils/serverErrors.ts +302 -0
- package/src/frontend/AuthFooter.tsx +29 -0
- package/src/frontend/LanguageSwitcher.tsx +66 -0
- package/src/frontend/Layout.tsx +13 -0
- package/src/index.ts +32 -0
- package/src/primitives/DataLoader.tsx +92 -0
- package/src/primitives/ErrorNotice.tsx +26 -0
- package/src/primitives/alert.tsx +52 -0
- package/src/primitives/badge.tsx +31 -0
- package/src/primitives/button.tsx +47 -0
- package/src/primitives/card.tsx +92 -0
- package/src/primitives/checkbox.tsx +28 -0
- package/src/primitives/dialog.tsx +110 -0
- package/src/primitives/input.tsx +20 -0
- package/src/primitives/label.tsx +18 -0
- package/src/primitives/separator.tsx +7 -0
- package/src/primitives/spinner.tsx +27 -0
- package/src/primitives/switch.tsx +86 -0
- package/src/primitives/table.tsx +27 -0
- package/src/primitives/tabs.tsx +128 -0
- package/src/primitives/textarea.tsx +20 -0
- package/src/primitives/tooltip.tsx +85 -0
- package/src/theme/QueryProvider.tsx +46 -0
- package/src/theme/ThemeProvider.tsx +120 -0
- package/src/theme/ThemeToggle.tsx +88 -0
- package/src/theme/index.ts +3 -0
- package/src/types/react-big-calendar.d.ts +16 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +9 -0
- 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
|
+
}
|