@open-mercato/core 0.6.6-develop.6519.1.bdc91fb755 → 0.6.6-develop.6520.1.55df6df792

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 (70) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customers/backend/calendar/page.js +9 -1
  3. package/dist/modules/customers/backend/calendar/page.js.map +2 -2
  4. package/dist/modules/customers/components/calendar/AgendaList.js +0 -11
  5. package/dist/modules/customers/components/calendar/AgendaList.js.map +2 -2
  6. package/dist/modules/customers/components/calendar/CalendarEventEditor.js +391 -235
  7. package/dist/modules/customers/components/calendar/CalendarEventEditor.js.map +2 -2
  8. package/dist/modules/customers/components/calendar/CalendarScreen.js +5 -5
  9. package/dist/modules/customers/components/calendar/CalendarScreen.js.map +2 -2
  10. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js +2 -2
  11. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
  12. package/dist/modules/customers/components/calendar/MonthGrid.js +1 -11
  13. package/dist/modules/customers/components/calendar/MonthGrid.js.map +2 -2
  14. package/dist/modules/customers/components/calendar/TimeGrid.js +162 -176
  15. package/dist/modules/customers/components/calendar/TimeGrid.js.map +2 -2
  16. package/dist/modules/customers/components/calendar/editor/LocationField.js +61 -14
  17. package/dist/modules/customers/components/calendar/editor/LocationField.js.map +2 -2
  18. package/dist/modules/customers/components/calendar/editor/PeopleField.js +10 -5
  19. package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
  20. package/dist/modules/customers/components/calendar/editor/PriorityField.js +85 -0
  21. package/dist/modules/customers/components/calendar/editor/PriorityField.js.map +7 -0
  22. package/dist/modules/customers/components/calendar/editor/RelatedToField.js +8 -3
  23. package/dist/modules/customers/components/calendar/editor/RelatedToField.js.map +2 -2
  24. package/dist/modules/customers/components/calendar/editor/ResourcesField.js +225 -0
  25. package/dist/modules/customers/components/calendar/editor/ResourcesField.js.map +7 -0
  26. package/dist/modules/customers/components/calendar/editor/SegmentGroup.js +12 -7
  27. package/dist/modules/customers/components/calendar/editor/SegmentGroup.js.map +2 -2
  28. package/dist/modules/customers/components/calendar/editor/inputs.js +68 -57
  29. package/dist/modules/customers/components/calendar/editor/inputs.js.map +2 -2
  30. package/dist/modules/customers/components/calendar/editor/lookups.js +82 -2
  31. package/dist/modules/customers/components/calendar/editor/lookups.js.map +2 -2
  32. package/dist/modules/customers/components/calendar/useCalendarItems.js +6 -1
  33. package/dist/modules/customers/components/calendar/useCalendarItems.js.map +2 -2
  34. package/dist/modules/customers/data/validators.js +3 -1
  35. package/dist/modules/customers/data/validators.js.map +2 -2
  36. package/dist/modules/customers/lib/calendar/editorPayload.js +53 -22
  37. package/dist/modules/customers/lib/calendar/editorPayload.js.map +2 -2
  38. package/dist/modules/entities/backend/entities/user/[entityId]/page.js +1 -1
  39. package/dist/modules/entities/backend/entities/user/[entityId]/page.js.map +2 -2
  40. package/package.json +7 -7
  41. package/src/modules/customers/backend/calendar/page.tsx +8 -1
  42. package/src/modules/customers/components/calendar/AgendaList.tsx +0 -10
  43. package/src/modules/customers/components/calendar/CalendarEventEditor.tsx +444 -261
  44. package/src/modules/customers/components/calendar/CalendarScreen.tsx +12 -5
  45. package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +6 -6
  46. package/src/modules/customers/components/calendar/MonthGrid.tsx +0 -11
  47. package/src/modules/customers/components/calendar/TimeGrid.tsx +1 -24
  48. package/src/modules/customers/components/calendar/editor/LocationField.tsx +64 -2
  49. package/src/modules/customers/components/calendar/editor/PeopleField.tsx +11 -4
  50. package/src/modules/customers/components/calendar/editor/PriorityField.tsx +90 -0
  51. package/src/modules/customers/components/calendar/editor/RelatedToField.tsx +8 -3
  52. package/src/modules/customers/components/calendar/editor/ResourcesField.tsx +264 -0
  53. package/src/modules/customers/components/calendar/editor/SegmentGroup.tsx +14 -6
  54. package/src/modules/customers/components/calendar/editor/inputs.tsx +91 -50
  55. package/src/modules/customers/components/calendar/editor/lookups.ts +115 -2
  56. package/src/modules/customers/components/calendar/useCalendarItems.ts +8 -1
  57. package/src/modules/customers/data/validators.ts +3 -1
  58. package/src/modules/customers/i18n/de.json +12 -2
  59. package/src/modules/customers/i18n/en.json +12 -2
  60. package/src/modules/customers/i18n/es.json +12 -2
  61. package/src/modules/customers/i18n/pl.json +12 -2
  62. package/src/modules/customers/lib/calendar/editorPayload.ts +94 -37
  63. package/src/modules/entities/backend/entities/user/[entityId]/page.tsx +1 -1
  64. package/src/modules/entities/i18n/de.json +120 -0
  65. package/src/modules/entities/i18n/en.json +120 -0
  66. package/src/modules/entities/i18n/es.json +120 -0
  67. package/src/modules/entities/i18n/pl.json +120 -0
  68. package/dist/modules/customers/components/calendar/editor/CategoryField.js +0 -75
  69. package/dist/modules/customers/components/calendar/editor/CategoryField.js.map +0 -7
  70. package/src/modules/customers/components/calendar/editor/CategoryField.tsx +0 -79
@@ -0,0 +1,264 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Box, Check, ChevronDown, Plus } from 'lucide-react'
5
+ import { cn } from '@open-mercato/shared/lib/utils'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { Button } from '@open-mercato/ui/primitives/button'
8
+ import { IconButton } from '@open-mercato/ui/primitives/icon-button'
9
+ import { Input } from '@open-mercato/ui/primitives/input'
10
+ import type { EditorResource } from '../../../lib/calendar/editorPayload'
11
+ import { fetchResourceTypes, searchResourceOptions, type ResourceOption, type ResourceType } from './lookups'
12
+ import { CONTROL_BORDER, DROPDOWN_PANEL_CLASS, useDropdownDismiss } from './inputs'
13
+
14
+ const TYPE_SEARCH_THRESHOLD = 8
15
+
16
+ // Multi-select of bookable resources (rooms, cars, equipment) from the
17
+ // resources module. Rendered only when that module is loaded — the calendar
18
+ // consumes its public list API and stores FK-id + label snapshots in
19
+ // `linkedEntities`, never resource entities (#3552). A potentially large
20
+ // catalog (many resources AND many types) stays navigable with a collapsible
21
+ // resource-type filter (searchable, with counts), server-side search and a
22
+ // "showing N of M" hint.
23
+ export function ResourcesField({
24
+ placeholder,
25
+ ariaLabel,
26
+ value,
27
+ onChange,
28
+ }: {
29
+ placeholder: string
30
+ ariaLabel: string
31
+ value: EditorResource[]
32
+ onChange(next: EditorResource[]): void
33
+ }) {
34
+ const t = useT()
35
+ const [query, setQuery] = React.useState('')
36
+ const [open, setOpen] = React.useState(false)
37
+ const [options, setOptions] = React.useState<ResourceOption[]>([])
38
+ const [total, setTotal] = React.useState(0)
39
+ const [loading, setLoading] = React.useState(false)
40
+ const [types, setTypes] = React.useState<ResourceType[]>([])
41
+ const [typeFilter, setTypeFilter] = React.useState<string | null>(null)
42
+ const [typeMenuOpen, setTypeMenuOpen] = React.useState(false)
43
+ const [typeQuery, setTypeQuery] = React.useState('')
44
+ const close = React.useCallback(() => {
45
+ setOpen(false)
46
+ setTypeMenuOpen(false)
47
+ }, [])
48
+ const rootRef = useDropdownDismiss(open, close)
49
+
50
+ // Load the type filter once the dropdown first opens.
51
+ React.useEffect(() => {
52
+ if (!open || types.length > 0) return
53
+ const controller = new AbortController()
54
+ let cancelled = false
55
+ fetchResourceTypes(controller.signal).then((found) => {
56
+ if (!cancelled) setTypes(found)
57
+ })
58
+ return () => {
59
+ cancelled = true
60
+ controller.abort()
61
+ }
62
+ }, [open, types.length])
63
+
64
+ React.useEffect(() => {
65
+ if (!open) return
66
+ const controller = new AbortController()
67
+ let cancelled = false
68
+ const timer = window.setTimeout(async () => {
69
+ setLoading(true)
70
+ try {
71
+ const result = await searchResourceOptions({ query: query.trim(), resourceTypeId: typeFilter }, controller.signal)
72
+ if (cancelled) return
73
+ setOptions(result.items)
74
+ setTotal(result.total)
75
+ } catch {
76
+ if (!cancelled) {
77
+ setOptions([])
78
+ setTotal(0)
79
+ }
80
+ } finally {
81
+ if (!cancelled) setLoading(false)
82
+ }
83
+ }, 250)
84
+ return () => {
85
+ cancelled = true
86
+ controller.abort()
87
+ window.clearTimeout(timer)
88
+ }
89
+ }, [open, query, typeFilter])
90
+
91
+ const selectedIds = new Set(value.map((resource) => resource.id))
92
+ const visibleOptions = options.filter((option) => !selectedIds.has(option.id))
93
+ const hiddenCount = Math.max(0, total - options.length)
94
+ const activeType = typeFilter ? types.find((type) => type.id === typeFilter) ?? null : null
95
+ const filteredTypes = typeQuery.trim().length
96
+ ? types.filter((type) => type.name.toLowerCase().includes(typeQuery.trim().toLowerCase()))
97
+ : types
98
+
99
+ const selectType = (id: string | null) => {
100
+ setTypeFilter(id)
101
+ setTypeMenuOpen(false)
102
+ setTypeQuery('')
103
+ }
104
+
105
+ return (
106
+ <div
107
+ ref={rootRef}
108
+ className="relative w-full"
109
+ onBlur={(event) => {
110
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) close()
111
+ }}
112
+ >
113
+ <div
114
+ className={cn(
115
+ 'flex min-h-14 w-full flex-wrap content-center items-center gap-2 rounded-md bg-background px-2.5 py-2',
116
+ CONTROL_BORDER,
117
+ )}
118
+ >
119
+ {value.map((resource) => (
120
+ <span key={resource.id} className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-muted py-1 pl-2 pr-2">
121
+ <Box aria-hidden className="size-3.5 text-muted-foreground" />
122
+ <span className="max-w-40 truncate text-xs font-medium text-foreground">{resource.label}</span>
123
+ <IconButton
124
+ variant="ghost"
125
+ size="xs"
126
+ onClick={(event) => {
127
+ event.stopPropagation()
128
+ onChange(value.filter((entry) => entry.id !== resource.id))
129
+ }}
130
+ aria-label={t('customers.calendar.editor.removeResource', 'Remove {name}', { name: resource.label })}
131
+ className="size-5 shrink-0"
132
+ >
133
+ <Plus aria-hidden className="size-3.5 rotate-45 opacity-50" />
134
+ </IconButton>
135
+ </span>
136
+ ))}
137
+ <Input
138
+ type="text"
139
+ value={query}
140
+ onChange={(event) => setQuery(event.target.value)}
141
+ onFocus={() => setOpen(true)}
142
+ placeholder={value.length > 0 ? '' : placeholder}
143
+ aria-label={ariaLabel}
144
+ role="combobox"
145
+ aria-expanded={open}
146
+ className="min-w-36 flex-1 border-0 bg-transparent px-0 shadow-none hover:bg-transparent focus-within:border-transparent focus-within:shadow-none"
147
+ inputClassName="text-sm text-foreground placeholder:text-muted-foreground"
148
+ />
149
+ </div>
150
+ {open ? (
151
+ <div className={cn(DROPDOWN_PANEL_CLASS, 'flex max-h-none flex-col overflow-visible p-0')}>
152
+ {types.length > 0 ? (
153
+ <div className="border-b border-border p-1.5">
154
+ <Button
155
+ type="button"
156
+ variant="outline"
157
+ aria-haspopup="listbox"
158
+ aria-expanded={typeMenuOpen}
159
+ onClick={() => setTypeMenuOpen((previous) => !previous)}
160
+ className={cn('h-8 w-full justify-between bg-background px-2 text-sm font-normal shadow-none', CONTROL_BORDER)}
161
+ >
162
+ <span className="truncate">
163
+ {activeType
164
+ ? `${activeType.name} (${activeType.count})`
165
+ : t('customers.calendar.editor.resourceTypes.all', 'All types')}
166
+ </span>
167
+ <ChevronDown aria-hidden className="size-4 shrink-0 opacity-60" />
168
+ </Button>
169
+ </div>
170
+ ) : null}
171
+
172
+ {typeMenuOpen ? (
173
+ <div role="listbox" aria-label={t('customers.calendar.editor.resourceTypes.filterLabel', 'Resource type')} className="flex flex-col">
174
+ {types.length > TYPE_SEARCH_THRESHOLD ? (
175
+ <div className="border-b border-border p-1.5">
176
+ <Input
177
+ type="text"
178
+ value={typeQuery}
179
+ onChange={(event) => setTypeQuery(event.target.value)}
180
+ placeholder={t('customers.calendar.editor.resourceTypes.searchPlaceholder', 'Search types…')}
181
+ aria-label={t('customers.calendar.editor.resourceTypes.searchPlaceholder', 'Search types…')}
182
+ autoFocus
183
+ className="h-8"
184
+ />
185
+ </div>
186
+ ) : null}
187
+ <div className="max-h-56 overflow-y-auto p-1">
188
+ <TypeRow label={t('customers.calendar.editor.resourceTypes.all', 'All types')} active={typeFilter === null} onSelect={() => selectType(null)} />
189
+ {filteredTypes.map((type) => (
190
+ <TypeRow key={type.id} label={type.name} count={type.count} active={typeFilter === type.id} onSelect={() => selectType(type.id)} />
191
+ ))}
192
+ {filteredTypes.length === 0 ? (
193
+ <p className="px-2 py-3 text-center text-xs text-muted-foreground">
194
+ {t('customers.calendar.editor.noResults', 'No results')}
195
+ </p>
196
+ ) : null}
197
+ </div>
198
+ </div>
199
+ ) : (
200
+ <>
201
+ <div role="listbox" aria-label={ariaLabel} className="max-h-56 overflow-y-auto p-1">
202
+ {loading ? (
203
+ <p className="px-2 py-3 text-center text-xs text-muted-foreground">
204
+ {t('customers.calendar.editor.searching', 'Searching…')}
205
+ </p>
206
+ ) : null}
207
+ {!loading && visibleOptions.length === 0 ? (
208
+ <p className="px-2 py-3 text-center text-xs text-muted-foreground">
209
+ {t('customers.calendar.editor.noResults', 'No results')}
210
+ </p>
211
+ ) : null}
212
+ {!loading
213
+ ? visibleOptions.map((option) => (
214
+ <Button
215
+ key={option.id}
216
+ type="button"
217
+ variant="ghost"
218
+ role="option"
219
+ aria-selected={false}
220
+ title={option.label}
221
+ onClick={() => {
222
+ onChange([...value, { id: option.id, label: option.label }])
223
+ setQuery('')
224
+ }}
225
+ className="h-auto w-full justify-start gap-2 whitespace-normal px-2 py-1.5 text-left text-sm font-normal text-foreground"
226
+ >
227
+ <Box aria-hidden className="size-4 text-muted-foreground" />
228
+ <span className="min-w-0 flex-1 truncate">{option.label}</span>
229
+ </Button>
230
+ ))
231
+ : null}
232
+ </div>
233
+ {!loading && hiddenCount > 0 ? (
234
+ <p className="border-t border-border px-2 py-1.5 text-center text-xs text-muted-foreground">
235
+ {t('customers.calendar.editor.resourceTypes.moreHint', 'Showing {shown} of {total} — keep typing to narrow', {
236
+ shown: options.length,
237
+ total,
238
+ })}
239
+ </p>
240
+ ) : null}
241
+ </>
242
+ )}
243
+ </div>
244
+ ) : null}
245
+ </div>
246
+ )
247
+ }
248
+
249
+ function TypeRow({ label, count, active, onSelect }: { label: string; count?: number; active: boolean; onSelect(): void }) {
250
+ return (
251
+ <Button
252
+ type="button"
253
+ variant="ghost"
254
+ role="option"
255
+ aria-selected={active}
256
+ onClick={onSelect}
257
+ className={cn('h-auto w-full justify-start gap-2 px-2 py-1.5 text-left text-sm font-normal', active ? 'bg-muted text-foreground' : 'text-foreground')}
258
+ >
259
+ <Check aria-hidden className={cn('size-4 shrink-0', active ? 'opacity-100' : 'opacity-0')} />
260
+ <span className="min-w-0 flex-1 truncate">{label}</span>
261
+ {typeof count === 'number' ? <span className="shrink-0 tabular-nums text-xs text-muted-foreground">{count}</span> : null}
262
+ </Button>
263
+ )
264
+ }
@@ -4,7 +4,7 @@ import * as React from 'react'
4
4
  import { cn } from '@open-mercato/shared/lib/utils'
5
5
  import { Button } from '@open-mercato/ui/primitives/button'
6
6
 
7
- export type SegmentOption<T extends string> = { value: T; label: string }
7
+ export type SegmentOption<T extends string> = { value: T; label: string; icon?: React.ReactNode }
8
8
 
9
9
  export function SegmentGroup<T extends string>({
10
10
  options,
@@ -23,7 +23,7 @@ export function SegmentGroup<T extends string>({
23
23
  <div
24
24
  role="group"
25
25
  aria-label={ariaLabel}
26
- className="inline-flex max-w-full items-start overflow-x-auto rounded-md border border-border bg-background"
26
+ className="flex w-full items-stretch overflow-hidden rounded-md border border-border bg-background"
27
27
  >
28
28
  {options.map((option, index) => {
29
29
  const isActive = option.value === value
@@ -34,14 +34,22 @@ export function SegmentGroup<T extends string>({
34
34
  variant="ghost"
35
35
  aria-pressed={isActive}
36
36
  onClick={() => onChange(option.value)}
37
+ title={option.label}
38
+ data-state={isActive ? 'active' : 'inactive'}
37
39
  className={cn(
38
- 'h-auto rounded-none border-0 text-sm font-medium leading-5 shadow-none',
39
- size === 'md' ? 'px-4 py-2' : 'px-3.5 py-1.5',
40
+ 'h-auto min-w-0 flex-1 justify-center gap-1 rounded-none border-0 px-1.5 text-sm font-medium leading-5 shadow-none',
41
+ size === 'md' ? 'py-2' : 'py-1.5',
40
42
  index > 0 && 'border-l border-border',
41
- isActive ? 'bg-muted text-foreground hover:bg-muted' : 'bg-background text-muted-foreground',
43
+ isActive
44
+ // Keep the indigo fill on hover in BOTH themes — the ghost Button
45
+ // ships `dark:hover:bg-accent/50`, which would otherwise wash the
46
+ // active segment out on hover in dark mode.
47
+ ? 'bg-accent-indigo font-semibold text-accent-indigo-foreground hover:bg-accent-indigo hover:text-accent-indigo-foreground dark:hover:bg-accent-indigo'
48
+ : 'bg-background text-muted-foreground hover:bg-muted hover:text-foreground',
42
49
  )}
43
50
  >
44
- {option.label}
51
+ {option.icon ? <span aria-hidden className={cn('shrink-0', isActive ? 'opacity-100' : 'opacity-70')}>{option.icon}</span> : null}
52
+ <span className="truncate">{option.label}</span>
45
53
  </Button>
46
54
  )
47
55
  })}
@@ -1,11 +1,12 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
- import { ChevronDown, Plus } from 'lucide-react'
4
+ import { Plus } from 'lucide-react'
5
5
  import { cn } from '@open-mercato/shared/lib/utils'
6
6
  import { Avatar } from '@open-mercato/ui/primitives/avatar'
7
+ import { DatePicker } from '@open-mercato/ui/primitives/date-picker'
7
8
  import { IconButton } from '@open-mercato/ui/primitives/icon-button'
8
- import { Input } from '@open-mercato/ui/primitives/input'
9
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@open-mercato/ui/primitives/select'
9
10
  import { Switch } from '@open-mercato/ui/primitives/switch'
10
11
 
11
12
  export const CONTROL_BORDER = 'border border-input'
@@ -14,9 +15,39 @@ export const LABEL_CLASS = 'text-xs font-medium text-muted-foreground'
14
15
  export const DROPDOWN_PANEL_CLASS =
15
16
  'absolute z-50 mt-1 max-h-56 w-full overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md'
16
17
 
17
- export function Field({ label, children, error }: { label: string; children: React.ReactNode; error?: string | null }) {
18
+ /**
19
+ * Closes an editor dropdown on pointer-down outside the component or Escape.
20
+ * Blur alone is unreliable inside the Radix dialog focus trap (#3552 feedback:
21
+ * the Resources list stayed open), so dismissal listens on the document while
22
+ * the dropdown is open. Returns the ref to attach to the component root.
23
+ */
24
+ export function useDropdownDismiss(open: boolean, onClose: () => void): React.RefObject<HTMLDivElement | null> {
25
+ const rootRef = React.useRef<HTMLDivElement | null>(null)
26
+ React.useEffect(() => {
27
+ if (!open) return
28
+ const handlePointerDown = (event: PointerEvent) => {
29
+ if (rootRef.current && event.target instanceof Node && !rootRef.current.contains(event.target)) onClose()
30
+ }
31
+ const handleKeyDown = (event: KeyboardEvent) => {
32
+ if (event.key === 'Escape') {
33
+ // Swallow Escape so the dialog itself stays open.
34
+ event.stopPropagation()
35
+ onClose()
36
+ }
37
+ }
38
+ document.addEventListener('pointerdown', handlePointerDown, true)
39
+ document.addEventListener('keydown', handleKeyDown, true)
40
+ return () => {
41
+ document.removeEventListener('pointerdown', handlePointerDown, true)
42
+ document.removeEventListener('keydown', handleKeyDown, true)
43
+ }
44
+ }, [open, onClose])
45
+ return rootRef
46
+ }
47
+
48
+ export function Field({ label, children, error, className }: { label: string; children: React.ReactNode; error?: string | null; className?: string }) {
18
49
  return (
19
- <div className="flex w-full flex-col gap-1.5">
50
+ <div className={cn('flex w-full flex-col gap-1.5', className)}>
20
51
  <span className={LABEL_CLASS}>{label}</span>
21
52
  {children}
22
53
  {error ? <p className="text-xs text-status-error-text">{error}</p> : null}
@@ -38,22 +69,6 @@ export function UppercaseBadge({ style, className, children }: { style?: React.C
38
69
  )
39
70
  }
40
71
 
41
- // Mockup tint colors are illustrative; runtime pills derive from the
42
- // dictionary entry color using the appearanceColor hex-alpha tint technique
43
- // (see EventBlock.softTintStyle) with a darkened text shade of the same hue.
44
- export function categoryPillStyle(color: string | null | undefined): React.CSSProperties {
45
- if (typeof color === 'string' && /^#[0-9a-fA-F]{6}$/.test(color)) {
46
- return { backgroundColor: `${color}59`, color: `color-mix(in srgb, ${color} 55%, black)` }
47
- }
48
- if (typeof color === 'string' && color.length > 0) {
49
- return {
50
- backgroundColor: `color-mix(in srgb, ${color} 35%, white)`,
51
- color: `color-mix(in srgb, ${color} 55%, black)`,
52
- }
53
- }
54
- return {}
55
- }
56
-
57
72
  export function AllDayToggle({ checked, onCheckedChange, label }: { checked: boolean; onCheckedChange(next: boolean): void; label: string }) {
58
73
  return (
59
74
  <Switch
@@ -65,17 +80,37 @@ export function AllDayToggle({ checked, onCheckedChange, label }: { checked: boo
65
80
  )
66
81
  }
67
82
 
68
- function formatDisplayDate(value: string, locale: string): string {
83
+ function parseDateValue(value: string): Date | null {
84
+ if (!value) return null
69
85
  const parsed = new Date(`${value}T00:00:00`)
70
- if (Number.isNaN(parsed.getTime())) return value
71
- return new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }).format(parsed)
86
+ return Number.isNaN(parsed.getTime()) ? null : parsed
87
+ }
88
+
89
+ function formatDateValue(date: Date): string {
90
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
91
+ }
92
+
93
+ // Close the field's popover when the surrounding editor dialog scrolls, so a
94
+ // portalled DS popover (DatePicker/Select) doesn't float over the form or drift
95
+ // from its field (#3747 feedback). Radix ignores synthetic dismiss events, so
96
+ // this drives the popover's controlled `open` state instead. The editor
97
+ // dispatches `EDITOR_SCROLL_EVENT` on scroll.
98
+ export const EDITOR_SCROLL_EVENT = 'om-calendar-editor-scroll'
99
+ function useCloseOnEditorScroll(setOpen: (open: boolean) => void) {
100
+ React.useEffect(() => {
101
+ const handler = () => setOpen(false)
102
+ document.addEventListener(EDITOR_SCROLL_EVENT, handler)
103
+ return () => document.removeEventListener(EDITOR_SCROLL_EVENT, handler)
104
+ }, [setOpen])
72
105
  }
73
106
 
107
+ // DS DatePicker (calendar popover) + DS Select (scrolling 30-min list) replace
108
+ // the native `<input type=date|time>` overlays, which only opened via the
109
+ // browser chevron and did not match the design system (#3747 feedback).
74
110
  export function DateControl({
75
111
  value,
76
112
  onChange,
77
113
  ariaLabel,
78
- locale,
79
114
  className,
80
115
  }: {
81
116
  value: string
@@ -84,38 +119,44 @@ export function DateControl({
84
119
  locale: string
85
120
  className?: string
86
121
  }) {
122
+ const [open, setOpen] = React.useState(false)
123
+ useCloseOnEditorScroll(setOpen)
87
124
  return (
88
- <div
89
- className={cn(
90
- 'relative flex h-9 w-full items-center justify-between rounded-md bg-background px-3',
91
- CONTROL_BORDER,
92
- className,
93
- )}
94
- >
95
- <span className="truncate text-sm text-foreground">{formatDisplayDate(value, locale)}</span>
96
- <span aria-hidden className="h-px w-2 shrink-0" />
97
- <ChevronDown aria-hidden className="size-4 shrink-0 opacity-60" />
98
- <Input
99
- type="date"
100
- value={value}
101
- onChange={(event) => { if (event.target.value) onChange(event.target.value) }}
102
- aria-label={ariaLabel}
103
- className="absolute inset-0 h-full w-full cursor-pointer border-0 bg-transparent p-0 opacity-0 shadow-none hover:bg-transparent"
104
- inputClassName="h-full cursor-pointer"
105
- />
106
- </div>
125
+ <DatePicker
126
+ value={parseDateValue(value)}
127
+ onChange={(date) => { if (date) onChange(formatDateValue(date)) }}
128
+ footer="none"
129
+ open={open}
130
+ onOpenChange={setOpen}
131
+ aria-label={ariaLabel}
132
+ className={cn('w-full', className)}
133
+ />
107
134
  )
108
135
  }
109
136
 
137
+ // 24h times at 30-minute steps.
138
+ const TIME_OPTIONS: string[] = Array.from({ length: 48 }, (_, index) => {
139
+ const hour = Math.floor(index / 2)
140
+ const minute = index % 2 === 0 ? '00' : '30'
141
+ return `${String(hour).padStart(2, '0')}:${minute}`
142
+ })
143
+
110
144
  export function TimeControl({ value, onChange, ariaLabel }: { value: string; onChange(next: string): void; ariaLabel: string }) {
145
+ const [open, setOpen] = React.useState(false)
146
+ useCloseOnEditorScroll(setOpen)
147
+ // Keep an off-grid value (e.g. an imported 22:15) selectable.
148
+ const options = value && !TIME_OPTIONS.includes(value) ? [value, ...TIME_OPTIONS] : TIME_OPTIONS
111
149
  return (
112
- <Input
113
- type="time"
114
- value={value}
115
- onChange={(event) => { if (event.target.value) onChange(event.target.value) }}
116
- aria-label={ariaLabel}
117
- className="h-9 w-32 shrink-0"
118
- />
150
+ <Select value={value} onValueChange={onChange} open={open} onOpenChange={setOpen}>
151
+ <SelectTrigger aria-label={ariaLabel} className="h-9 w-32 shrink-0">
152
+ <SelectValue />
153
+ </SelectTrigger>
154
+ <SelectContent className="max-h-64">
155
+ {options.map((time) => (
156
+ <SelectItem key={time} value={time}>{time}</SelectItem>
157
+ ))}
158
+ </SelectContent>
159
+ </Select>
119
160
  )
120
161
  }
121
162
 
@@ -126,10 +126,15 @@ export async function fetchDealById(dealId: string, signal: AbortSignal): Promis
126
126
 
127
127
  export async function searchPeopleOptions(
128
128
  query: string,
129
- options: { includeCustomers: boolean; signal: AbortSignal },
129
+ options: { includeCustomers: boolean; includeStaff?: boolean; signal: AbortSignal },
130
130
  ): Promise<PersonOption[]> {
131
+ // Staff lookup is optional twice over: skipped when the staff module is not
132
+ // loaded (includeStaff === false) and non-fatal when the endpoint errors, so
133
+ // the people picker always degrades to customer-only options (#3552).
131
134
  const [staff, customers] = await Promise.all([
132
- fetchAssignableStaffMembers(query, { pageSize: 10, signal: options.signal }),
135
+ options.includeStaff === false
136
+ ? Promise.resolve<Awaited<ReturnType<typeof fetchAssignableStaffMembers>>>([])
137
+ : fetchAssignableStaffMembers(query, { pageSize: 10, signal: options.signal }).catch(() => []),
133
138
  options.includeCustomers
134
139
  ? searchRelatedEntities('person', query, options.signal)
135
140
  : Promise.resolve<RelatedEntityOption[]>([]),
@@ -149,6 +154,114 @@ export async function searchPeopleOptions(
149
154
  return [...staffOptions, ...customerOptions]
150
155
  }
151
156
 
157
+ export type ResourceOption = {
158
+ id: string
159
+ label: string
160
+ }
161
+
162
+ // Consumed only when the resources module is loaded (server flag from the
163
+ // calendar page); reads the resources CRUD list API — never resource entities.
164
+ export type ResourceType = { id: string; name: string; count: number }
165
+
166
+ export type ResourceSearchResult = { items: ResourceOption[]; total: number }
167
+
168
+ const RESOURCE_PAGE_SIZE = 20
169
+
170
+ // Server-side resource search, optionally scoped to one resource type. Returns
171
+ // the (page-limited) options plus the full match count so the UI can show a
172
+ // "showing N of M" hint and nudge the user to narrow a large catalog (#3552).
173
+ export async function searchResourceOptions(
174
+ params: { query: string; resourceTypeId?: string | null },
175
+ signal: AbortSignal,
176
+ ): Promise<ResourceSearchResult> {
177
+ const search = new URLSearchParams({
178
+ page: '1',
179
+ pageSize: String(RESOURCE_PAGE_SIZE),
180
+ sortField: 'name',
181
+ sortDir: 'asc',
182
+ isActive: 'true',
183
+ })
184
+ if (params.query.length) search.set('search', params.query)
185
+ if (params.resourceTypeId) search.set('resourceTypeId', params.resourceTypeId)
186
+ const data = await readApiResultOrThrow<{ items?: Array<Record<string, unknown>>; total?: number }>(
187
+ `/api/resources/resources?${search.toString()}`,
188
+ { signal },
189
+ )
190
+ const rows = Array.isArray(data?.items) ? data.items : []
191
+ const items = rows
192
+ .map((item): ResourceOption | null => {
193
+ if (!item || typeof item !== 'object') return null
194
+ const id = typeof item.id === 'string' ? item.id : null
195
+ if (!id) return null
196
+ const label = readNonEmptyString(item.name) ?? id
197
+ return { id, label }
198
+ })
199
+ .filter((option): option is ResourceOption => option !== null)
200
+ const total = typeof data?.total === 'number' && Number.isFinite(data.total) ? data.total : items.length
201
+ return { items, total }
202
+ }
203
+
204
+ // Resource types with their resource counts, for the dropdown's type filter.
205
+ export async function fetchResourceTypes(signal: AbortSignal): Promise<ResourceType[]> {
206
+ const params = new URLSearchParams({
207
+ page: '1',
208
+ pageSize: '100',
209
+ sortField: 'name',
210
+ sortDir: 'asc',
211
+ withResourceCounts: 'true',
212
+ })
213
+ try {
214
+ const data = await readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(
215
+ `/api/resources/resource-types?${params.toString()}`,
216
+ { signal },
217
+ )
218
+ const rows = Array.isArray(data?.items) ? data.items : []
219
+ return rows
220
+ .map((item): ResourceType | null => {
221
+ if (!item || typeof item !== 'object') return null
222
+ const id = typeof item.id === 'string' ? item.id : null
223
+ if (!id) return null
224
+ const name = readNonEmptyString(item.name) ?? id
225
+ const rawCount = (item as Record<string, unknown>).resourceCount
226
+ const count = typeof rawCount === 'number' && Number.isFinite(rawCount) ? rawCount : 0
227
+ return { id, name, count }
228
+ })
229
+ .filter((type): type is ResourceType => type !== null)
230
+ } catch {
231
+ return []
232
+ }
233
+ }
234
+
235
+ export type ContactPhone = { id: string; name: string; phone: string }
236
+
237
+ // Resolves primary phone numbers for the given person ids (companies/staff have
238
+ // none and simply drop out — the people query only matches people). Used by the
239
+ // Call editor's "insert phone from contact" button (#3552).
240
+ export async function fetchPeoplePhones(ids: string[], signal: AbortSignal): Promise<ContactPhone[]> {
241
+ const unique = Array.from(new Set(ids.filter((id) => typeof id === 'string' && id.length > 0)))
242
+ if (unique.length === 0) return []
243
+ const params = new URLSearchParams({ page: '1', pageSize: '100', ids: unique.join(',') })
244
+ try {
245
+ const data = await readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(
246
+ `/api/customers/people?${params.toString()}`,
247
+ { signal },
248
+ )
249
+ const items = Array.isArray(data?.items) ? data.items : []
250
+ const result: ContactPhone[] = []
251
+ for (const item of items) {
252
+ if (!item || typeof item !== 'object') continue
253
+ const id = typeof item.id === 'string' ? item.id : null
254
+ const phone = readNonEmptyString(item.primaryPhone) ?? readNonEmptyString(item.primary_phone)
255
+ if (!id || !phone) continue
256
+ const name = readNonEmptyString(item.displayName) ?? readNonEmptyString(item.display_name) ?? id
257
+ result.push({ id, name, phone })
258
+ }
259
+ return result
260
+ } catch {
261
+ return []
262
+ }
263
+ }
264
+
152
265
  export async function findStaffMemberName(userId: string, signal: AbortSignal): Promise<string | null> {
153
266
  try {
154
267
  const members = await fetchAssignableStaffMembers('', { pageSize: 100, signal })