@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/customers/backend/calendar/page.js +9 -1
- package/dist/modules/customers/backend/calendar/page.js.map +2 -2
- package/dist/modules/customers/components/calendar/AgendaList.js +0 -11
- package/dist/modules/customers/components/calendar/AgendaList.js.map +2 -2
- package/dist/modules/customers/components/calendar/CalendarEventEditor.js +391 -235
- package/dist/modules/customers/components/calendar/CalendarEventEditor.js.map +2 -2
- package/dist/modules/customers/components/calendar/CalendarScreen.js +5 -5
- package/dist/modules/customers/components/calendar/CalendarScreen.js.map +2 -2
- package/dist/modules/customers/components/calendar/CalendarSettingsModal.js +2 -2
- package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
- package/dist/modules/customers/components/calendar/MonthGrid.js +1 -11
- package/dist/modules/customers/components/calendar/MonthGrid.js.map +2 -2
- package/dist/modules/customers/components/calendar/TimeGrid.js +162 -176
- package/dist/modules/customers/components/calendar/TimeGrid.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/LocationField.js +61 -14
- package/dist/modules/customers/components/calendar/editor/LocationField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/PeopleField.js +10 -5
- package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/PriorityField.js +85 -0
- package/dist/modules/customers/components/calendar/editor/PriorityField.js.map +7 -0
- package/dist/modules/customers/components/calendar/editor/RelatedToField.js +8 -3
- package/dist/modules/customers/components/calendar/editor/RelatedToField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/ResourcesField.js +225 -0
- package/dist/modules/customers/components/calendar/editor/ResourcesField.js.map +7 -0
- package/dist/modules/customers/components/calendar/editor/SegmentGroup.js +12 -7
- package/dist/modules/customers/components/calendar/editor/SegmentGroup.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/inputs.js +68 -57
- package/dist/modules/customers/components/calendar/editor/inputs.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/lookups.js +82 -2
- package/dist/modules/customers/components/calendar/editor/lookups.js.map +2 -2
- package/dist/modules/customers/components/calendar/useCalendarItems.js +6 -1
- package/dist/modules/customers/components/calendar/useCalendarItems.js.map +2 -2
- package/dist/modules/customers/data/validators.js +3 -1
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/lib/calendar/editorPayload.js +53 -22
- package/dist/modules/customers/lib/calendar/editorPayload.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/page.js +1 -1
- package/dist/modules/entities/backend/entities/user/[entityId]/page.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/customers/backend/calendar/page.tsx +8 -1
- package/src/modules/customers/components/calendar/AgendaList.tsx +0 -10
- package/src/modules/customers/components/calendar/CalendarEventEditor.tsx +444 -261
- package/src/modules/customers/components/calendar/CalendarScreen.tsx +12 -5
- package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +6 -6
- package/src/modules/customers/components/calendar/MonthGrid.tsx +0 -11
- package/src/modules/customers/components/calendar/TimeGrid.tsx +1 -24
- package/src/modules/customers/components/calendar/editor/LocationField.tsx +64 -2
- package/src/modules/customers/components/calendar/editor/PeopleField.tsx +11 -4
- package/src/modules/customers/components/calendar/editor/PriorityField.tsx +90 -0
- package/src/modules/customers/components/calendar/editor/RelatedToField.tsx +8 -3
- package/src/modules/customers/components/calendar/editor/ResourcesField.tsx +264 -0
- package/src/modules/customers/components/calendar/editor/SegmentGroup.tsx +14 -6
- package/src/modules/customers/components/calendar/editor/inputs.tsx +91 -50
- package/src/modules/customers/components/calendar/editor/lookups.ts +115 -2
- package/src/modules/customers/components/calendar/useCalendarItems.ts +8 -1
- package/src/modules/customers/data/validators.ts +3 -1
- package/src/modules/customers/i18n/de.json +12 -2
- package/src/modules/customers/i18n/en.json +12 -2
- package/src/modules/customers/i18n/es.json +12 -2
- package/src/modules/customers/i18n/pl.json +12 -2
- package/src/modules/customers/lib/calendar/editorPayload.ts +94 -37
- package/src/modules/entities/backend/entities/user/[entityId]/page.tsx +1 -1
- package/src/modules/entities/i18n/de.json +120 -0
- package/src/modules/entities/i18n/en.json +120 -0
- package/src/modules/entities/i18n/es.json +120 -0
- package/src/modules/entities/i18n/pl.json +120 -0
- package/dist/modules/customers/components/calendar/editor/CategoryField.js +0 -75
- package/dist/modules/customers/components/calendar/editor/CategoryField.js.map +0 -7
- 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="
|
|
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' ? '
|
|
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
|
|
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.
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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=
|
|
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
|
|
83
|
+
function parseDateValue(value: string): Date | null {
|
|
84
|
+
if (!value) return null
|
|
69
85
|
const parsed = new Date(`${value}T00:00:00`)
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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 })
|