@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.
- package/package.json +1 -1
- package/registry/traky/json/office-switcher.json +24 -0
- package/registry/traky/json/option-combobox.json +22 -0
- package/registry/traky/registry.json +20 -0
- package/registry/traky/ui/office-switcher.tsx +406 -0
- package/registry/traky/ui/option-combobox.tsx +307 -0
- package/src/ui/office-switcher.tsx +406 -0
- package/src/ui/option-combobox.tsx +307 -0
package/package.json
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "office-switcher",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"author": "Traky (https://traky.app)",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@tabler/icons-react"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"alert-dialog",
|
|
11
|
+
"badge",
|
|
12
|
+
"dropdown-menu",
|
|
13
|
+
"sidebar",
|
|
14
|
+
"skeleton"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "ui/office-switcher.tsx",
|
|
19
|
+
"content": "\"use client\"\n\nimport {\n IconCheck,\n IconLayoutGrid,\n IconPlus,\n IconSelector,\n} from \"@tabler/icons-react\"\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\"\nimport { Badge } from \"@/components/ui/badge\"\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\"\nimport {\n SidebarMenu,\n SidebarMenuButton,\n SidebarMenuItem,\n useSidebar,\n} from \"@/components/ui/sidebar\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\n\nexport type OfficeSwitcherKind = \"branch\" | \"warehouse\"\n\nexport type OfficeSwitcherItem = {\n id: string\n label: string\n subtitle?: string\n kind: OfficeSwitcherKind\n isMain?: boolean\n}\n\nexport type OfficeSwitcherAction = {\n id: string\n label: string\n href?: string\n onSelect?: () => void\n}\n\nexport type OfficeSwitcherConfirmation = {\n open: boolean\n title: string\n description: React.ReactNode\n confirmLabel?: string\n cancelLabel?: string\n onConfirm: () => void\n onOpenChange: (open: boolean) => void\n}\n\nexport type OfficeSwitcherLabels = {\n filter: string\n all: string\n allHint: string\n placeholder: string\n placeholderHint: string\n branches: string\n warehouses: string\n empty: string\n main: string\n change: string\n}\n\nconst DEFAULT_LABELS: OfficeSwitcherLabels = {\n filter: \"Filtro por oficina\",\n all: \"Todas las ubicaciones\",\n allHint: \"Mostrar todo sin filtrar\",\n placeholder: \"Selecciona una oficina\",\n placeholderHint: \"Elige una sucursal o bodega\",\n branches: \"Sucursales\",\n warehouses: \"Bodegas\",\n empty: \"No hay sucursales ni bodegas activas.\",\n main: \"Principal\",\n change: \"Cambiar oficina\",\n}\n\nexport type OfficeSwitcherProps = {\n items: OfficeSwitcherItem[]\n selectedId?: string | null\n onSelect: (item: OfficeSwitcherItem) => void\n onClear?: () => void\n loading?: boolean\n /** Una sola oficina: se muestra el nombre y no abre el menú. */\n locked?: boolean\n /** Dueños pueden ver todo sin filtrar. El personal no. */\n allowAll?: boolean\n labels?: Partial<OfficeSwitcherLabels>\n actions?: OfficeSwitcherAction[]\n /** Pieza superior, por ejemplo el cambio de cuenta. */\n header?: React.ReactNode\n confirmation?: OfficeSwitcherConfirmation | null\n className?: string\n}\n\nfunction AllLocationsMark() {\n return (\n <span className=\"flex size-6 shrink-0 items-center justify-center rounded-md border border-border bg-background text-muted-foreground\">\n <IconLayoutGrid className=\"size-4\" stroke={1.75} />\n </span>\n )\n}\n\nfunction OfficeCopy({\n label,\n subtitle,\n isMain,\n mainLabel,\n uppercaseSubtitle,\n}: {\n label: string\n subtitle?: string\n isMain?: boolean\n mainLabel: string\n uppercaseSubtitle?: boolean\n}) {\n return (\n <span className=\"min-w-0 flex-1\">\n <span className=\"flex min-w-0 items-center gap-1.5\">\n <span className=\"truncate text-sm font-medium\">{label}</span>\n {isMain ? (\n <Badge\n variant=\"outline\"\n className=\"h-4 px-1.5 text-[10px] font-medium tracking-wide uppercase\"\n >\n {mainLabel}\n </Badge>\n ) : null}\n </span>\n {subtitle ? (\n <span\n className={cn(\n \"mt-0.5 line-clamp-2 block text-xs text-muted-foreground\",\n uppercaseSubtitle && \"uppercase\",\n )}\n >\n {subtitle}\n </span>\n ) : null}\n </span>\n )\n}\n\nfunction OfficeSwitcher({\n items,\n selectedId,\n onSelect,\n onClear,\n loading = false,\n locked = false,\n allowAll = true,\n labels: labelsProp,\n actions = [],\n header,\n confirmation,\n className,\n}: OfficeSwitcherProps) {\n const { isMobile } = useSidebar()\n const labels = { ...DEFAULT_LABELS, ...labelsProp }\n const selected = items.find((item) => item.id === selectedId) ?? null\n const branches = items.filter((item) => item.kind === \"branch\")\n const warehouses = items.filter((item) => item.kind === \"warehouse\")\n const hasActions = actions.length > 0\n\n const triggerLabel = selected\n ? selected.label\n : allowAll\n ? labels.all\n : labels.placeholder\n const triggerSubtitle = selected\n ? selected.subtitle\n : allowAll\n ? labels.allHint\n : labels.placeholderHint\n\n const trigger = (\n <SidebarMenuButton\n size=\"lg\"\n aria-label={labels.change}\n className={cn(\n \"h-auto min-h-12 gap-2 border\",\n selected?.isMain\n ? \"border-primary/25 bg-primary/8\"\n : \"border-border\",\n !locked &&\n \"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground\",\n )}\n >\n <OfficeCopy\n label={triggerLabel}\n subtitle={triggerSubtitle}\n isMain={selected?.isMain}\n mainLabel={labels.main}\n uppercaseSubtitle={Boolean(selected)}\n />\n {locked ? null : <IconSelector className=\"ml-auto size-4 shrink-0\" />}\n </SidebarMenuButton>\n )\n\n return (\n <div\n data-slot=\"office-switcher\"\n className={cn(\"flex w-full flex-col gap-1.5\", className)}\n >\n {header}\n {loading ? (\n <SidebarMenu>\n <SidebarMenuItem>\n <div className=\"flex items-center rounded-md border border-border p-2\">\n <div className=\"grid flex-1 gap-1\">\n <Skeleton className=\"h-4 w-28\" />\n <Skeleton className=\"h-3 w-36\" />\n </div>\n </div>\n </SidebarMenuItem>\n </SidebarMenu>\n ) : items.length === 0 ? null : (\n <SidebarMenu>\n <SidebarMenuItem>\n {locked ? (\n trigger\n ) : (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>\n <DropdownMenuContent\n className=\"w-(--radix-dropdown-menu-trigger-width) min-w-72 max-w-80 rounded-lg\"\n align=\"start\"\n side={isMobile ? \"bottom\" : \"right\"}\n sideOffset={4}\n >\n <DropdownMenuLabel className=\"text-xs text-muted-foreground\">\n {labels.filter}\n </DropdownMenuLabel>\n\n {allowAll ? (\n <DropdownMenuItem\n onClick={onClear}\n className={cn(\"gap-2 p-2\", !selected && \"bg-accent\")}\n >\n <AllLocationsMark />\n <OfficeCopy\n label={labels.all}\n subtitle={labels.allHint}\n mainLabel={labels.main}\n />\n {selected ? null : (\n <IconCheck className=\"size-4 shrink-0 text-primary\" />\n )}\n </DropdownMenuItem>\n ) : null}\n\n {allowAll && items.length > 0 ? <DropdownMenuSeparator /> : null}\n\n <OfficeGroup\n label={labels.branches}\n items={branches}\n selectedId={selected?.id}\n mainLabel={labels.main}\n onSelect={onSelect}\n />\n\n {branches.length > 0 && warehouses.length > 0 ? (\n <DropdownMenuSeparator />\n ) : null}\n\n <OfficeGroup\n label={labels.warehouses}\n items={warehouses}\n selectedId={selected?.id}\n mainLabel={labels.main}\n onSelect={onSelect}\n />\n\n {items.length === 0 ? (\n <p className=\"px-2 py-2 text-xs text-muted-foreground\">\n {labels.empty}\n </p>\n ) : null}\n\n {hasActions ? <DropdownMenuSeparator /> : null}\n {actions.map((action) => (\n <OfficeAction key={action.id} action={action} />\n ))}\n </DropdownMenuContent>\n </DropdownMenu>\n )}\n </SidebarMenuItem>\n </SidebarMenu>\n )}\n\n {confirmation ? (\n <AlertDialog\n open={confirmation.open}\n onOpenChange={confirmation.onOpenChange}\n >\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>{confirmation.title}</AlertDialogTitle>\n <AlertDialogDescription asChild>\n <div className=\"space-y-2 text-sm text-muted-foreground\">\n {confirmation.description}\n </div>\n </AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel>\n {confirmation.cancelLabel ?? \"Cancelar\"}\n </AlertDialogCancel>\n <AlertDialogAction onClick={confirmation.onConfirm}>\n {confirmation.confirmLabel ?? \"Confirmar\"}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n ) : null}\n </div>\n )\n}\n\nfunction OfficeGroup({\n label,\n items,\n selectedId,\n mainLabel,\n onSelect,\n}: {\n label: string\n items: OfficeSwitcherItem[]\n selectedId?: string\n mainLabel: string\n onSelect: (item: OfficeSwitcherItem) => void\n}) {\n if (items.length === 0) return null\n\n return (\n <>\n <DropdownMenuLabel className=\"text-xs text-muted-foreground\">\n {label}\n </DropdownMenuLabel>\n {items.map((item) => {\n const selected = item.id === selectedId\n return (\n <DropdownMenuItem\n key={item.id}\n onClick={() => onSelect(item)}\n className={cn(\"gap-2 p-2\", selected && \"bg-accent\")}\n >\n <OfficeCopy\n label={item.label}\n subtitle={item.subtitle}\n isMain={item.isMain}\n mainLabel={mainLabel}\n uppercaseSubtitle\n />\n {selected ? (\n <IconCheck className=\"size-4 shrink-0 text-primary\" />\n ) : null}\n </DropdownMenuItem>\n )\n })}\n </>\n )\n}\n\nfunction OfficeAction({ action }: { action: OfficeSwitcherAction }) {\n const body = (\n <>\n <span className=\"flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-background text-muted-foreground\">\n <IconPlus className=\"size-4\" stroke={1.75} />\n </span>\n <span className=\"font-medium text-muted-foreground\">{action.label}</span>\n </>\n )\n\n if (action.href) {\n return (\n <DropdownMenuItem asChild>\n <a href={action.href} className=\"flex cursor-pointer items-center gap-2 p-2\">\n {body}\n </a>\n </DropdownMenuItem>\n )\n }\n\n return (\n <DropdownMenuItem onClick={action.onSelect} className=\"gap-2 p-2\">\n {body}\n </DropdownMenuItem>\n )\n}\n\nexport { OfficeSwitcher }\n",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"target": ""
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "option-combobox",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"author": "Traky (https://traky.app)",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@tabler/icons-react"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"button",
|
|
11
|
+
"command",
|
|
12
|
+
"popover"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "ui/option-combobox.tsx",
|
|
17
|
+
"content": "\"use client\"\n\nimport { useState, type ComponentType } from \"react\"\nimport { IconCheck, IconSelector } from \"@tabler/icons-react\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\"\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\"\nimport { cn, commandFilterAccentInsensitive } from \"@/lib/utils\"\n\nexport type OptionComboboxOption = {\n value: string\n label: string\n icon?: ComponentType<{ className?: string; stroke?: number }>\n iconClassName?: string\n /** Pastilla de color, por ejemplo una etiqueta. */\n color?: string\n /** Sangría para una opción hija. */\n indented?: boolean\n}\n\nfunction OptionGlyph({\n icon: Icon,\n className,\n}: {\n icon?: OptionComboboxOption[\"icon\"]\n className?: string\n}) {\n if (!Icon) return null\n return <Icon className={cn(\"size-4 shrink-0\", className)} stroke={1.75} />\n}\n\nfunction OptionFace({ option }: { option: OptionComboboxOption }) {\n if (option.color) {\n return (\n <span\n className=\"inline-flex max-w-full items-center truncate rounded-full border px-2 py-0.5 text-xs font-medium\"\n style={{\n color: option.color,\n borderColor: option.color,\n backgroundColor: `${option.color}1A`,\n }}\n >\n {option.label}\n </span>\n )\n }\n return (\n <span className=\"flex min-w-0 items-center gap-2\">\n <OptionGlyph icon={option.icon} className={option.iconClassName} />\n <span className=\"truncate\">{option.label}</span>\n </span>\n )\n}\n\nexport function OptionCombobox({\n value = \"\",\n options,\n onChange,\n placeholder = \"Seleccionar…\",\n searchPlaceholder = \"Buscar…\",\n emptyLabel = \"No se encontraron resultados.\",\n groupHeading,\n allowEmpty = false,\n emptyValueLabel,\n emptyValueIcon,\n clearLabel,\n clearOption,\n allOption,\n groups,\n multiple = false,\n values = [],\n onValuesChange,\n className,\n disabled = false,\n modal = false,\n title,\n}: {\n value?: string\n options: OptionComboboxOption[]\n onChange?: (value: string) => void\n /** Varias opciones a la vez. El menú no se cierra al marcar. */\n multiple?: boolean\n values?: string[]\n onValuesChange?: (values: string[]) => void\n placeholder?: string\n searchPlaceholder?: string\n emptyLabel?: string\n groupHeading?: string\n /** Si es true, la primera fila deja el valor vacío y no pertenece a `options`. */\n allowEmpty?: boolean\n /** Texto de esa fila vacía. Si no se indica, usa `placeholder`. */\n emptyValueLabel?: string\n emptyValueIcon?: OptionComboboxOption[\"icon\"]\n /** Opción para vaciar el valor. El botón sigue mostrando `placeholder`. */\n clearLabel?: string\n /** Opción para vaciar el valor, visible también en el botón. */\n clearOption?: OptionComboboxOption\n /** Opción extra al inicio. Si no hay valor, el botón la muestra. */\n allOption?: OptionComboboxOption\n /** Varios grupos, por ejemplo persona y empresa. */\n groups?: { heading?: string; options: OptionComboboxOption[] }[]\n className?: string\n disabled?: boolean\n modal?: boolean\n title?: string\n}) {\n const [open, setOpen] = useState(false)\n const blankLabel = emptyValueLabel || placeholder\n const realOptions = allowEmpty\n ? options.filter((option) => option.value !== \"\")\n : options\n const grouped = (groups ?? []).filter((group) => group.options.length > 0)\n const catalog = grouped.length\n ? grouped.flatMap((group) => group.options)\n : realOptions\n const listOptions = allOption ? [allOption, ...catalog] : catalog\n const emptyChoice: OptionComboboxOption = {\n value: \"\",\n label: blankLabel,\n icon: emptyValueIcon,\n }\n const selected = allowEmpty && value === \"\"\n ? emptyChoice\n : clearOption && value === \"\"\n ? clearOption\n : listOptions.find((option) => option.value === value)\n ?? (!value ? allOption : undefined)\n const selectedMany = multiple\n ? values.flatMap((id) => {\n const option = listOptions.find((item) => item.value === id)\n return option ? [option] : []\n })\n : []\n\n const chosen = (optionValue: string) =>\n multiple ? values.includes(optionValue) : value === optionValue\n\n const choose = (next: string) => {\n if (multiple) {\n onValuesChange?.(\n values.includes(next)\n ? values.filter((item) => item !== next)\n : [...values, next],\n )\n return\n }\n onChange?.(next)\n setOpen(false)\n }\n\n return (\n <Popover\n open={open}\n onOpenChange={disabled ? undefined : setOpen}\n modal={modal}\n >\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n role=\"combobox\"\n aria-expanded={open}\n disabled={disabled}\n title={title}\n className={cn(\n \"w-full justify-between font-normal\",\n multiple && \"h-auto py-2\",\n className,\n )}\n >\n {multiple ? (\n <span className=\"flex min-w-0 flex-1 flex-wrap items-center gap-1.5\">\n {selectedMany.length ? (\n selectedMany.map((option) => (\n <OptionFace key={option.value} option={option} />\n ))\n ) : (\n <span className=\"truncate text-muted-foreground\">\n {placeholder}\n </span>\n )}\n </span>\n ) : selected ? (\n <OptionFace option={selected} />\n ) : (\n <span className=\"truncate text-muted-foreground\">{placeholder}</span>\n )}\n <IconSelector className=\"size-4 shrink-0 opacity-50\" />\n </Button>\n </PopoverTrigger>\n {disabled ? null : (\n <PopoverContent\n align=\"start\"\n className=\"z-[60] w-[var(--radix-popover-trigger-width)] min-w-56 p-0\"\n >\n <Command filter={commandFilterAccentInsensitive}>\n <CommandInput placeholder={searchPlaceholder} />\n <CommandList>\n <CommandEmpty>{emptyLabel}</CommandEmpty>\n {grouped.length ? (\n grouped.map((group) => (\n <CommandGroup\n key={group.heading ?? group.options[0]?.value}\n heading={group.heading}\n >\n {group.options.map((option) => (\n <CommandItem\n key={option.value || option.label}\n value={`${option.label} ${option.value}`}\n className={option.indented ? \"pl-6\" : undefined}\n onSelect={() => choose(option.value)}\n >\n <OptionFace option={option} />\n <IconCheck\n className={cn(\n \"ml-auto size-4\",\n chosen(option.value) ? \"opacity-100\" : \"opacity-0\",\n )}\n />\n </CommandItem>\n ))}\n </CommandGroup>\n ))\n ) : (\n <CommandGroup heading={groupHeading}>\n {allowEmpty ? (\n <CommandItem\n value={`__empty__ ${blankLabel}`}\n onSelect={() => choose(\"\")}\n >\n <OptionGlyph icon={emptyValueIcon} />\n {blankLabel}\n <IconCheck\n className={cn(\n \"ml-auto size-4\",\n value ? \"opacity-0\" : \"opacity-100\",\n )}\n />\n </CommandItem>\n ) : null}\n {clearOption ? (\n <CommandItem\n value={`__clear__ ${clearOption.label}`}\n onSelect={() => choose(\"\")}\n >\n <OptionGlyph\n icon={clearOption.icon}\n className={clearOption.iconClassName}\n />\n {clearOption.label}\n <IconCheck\n className={cn(\n \"ml-auto size-4\",\n value ? \"opacity-0\" : \"opacity-100\",\n )}\n />\n </CommandItem>\n ) : clearLabel ? (\n <CommandItem\n value={`__clear__ ${clearLabel}`}\n onSelect={() => choose(\"\")}\n >\n {clearLabel}\n <IconCheck\n className={cn(\n \"ml-auto size-4\",\n value ? \"opacity-0\" : \"opacity-100\",\n )}\n />\n </CommandItem>\n ) : null}\n {listOptions.map((option) => (\n <CommandItem\n key={option.value || option.label}\n value={`${option.label} ${option.value}`}\n className={option.indented ? \"pl-6\" : undefined}\n onSelect={() => choose(option.value)}\n >\n <OptionFace option={option} />\n <IconCheck\n className={cn(\n \"ml-auto size-4\",\n chosen(option.value) ? \"opacity-100\" : \"opacity-0\",\n )}\n />\n </CommandItem>\n ))}\n </CommandGroup>\n )}\n </CommandList>\n </Command>\n </PopoverContent>\n )}\n </Popover>\n )\n}\n",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"target": ""
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -453,6 +453,26 @@
|
|
|
453
453
|
}
|
|
454
454
|
]
|
|
455
455
|
},
|
|
456
|
+
{
|
|
457
|
+
"name": "office-switcher",
|
|
458
|
+
"type": "registry:ui",
|
|
459
|
+
"files": [
|
|
460
|
+
{
|
|
461
|
+
"path": "json/office-switcher.json",
|
|
462
|
+
"type": "registry:file"
|
|
463
|
+
}
|
|
464
|
+
]
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
"name": "option-combobox",
|
|
468
|
+
"type": "registry:ui",
|
|
469
|
+
"files": [
|
|
470
|
+
{
|
|
471
|
+
"path": "json/option-combobox.json",
|
|
472
|
+
"type": "registry:file"
|
|
473
|
+
}
|
|
474
|
+
]
|
|
475
|
+
},
|
|
456
476
|
{
|
|
457
477
|
"name": "pagination",
|
|
458
478
|
"type": "registry:ui",
|
|
@@ -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 "@/components/ui/alert-dialog"
|
|
22
|
+
import { Badge } from "@/components/ui/badge"
|
|
23
|
+
import {
|
|
24
|
+
DropdownMenu,
|
|
25
|
+
DropdownMenuContent,
|
|
26
|
+
DropdownMenuItem,
|
|
27
|
+
DropdownMenuLabel,
|
|
28
|
+
DropdownMenuSeparator,
|
|
29
|
+
DropdownMenuTrigger,
|
|
30
|
+
} from "@/components/ui/dropdown-menu"
|
|
31
|
+
import {
|
|
32
|
+
SidebarMenu,
|
|
33
|
+
SidebarMenuButton,
|
|
34
|
+
SidebarMenuItem,
|
|
35
|
+
useSidebar,
|
|
36
|
+
} from "@/components/ui/sidebar"
|
|
37
|
+
import { Skeleton } from "@/components/ui/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 }
|