@traky-app/ui 1.3.0 → 1.5.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/header-page.json +20 -0
- package/registry/traky/json/office-switcher.json +24 -0
- package/registry/traky/registry.json +20 -0
- package/registry/traky/ui/header-page.tsx +171 -0
- package/registry/traky/ui/office-switcher.tsx +406 -0
- package/src/ui/header-page.tsx +171 -0
- package/src/ui/office-switcher.tsx +406 -0
package/package.json
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "header-page",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"author": "Traky (https://traky.app)",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@tabler/icons-react"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"entity-icon"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "ui/header-page.tsx",
|
|
15
|
+
"content": "\"use client\"\n\nimport type { Icon } from \"@tabler/icons-react\"\nimport type { ReactNode } from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { EntityIcon } from \"@/components/ui/entity-icon\"\n\ntype HeaderPageProps = {\n icon: Icon\n title: string\n description: string\n className?: string\n /** Contenido izquierdo de la barra (filtros, enlaces secundarios, etc.). */\n toolbarStart?: ReactNode\n /** Contenido derecho de la barra (resumen, paginación, acción principal). */\n toolbarEnd?: ReactNode\n /** Panel expandible debajo de la barra (p. ej. filtros avanzados). */\n filters?: ReactNode\n showFilters?: boolean\n toolbarAriaLabel?: string\n toolbarId?: string\n /** Acción principal junto al título (p. ej. crear registro). */\n action?: ReactNode\n}\n\nfunction HeaderPageMeta({\n children,\n className,\n}: {\n children: ReactNode\n className?: string\n}) {\n return (\n <p\n data-header-meta\n className={cn(\n \"text-sm text-muted-foreground max-sm:order-3 max-sm:basis-full max-sm:shrink-0 sm:order-1 xl:order-none\",\n className,\n )}\n >\n {children}\n </p>\n )\n}\n\nfunction HeaderPageToolbarAction({\n children,\n className,\n order = \"primary\",\n}: {\n children: ReactNode\n className?: string\n order?: \"primary\" | \"secondary\"\n}) {\n return (\n <div\n className={cn(\n \"w-full sm:w-auto xl:order-none [&_a]:w-full sm:[&_a]:w-auto [&_button:not([data-icon-only])]:w-full sm:[&_button:not([data-icon-only])]:w-auto\",\n order === \"primary\" && \"sm:order-2 sm:ml-auto\",\n order === \"secondary\" && \"sm:order-3\",\n className,\n )}\n >\n {children}\n </div>\n )\n}\n\nfunction HeaderPageToolbarPagination({\n children,\n className,\n}: {\n children: ReactNode\n className?: string\n}) {\n return (\n <div\n className={cn(\n \"flex items-center justify-center sm:order-3 xl:order-none\",\n className,\n )}\n >\n {children}\n </div>\n )\n}\n\nfunction HeaderPageRoot({\n icon: Icon,\n title,\n description,\n className,\n toolbarStart,\n toolbarEnd,\n filters,\n showFilters = false,\n toolbarAriaLabel = \"Filtros y acciones\",\n toolbarId,\n action,\n}: HeaderPageProps) {\n const hasToolbar = Boolean(toolbarStart || toolbarEnd)\n const hasFiltersPanel = Boolean(filters)\n\n return (\n <header className={cn(\"space-y-4\", className)}>\n <div className=\"flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between\">\n <div className=\"flex min-w-0 items-start gap-3\">\n <EntityIcon icon={Icon} size=\"lg\" />\n <div className=\"min-w-0\">\n <h1 className=\"text-xl font-semibold\">{title}</h1>\n <p className=\"mt-1 text-sm text-muted-foreground\">{description}</p>\n </div>\n </div>\n\n {action ? (\n <div className=\"flex w-full shrink-0 flex-wrap items-center gap-2 sm:w-auto sm:flex-nowrap sm:justify-end sm:pt-0.5 [&_a:not([data-icon-only])]:w-full sm:[&_a:not([data-icon-only])]:w-auto [&_button:not([data-icon-only])]:w-full sm:[&_button:not([data-icon-only])]:w-auto max-sm:[&>a:not([data-icon-only])]:basis-full max-sm:[&>a:not([data-icon-only])]:shrink-0 max-sm:[&>[data-icon-only]]:order-first max-sm:[&>button:not([data-icon-only])]:basis-full max-sm:[&>button:not([data-icon-only])]:shrink-0\">\n {action}\n </div>\n ) : null}\n </div>\n\n {(hasToolbar || hasFiltersPanel) && (\n <section\n id={toolbarId}\n className=\"space-y-4\"\n aria-label={toolbarAriaLabel}\n >\n {hasToolbar && (\n <div\n className={cn(\n \"flex flex-col gap-3\",\n \"max-sm:flex-row max-sm:flex-wrap max-sm:items-center max-sm:gap-2\",\n \"max-sm:[&_[data-icon-only]]:order-1 max-sm:[&_[data-icon-only]]:shrink-0\",\n \"max-sm:[&_[data-header-icons]_[data-icon-only]]:order-none\",\n \"max-sm:[&_.w-full]:order-2 max-sm:[&_.w-full]:basis-full max-sm:[&_.w-full]:shrink-0\",\n \"max-sm:[&_[data-header-icons]_.w-full]:order-none max-sm:[&_[data-header-icons]_.w-full]:basis-auto max-sm:[&_[data-header-icons]_.w-full]:shrink\",\n toolbarStart\n ? \"xl:flex-row xl:items-center xl:justify-between\"\n : \"sm:flex-row sm:flex-wrap sm:items-center sm:justify-between xl:justify-end xl:gap-4\",\n )}\n >\n {toolbarStart ? (\n <div className=\"max-sm:contents sm:flex sm:flex-wrap sm:items-center sm:gap-2\">\n {toolbarStart}\n </div>\n ) : null}\n\n {toolbarEnd ? (\n <div className=\"max-sm:contents sm:flex sm:flex-wrap sm:items-center sm:justify-between sm:gap-3 xl:justify-end xl:gap-4\">\n {toolbarEnd}\n </div>\n ) : null}\n </div>\n )}\n\n {showFilters && filters}\n </section>\n )}\n </header>\n )\n}\n\nconst HeaderPage = Object.assign(HeaderPageRoot, {\n Meta: HeaderPageMeta,\n ToolbarAction: HeaderPageToolbarAction,\n ToolbarPagination: HeaderPageToolbarPagination,\n})\n\nexport { HeaderPage }\nexport default HeaderPage\n",
|
|
16
|
+
"type": "registry:ui",
|
|
17
|
+
"target": ""
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -293,6 +293,16 @@
|
|
|
293
293
|
}
|
|
294
294
|
]
|
|
295
295
|
},
|
|
296
|
+
{
|
|
297
|
+
"name": "header-page",
|
|
298
|
+
"type": "registry:ui",
|
|
299
|
+
"files": [
|
|
300
|
+
{
|
|
301
|
+
"path": "json/header-page.json",
|
|
302
|
+
"type": "registry:file"
|
|
303
|
+
}
|
|
304
|
+
]
|
|
305
|
+
},
|
|
296
306
|
{
|
|
297
307
|
"name": "hover-card",
|
|
298
308
|
"type": "registry:ui",
|
|
@@ -443,6 +453,16 @@
|
|
|
443
453
|
}
|
|
444
454
|
]
|
|
445
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
|
+
},
|
|
446
466
|
{
|
|
447
467
|
"name": "pagination",
|
|
448
468
|
"type": "registry:ui",
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { Icon } from "@tabler/icons-react"
|
|
4
|
+
import type { ReactNode } from "react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "@/lib/utils"
|
|
7
|
+
import { EntityIcon } from "@/components/ui/entity-icon"
|
|
8
|
+
|
|
9
|
+
type HeaderPageProps = {
|
|
10
|
+
icon: Icon
|
|
11
|
+
title: string
|
|
12
|
+
description: string
|
|
13
|
+
className?: string
|
|
14
|
+
/** Contenido izquierdo de la barra (filtros, enlaces secundarios, etc.). */
|
|
15
|
+
toolbarStart?: ReactNode
|
|
16
|
+
/** Contenido derecho de la barra (resumen, paginación, acción principal). */
|
|
17
|
+
toolbarEnd?: ReactNode
|
|
18
|
+
/** Panel expandible debajo de la barra (p. ej. filtros avanzados). */
|
|
19
|
+
filters?: ReactNode
|
|
20
|
+
showFilters?: boolean
|
|
21
|
+
toolbarAriaLabel?: string
|
|
22
|
+
toolbarId?: string
|
|
23
|
+
/** Acción principal junto al título (p. ej. crear registro). */
|
|
24
|
+
action?: ReactNode
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function HeaderPageMeta({
|
|
28
|
+
children,
|
|
29
|
+
className,
|
|
30
|
+
}: {
|
|
31
|
+
children: ReactNode
|
|
32
|
+
className?: string
|
|
33
|
+
}) {
|
|
34
|
+
return (
|
|
35
|
+
<p
|
|
36
|
+
data-header-meta
|
|
37
|
+
className={cn(
|
|
38
|
+
"text-sm text-muted-foreground max-sm:order-3 max-sm:basis-full max-sm:shrink-0 sm:order-1 xl:order-none",
|
|
39
|
+
className,
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
{children}
|
|
43
|
+
</p>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function HeaderPageToolbarAction({
|
|
48
|
+
children,
|
|
49
|
+
className,
|
|
50
|
+
order = "primary",
|
|
51
|
+
}: {
|
|
52
|
+
children: ReactNode
|
|
53
|
+
className?: string
|
|
54
|
+
order?: "primary" | "secondary"
|
|
55
|
+
}) {
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
className={cn(
|
|
59
|
+
"w-full sm:w-auto xl:order-none [&_a]:w-full sm:[&_a]:w-auto [&_button:not([data-icon-only])]:w-full sm:[&_button:not([data-icon-only])]:w-auto",
|
|
60
|
+
order === "primary" && "sm:order-2 sm:ml-auto",
|
|
61
|
+
order === "secondary" && "sm:order-3",
|
|
62
|
+
className,
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
{children}
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function HeaderPageToolbarPagination({
|
|
71
|
+
children,
|
|
72
|
+
className,
|
|
73
|
+
}: {
|
|
74
|
+
children: ReactNode
|
|
75
|
+
className?: string
|
|
76
|
+
}) {
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
className={cn(
|
|
80
|
+
"flex items-center justify-center sm:order-3 xl:order-none",
|
|
81
|
+
className,
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
{children}
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function HeaderPageRoot({
|
|
90
|
+
icon: Icon,
|
|
91
|
+
title,
|
|
92
|
+
description,
|
|
93
|
+
className,
|
|
94
|
+
toolbarStart,
|
|
95
|
+
toolbarEnd,
|
|
96
|
+
filters,
|
|
97
|
+
showFilters = false,
|
|
98
|
+
toolbarAriaLabel = "Filtros y acciones",
|
|
99
|
+
toolbarId,
|
|
100
|
+
action,
|
|
101
|
+
}: HeaderPageProps) {
|
|
102
|
+
const hasToolbar = Boolean(toolbarStart || toolbarEnd)
|
|
103
|
+
const hasFiltersPanel = Boolean(filters)
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<header className={cn("space-y-4", className)}>
|
|
107
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
108
|
+
<div className="flex min-w-0 items-start gap-3">
|
|
109
|
+
<EntityIcon icon={Icon} size="lg" />
|
|
110
|
+
<div className="min-w-0">
|
|
111
|
+
<h1 className="text-xl font-semibold">{title}</h1>
|
|
112
|
+
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{action ? (
|
|
117
|
+
<div className="flex w-full shrink-0 flex-wrap items-center gap-2 sm:w-auto sm:flex-nowrap sm:justify-end sm:pt-0.5 [&_a:not([data-icon-only])]:w-full sm:[&_a:not([data-icon-only])]:w-auto [&_button:not([data-icon-only])]:w-full sm:[&_button:not([data-icon-only])]:w-auto max-sm:[&>a:not([data-icon-only])]:basis-full max-sm:[&>a:not([data-icon-only])]:shrink-0 max-sm:[&>[data-icon-only]]:order-first max-sm:[&>button:not([data-icon-only])]:basis-full max-sm:[&>button:not([data-icon-only])]:shrink-0">
|
|
118
|
+
{action}
|
|
119
|
+
</div>
|
|
120
|
+
) : null}
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{(hasToolbar || hasFiltersPanel) && (
|
|
124
|
+
<section
|
|
125
|
+
id={toolbarId}
|
|
126
|
+
className="space-y-4"
|
|
127
|
+
aria-label={toolbarAriaLabel}
|
|
128
|
+
>
|
|
129
|
+
{hasToolbar && (
|
|
130
|
+
<div
|
|
131
|
+
className={cn(
|
|
132
|
+
"flex flex-col gap-3",
|
|
133
|
+
"max-sm:flex-row max-sm:flex-wrap max-sm:items-center max-sm:gap-2",
|
|
134
|
+
"max-sm:[&_[data-icon-only]]:order-1 max-sm:[&_[data-icon-only]]:shrink-0",
|
|
135
|
+
"max-sm:[&_[data-header-icons]_[data-icon-only]]:order-none",
|
|
136
|
+
"max-sm:[&_.w-full]:order-2 max-sm:[&_.w-full]:basis-full max-sm:[&_.w-full]:shrink-0",
|
|
137
|
+
"max-sm:[&_[data-header-icons]_.w-full]:order-none max-sm:[&_[data-header-icons]_.w-full]:basis-auto max-sm:[&_[data-header-icons]_.w-full]:shrink",
|
|
138
|
+
toolbarStart
|
|
139
|
+
? "xl:flex-row xl:items-center xl:justify-between"
|
|
140
|
+
: "sm:flex-row sm:flex-wrap sm:items-center sm:justify-between xl:justify-end xl:gap-4",
|
|
141
|
+
)}
|
|
142
|
+
>
|
|
143
|
+
{toolbarStart ? (
|
|
144
|
+
<div className="max-sm:contents sm:flex sm:flex-wrap sm:items-center sm:gap-2">
|
|
145
|
+
{toolbarStart}
|
|
146
|
+
</div>
|
|
147
|
+
) : null}
|
|
148
|
+
|
|
149
|
+
{toolbarEnd ? (
|
|
150
|
+
<div className="max-sm:contents sm:flex sm:flex-wrap sm:items-center sm:justify-between sm:gap-3 xl:justify-end xl:gap-4">
|
|
151
|
+
{toolbarEnd}
|
|
152
|
+
</div>
|
|
153
|
+
) : null}
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
{showFilters && filters}
|
|
158
|
+
</section>
|
|
159
|
+
)}
|
|
160
|
+
</header>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const HeaderPage = Object.assign(HeaderPageRoot, {
|
|
165
|
+
Meta: HeaderPageMeta,
|
|
166
|
+
ToolbarAction: HeaderPageToolbarAction,
|
|
167
|
+
ToolbarPagination: HeaderPageToolbarPagination,
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
export { HeaderPage }
|
|
171
|
+
export default HeaderPage
|
|
@@ -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 }
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { Icon } from "@tabler/icons-react"
|
|
4
|
+
import type { ReactNode } from "react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/utils"
|
|
7
|
+
import { EntityIcon } from "./entity-icon"
|
|
8
|
+
|
|
9
|
+
type HeaderPageProps = {
|
|
10
|
+
icon: Icon
|
|
11
|
+
title: string
|
|
12
|
+
description: string
|
|
13
|
+
className?: string
|
|
14
|
+
/** Contenido izquierdo de la barra (filtros, enlaces secundarios, etc.). */
|
|
15
|
+
toolbarStart?: ReactNode
|
|
16
|
+
/** Contenido derecho de la barra (resumen, paginación, acción principal). */
|
|
17
|
+
toolbarEnd?: ReactNode
|
|
18
|
+
/** Panel expandible debajo de la barra (p. ej. filtros avanzados). */
|
|
19
|
+
filters?: ReactNode
|
|
20
|
+
showFilters?: boolean
|
|
21
|
+
toolbarAriaLabel?: string
|
|
22
|
+
toolbarId?: string
|
|
23
|
+
/** Acción principal junto al título (p. ej. crear registro). */
|
|
24
|
+
action?: ReactNode
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function HeaderPageMeta({
|
|
28
|
+
children,
|
|
29
|
+
className,
|
|
30
|
+
}: {
|
|
31
|
+
children: ReactNode
|
|
32
|
+
className?: string
|
|
33
|
+
}) {
|
|
34
|
+
return (
|
|
35
|
+
<p
|
|
36
|
+
data-header-meta
|
|
37
|
+
className={cn(
|
|
38
|
+
"text-sm text-muted-foreground max-sm:order-3 max-sm:basis-full max-sm:shrink-0 sm:order-1 xl:order-none",
|
|
39
|
+
className,
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
{children}
|
|
43
|
+
</p>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function HeaderPageToolbarAction({
|
|
48
|
+
children,
|
|
49
|
+
className,
|
|
50
|
+
order = "primary",
|
|
51
|
+
}: {
|
|
52
|
+
children: ReactNode
|
|
53
|
+
className?: string
|
|
54
|
+
order?: "primary" | "secondary"
|
|
55
|
+
}) {
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
className={cn(
|
|
59
|
+
"w-full sm:w-auto xl:order-none [&_a]:w-full sm:[&_a]:w-auto [&_button:not([data-icon-only])]:w-full sm:[&_button:not([data-icon-only])]:w-auto",
|
|
60
|
+
order === "primary" && "sm:order-2 sm:ml-auto",
|
|
61
|
+
order === "secondary" && "sm:order-3",
|
|
62
|
+
className,
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
{children}
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function HeaderPageToolbarPagination({
|
|
71
|
+
children,
|
|
72
|
+
className,
|
|
73
|
+
}: {
|
|
74
|
+
children: ReactNode
|
|
75
|
+
className?: string
|
|
76
|
+
}) {
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
className={cn(
|
|
80
|
+
"flex items-center justify-center sm:order-3 xl:order-none",
|
|
81
|
+
className,
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
{children}
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function HeaderPageRoot({
|
|
90
|
+
icon: Icon,
|
|
91
|
+
title,
|
|
92
|
+
description,
|
|
93
|
+
className,
|
|
94
|
+
toolbarStart,
|
|
95
|
+
toolbarEnd,
|
|
96
|
+
filters,
|
|
97
|
+
showFilters = false,
|
|
98
|
+
toolbarAriaLabel = "Filtros y acciones",
|
|
99
|
+
toolbarId,
|
|
100
|
+
action,
|
|
101
|
+
}: HeaderPageProps) {
|
|
102
|
+
const hasToolbar = Boolean(toolbarStart || toolbarEnd)
|
|
103
|
+
const hasFiltersPanel = Boolean(filters)
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<header className={cn("space-y-4", className)}>
|
|
107
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
108
|
+
<div className="flex min-w-0 items-start gap-3">
|
|
109
|
+
<EntityIcon icon={Icon} size="lg" />
|
|
110
|
+
<div className="min-w-0">
|
|
111
|
+
<h1 className="text-xl font-semibold">{title}</h1>
|
|
112
|
+
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{action ? (
|
|
117
|
+
<div className="flex w-full shrink-0 flex-wrap items-center gap-2 sm:w-auto sm:flex-nowrap sm:justify-end sm:pt-0.5 [&_a:not([data-icon-only])]:w-full sm:[&_a:not([data-icon-only])]:w-auto [&_button:not([data-icon-only])]:w-full sm:[&_button:not([data-icon-only])]:w-auto max-sm:[&>a:not([data-icon-only])]:basis-full max-sm:[&>a:not([data-icon-only])]:shrink-0 max-sm:[&>[data-icon-only]]:order-first max-sm:[&>button:not([data-icon-only])]:basis-full max-sm:[&>button:not([data-icon-only])]:shrink-0">
|
|
118
|
+
{action}
|
|
119
|
+
</div>
|
|
120
|
+
) : null}
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{(hasToolbar || hasFiltersPanel) && (
|
|
124
|
+
<section
|
|
125
|
+
id={toolbarId}
|
|
126
|
+
className="space-y-4"
|
|
127
|
+
aria-label={toolbarAriaLabel}
|
|
128
|
+
>
|
|
129
|
+
{hasToolbar && (
|
|
130
|
+
<div
|
|
131
|
+
className={cn(
|
|
132
|
+
"flex flex-col gap-3",
|
|
133
|
+
"max-sm:flex-row max-sm:flex-wrap max-sm:items-center max-sm:gap-2",
|
|
134
|
+
"max-sm:[&_[data-icon-only]]:order-1 max-sm:[&_[data-icon-only]]:shrink-0",
|
|
135
|
+
"max-sm:[&_[data-header-icons]_[data-icon-only]]:order-none",
|
|
136
|
+
"max-sm:[&_.w-full]:order-2 max-sm:[&_.w-full]:basis-full max-sm:[&_.w-full]:shrink-0",
|
|
137
|
+
"max-sm:[&_[data-header-icons]_.w-full]:order-none max-sm:[&_[data-header-icons]_.w-full]:basis-auto max-sm:[&_[data-header-icons]_.w-full]:shrink",
|
|
138
|
+
toolbarStart
|
|
139
|
+
? "xl:flex-row xl:items-center xl:justify-between"
|
|
140
|
+
: "sm:flex-row sm:flex-wrap sm:items-center sm:justify-between xl:justify-end xl:gap-4",
|
|
141
|
+
)}
|
|
142
|
+
>
|
|
143
|
+
{toolbarStart ? (
|
|
144
|
+
<div className="max-sm:contents sm:flex sm:flex-wrap sm:items-center sm:gap-2">
|
|
145
|
+
{toolbarStart}
|
|
146
|
+
</div>
|
|
147
|
+
) : null}
|
|
148
|
+
|
|
149
|
+
{toolbarEnd ? (
|
|
150
|
+
<div className="max-sm:contents sm:flex sm:flex-wrap sm:items-center sm:justify-between sm:gap-3 xl:justify-end xl:gap-4">
|
|
151
|
+
{toolbarEnd}
|
|
152
|
+
</div>
|
|
153
|
+
) : null}
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
{showFilters && filters}
|
|
158
|
+
</section>
|
|
159
|
+
)}
|
|
160
|
+
</header>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const HeaderPage = Object.assign(HeaderPageRoot, {
|
|
165
|
+
Meta: HeaderPageMeta,
|
|
166
|
+
ToolbarAction: HeaderPageToolbarAction,
|
|
167
|
+
ToolbarPagination: HeaderPageToolbarPagination,
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
export { HeaderPage }
|
|
171
|
+
export default HeaderPage
|
|
@@ -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 }
|