@traky-app/ui 1.4.0 → 1.6.0

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.
@@ -0,0 +1,307 @@
1
+ "use client"
2
+
3
+ import { useState, type ComponentType } from "react"
4
+ import { IconCheck, IconSelector } from "@tabler/icons-react"
5
+ import { Button } from "@/components/ui/button"
6
+ import {
7
+ Command,
8
+ CommandEmpty,
9
+ CommandGroup,
10
+ CommandInput,
11
+ CommandItem,
12
+ CommandList,
13
+ } from "@/components/ui/command"
14
+ import {
15
+ Popover,
16
+ PopoverContent,
17
+ PopoverTrigger,
18
+ } from "@/components/ui/popover"
19
+ import { cn, commandFilterAccentInsensitive } from "@/lib/utils"
20
+
21
+ export type OptionComboboxOption = {
22
+ value: string
23
+ label: string
24
+ icon?: ComponentType<{ className?: string; stroke?: number }>
25
+ iconClassName?: string
26
+ /** Pastilla de color, por ejemplo una etiqueta. */
27
+ color?: string
28
+ /** Sangría para una opción hija. */
29
+ indented?: boolean
30
+ }
31
+
32
+ function OptionGlyph({
33
+ icon: Icon,
34
+ className,
35
+ }: {
36
+ icon?: OptionComboboxOption["icon"]
37
+ className?: string
38
+ }) {
39
+ if (!Icon) return null
40
+ return <Icon className={cn("size-4 shrink-0", className)} stroke={1.75} />
41
+ }
42
+
43
+ function OptionFace({ option }: { option: OptionComboboxOption }) {
44
+ if (option.color) {
45
+ return (
46
+ <span
47
+ className="inline-flex max-w-full items-center truncate rounded-full border px-2 py-0.5 text-xs font-medium"
48
+ style={{
49
+ color: option.color,
50
+ borderColor: option.color,
51
+ backgroundColor: `${option.color}1A`,
52
+ }}
53
+ >
54
+ {option.label}
55
+ </span>
56
+ )
57
+ }
58
+ return (
59
+ <span className="flex min-w-0 items-center gap-2">
60
+ <OptionGlyph icon={option.icon} className={option.iconClassName} />
61
+ <span className="truncate">{option.label}</span>
62
+ </span>
63
+ )
64
+ }
65
+
66
+ export function OptionCombobox({
67
+ value = "",
68
+ options,
69
+ onChange,
70
+ placeholder = "Seleccionar…",
71
+ searchPlaceholder = "Buscar…",
72
+ emptyLabel = "No se encontraron resultados.",
73
+ groupHeading,
74
+ allowEmpty = false,
75
+ emptyValueLabel,
76
+ emptyValueIcon,
77
+ clearLabel,
78
+ clearOption,
79
+ allOption,
80
+ groups,
81
+ multiple = false,
82
+ values = [],
83
+ onValuesChange,
84
+ className,
85
+ disabled = false,
86
+ modal = false,
87
+ title,
88
+ }: {
89
+ value?: string
90
+ options: OptionComboboxOption[]
91
+ onChange?: (value: string) => void
92
+ /** Varias opciones a la vez. El menú no se cierra al marcar. */
93
+ multiple?: boolean
94
+ values?: string[]
95
+ onValuesChange?: (values: string[]) => void
96
+ placeholder?: string
97
+ searchPlaceholder?: string
98
+ emptyLabel?: string
99
+ groupHeading?: string
100
+ /** Si es true, la primera fila deja el valor vacío y no pertenece a `options`. */
101
+ allowEmpty?: boolean
102
+ /** Texto de esa fila vacía. Si no se indica, usa `placeholder`. */
103
+ emptyValueLabel?: string
104
+ emptyValueIcon?: OptionComboboxOption["icon"]
105
+ /** Opción para vaciar el valor. El botón sigue mostrando `placeholder`. */
106
+ clearLabel?: string
107
+ /** Opción para vaciar el valor, visible también en el botón. */
108
+ clearOption?: OptionComboboxOption
109
+ /** Opción extra al inicio. Si no hay valor, el botón la muestra. */
110
+ allOption?: OptionComboboxOption
111
+ /** Varios grupos, por ejemplo persona y empresa. */
112
+ groups?: { heading?: string; options: OptionComboboxOption[] }[]
113
+ className?: string
114
+ disabled?: boolean
115
+ modal?: boolean
116
+ title?: string
117
+ }) {
118
+ const [open, setOpen] = useState(false)
119
+ const blankLabel = emptyValueLabel || placeholder
120
+ const realOptions = allowEmpty
121
+ ? options.filter((option) => option.value !== "")
122
+ : options
123
+ const grouped = (groups ?? []).filter((group) => group.options.length > 0)
124
+ const catalog = grouped.length
125
+ ? grouped.flatMap((group) => group.options)
126
+ : realOptions
127
+ const listOptions = allOption ? [allOption, ...catalog] : catalog
128
+ const emptyChoice: OptionComboboxOption = {
129
+ value: "",
130
+ label: blankLabel,
131
+ icon: emptyValueIcon,
132
+ }
133
+ const selected = allowEmpty && value === ""
134
+ ? emptyChoice
135
+ : clearOption && value === ""
136
+ ? clearOption
137
+ : listOptions.find((option) => option.value === value)
138
+ ?? (!value ? allOption : undefined)
139
+ const selectedMany = multiple
140
+ ? values.flatMap((id) => {
141
+ const option = listOptions.find((item) => item.value === id)
142
+ return option ? [option] : []
143
+ })
144
+ : []
145
+
146
+ const chosen = (optionValue: string) =>
147
+ multiple ? values.includes(optionValue) : value === optionValue
148
+
149
+ const choose = (next: string) => {
150
+ if (multiple) {
151
+ onValuesChange?.(
152
+ values.includes(next)
153
+ ? values.filter((item) => item !== next)
154
+ : [...values, next],
155
+ )
156
+ return
157
+ }
158
+ onChange?.(next)
159
+ setOpen(false)
160
+ }
161
+
162
+ return (
163
+ <Popover
164
+ open={open}
165
+ onOpenChange={disabled ? undefined : setOpen}
166
+ modal={modal}
167
+ >
168
+ <PopoverTrigger asChild>
169
+ <Button
170
+ type="button"
171
+ variant="outline"
172
+ role="combobox"
173
+ aria-expanded={open}
174
+ disabled={disabled}
175
+ title={title}
176
+ className={cn(
177
+ "w-full justify-between font-normal",
178
+ multiple && "h-auto py-2",
179
+ className,
180
+ )}
181
+ >
182
+ {multiple ? (
183
+ <span className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
184
+ {selectedMany.length ? (
185
+ selectedMany.map((option) => (
186
+ <OptionFace key={option.value} option={option} />
187
+ ))
188
+ ) : (
189
+ <span className="truncate text-muted-foreground">
190
+ {placeholder}
191
+ </span>
192
+ )}
193
+ </span>
194
+ ) : selected ? (
195
+ <OptionFace option={selected} />
196
+ ) : (
197
+ <span className="truncate text-muted-foreground">{placeholder}</span>
198
+ )}
199
+ <IconSelector className="size-4 shrink-0 opacity-50" />
200
+ </Button>
201
+ </PopoverTrigger>
202
+ {disabled ? null : (
203
+ <PopoverContent
204
+ align="start"
205
+ className="z-[60] w-[var(--radix-popover-trigger-width)] min-w-56 p-0"
206
+ >
207
+ <Command filter={commandFilterAccentInsensitive}>
208
+ <CommandInput placeholder={searchPlaceholder} />
209
+ <CommandList>
210
+ <CommandEmpty>{emptyLabel}</CommandEmpty>
211
+ {grouped.length ? (
212
+ grouped.map((group) => (
213
+ <CommandGroup
214
+ key={group.heading ?? group.options[0]?.value}
215
+ heading={group.heading}
216
+ >
217
+ {group.options.map((option) => (
218
+ <CommandItem
219
+ key={option.value || option.label}
220
+ value={`${option.label} ${option.value}`}
221
+ className={option.indented ? "pl-6" : undefined}
222
+ onSelect={() => choose(option.value)}
223
+ >
224
+ <OptionFace option={option} />
225
+ <IconCheck
226
+ className={cn(
227
+ "ml-auto size-4",
228
+ chosen(option.value) ? "opacity-100" : "opacity-0",
229
+ )}
230
+ />
231
+ </CommandItem>
232
+ ))}
233
+ </CommandGroup>
234
+ ))
235
+ ) : (
236
+ <CommandGroup heading={groupHeading}>
237
+ {allowEmpty ? (
238
+ <CommandItem
239
+ value={`__empty__ ${blankLabel}`}
240
+ onSelect={() => choose("")}
241
+ >
242
+ <OptionGlyph icon={emptyValueIcon} />
243
+ {blankLabel}
244
+ <IconCheck
245
+ className={cn(
246
+ "ml-auto size-4",
247
+ value ? "opacity-0" : "opacity-100",
248
+ )}
249
+ />
250
+ </CommandItem>
251
+ ) : null}
252
+ {clearOption ? (
253
+ <CommandItem
254
+ value={`__clear__ ${clearOption.label}`}
255
+ onSelect={() => choose("")}
256
+ >
257
+ <OptionGlyph
258
+ icon={clearOption.icon}
259
+ className={clearOption.iconClassName}
260
+ />
261
+ {clearOption.label}
262
+ <IconCheck
263
+ className={cn(
264
+ "ml-auto size-4",
265
+ value ? "opacity-0" : "opacity-100",
266
+ )}
267
+ />
268
+ </CommandItem>
269
+ ) : clearLabel ? (
270
+ <CommandItem
271
+ value={`__clear__ ${clearLabel}`}
272
+ onSelect={() => choose("")}
273
+ >
274
+ {clearLabel}
275
+ <IconCheck
276
+ className={cn(
277
+ "ml-auto size-4",
278
+ value ? "opacity-0" : "opacity-100",
279
+ )}
280
+ />
281
+ </CommandItem>
282
+ ) : null}
283
+ {listOptions.map((option) => (
284
+ <CommandItem
285
+ key={option.value || option.label}
286
+ value={`${option.label} ${option.value}`}
287
+ className={option.indented ? "pl-6" : undefined}
288
+ onSelect={() => choose(option.value)}
289
+ >
290
+ <OptionFace option={option} />
291
+ <IconCheck
292
+ className={cn(
293
+ "ml-auto size-4",
294
+ chosen(option.value) ? "opacity-100" : "opacity-0",
295
+ )}
296
+ />
297
+ </CommandItem>
298
+ ))}
299
+ </CommandGroup>
300
+ )}
301
+ </CommandList>
302
+ </Command>
303
+ </PopoverContent>
304
+ )}
305
+ </Popover>
306
+ )
307
+ }
@@ -0,0 +1,406 @@
1
+ "use client"
2
+
3
+ import {
4
+ IconCheck,
5
+ IconLayoutGrid,
6
+ IconPlus,
7
+ IconSelector,
8
+ } from "@tabler/icons-react"
9
+ import * as React from "react"
10
+
11
+ import { cn } from "../lib/utils"
12
+ import {
13
+ AlertDialog,
14
+ AlertDialogAction,
15
+ AlertDialogCancel,
16
+ AlertDialogContent,
17
+ AlertDialogDescription,
18
+ AlertDialogFooter,
19
+ AlertDialogHeader,
20
+ AlertDialogTitle,
21
+ } from "./alert-dialog"
22
+ import { Badge } from "./badge"
23
+ import {
24
+ DropdownMenu,
25
+ DropdownMenuContent,
26
+ DropdownMenuItem,
27
+ DropdownMenuLabel,
28
+ DropdownMenuSeparator,
29
+ DropdownMenuTrigger,
30
+ } from "./dropdown-menu"
31
+ import {
32
+ SidebarMenu,
33
+ SidebarMenuButton,
34
+ SidebarMenuItem,
35
+ useSidebar,
36
+ } from "./sidebar"
37
+ import { Skeleton } from "./skeleton"
38
+
39
+ export type OfficeSwitcherKind = "branch" | "warehouse"
40
+
41
+ export type OfficeSwitcherItem = {
42
+ id: string
43
+ label: string
44
+ subtitle?: string
45
+ kind: OfficeSwitcherKind
46
+ isMain?: boolean
47
+ }
48
+
49
+ export type OfficeSwitcherAction = {
50
+ id: string
51
+ label: string
52
+ href?: string
53
+ onSelect?: () => void
54
+ }
55
+
56
+ export type OfficeSwitcherConfirmation = {
57
+ open: boolean
58
+ title: string
59
+ description: React.ReactNode
60
+ confirmLabel?: string
61
+ cancelLabel?: string
62
+ onConfirm: () => void
63
+ onOpenChange: (open: boolean) => void
64
+ }
65
+
66
+ export type OfficeSwitcherLabels = {
67
+ filter: string
68
+ all: string
69
+ allHint: string
70
+ placeholder: string
71
+ placeholderHint: string
72
+ branches: string
73
+ warehouses: string
74
+ empty: string
75
+ main: string
76
+ change: string
77
+ }
78
+
79
+ const DEFAULT_LABELS: OfficeSwitcherLabels = {
80
+ filter: "Filtro por oficina",
81
+ all: "Todas las ubicaciones",
82
+ allHint: "Mostrar todo sin filtrar",
83
+ placeholder: "Selecciona una oficina",
84
+ placeholderHint: "Elige una sucursal o bodega",
85
+ branches: "Sucursales",
86
+ warehouses: "Bodegas",
87
+ empty: "No hay sucursales ni bodegas activas.",
88
+ main: "Principal",
89
+ change: "Cambiar oficina",
90
+ }
91
+
92
+ export type OfficeSwitcherProps = {
93
+ items: OfficeSwitcherItem[]
94
+ selectedId?: string | null
95
+ onSelect: (item: OfficeSwitcherItem) => void
96
+ onClear?: () => void
97
+ loading?: boolean
98
+ /** Una sola oficina: se muestra el nombre y no abre el menú. */
99
+ locked?: boolean
100
+ /** Dueños pueden ver todo sin filtrar. El personal no. */
101
+ allowAll?: boolean
102
+ labels?: Partial<OfficeSwitcherLabels>
103
+ actions?: OfficeSwitcherAction[]
104
+ /** Pieza superior, por ejemplo el cambio de cuenta. */
105
+ header?: React.ReactNode
106
+ confirmation?: OfficeSwitcherConfirmation | null
107
+ className?: string
108
+ }
109
+
110
+ function AllLocationsMark() {
111
+ return (
112
+ <span className="flex size-6 shrink-0 items-center justify-center rounded-md border border-border bg-background text-muted-foreground">
113
+ <IconLayoutGrid className="size-4" stroke={1.75} />
114
+ </span>
115
+ )
116
+ }
117
+
118
+ function OfficeCopy({
119
+ label,
120
+ subtitle,
121
+ isMain,
122
+ mainLabel,
123
+ uppercaseSubtitle,
124
+ }: {
125
+ label: string
126
+ subtitle?: string
127
+ isMain?: boolean
128
+ mainLabel: string
129
+ uppercaseSubtitle?: boolean
130
+ }) {
131
+ return (
132
+ <span className="min-w-0 flex-1">
133
+ <span className="flex min-w-0 items-center gap-1.5">
134
+ <span className="truncate text-sm font-medium">{label}</span>
135
+ {isMain ? (
136
+ <Badge
137
+ variant="outline"
138
+ className="h-4 px-1.5 text-[10px] font-medium tracking-wide uppercase"
139
+ >
140
+ {mainLabel}
141
+ </Badge>
142
+ ) : null}
143
+ </span>
144
+ {subtitle ? (
145
+ <span
146
+ className={cn(
147
+ "mt-0.5 line-clamp-2 block text-xs text-muted-foreground",
148
+ uppercaseSubtitle && "uppercase",
149
+ )}
150
+ >
151
+ {subtitle}
152
+ </span>
153
+ ) : null}
154
+ </span>
155
+ )
156
+ }
157
+
158
+ function OfficeSwitcher({
159
+ items,
160
+ selectedId,
161
+ onSelect,
162
+ onClear,
163
+ loading = false,
164
+ locked = false,
165
+ allowAll = true,
166
+ labels: labelsProp,
167
+ actions = [],
168
+ header,
169
+ confirmation,
170
+ className,
171
+ }: OfficeSwitcherProps) {
172
+ const { isMobile } = useSidebar()
173
+ const labels = { ...DEFAULT_LABELS, ...labelsProp }
174
+ const selected = items.find((item) => item.id === selectedId) ?? null
175
+ const branches = items.filter((item) => item.kind === "branch")
176
+ const warehouses = items.filter((item) => item.kind === "warehouse")
177
+ const hasActions = actions.length > 0
178
+
179
+ const triggerLabel = selected
180
+ ? selected.label
181
+ : allowAll
182
+ ? labels.all
183
+ : labels.placeholder
184
+ const triggerSubtitle = selected
185
+ ? selected.subtitle
186
+ : allowAll
187
+ ? labels.allHint
188
+ : labels.placeholderHint
189
+
190
+ const trigger = (
191
+ <SidebarMenuButton
192
+ size="lg"
193
+ aria-label={labels.change}
194
+ className={cn(
195
+ "h-auto min-h-12 gap-2 border",
196
+ selected?.isMain
197
+ ? "border-primary/25 bg-primary/8"
198
+ : "border-border",
199
+ !locked &&
200
+ "data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground",
201
+ )}
202
+ >
203
+ <OfficeCopy
204
+ label={triggerLabel}
205
+ subtitle={triggerSubtitle}
206
+ isMain={selected?.isMain}
207
+ mainLabel={labels.main}
208
+ uppercaseSubtitle={Boolean(selected)}
209
+ />
210
+ {locked ? null : <IconSelector className="ml-auto size-4 shrink-0" />}
211
+ </SidebarMenuButton>
212
+ )
213
+
214
+ return (
215
+ <div
216
+ data-slot="office-switcher"
217
+ className={cn("flex w-full flex-col gap-1.5", className)}
218
+ >
219
+ {header}
220
+ {loading ? (
221
+ <SidebarMenu>
222
+ <SidebarMenuItem>
223
+ <div className="flex items-center rounded-md border border-border p-2">
224
+ <div className="grid flex-1 gap-1">
225
+ <Skeleton className="h-4 w-28" />
226
+ <Skeleton className="h-3 w-36" />
227
+ </div>
228
+ </div>
229
+ </SidebarMenuItem>
230
+ </SidebarMenu>
231
+ ) : items.length === 0 ? null : (
232
+ <SidebarMenu>
233
+ <SidebarMenuItem>
234
+ {locked ? (
235
+ trigger
236
+ ) : (
237
+ <DropdownMenu>
238
+ <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
239
+ <DropdownMenuContent
240
+ className="w-(--radix-dropdown-menu-trigger-width) min-w-72 max-w-80 rounded-lg"
241
+ align="start"
242
+ side={isMobile ? "bottom" : "right"}
243
+ sideOffset={4}
244
+ >
245
+ <DropdownMenuLabel className="text-xs text-muted-foreground">
246
+ {labels.filter}
247
+ </DropdownMenuLabel>
248
+
249
+ {allowAll ? (
250
+ <DropdownMenuItem
251
+ onClick={onClear}
252
+ className={cn("gap-2 p-2", !selected && "bg-accent")}
253
+ >
254
+ <AllLocationsMark />
255
+ <OfficeCopy
256
+ label={labels.all}
257
+ subtitle={labels.allHint}
258
+ mainLabel={labels.main}
259
+ />
260
+ {selected ? null : (
261
+ <IconCheck className="size-4 shrink-0 text-primary" />
262
+ )}
263
+ </DropdownMenuItem>
264
+ ) : null}
265
+
266
+ {allowAll && items.length > 0 ? <DropdownMenuSeparator /> : null}
267
+
268
+ <OfficeGroup
269
+ label={labels.branches}
270
+ items={branches}
271
+ selectedId={selected?.id}
272
+ mainLabel={labels.main}
273
+ onSelect={onSelect}
274
+ />
275
+
276
+ {branches.length > 0 && warehouses.length > 0 ? (
277
+ <DropdownMenuSeparator />
278
+ ) : null}
279
+
280
+ <OfficeGroup
281
+ label={labels.warehouses}
282
+ items={warehouses}
283
+ selectedId={selected?.id}
284
+ mainLabel={labels.main}
285
+ onSelect={onSelect}
286
+ />
287
+
288
+ {items.length === 0 ? (
289
+ <p className="px-2 py-2 text-xs text-muted-foreground">
290
+ {labels.empty}
291
+ </p>
292
+ ) : null}
293
+
294
+ {hasActions ? <DropdownMenuSeparator /> : null}
295
+ {actions.map((action) => (
296
+ <OfficeAction key={action.id} action={action} />
297
+ ))}
298
+ </DropdownMenuContent>
299
+ </DropdownMenu>
300
+ )}
301
+ </SidebarMenuItem>
302
+ </SidebarMenu>
303
+ )}
304
+
305
+ {confirmation ? (
306
+ <AlertDialog
307
+ open={confirmation.open}
308
+ onOpenChange={confirmation.onOpenChange}
309
+ >
310
+ <AlertDialogContent>
311
+ <AlertDialogHeader>
312
+ <AlertDialogTitle>{confirmation.title}</AlertDialogTitle>
313
+ <AlertDialogDescription asChild>
314
+ <div className="space-y-2 text-sm text-muted-foreground">
315
+ {confirmation.description}
316
+ </div>
317
+ </AlertDialogDescription>
318
+ </AlertDialogHeader>
319
+ <AlertDialogFooter>
320
+ <AlertDialogCancel>
321
+ {confirmation.cancelLabel ?? "Cancelar"}
322
+ </AlertDialogCancel>
323
+ <AlertDialogAction onClick={confirmation.onConfirm}>
324
+ {confirmation.confirmLabel ?? "Confirmar"}
325
+ </AlertDialogAction>
326
+ </AlertDialogFooter>
327
+ </AlertDialogContent>
328
+ </AlertDialog>
329
+ ) : null}
330
+ </div>
331
+ )
332
+ }
333
+
334
+ function OfficeGroup({
335
+ label,
336
+ items,
337
+ selectedId,
338
+ mainLabel,
339
+ onSelect,
340
+ }: {
341
+ label: string
342
+ items: OfficeSwitcherItem[]
343
+ selectedId?: string
344
+ mainLabel: string
345
+ onSelect: (item: OfficeSwitcherItem) => void
346
+ }) {
347
+ if (items.length === 0) return null
348
+
349
+ return (
350
+ <>
351
+ <DropdownMenuLabel className="text-xs text-muted-foreground">
352
+ {label}
353
+ </DropdownMenuLabel>
354
+ {items.map((item) => {
355
+ const selected = item.id === selectedId
356
+ return (
357
+ <DropdownMenuItem
358
+ key={item.id}
359
+ onClick={() => onSelect(item)}
360
+ className={cn("gap-2 p-2", selected && "bg-accent")}
361
+ >
362
+ <OfficeCopy
363
+ label={item.label}
364
+ subtitle={item.subtitle}
365
+ isMain={item.isMain}
366
+ mainLabel={mainLabel}
367
+ uppercaseSubtitle
368
+ />
369
+ {selected ? (
370
+ <IconCheck className="size-4 shrink-0 text-primary" />
371
+ ) : null}
372
+ </DropdownMenuItem>
373
+ )
374
+ })}
375
+ </>
376
+ )
377
+ }
378
+
379
+ function OfficeAction({ action }: { action: OfficeSwitcherAction }) {
380
+ const body = (
381
+ <>
382
+ <span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-background text-muted-foreground">
383
+ <IconPlus className="size-4" stroke={1.75} />
384
+ </span>
385
+ <span className="font-medium text-muted-foreground">{action.label}</span>
386
+ </>
387
+ )
388
+
389
+ if (action.href) {
390
+ return (
391
+ <DropdownMenuItem asChild>
392
+ <a href={action.href} className="flex cursor-pointer items-center gap-2 p-2">
393
+ {body}
394
+ </a>
395
+ </DropdownMenuItem>
396
+ )
397
+ }
398
+
399
+ return (
400
+ <DropdownMenuItem onClick={action.onSelect} className="gap-2 p-2">
401
+ {body}
402
+ </DropdownMenuItem>
403
+ )
404
+ }
405
+
406
+ export { OfficeSwitcher }