@traky-app/ui 1.6.4 → 1.6.5
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/internal-chat-panel.json +1 -1
- package/registry/traky/json/office-address-form.json +5 -1
- package/registry/traky/ui/internal-chat-panel.tsx +1417 -71
- package/registry/traky/ui/office-address-form.tsx +122 -76
- package/src/ui/internal-chat-panel.tsx +1417 -71
- package/src/ui/office-address-form.tsx +80 -43
package/package.json
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"files": [
|
|
24
24
|
{
|
|
25
25
|
"path": "ui/internal-chat-panel.tsx",
|
|
26
|
-
"content": "\"use client\";\n\nimport {\n IconArrowUp,\n IconDotsVertical,\n IconLoader2,\n IconMessages,\n IconPackage,\n IconPaperclip,\n IconPlus,\n IconSparkles,\n IconTrash,\n IconTruckLoading,\n IconX,\n type Icon,\n} from \"@tabler/icons-react\";\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type FormEvent,\n type ReactNode,\n} from \"react\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Bubble, BubbleContent } from \"@/components/ui/bubble\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n Drawer,\n DrawerClose,\n DrawerContent,\n DrawerDescription,\n DrawerTitle,\n} from \"@/components/ui/drawer\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n Empty,\n EmptyDescription,\n EmptyHeader,\n EmptyMedia,\n EmptyTitle,\n} from \"@/components/ui/empty\";\nimport {\n InputGroup,\n InputGroupAddon,\n InputGroupButton,\n InputGroupTextarea,\n} from \"@/components/ui/input-group\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Message,\n MessageAvatar,\n MessageContent,\n MessageFooter,\n MessageHeader,\n} from \"@/components/ui/message\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport {\n buildMentionList,\n DEFAULT_MENTION_KIND_LABEL,\n DEFAULT_MENTION_KIND_ORDER,\n formatMessageTime,\n parseMentionSegment,\n parseSlashMention,\n roomInitialLetter,\n type BuildMentionListOptions,\n type InternalChatMentionCatalogState,\n type InternalChatMentionConfig,\n type InternalChatMentionItem,\n type InternalChatMentionKind,\n type InternalChatMentionListEntry,\n} from \"@/components/ui/internal-chat-mention\";\n\nexport type InternalChatRoom = {\n id: string;\n name: string;\n isOrganization?: boolean;\n};\n\nexport type InternalChatMessageLine = {\n id: string;\n role: \"me\" | \"team\";\n author: string;\n initials: string;\n text: string;\n at: string;\n};\n\nexport type InternalChatMessagesStatus =\n | \"idle\"\n | \"loading\"\n | \"error\"\n | \"empty\"\n | \"ready\";\n\nexport type InternalChatPanelLabels = {\n openChat: string;\n newRoom: string;\n roomMenu: string;\n deleteRoom: string;\n closeChat: string;\n newRoomTitle: string;\n newRoomDescription: string;\n newRoomPlaceholder: string;\n cancel: string;\n create: string;\n deleteRoomTitle: string;\n deleteRoomDescription: (roomName: string) => string;\n deleteConfirm: string;\n noRoomTitle: string;\n noRoomDescription: string;\n loadingMessages: string;\n loadMessagesErrorTitle: string;\n loadMessagesErrorFallback: string;\n emptyThreadTitle: string;\n emptyThreadDescription: (roomName: string) => string;\n loadingOlderMessages: string;\n mentionListLabel: string;\n messagePlaceholder: string;\n messagePlaceholderNoRoom: string;\n messageAriaLabel: string;\n attachAriaLabel: string;\n attachPhotos: string;\n sendSrOnly: string;\n orgRoomSuffix: string;\n orgRoomTooltip: string;\n slashMentionHint: string;\n selectRoomHint: string;\n};\n\nconst DEFAULT_LABELS: InternalChatPanelLabels = {\n openChat: \"Abrir chat de la empresa\",\n newRoom: \"Nueva sala\",\n roomMenu: \"Más acciones de salas\",\n deleteRoom: \"Eliminar sala actual\",\n closeChat: \"Cerrar chat\",\n newRoomTitle: \"Nueva sala\",\n newRoomDescription:\n \"Crea un espacio para conversar con tu equipo en tiempo real.\",\n newRoomPlaceholder: \"Nombre de la sala\",\n cancel: \"Cancelar\",\n create: \"Crear\",\n deleteRoomTitle: \"¿Eliminar esta sala?\",\n deleteRoomDescription: (roomName) =>\n `Se borrarán los mensajes de «${roomName}» en el servidor. Esta acción no se puede deshacer.`,\n deleteConfirm: \"Eliminar\",\n noRoomTitle: \"Sin sala\",\n noRoomDescription: \"Crea una sala con el botón + a la izquierda.\",\n loadingMessages: \"Cargando mensajes…\",\n loadMessagesErrorTitle: \"No se pudieron cargar los mensajes\",\n loadMessagesErrorFallback:\n \"Revisa que la API esté en marcha y que el módulo de chat interno esté migrado.\",\n emptyThreadTitle: \"¿De qué hablamos?\",\n emptyThreadDescription: (roomName) =>\n `Escribe el primer mensaje en «${roomName}».`,\n loadingOlderMessages: \"Cargando mensajes anteriores…\",\n mentionListLabel: \"Insertar mención\",\n messagePlaceholder: \"Mensaje… · / comandos\",\n messagePlaceholderNoRoom: \"Selecciona una sala…\",\n messageAriaLabel: \"Mensaje\",\n attachAriaLabel: \"Adjuntar\",\n attachPhotos: \"Fotos y archivos\",\n sendSrOnly: \"Enviar\",\n orgRoomSuffix: \" · Sala de toda la organización\",\n orgRoomTooltip: \" — toda la organización (no se elimina)\",\n slashMentionHint: \"menciones (guías y paquetes)\",\n selectRoomHint: \"Selecciona una sala o crea una nueva.\",\n};\n\nconst DEFAULT_MENTION_CONFIG: InternalChatMentionConfig = {\n kindOrder: DEFAULT_MENTION_KIND_ORDER,\n kindIcons: {\n guide: IconTruckLoading,\n package: IconPackage,\n ai: IconSparkles,\n },\n categoryMeta: {\n guide: {\n label: \"Guía\",\n hint: \"Guías de envío\",\n keywords: [\"guía\", \"guia\", \"guide\", \"envío\", \"envio\"],\n },\n package: {\n label: \"Paquete\",\n hint: \"Paquetes de la empresa (WR / tracking)\",\n keywords: [\"paquete\", \"paquetes\", \"package\", \"trk\", \"wr\"],\n },\n ai: {\n label: \"Asistencia de IA\",\n hint: \"Consultas sobre operación y datos (próximamente)\",\n keywords: [\"ia\", \"ai\", \"asistencia\", \"inteligencia\", \"asistente\"],\n disabled: true,\n },\n },\n};\n\nfunction isMentionEntryDisabled(\n entry: InternalChatMentionListEntry,\n config: InternalChatMentionConfig,\n): boolean {\n if (entry.type !== \"category\") return false;\n return Boolean(\n entry.disabled ?? config.categoryMeta[entry.kind]?.disabled,\n );\n}\n\nfunction stepMentionHighlight(\n entries: InternalChatMentionListEntry[],\n config: InternalChatMentionConfig,\n current: number,\n delta: 1 | -1,\n): number {\n if (entries.length === 0) return 0;\n let next = current;\n for (let i = 0; i < entries.length; i++) {\n next = (next + delta + entries.length) % entries.length;\n if (!isMentionEntryDisabled(entries[next]!, config)) return next;\n }\n return current;\n}\n\nfunction renderMessageTextWithMentions(text: string): ReactNode {\n const parts = text.split(/(@\\[[^\\]]+\\])/g);\n return parts.map((part, index) => {\n const mention = /^@\\[(.+)\\]$/.exec(part);\n if (mention) {\n return (\n <span\n key={index}\n className=\"inline-flex max-w-full items-center rounded-md border border-primary/35 bg-primary/12 px-1.5 py-0.5 font-medium text-primary\"\n >\n {mention[1]}\n </span>\n );\n }\n return <span key={index}>{part}</span>;\n });\n}\n\nexport type InternalChatPanelProps = {\n title: string;\n rooms: InternalChatRoom[];\n activeRoomId: string | null;\n onActiveRoomChange: (roomId: string) => void;\n messages: InternalChatMessageLine[];\n messagesStatus: InternalChatMessagesStatus;\n messagesError?: string | null;\n onLoadOlderMessages?: () => void | Promise<void>;\n isLoadingOlder?: boolean;\n hasOlderMessages?: boolean;\n mentionGuideItems?: InternalChatMentionItem[];\n mentionPackageItems?: InternalChatMentionItem[];\n mentionConfig?: InternalChatMentionConfig;\n /** Catálogo de ítems ya filtrado en servidor (guías/paquetes). */\n mentionServerFilteredItems?: boolean;\n mentionCatalogLoading?: boolean;\n onMentionCatalogChange?: (state: InternalChatMentionCatalogState) => void;\n typingLine?: string | null;\n onSendMessage: (text: string) => void | Promise<void>;\n onCreateRoom: (name: string) => void | Promise<void>;\n onDeleteRoom: (roomId: string) => void | Promise<void>;\n onOpenChange?: (open: boolean) => void;\n onDraftChange?: (draft: string) => void;\n onUserGesture?: () => void;\n isSending?: boolean;\n isCreatingRoom?: boolean;\n isDeletingRoom?: boolean;\n onDeleteRoomBlocked?: (reason: string) => void;\n labels?: Partial<InternalChatPanelLabels>;\n className?: string;\n};\n\nfunction InternalChatPanel({\n title,\n rooms,\n activeRoomId,\n onActiveRoomChange,\n messages,\n messagesStatus,\n messagesError = null,\n onLoadOlderMessages,\n isLoadingOlder = false,\n hasOlderMessages = false,\n mentionGuideItems = [],\n mentionPackageItems = [],\n mentionConfig = DEFAULT_MENTION_CONFIG,\n mentionServerFilteredItems = false,\n mentionCatalogLoading = false,\n onMentionCatalogChange,\n typingLine = null,\n onSendMessage,\n onCreateRoom,\n onDeleteRoom,\n onOpenChange,\n onDraftChange,\n onUserGesture,\n isSending = false,\n isCreatingRoom = false,\n isDeletingRoom = false,\n onDeleteRoomBlocked,\n labels: labelsPartial,\n className,\n}: InternalChatPanelProps) {\n const labels = { ...DEFAULT_LABELS, ...labelsPartial };\n\n const [open, setOpen] = useState(false);\n const [draft, setDraft] = useState(\"\");\n const [createOpen, setCreateOpen] = useState(false);\n const [newRoomName, setNewRoomName] = useState(\"\");\n const [deleteOpen, setDeleteOpen] = useState(false);\n const [mentionStart, setMentionStart] = useState<number | null>(null);\n const [mentionQuery, setMentionQuery] = useState(\"\");\n const [mentionHighlight, setMentionHighlight] = useState(0);\n const lastMentionQueryRef = useRef(\"\");\n const loadingOlderMessagesRef = useRef(false);\n\n const formRef = useRef<HTMLFormElement>(null);\n const scrollerRef = useRef<HTMLDivElement>(null);\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n\n const activeRoom = useMemo(\n () => rooms.find((r) => r.id === activeRoomId) ?? null,\n [rooms, activeRoomId],\n );\n\n const mentionListOptions = useMemo((): BuildMentionListOptions | undefined => {\n if (!mentionServerFilteredItems) return undefined;\n const seg = parseMentionSegment(mentionQuery, mentionConfig);\n if (seg.phase !== \"item\") return undefined;\n return { serverFilteredItems: true };\n }, [mentionServerFilteredItems, mentionQuery, mentionConfig]);\n\n const mentionEntries = useMemo(\n () =>\n buildMentionList(\n mentionQuery,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n mentionListOptions,\n ),\n [\n mentionQuery,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n mentionListOptions,\n ],\n );\n\n const mentionSeg = useMemo(\n () => parseMentionSegment(mentionQuery, mentionConfig),\n [mentionQuery, mentionConfig],\n );\n\n const mentionOpen =\n Boolean(activeRoom) &&\n mentionStart !== null &&\n (mentionEntries.length > 0 ||\n mentionCatalogLoading ||\n (mentionServerFilteredItems && mentionSeg.phase === \"item\"));\n\n const canSend =\n draft.trim().length > 0 &&\n !isSending &&\n Boolean(activeRoomId);\n\n const setOpenWithNotify = useCallback(\n (next: boolean) => {\n if (next) onUserGesture?.();\n setOpen(next);\n onOpenChange?.(next);\n },\n [onOpenChange, onUserGesture],\n );\n\n const handleMessagesScroll = useCallback(() => {\n const node = scrollerRef.current;\n if (!node || !activeRoomId || !onLoadOlderMessages) return;\n if (loadingOlderMessagesRef.current) return;\n if (node.scrollTop > 120) return;\n if (!hasOlderMessages || isLoadingOlder) return;\n loadingOlderMessagesRef.current = true;\n const prevHeight = node.scrollHeight;\n const prevTop = node.scrollTop;\n void Promise.resolve(onLoadOlderMessages()).finally(() => {\n loadingOlderMessagesRef.current = false;\n requestAnimationFrame(() => {\n const n = scrollerRef.current;\n if (!n) return;\n const delta = n.scrollHeight - prevHeight;\n n.scrollTop = prevTop + delta;\n });\n });\n }, [\n activeRoomId,\n hasOlderMessages,\n isLoadingOlder,\n onLoadOlderMessages,\n ]);\n\n useLayoutEffect(() => {\n if (!open) return;\n const scrollToEnd = () => {\n const node = scrollerRef.current;\n if (!node) return;\n node.scrollTop = node.scrollHeight;\n };\n scrollToEnd();\n let rafNested = 0;\n const rafOuter = requestAnimationFrame(() => {\n scrollToEnd();\n rafNested = requestAnimationFrame(scrollToEnd);\n });\n const timeouts = [0, 50, 150].map((ms) =>\n window.setTimeout(scrollToEnd, ms),\n );\n return () => {\n cancelAnimationFrame(rafOuter);\n if (rafNested) cancelAnimationFrame(rafNested);\n timeouts.forEach(clearTimeout);\n };\n }, [open, activeRoomId, messages.length]);\n\n useEffect(() => {\n if (!mentionOpen || mentionEntries.length === 0) return;\n setMentionHighlight((h) => Math.min(h, mentionEntries.length - 1));\n }, [mentionOpen, mentionEntries.length]);\n\n useEffect(() => {\n setMentionStart(null);\n setMentionQuery(\"\");\n lastMentionQueryRef.current = \"\";\n }, [activeRoomId]);\n\n useEffect(() => {\n if (!onMentionCatalogChange) return;\n if (mentionStart === null) {\n onMentionCatalogChange({\n open: false,\n phase: \"category\",\n filter: \"\",\n });\n return;\n }\n if (mentionSeg.phase === \"category\") {\n onMentionCatalogChange({\n open: true,\n phase: \"category\",\n filter: mentionSeg.filter,\n });\n return;\n }\n onMentionCatalogChange({\n open: true,\n phase: \"item\",\n kind: mentionSeg.kind,\n filter: mentionSeg.filter,\n });\n }, [mentionStart, mentionQuery, mentionConfig, onMentionCatalogChange]);\n\n function closeMentionMenu() {\n setMentionStart(null);\n setMentionQuery(\"\");\n lastMentionQueryRef.current = \"\";\n }\n\n function syncMentionMenu(nextDraft: string, cursor: number) {\n const m = parseSlashMention(nextDraft, cursor);\n if (!m) {\n closeMentionMenu();\n return;\n }\n const seg = parseMentionSegment(m.query, mentionConfig);\n const listOptions: BuildMentionListOptions | undefined =\n mentionServerFilteredItems && seg.phase === \"item\"\n ? { serverFilteredItems: true }\n : undefined;\n const entries = buildMentionList(\n m.query,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n listOptions,\n );\n const keepOpenOnEmpty =\n mentionCatalogLoading ||\n (mentionServerFilteredItems && seg.phase === \"item\");\n if (entries.length === 0 && !keepOpenOnEmpty) {\n closeMentionMenu();\n return;\n }\n if (m.query !== lastMentionQueryRef.current) {\n setMentionHighlight(0);\n lastMentionQueryRef.current = m.query;\n }\n setMentionStart(m.start);\n setMentionQuery(m.query);\n }\n\n function replaceSlashSegment(newSegment: string) {\n const el = textareaRef.current;\n if (el === null || mentionStart === null) return;\n const cursor = el.selectionStart ?? draft.length;\n const before = draft.slice(0, mentionStart);\n const after = draft.slice(cursor);\n const newDraft = `${before}/${newSegment}${after}`;\n const slashEnd = before.length + 1 + newSegment.length;\n setDraft(newDraft);\n onDraftChange?.(newDraft);\n setMentionQuery(newSegment);\n lastMentionQueryRef.current = newSegment;\n requestAnimationFrame(() => {\n el.focus();\n el.setSelectionRange(slashEnd, slashEnd);\n });\n }\n\n function applyCategoryPick(kind: InternalChatMentionKind) {\n if (mentionConfig.categoryMeta[kind]?.disabled) return;\n replaceSlashSegment(`${kind}:`);\n setMentionHighlight(0);\n }\n\n function applyItemPick(item: InternalChatMentionItem) {\n const el = textareaRef.current;\n if (el === null || mentionStart === null) return;\n const cursor = el.selectionStart ?? draft.length;\n const before = draft.slice(0, mentionStart);\n const after = draft.slice(cursor);\n const token = `@[${item.insertLabel}]`;\n const needsSpace =\n after.length > 0 && !/^\\s/.test(after) && !after.startsWith(\"\\n\");\n const newDraft = `${before}${token}${needsSpace ? \" \" : \"\"}${after}`;\n setDraft(newDraft);\n onDraftChange?.(newDraft);\n closeMentionMenu();\n const newPos = before.length + token.length + (needsSpace ? 1 : 0);\n requestAnimationFrame(() => {\n el.focus();\n el.setSelectionRange(newPos, newPos);\n });\n }\n\n async function onSubmit(event: FormEvent<HTMLFormElement>) {\n event.preventDefault();\n if (mentionOpen) return;\n const text = draft.trim();\n if (!text || !activeRoomId) return;\n onUserGesture?.();\n try {\n await onSendMessage(text);\n setDraft(\"\");\n onDraftChange?.(\"\");\n } catch {\n /* errores vía contenedor */\n }\n }\n\n async function handleCreateRoom() {\n const name = newRoomName.trim();\n if (!name) return;\n try {\n await onCreateRoom(name);\n setNewRoomName(\"\");\n setCreateOpen(false);\n } catch {\n /* contenedor */\n }\n }\n\n async function handleConfirmDeleteRoom() {\n if (!activeRoom) return;\n try {\n await onDeleteRoom(activeRoom.id);\n setDeleteOpen(false);\n } catch {\n /* contenedor */\n }\n }\n\n function requestDeleteRoom() {\n if (!activeRoom) return;\n if (activeRoom.isOrganization) {\n onDeleteRoomBlocked?.(\"La sala Organización no se puede eliminar.\");\n return;\n }\n if (rooms.length <= 1) {\n onDeleteRoomBlocked?.(\"Debe existir al menos una sala.\");\n return;\n }\n setDeleteOpen(true);\n }\n\n return (\n <>\n <Button\n type=\"button\"\n size=\"icon\"\n className={cn(\"relative size-9 text-primary-foreground\", className)}\n aria-label={labels.openChat}\n onClick={() => setOpenWithNotify(true)}\n >\n <IconMessages className=\"size-4\" />\n </Button>\n\n <Dialog open={createOpen} onOpenChange={setCreateOpen}>\n <DialogContent className=\"sm:max-w-md\">\n <DialogHeader>\n <DialogTitle>{labels.newRoomTitle}</DialogTitle>\n <DialogDescription>{labels.newRoomDescription}</DialogDescription>\n </DialogHeader>\n <Input\n value={newRoomName}\n onChange={(e) => setNewRoomName(e.target.value)}\n placeholder={labels.newRoomPlaceholder}\n autoFocus\n onKeyDown={(e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n void handleCreateRoom();\n }\n }}\n />\n <DialogFooter className=\"gap-2 sm:gap-0\">\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={() => {\n setCreateOpen(false);\n setNewRoomName(\"\");\n }}\n >\n {labels.cancel}\n </Button>\n <Button\n type=\"button\"\n onClick={() => void handleCreateRoom()}\n disabled={isCreatingRoom}\n >\n {labels.create}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n\n <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>{labels.deleteRoomTitle}</AlertDialogTitle>\n <AlertDialogDescription>\n {labels.deleteRoomDescription(activeRoom?.name ?? \"la sala\")}\n </AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel>{labels.cancel}</AlertDialogCancel>\n <AlertDialogAction\n className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n onClick={() => void handleConfirmDeleteRoom()}\n disabled={isDeletingRoom}\n >\n {labels.deleteConfirm}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n\n <Drawer\n open={open}\n onOpenChange={setOpenWithNotify}\n direction=\"right\"\n shouldScaleBackground={false}\n >\n <DrawerContent\n className={cn(\n \"gap-0 overflow-hidden p-0 shadow-xl\",\n \"data-[vaul-drawer-direction=right]:inset-y-3\",\n \"data-[vaul-drawer-direction=right]:right-3\",\n \"data-[vaul-drawer-direction=right]:left-auto\",\n \"data-[vaul-drawer-direction=right]:h-[calc(100%-1.5rem)]\",\n \"data-[vaul-drawer-direction=right]:max-h-[calc(100%-1.5rem)]\",\n \"data-[vaul-drawer-direction=right]:w-[min(36rem,calc(100vw-1.5rem))]\",\n \"data-[vaul-drawer-direction=right]:max-w-[min(36rem,calc(100vw-1.5rem))]\",\n \"data-[vaul-drawer-direction=right]:sm:max-w-[min(36rem,calc(100vw-1.5rem))]\",\n \"data-[vaul-drawer-direction=right]:rounded-2xl\",\n \"data-[vaul-drawer-direction=right]:border\",\n )}\n >\n <div className=\"flex h-full min-h-0 flex-row\">\n <div className=\"flex w-[3.25rem] shrink-0 flex-col items-center gap-2 border-r border-border py-4\">\n <Tooltip delayDuration={300}>\n <TooltipTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-9 shrink-0 rounded-full\"\n aria-label={labels.newRoom}\n onClick={() => setCreateOpen(true)}\n >\n <IconPlus className=\"size-4\" />\n </Button>\n </TooltipTrigger>\n <TooltipContent side=\"left\">{labels.newRoom}</TooltipContent>\n </Tooltip>\n\n <div className=\"flex min-h-0 flex-1 flex-col items-center gap-2 overflow-y-auto px-1 py-1\">\n {rooms.map((room) => {\n const selected = room.id === activeRoomId;\n return (\n <Tooltip key={room.id} delayDuration={300}>\n <TooltipTrigger asChild>\n <button\n type=\"button\"\n onClick={() => onActiveRoomChange(room.id)}\n className={cn(\n \"relative flex size-9 shrink-0 items-center justify-center rounded-full border-2 transition-colors\",\n selected\n ? \"border-primary bg-primary/15\"\n : \"border-transparent bg-muted hover:bg-muted/80\",\n )}\n aria-label={`Sala ${room.name}${room.isOrganization ? \" (toda la organización)\" : \"\"}`}\n aria-current={selected ? \"true\" : undefined}\n >\n <span className=\"text-xs font-semibold leading-none\">\n {roomInitialLetter(room.name)}\n </span>\n </button>\n </TooltipTrigger>\n <TooltipContent side=\"left\">\n {room.isOrganization\n ? `${room.name}${labels.orgRoomTooltip}`\n : room.name}\n </TooltipContent>\n </Tooltip>\n );\n })}\n </div>\n\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-9 shrink-0 rounded-full\"\n aria-label={labels.roomMenu}\n >\n <IconDotsVertical className=\"size-4\" />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent side=\"left\" align=\"end\" className=\"w-48\">\n <DropdownMenuItem onClick={() => setCreateOpen(true)}>\n <IconPlus className=\"size-4\" />\n {labels.newRoom}\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem\n disabled={\n rooms.length <= 1 || activeRoom?.isOrganization\n }\n className=\"text-destructive focus:text-destructive\"\n onClick={requestDeleteRoom}\n >\n <IconTrash className=\"size-4\" />\n {labels.deleteRoom}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </div>\n\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n <div className=\"flex items-start justify-between gap-4 px-5 pt-5 pb-3\">\n <div className=\"flex min-w-0 flex-col gap-1\">\n <DrawerTitle className=\"flex items-center gap-2 font-medium\">\n <IconMessages className=\"size-5 shrink-0\" aria-hidden />\n <span className=\"min-w-0 truncate\">{title}</span>\n </DrawerTitle>\n <DrawerDescription className=\"line-clamp-2\">\n {activeRoom ? (\n <>\n <span className=\"font-medium text-foreground\">\n {activeRoom.name}\n </span>\n {activeRoom.isOrganization ? (\n <>{labels.orgRoomSuffix}</>\n ) : null}\n {\" · \"}\n <kbd className=\"pointer-events-none rounded border bg-muted px-0.5 font-mono text-[0.65rem]\">\n /\n </kbd>{\" \"}\n {labels.slashMentionHint}\n </>\n ) : (\n labels.selectRoomHint\n )}\n </DrawerDescription>\n </div>\n <DrawerClose asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n className=\"size-8 shrink-0\"\n aria-label={labels.closeChat}\n >\n <IconX className=\"size-4\" />\n </Button>\n </DrawerClose>\n </div>\n\n <div\n ref={scrollerRef}\n className=\"min-h-0 flex-1 overflow-y-auto\"\n onScroll={handleMessagesScroll}\n >\n {!activeRoom ? (\n <Empty className=\"h-full border-0\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <IconMessages />\n </EmptyMedia>\n <EmptyTitle>{labels.noRoomTitle}</EmptyTitle>\n <EmptyDescription>\n {labels.noRoomDescription}\n </EmptyDescription>\n </EmptyHeader>\n </Empty>\n ) : messagesStatus === \"loading\" ? (\n <div className=\"flex h-full flex-col items-center justify-center gap-2 text-muted-foreground\">\n <IconLoader2 className=\"size-6 animate-spin\" aria-hidden />\n <span className=\"text-sm\">{labels.loadingMessages}</span>\n </div>\n ) : messagesStatus === \"error\" ? (\n <Empty className=\"h-full border-0\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <IconMessages />\n </EmptyMedia>\n <EmptyTitle>{labels.loadMessagesErrorTitle}</EmptyTitle>\n <EmptyDescription className=\"text-left\">\n {messagesError ?? labels.loadMessagesErrorFallback}\n </EmptyDescription>\n </EmptyHeader>\n </Empty>\n ) : messagesStatus === \"empty\" ? (\n <Empty className=\"h-full border-0\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <IconMessages />\n </EmptyMedia>\n <EmptyTitle>{labels.emptyThreadTitle}</EmptyTitle>\n <EmptyDescription>\n {labels.emptyThreadDescription(activeRoom.name)}\n </EmptyDescription>\n </EmptyHeader>\n </Empty>\n ) : (\n <div className=\"flex flex-col gap-6 px-5 py-2\">\n {isLoadingOlder ? (\n <div className=\"flex items-center justify-center gap-2 py-2 text-xs text-muted-foreground\">\n <IconLoader2\n className=\"size-3.5 shrink-0 animate-spin\"\n aria-hidden\n />\n {labels.loadingOlderMessages}\n </div>\n ) : null}\n {messages.map((message) => {\n const mine = message.role === \"me\";\n return (\n <Message\n key={message.id}\n align={mine ? \"end\" : \"start\"}\n >\n {mine ? null : (\n <MessageAvatar className=\"size-8 text-xs font-medium\">\n {message.initials}\n </MessageAvatar>\n )}\n <MessageContent>\n {mine ? null : (\n <MessageHeader>{message.author}</MessageHeader>\n )}\n <Bubble\n align={mine ? \"end\" : \"start\"}\n variant={mine ? \"muted\" : \"outline\"}\n >\n <BubbleContent className=\"break-words whitespace-pre-wrap\">\n {renderMessageTextWithMentions(message.text)}\n </BubbleContent>\n </Bubble>\n <MessageFooter>\n {formatMessageTime(message.at)}\n </MessageFooter>\n </MessageContent>\n </Message>\n );\n })}\n </div>\n )}\n {activeRoom && typingLine ? (\n <div\n className=\"border-t border-border px-5 py-2 text-xs text-muted-foreground\"\n aria-live=\"polite\"\n >\n {typingLine}\n </div>\n ) : null}\n </div>\n\n <form\n ref={formRef}\n onSubmit={onSubmit}\n className=\"relative px-5 pt-2 pb-5\"\n >\n {mentionOpen ? (\n <div\n role=\"listbox\"\n aria-label={labels.mentionListLabel}\n className=\"absolute bottom-full left-5 right-5 z-50 mb-1 flex max-h-60 flex-col overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-md\"\n >\n <div className=\"shrink-0 border-b border-border px-2 py-1.5 text-[0.7rem] text-muted-foreground\">\n {mentionSeg.phase === \"category\" ? (\n <>\n <span className=\"font-medium text-foreground\">\n Tipo\n </span>\n {\" · \"}\n <kbd className=\"rounded bg-muted px-1 font-mono text-[0.65rem]\">\n Tab\n </kbd>\n /\n <kbd className=\"rounded bg-muted px-1 font-mono text-[0.65rem]\">\n ↵\n </kbd>\n </>\n ) : (\n <>\n <span className=\"font-medium text-foreground\">\n {DEFAULT_MENTION_KIND_LABEL[mentionSeg.kind]}\n </span>\n {\" · \"}\n <kbd className=\"rounded bg-muted px-1 font-mono text-[0.65rem]\">\n ←\n </kbd>\n </>\n )}\n </div>\n <div className=\"min-h-0 flex-1 overflow-y-auto p-1\">\n {mentionCatalogLoading && mentionEntries.length === 0 ? (\n <div className=\"flex items-center justify-center gap-2 px-2 py-6 text-sm text-muted-foreground\">\n <IconLoader2 className=\"size-4 animate-spin\" />\n Buscando…\n </div>\n ) : null}\n {!mentionCatalogLoading &&\n mentionEntries.length === 0 &&\n mentionSeg.phase === \"item\" ? (\n <p className=\"px-2 py-4 text-center text-sm text-muted-foreground\">\n Sin resultados\n </p>\n ) : null}\n {mentionEntries.map((entry, index) => {\n if (entry.type === \"category\") {\n const IconComp = entry.icon;\n const categoryDisabled = isMentionEntryDisabled(\n entry,\n mentionConfig,\n );\n return (\n <button\n key={`cat-${entry.kind}`}\n type=\"button\"\n role=\"option\"\n aria-selected={index === mentionHighlight}\n aria-disabled={categoryDisabled}\n disabled={categoryDisabled}\n className={cn(\n \"flex w-full items-start gap-2 rounded-lg px-2 py-2 text-left text-sm\",\n categoryDisabled &&\n \"cursor-not-allowed opacity-50\",\n !categoryDisabled &&\n (index === mentionHighlight\n ? \"bg-accent text-accent-foreground\"\n : \"hover:bg-muted/80\"),\n )}\n onMouseEnter={() => {\n if (!categoryDisabled) setMentionHighlight(index);\n }}\n onMouseDown={(event) => {\n event.preventDefault();\n if (categoryDisabled) return;\n applyCategoryPick(entry.kind);\n }}\n >\n <IconComp className=\"mt-0.5 size-4 shrink-0 opacity-80\" />\n <span className=\"min-w-0 flex-1\">\n <span className=\"block font-medium leading-tight\">\n {entry.label}\n </span>\n <span className=\"mt-0.5 block text-xs text-muted-foreground\">\n {entry.hint}\n </span>\n </span>\n </button>\n );\n }\n const item = entry.data;\n const IconComp = item.icon;\n return (\n <button\n key={item.id}\n type=\"button\"\n role=\"option\"\n aria-selected={index === mentionHighlight}\n className={cn(\n \"flex w-full items-start gap-2 rounded-lg px-2 py-2 text-left text-sm\",\n index === mentionHighlight\n ? \"bg-accent text-accent-foreground\"\n : \"hover:bg-muted/80\",\n )}\n onMouseEnter={() => setMentionHighlight(index)}\n onMouseDown={(event) => {\n event.preventDefault();\n applyItemPick(item);\n }}\n >\n <IconComp className=\"mt-0.5 size-4 shrink-0 opacity-80\" />\n <span className=\"min-w-0 flex-1\">\n <span className=\"block font-medium leading-tight\">\n {item.label}\n </span>\n <span className=\"mt-0.5 block text-xs text-muted-foreground\">\n {item.subtitle}\n </span>\n </span>\n </button>\n );\n })}\n </div>\n </div>\n ) : null}\n\n <InputGroup>\n <InputGroupTextarea\n ref={textareaRef}\n value={draft}\n onChange={(event) => {\n const v = event.target.value;\n const cursor =\n event.target.selectionStart ?? v.length;\n setDraft(v);\n onDraftChange?.(v);\n syncMentionMenu(v, cursor);\n }}\n onSelect={(event) => {\n const t = event.target as HTMLTextAreaElement;\n syncMentionMenu(\n t.value,\n t.selectionStart ?? t.value.length,\n );\n }}\n placeholder={\n activeRoom\n ? labels.messagePlaceholder\n : labels.messagePlaceholderNoRoom\n }\n aria-label={labels.messageAriaLabel}\n className=\"min-h-14\"\n disabled={!activeRoom}\n onKeyDown={(event) => {\n onUserGesture?.();\n if (mentionOpen) {\n if (event.key === \"ArrowDown\") {\n event.preventDefault();\n setMentionHighlight((h) =>\n stepMentionHighlight(\n mentionEntries,\n mentionConfig,\n h,\n 1,\n ),\n );\n return;\n }\n if (event.key === \"ArrowUp\") {\n event.preventDefault();\n setMentionHighlight((h) =>\n stepMentionHighlight(\n mentionEntries,\n mentionConfig,\n h,\n -1,\n ),\n );\n return;\n }\n if (event.key === \"Backspace\") {\n const seg = parseMentionSegment(\n mentionQuery,\n mentionConfig,\n );\n if (seg.phase === \"item\" && seg.filter === \"\") {\n event.preventDefault();\n replaceSlashSegment(\"\");\n setMentionHighlight(0);\n return;\n }\n }\n if (event.key === \"ArrowLeft\") {\n const seg = parseMentionSegment(\n mentionQuery,\n mentionConfig,\n );\n if (seg.phase === \"item\" && seg.filter === \"\") {\n event.preventDefault();\n replaceSlashSegment(\"\");\n setMentionHighlight(0);\n return;\n }\n }\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n const pick = mentionEntries[mentionHighlight];\n if (!pick || isMentionEntryDisabled(pick, mentionConfig)) {\n return;\n }\n if (pick.type === \"category\") {\n applyCategoryPick(pick.kind);\n } else {\n applyItemPick(pick.data);\n }\n return;\n }\n if (event.key === \"Tab\") {\n event.preventDefault();\n const pick = mentionEntries[mentionHighlight];\n if (!pick || isMentionEntryDisabled(pick, mentionConfig)) {\n return;\n }\n if (pick.type === \"category\") {\n applyCategoryPick(pick.kind);\n } else {\n applyItemPick(pick.data);\n }\n return;\n }\n if (event.key === \"Escape\") {\n event.preventDefault();\n closeMentionMenu();\n return;\n }\n }\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n formRef.current?.requestSubmit();\n }\n }}\n />\n <InputGroupAddon align=\"block-end\" className=\"pt-1\">\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <InputGroupButton\n aria-label={labels.attachAriaLabel}\n type=\"button\"\n size=\"icon-sm\"\n variant=\"outline\"\n disabled={!activeRoom}\n >\n <IconPlus className=\"size-4\" />\n </InputGroupButton>\n </DropdownMenuTrigger>\n <DropdownMenuContent\n align=\"start\"\n side=\"top\"\n className=\"w-44\"\n >\n <DropdownMenuItem>\n <IconPaperclip className=\"size-4\" />\n {labels.attachPhotos}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n <InputGroupButton\n type=\"submit\"\n variant=\"default\"\n size=\"icon-sm\"\n disabled={!canSend || !activeRoom}\n className=\"ml-auto\"\n >\n <IconArrowUp className=\"size-4\" />\n <span className=\"sr-only\">{labels.sendSrOnly}</span>\n </InputGroupButton>\n </InputGroupAddon>\n </InputGroup>\n </form>\n </div>\n </div>\n </DrawerContent>\n </Drawer>\n </>\n );\n}\n\nexport {\n InternalChatPanel,\n DEFAULT_MENTION_CONFIG,\n DEFAULT_LABELS as DEFAULT_INTERNAL_CHAT_PANEL_LABELS,\n};\n\nexport { initialsFromDisplayName } from \"@/components/ui/internal-chat-mention\";\n\nexport type {\n BuildMentionListOptions,\n InternalChatMentionCatalogState,\n InternalChatMentionConfig,\n InternalChatMentionItem,\n InternalChatMentionKind,\n} from \"@/components/ui/internal-chat-mention\";\n",
|
|
26
|
+
"content": "\"use client\";\n\nimport {\n IconArrowUp,\n IconCheck,\n IconChecks,\n IconDotsVertical,\n IconFile,\n IconHeadset,\n IconLoader2,\n IconMicrophone,\n IconMessages,\n IconPackage,\n IconPaperclip,\n IconPhoto,\n IconPlayerPause,\n IconPlayerPlay,\n IconSquare,\n IconPlus,\n IconScreenShare,\n IconSparkles,\n IconTrash,\n IconTruckLoading,\n IconX,\n type Icon,\n} from \"@tabler/icons-react\";\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type FormEvent,\n type ReactNode,\n} from \"react\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Bubble, BubbleContent } from \"@/components/ui/bubble\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n Drawer,\n DrawerClose,\n DrawerContent,\n DrawerDescription,\n DrawerTitle,\n} from \"@/components/ui/drawer\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n Empty,\n EmptyDescription,\n EmptyHeader,\n EmptyMedia,\n EmptyTitle,\n} from \"@/components/ui/empty\";\nimport {\n InputGroup,\n InputGroupAddon,\n InputGroupButton,\n InputGroupTextarea,\n} from \"@/components/ui/input-group\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Message,\n MessageAvatar,\n MessageContent,\n MessageFooter,\n MessageHeader,\n} from \"@/components/ui/message\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport {\n buildMentionList,\n DEFAULT_MENTION_KIND_LABEL,\n DEFAULT_MENTION_KIND_ORDER,\n formatMessageTime,\n parseMentionSegment,\n parseSlashMention,\n roomInitialLetter,\n type BuildMentionListOptions,\n type InternalChatMentionCatalogState,\n type InternalChatMentionConfig,\n type InternalChatMentionItem,\n type InternalChatMentionKind,\n type InternalChatMentionListEntry,\n} from \"@/components/ui/internal-chat-mention\";\n\nexport type InternalChatRoom = {\n id: string;\n name: string;\n isOrganization?: boolean;\n /** Canal reservado con el administrador / soporte Traky. */\n isSupport?: boolean;\n};\n\n/** Acuse de recibo estilo mensajería (1 palomita = enviado, 2 grises = llegó, 2 azules = leído). */\nexport type InternalChatMessageReceiptStatus =\n | \"pending\"\n | \"sent\"\n | \"delivered\"\n | \"read\";\n\nexport type InternalChatMessageAttachment = {\n kind: \"image\" | \"file\" | \"video\";\n name: string;\n url?: string;\n sizeLabel?: string;\n};\n\nconst SCREEN_RECORD_MAX_MS = 60_000;\n\nexport type InternalChatMessageAudio = {\n src: string;\n durationMs?: number;\n};\n\nexport type InternalChatMessageLine = {\n id: string;\n role: \"me\" | \"team\";\n author: string;\n initials: string;\n text: string;\n at: string;\n kind?: \"text\" | \"audio\";\n audio?: InternalChatMessageAudio;\n attachments?: InternalChatMessageAttachment[];\n /** Solo mensajes propios (`role: \"me\"`). */\n receiptStatus?: InternalChatMessageReceiptStatus;\n};\n\nexport type InternalChatMessagesStatus =\n | \"idle\"\n | \"loading\"\n | \"error\"\n | \"empty\"\n | \"ready\";\n\nexport type InternalChatPanelLabels = {\n openChat: string;\n newRoom: string;\n roomMenu: string;\n deleteRoom: string;\n closeChat: string;\n newRoomTitle: string;\n newRoomDescription: string;\n newRoomPlaceholder: string;\n cancel: string;\n create: string;\n deleteRoomTitle: string;\n deleteRoomDescription: (roomName: string) => string;\n deleteConfirm: string;\n noRoomTitle: string;\n noRoomDescription: string;\n loadingMessages: string;\n loadMessagesErrorTitle: string;\n loadMessagesErrorFallback: string;\n emptyThreadTitle: string;\n emptyThreadDescription: (roomName: string) => string;\n loadingOlderMessages: string;\n mentionListLabel: string;\n messagePlaceholder: string;\n messagePlaceholderNoRoom: string;\n messageAriaLabel: string;\n attachAriaLabel: string;\n attachPhotos: string;\n attachFiles: string;\n attachAudio: string;\n attachAudioFile: string;\n attachmentPending: string;\n voiceRecordTitle: string;\n voiceRecordHint: string;\n voiceRecordStart: string;\n voiceRecordStop: string;\n voiceRecordSend: string;\n voiceRecordCancel: string;\n voiceRecordPermissionError: string;\n screenCaptureAriaLabel: string;\n screenRecordTitle: string;\n screenRecordHint: string;\n screenRecordStart: string;\n screenRecordStop: string;\n screenRecordSend: string;\n screenRecordCancel: string;\n screenRecordPermissionError: string;\n screenRecordMaxReached: string;\n screenRecordBackgroundHint: string;\n screenRecordOpenChatToFinish: string;\n sendSrOnly: string;\n orgRoomSuffix: string;\n orgRoomTooltip: string;\n slashMentionHint: string;\n selectRoomHint: string;\n supportChat: string;\n supportChatTooltip: string;\n supportRoomDescription: string;\n};\n\nconst DEFAULT_LABELS: InternalChatPanelLabels = {\n openChat: \"Abrir chat de la empresa\",\n newRoom: \"Nueva sala\",\n roomMenu: \"Más acciones de salas\",\n deleteRoom: \"Eliminar sala actual\",\n closeChat: \"Cerrar chat\",\n newRoomTitle: \"Nueva sala\",\n newRoomDescription:\n \"Crea un espacio para conversar con tu equipo en tiempo real.\",\n newRoomPlaceholder: \"Nombre de la sala\",\n cancel: \"Cancelar\",\n create: \"Crear\",\n deleteRoomTitle: \"¿Eliminar esta sala?\",\n deleteRoomDescription: (roomName) =>\n `Se borrarán los mensajes de «${roomName}» en el servidor. Esta acción no se puede deshacer.`,\n deleteConfirm: \"Eliminar\",\n noRoomTitle: \"Sin sala\",\n noRoomDescription: \"Crea una sala con el botón + a la izquierda.\",\n loadingMessages: \"Cargando mensajes…\",\n loadMessagesErrorTitle: \"No se pudieron cargar los mensajes\",\n loadMessagesErrorFallback:\n \"Revisa que la API esté en marcha y que el módulo de chat interno esté migrado.\",\n emptyThreadTitle: \"¿De qué hablamos?\",\n emptyThreadDescription: (roomName) =>\n `Escribe el primer mensaje en «${roomName}».`,\n loadingOlderMessages: \"Cargando mensajes anteriores…\",\n mentionListLabel: \"Insertar mención\",\n messagePlaceholder: \"Mensaje… · / comandos\",\n messagePlaceholderNoRoom: \"Selecciona una sala…\",\n messageAriaLabel: \"Mensaje\",\n attachAriaLabel: \"Adjuntar\",\n attachPhotos: \"Fotos\",\n attachFiles: \"Archivos\",\n attachAudio: \"Grabar nota de voz\",\n attachAudioFile: \"Enviar audio guardado\",\n attachmentPending: \"Vista previa · conexión lista\",\n voiceRecordTitle: \"Grabar nota de voz\",\n voiceRecordHint: \"Pulsa grabar, habla y envía cuando termines.\",\n voiceRecordStart: \"Grabar\",\n voiceRecordStop: \"Detener\",\n voiceRecordSend: \"Enviar audio\",\n voiceRecordCancel: \"Cancelar\",\n voiceRecordPermissionError:\n \"No pudimos usar el micrófono. Revisa los permisos del navegador.\",\n screenCaptureAriaLabel: \"Capturar pantalla\",\n screenRecordTitle: \"Captura de pantalla\",\n screenRecordHint:\n \"Inicia y elige compartir la pestaña de Traky (no solo el panel del chat). Puedes cerrar el chat y seguir navegando (máx. 1 min).\",\n screenRecordStart: \"Iniciar captura\",\n screenRecordStop: \"Detener\",\n screenRecordSend: \"Enviar video\",\n screenRecordCancel: \"Cancelar\",\n screenRecordPermissionError:\n \"No se pudo iniciar la captura. Revisa los permisos del navegador.\",\n screenRecordMaxReached: \"Se alcanzó el máximo de 1 minuto.\",\n screenRecordBackgroundHint:\n \"Puedes cerrar el chat; la grabación continúa. Vuelve aquí para detener y enviar.\",\n screenRecordOpenChatToFinish: \"Abrir chat · captura en curso\",\n sendSrOnly: \"Enviar\",\n orgRoomSuffix: \" · Sala de toda la organización\",\n orgRoomTooltip: \" — toda la organización (no se elimina)\",\n slashMentionHint: \"menciones (guías y paquetes)\",\n selectRoomHint: \"Selecciona una sala o crea una nueva.\",\n supportChat: \"Soporte Traky\",\n supportChatTooltip: \"Chat con el equipo de soporte de Traky\",\n supportRoomDescription:\n \"Canal directo con soporte Traky para tu organización.\",\n};\n\nconst DEFAULT_MENTION_CONFIG: InternalChatMentionConfig = {\n kindOrder: DEFAULT_MENTION_KIND_ORDER,\n kindIcons: {\n guide: IconTruckLoading,\n package: IconPackage,\n ai: IconSparkles,\n },\n categoryMeta: {\n guide: {\n label: \"Guía\",\n hint: \"Guías de envío\",\n keywords: [\"guía\", \"guia\", \"guide\", \"envío\", \"envio\"],\n },\n package: {\n label: \"Paquete\",\n hint: \"Paquetes de la empresa (WR / tracking)\",\n keywords: [\"paquete\", \"paquetes\", \"package\", \"trk\", \"wr\"],\n },\n ai: {\n label: \"Asistencia de IA\",\n hint: \"Consultas sobre operación y datos (próximamente)\",\n keywords: [\"ia\", \"ai\", \"asistencia\", \"inteligencia\", \"asistente\"],\n disabled: true,\n },\n },\n};\n\nfunction isMentionEntryDisabled(\n entry: InternalChatMentionListEntry,\n config: InternalChatMentionConfig,\n): boolean {\n if (entry.type !== \"category\") return false;\n return Boolean(\n entry.disabled ?? config.categoryMeta[entry.kind]?.disabled,\n );\n}\n\nfunction stepMentionHighlight(\n entries: InternalChatMentionListEntry[],\n config: InternalChatMentionConfig,\n current: number,\n delta: 1 | -1,\n): number {\n if (entries.length === 0) return 0;\n let next = current;\n for (let i = 0; i < entries.length; i++) {\n next = (next + delta + entries.length) % entries.length;\n if (!isMentionEntryDisabled(entries[next]!, config)) return next;\n }\n return current;\n}\n\nfunction formatUnreadBadge(count: number): string {\n if (count <= 0) return \"\";\n if (count > 9) return \"9+\";\n return String(count);\n}\n\n/** Contador en círculo perfecto (sin `min-w`/`px` que lo ovalan). */\nfunction unreadCountBadgeClasses(label: string): string {\n const wide = label.length > 1;\n return cn(\n \"pointer-events-none absolute flex shrink-0 items-center justify-center rounded-full bg-destructive font-bold tabular-nums leading-none text-destructive-foreground shadow-sm\",\n wide\n ? \"-right-1 -top-1 size-5 text-[0.5rem]\"\n : \"-right-0.5 -top-0.5 size-4 text-[0.625rem]\",\n );\n}\n\nfunction MessageReceiptTicks({\n status,\n}: {\n status: InternalChatMessageReceiptStatus;\n}) {\n if (status === \"pending\") {\n return (\n <IconLoader2\n className=\"size-3 shrink-0 animate-spin text-muted-foreground\"\n aria-hidden\n />\n );\n }\n if (status === \"sent\") {\n return (\n <IconCheck\n className=\"size-3.5 shrink-0 text-muted-foreground\"\n aria-hidden\n />\n );\n }\n const read = status === \"read\";\n return (\n <IconChecks\n className={cn(\n \"size-3.5 shrink-0\",\n read ? \"text-sky-500 dark:text-sky-400\" : \"text-muted-foreground\",\n )}\n aria-hidden\n />\n );\n}\n\nfunction formatAudioDuration(ms: number): string {\n const totalSec = Math.max(0, Math.round(ms / 1000));\n const m = Math.floor(totalSec / 60);\n const s = totalSec % 60;\n return `${m}:${s.toString().padStart(2, \"0\")}`;\n}\n\nfunction pseudoWaveformBars(seed: string, count = 28): number[] {\n let h = 0;\n for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;\n return Array.from({ length: count }, (_, i) => {\n h = (h * 1103515245 + 12345 + i) >>> 0;\n return 0.25 + (h % 75) / 100;\n });\n}\n\nfunction InternalChatAudioBubble({\n audio,\n seed,\n mine,\n}: {\n audio: InternalChatMessageAudio;\n seed: string;\n mine: boolean;\n}) {\n const ref = useRef<HTMLAudioElement>(null);\n const [playing, setPlaying] = useState(false);\n const [durationMs, setDurationMs] = useState(audio.durationMs ?? 0);\n const [progress, setProgress] = useState(0);\n\n const bars = useMemo(() => pseudoWaveformBars(seed), [seed]);\n\n useEffect(() => {\n const el = ref.current;\n if (!el) return;\n const onMeta = () => {\n if (Number.isFinite(el.duration) && el.duration > 0) {\n setDurationMs(Math.round(el.duration * 1000));\n }\n };\n const onTime = () => {\n if (!el.duration) return;\n setProgress(el.currentTime / el.duration);\n };\n const onEnd = () => {\n setPlaying(false);\n setProgress(0);\n };\n el.addEventListener(\"loadedmetadata\", onMeta);\n el.addEventListener(\"timeupdate\", onTime);\n el.addEventListener(\"ended\", onEnd);\n return () => {\n el.removeEventListener(\"loadedmetadata\", onMeta);\n el.removeEventListener(\"timeupdate\", onTime);\n el.removeEventListener(\"ended\", onEnd);\n };\n }, [audio.src]);\n\n const toggle = useCallback(() => {\n const el = ref.current;\n if (!el) return;\n if (playing) {\n el.pause();\n setPlaying(false);\n return;\n }\n void el.play();\n setPlaying(true);\n }, [playing]);\n\n const displayMs = durationMs || audio.durationMs || 0;\n\n return (\n <div className=\"flex min-w-[min(100%,14rem)] items-center gap-2\">\n <audio ref={ref} src={audio.src} preload=\"metadata\" className=\"hidden\" />\n <Button\n type=\"button\"\n variant={mine ? \"secondary\" : \"outline\"}\n size=\"icon\"\n className=\"size-9 shrink-0 rounded-full\"\n aria-label={playing ? \"Pausar audio\" : \"Reproducir audio\"}\n onClick={toggle}\n >\n {playing ? (\n <IconPlayerPause className=\"size-4\" />\n ) : (\n <IconPlayerPlay className=\"size-4\" />\n )}\n </Button>\n <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n <div className=\"flex h-7 items-end gap-px\" aria-hidden>\n {bars.map((h, i) => {\n const lit = progress > 0 && i / bars.length <= progress;\n return (\n <span\n key={i}\n className={cn(\n \"w-[3px] shrink-0 rounded-full transition-colors\",\n lit\n ? mine\n ? \"bg-foreground/70\"\n : \"bg-primary\"\n : \"bg-muted-foreground/35\",\n )}\n style={{ height: `${Math.round(h * 100)}%` }}\n />\n );\n })}\n </div>\n <span className=\"text-[0.65rem] tabular-nums text-muted-foreground\">\n {displayMs > 0 ? formatAudioDuration(displayMs) : \"0:00\"}\n </span>\n </div>\n </div>\n );\n}\n\nfunction InternalChatAttachmentStack({\n items,\n labels,\n}: {\n items: InternalChatMessageAttachment[];\n labels: InternalChatPanelLabels;\n}) {\n if (items.length === 0) return null;\n return (\n <div className=\"flex flex-col gap-1.5\">\n {items.map((att, i) => (\n <div\n key={`${att.name}-${i}`}\n className=\"overflow-hidden rounded-lg border border-border bg-muted/40\"\n >\n {att.kind === \"video\" && att.url ? (\n <video\n src={att.url}\n controls\n playsInline\n className=\"max-h-48 w-full bg-black object-contain\"\n />\n ) : att.kind === \"image\" && att.url ? (\n <img\n src={att.url}\n alt={att.name}\n className=\"max-h-40 w-full object-cover\"\n />\n ) : (\n <div className=\"flex items-center gap-2 px-3 py-2 text-sm\">\n {att.kind === \"image\" ? (\n <IconPhoto className=\"size-4 shrink-0 text-muted-foreground\" />\n ) : att.kind === \"video\" ? (\n <IconScreenShare className=\"size-4 shrink-0 text-muted-foreground\" />\n ) : (\n <IconFile className=\"size-4 shrink-0 text-muted-foreground\" />\n )}\n <span className=\"min-w-0 truncate font-medium\">{att.name}</span>\n {att.sizeLabel ? (\n <span className=\"ml-auto shrink-0 text-xs text-muted-foreground\">\n {att.sizeLabel}\n </span>\n ) : null}\n </div>\n )}\n {!att.url ? (\n <p className=\"border-t border-border px-3 py-1 text-[0.65rem] text-muted-foreground\">\n {labels.attachmentPending}\n </p>\n ) : null}\n </div>\n ))}\n </div>\n );\n}\n\nfunction InternalChatVoiceRecorderBar({\n labels,\n onClose,\n onSendBlob,\n onSendLegacy,\n}: {\n labels: InternalChatPanelLabels;\n onClose: () => void;\n onSendBlob?: (blob: Blob, durationMs: number) => void | Promise<void>;\n onSendLegacy?: () => void | Promise<void>;\n}) {\n const recorderRef = useRef<MediaRecorder | null>(null);\n const chunksRef = useRef<Blob[]>([]);\n const startedAtRef = useRef(0);\n const [recording, setRecording] = useState(false);\n const [blob, setBlob] = useState<Blob | null>(null);\n const [durationMs, setDurationMs] = useState(0);\n const [error, setError] = useState<string | null>(null);\n const [sending, setSending] = useState(false);\n\n const stopTracks = useCallback((recorder: MediaRecorder | null) => {\n recorder?.stream.getTracks().forEach((t) => t.stop());\n }, []);\n\n const startRecording = useCallback(async () => {\n setError(null);\n setBlob(null);\n chunksRef.current = [];\n try {\n const stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n const mime = MediaRecorder.isTypeSupported(\"audio/webm\")\n ? \"audio/webm\"\n : \"audio/mp4\";\n const recorder = new MediaRecorder(stream, { mimeType: mime });\n recorderRef.current = recorder;\n recorder.ondataavailable = (e) => {\n if (e.data.size > 0) chunksRef.current.push(e.data);\n };\n recorder.onstop = () => {\n const next = new Blob(chunksRef.current, { type: mime });\n setBlob(next);\n const ms = Math.max(\n 0,\n Date.now() - (startedAtRef.current || Date.now()),\n );\n setDurationMs(ms);\n stopTracks(recorder);\n recorderRef.current = null;\n };\n startedAtRef.current = Date.now();\n recorder.start();\n setRecording(true);\n } catch {\n setError(labels.voiceRecordPermissionError);\n }\n }, [labels.voiceRecordPermissionError, stopTracks]);\n\n const stopRecording = useCallback(() => {\n const rec = recorderRef.current;\n if (!rec || rec.state === \"inactive\") return;\n rec.stop();\n setRecording(false);\n }, []);\n\n const handleClose = useCallback(() => {\n if (recording) stopRecording();\n stopTracks(recorderRef.current);\n recorderRef.current = null;\n onClose();\n }, [onClose, recording, stopRecording, stopTracks]);\n\n const handleSend = useCallback(async () => {\n if (sending) return;\n setSending(true);\n try {\n if (blob && onSendBlob) {\n await onSendBlob(blob, durationMs);\n } else if (onSendLegacy) {\n await onSendLegacy();\n }\n onClose();\n } finally {\n setSending(false);\n }\n }, [blob, durationMs, onClose, onSendBlob, onSendLegacy, sending]);\n\n const previewUrl = useMemo(\n () => (blob ? URL.createObjectURL(blob) : null),\n [blob],\n );\n\n useEffect(() => {\n return () => {\n if (previewUrl) URL.revokeObjectURL(previewUrl);\n };\n }, [previewUrl]);\n\n return (\n <div\n className=\"mx-5 mb-2 rounded-xl border border-border bg-muted/35 px-3 py-2\"\n role=\"region\"\n aria-label={labels.voiceRecordTitle}\n >\n <div className=\"flex items-center justify-between gap-2\">\n <p className=\"text-xs font-medium text-foreground\">\n {labels.voiceRecordTitle}\n </p>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-8 shrink-0\"\n aria-label={labels.voiceRecordCancel}\n onClick={handleClose}\n >\n <IconX className=\"size-4\" />\n </Button>\n </div>\n {error ? (\n <p className=\"mt-1 text-xs leading-snug text-destructive\">{error}</p>\n ) : (\n <p className=\"mt-0.5 text-xs leading-snug text-muted-foreground\">\n {recording\n ? \"Pulsa el cuadrado rojo para terminar.\"\n : blob\n ? \"Escucha la vista previa y envía cuando quieras.\"\n : labels.voiceRecordHint}\n </p>\n )}\n <div className=\"mt-2 flex items-center gap-2\">\n {previewUrl && blob ? (\n <VoiceRecorderPreviewBar\n src={previewUrl}\n durationMs={durationMs}\n />\n ) : recording ? (\n <>\n <Button\n type=\"button\"\n variant=\"destructive\"\n size=\"sm\"\n className=\"h-8 shrink-0 gap-1.5\"\n onClick={stopRecording}\n >\n <IconSquare className=\"size-3.5 fill-current\" />\n {labels.voiceRecordStop}\n </Button>\n <span className=\"inline-flex min-w-0 flex-1 items-center gap-1.5 text-xs text-destructive\">\n <span className=\"size-2 shrink-0 animate-pulse rounded-full bg-destructive\" />\n Grabando…\n </span>\n </>\n ) : (\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n className=\"h-8 gap-1.5\"\n onClick={() => void startRecording()}\n >\n <IconMicrophone className=\"size-3.5\" />\n {labels.voiceRecordStart}\n </Button>\n )}\n <Button\n type=\"button\"\n size=\"sm\"\n className=\"ml-auto h-8 shrink-0\"\n disabled={sending || (!blob && !onSendLegacy)}\n onClick={() => void handleSend()}\n >\n {labels.voiceRecordSend}\n </Button>\n </div>\n </div>\n );\n}\n\nfunction VoiceRecorderPreviewBar({\n src,\n durationMs,\n}: {\n src: string;\n durationMs: number;\n}) {\n const ref = useRef<HTMLAudioElement>(null);\n const [playing, setPlaying] = useState(false);\n const [progress, setProgress] = useState(0);\n const [resolvedMs, setResolvedMs] = useState(durationMs);\n const bars = useMemo(() => pseudoWaveformBars(\"voice-recorder-preview\"), []);\n\n const toggle = useCallback(() => {\n const el = ref.current;\n if (!el) return;\n if (playing) {\n el.pause();\n setPlaying(false);\n return;\n }\n void el.play();\n setPlaying(true);\n }, [playing]);\n\n useEffect(() => {\n const el = ref.current;\n if (!el) return;\n const onMeta = () => {\n if (Number.isFinite(el.duration) && el.duration > 0) {\n setResolvedMs(Math.round(el.duration * 1000));\n }\n };\n const onTime = () => {\n if (!el.duration) return;\n setProgress(el.currentTime / el.duration);\n };\n const onEnd = () => {\n setPlaying(false);\n setProgress(0);\n };\n el.addEventListener(\"loadedmetadata\", onMeta);\n el.addEventListener(\"timeupdate\", onTime);\n el.addEventListener(\"ended\", onEnd);\n return () => {\n el.removeEventListener(\"loadedmetadata\", onMeta);\n el.removeEventListener(\"timeupdate\", onTime);\n el.removeEventListener(\"ended\", onEnd);\n };\n }, [src]);\n\n const displayMs = resolvedMs || durationMs;\n\n return (\n <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n <audio ref={ref} src={src} preload=\"metadata\" className=\"hidden\" />\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n className=\"size-8 shrink-0 rounded-full\"\n aria-label={playing ? \"Pausar vista previa\" : \"Reproducir vista previa\"}\n onClick={toggle}\n >\n {playing ? (\n <IconPlayerPause className=\"size-4\" />\n ) : (\n <IconPlayerPlay className=\"size-4\" />\n )}\n </Button>\n <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n <div className=\"flex h-6 items-end gap-px\" aria-hidden>\n {bars.map((h, i) => {\n const lit = progress > 0 && i / bars.length <= progress;\n return (\n <span\n key={i}\n className={cn(\n \"w-[2px] shrink-0 rounded-full transition-colors\",\n lit ? \"bg-primary\" : \"bg-muted-foreground/35\",\n )}\n style={{ height: `${Math.round(h * 100)}%` }}\n />\n );\n })}\n </div>\n <span className=\"text-[0.65rem] tabular-nums text-muted-foreground\">\n {displayMs > 0 ? formatAudioDuration(displayMs) : \"0:00\"}\n </span>\n </div>\n </div>\n );\n}\n\nfunction pickScreenRecorderMime(): string {\n const candidates = [\n \"video/webm;codecs=vp9\",\n \"video/webm;codecs=vp8\",\n \"video/webm\",\n \"video/mp4\",\n ];\n return candidates.find((m) => MediaRecorder.isTypeSupported(m)) ?? \"video/webm\";\n}\n\ntype ScreenRecorderSession = {\n recording: boolean;\n elapsedMs: number;\n blob: Blob | null;\n error: string | null;\n maxReached: boolean;\n sending: boolean;\n previewUrl: string | null;\n canSend: boolean;\n timerLabel: string;\n active: boolean;\n startCapture: () => Promise<void>;\n stopRecording: () => void;\n cancelSession: () => void;\n sendRecording: () => Promise<void>;\n};\n\nfunction useInternalChatScreenRecorderSession(\n labels: InternalChatPanelLabels,\n onSendBlob?: (blob: Blob, durationMs: number) => void | Promise<void>,\n onRecordingChange?: (recording: boolean) => void,\n): ScreenRecorderSession {\n const recorderRef = useRef<MediaRecorder | null>(null);\n const streamRef = useRef<MediaStream | null>(null);\n const chunksRef = useRef<Blob[]>([]);\n const startedAtRef = useRef(0);\n const durationMsRef = useRef(0);\n const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);\n const maxTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const previewUrlRef = useRef<string | null>(null);\n\n const [recording, setRecording] = useState(false);\n const [elapsedMs, setElapsedMs] = useState(0);\n const [blob, setBlob] = useState<Blob | null>(null);\n const [error, setError] = useState<string | null>(null);\n const [maxReached, setMaxReached] = useState(false);\n const [sending, setSending] = useState(false);\n const [previewUrl, setPreviewUrl] = useState<string | null>(null);\n\n const clearTimers = useCallback(() => {\n if (tickRef.current) {\n clearInterval(tickRef.current);\n tickRef.current = null;\n }\n if (maxTimerRef.current) {\n clearTimeout(maxTimerRef.current);\n maxTimerRef.current = null;\n }\n }, []);\n\n const stopStream = useCallback(() => {\n streamRef.current?.getTracks().forEach((t) => t.stop());\n streamRef.current = null;\n }, []);\n\n const revokePreview = useCallback(() => {\n if (previewUrlRef.current) {\n URL.revokeObjectURL(previewUrlRef.current);\n previewUrlRef.current = null;\n }\n setPreviewUrl(null);\n }, []);\n\n const stopRecording = useCallback(\n (reason?: \"max\") => {\n const rec = recorderRef.current;\n if (rec && rec.state !== \"inactive\") {\n rec.stop();\n } else {\n clearTimers();\n stopStream();\n setRecording(false);\n onRecordingChange?.(false);\n }\n if (reason === \"max\") setMaxReached(true);\n },\n [clearTimers, onRecordingChange, stopStream],\n );\n\n const startCapture = useCallback(async () => {\n setError(null);\n revokePreview();\n setBlob(null);\n setMaxReached(false);\n setElapsedMs(0);\n chunksRef.current = [];\n try {\n const stream = await navigator.mediaDevices.getDisplayMedia({\n video: true,\n audio: false,\n preferCurrentTab: true,\n } as DisplayMediaStreamOptions);\n streamRef.current = stream;\n const videoTrack = stream.getVideoTracks()[0];\n videoTrack?.addEventListener(\"ended\", () => stopRecording());\n\n const mime = pickScreenRecorderMime();\n const recorder = new MediaRecorder(stream, { mimeType: mime });\n recorderRef.current = recorder;\n recorder.ondataavailable = (e) => {\n if (e.data.size > 0) chunksRef.current.push(e.data);\n };\n recorder.onstop = () => {\n clearTimers();\n const finalMs = Math.min(\n SCREEN_RECORD_MAX_MS,\n Math.max(0, Date.now() - (startedAtRef.current || Date.now())),\n );\n durationMsRef.current = finalMs;\n setElapsedMs(finalMs);\n const next = new Blob(chunksRef.current, {\n type: recorder.mimeType || mime,\n });\n setBlob(next);\n const url = URL.createObjectURL(next);\n previewUrlRef.current = url;\n setPreviewUrl(url);\n stopStream();\n recorderRef.current = null;\n setRecording(false);\n onRecordingChange?.(false);\n };\n startedAtRef.current = Date.now();\n recorder.start(500);\n setRecording(true);\n onRecordingChange?.(true);\n tickRef.current = setInterval(() => {\n const ms = Date.now() - startedAtRef.current;\n const clamped = Math.min(ms, SCREEN_RECORD_MAX_MS);\n setElapsedMs(clamped);\n if (ms >= SCREEN_RECORD_MAX_MS) stopRecording(\"max\");\n }, 200);\n maxTimerRef.current = setTimeout(\n () => stopRecording(\"max\"),\n SCREEN_RECORD_MAX_MS,\n );\n } catch {\n stopStream();\n setError(labels.screenRecordPermissionError);\n setRecording(false);\n onRecordingChange?.(false);\n }\n }, [\n clearTimers,\n labels.screenRecordPermissionError,\n onRecordingChange,\n revokePreview,\n stopRecording,\n stopStream,\n ]);\n\n const cancelSession = useCallback(() => {\n if (recording) stopRecording();\n clearTimers();\n stopStream();\n recorderRef.current = null;\n revokePreview();\n setBlob(null);\n setError(null);\n setMaxReached(false);\n setElapsedMs(0);\n durationMsRef.current = 0;\n setRecording(false);\n onRecordingChange?.(false);\n }, [\n clearTimers,\n onRecordingChange,\n recording,\n revokePreview,\n stopRecording,\n stopStream,\n ]);\n\n const sendRecording = useCallback(async () => {\n if (sending || !blob || !onSendBlob) return;\n setSending(true);\n try {\n await onSendBlob(blob, durationMsRef.current || elapsedMs);\n cancelSession();\n } finally {\n setSending(false);\n }\n }, [blob, cancelSession, elapsedMs, onSendBlob, sending]);\n\n useEffect(() => {\n return () => {\n clearTimers();\n if (recorderRef.current?.state === \"recording\") {\n recorderRef.current.stop();\n }\n stopStream();\n revokePreview();\n };\n }, [clearTimers, revokePreview, stopStream]);\n\n const timerLabel = `${formatAudioDuration(elapsedMs)} / ${formatAudioDuration(SCREEN_RECORD_MAX_MS)}`;\n const active = recording || Boolean(blob) || Boolean(error);\n\n return {\n recording,\n elapsedMs,\n blob,\n error,\n maxReached,\n sending,\n previewUrl,\n canSend: Boolean(blob) && Boolean(onSendBlob),\n timerLabel,\n active,\n startCapture,\n stopRecording: () => stopRecording(),\n cancelSession,\n sendRecording,\n };\n}\n\nfunction InternalChatScreenRecorderBar({\n labels,\n session,\n onDismiss,\n}: {\n labels: InternalChatPanelLabels;\n session: ScreenRecorderSession;\n onDismiss: () => void;\n}) {\n const {\n recording,\n blob,\n error,\n maxReached,\n sending,\n previewUrl,\n canSend,\n timerLabel,\n startCapture,\n stopRecording,\n cancelSession,\n sendRecording,\n } = session;\n\n const handleDismiss = () => {\n if (!recording && !blob) {\n cancelSession();\n }\n onDismiss();\n };\n\n return (\n <div\n className=\"mx-5 mb-2 rounded-xl border border-border bg-muted/35 px-3 py-2\"\n role=\"region\"\n aria-label={labels.screenRecordTitle}\n >\n <div className=\"flex items-center justify-between gap-2\">\n <p className=\"text-xs font-medium text-foreground\">\n {labels.screenRecordTitle}\n </p>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-8 shrink-0\"\n aria-label={\n recording\n ? \"Ocultar panel (la grabación continúa)\"\n : labels.screenRecordCancel\n }\n onClick={handleDismiss}\n >\n <IconX className=\"size-4\" />\n </Button>\n </div>\n {error ? (\n <p className=\"mt-1 text-xs leading-snug text-destructive\">{error}</p>\n ) : (\n <p className=\"mt-0.5 text-xs leading-snug text-muted-foreground\">\n {recording\n ? labels.screenRecordBackgroundHint\n : maxReached\n ? labels.screenRecordMaxReached\n : blob\n ? \"Revisa el video y envíalo al chat.\"\n : labels.screenRecordHint}\n </p>\n )}\n {recording ? (\n <p className=\"mt-1 text-xs font-medium tabular-nums text-destructive\">\n {timerLabel}\n </p>\n ) : null}\n <div className=\"mt-2 flex flex-col gap-2\">\n {previewUrl && blob ? (\n <video\n src={previewUrl}\n controls\n playsInline\n className=\"max-h-32 w-full rounded-md border border-border bg-black object-contain\"\n />\n ) : null}\n <div className=\"flex flex-wrap items-center gap-2\">\n {recording ? (\n <Button\n type=\"button\"\n variant=\"destructive\"\n size=\"sm\"\n className=\"h-8 gap-1.5\"\n onClick={stopRecording}\n >\n <IconSquare className=\"size-3.5 fill-current\" />\n {labels.screenRecordStop}\n </Button>\n ) : (\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n className=\"h-8 gap-1.5\"\n disabled={Boolean(blob)}\n onClick={() => void startCapture()}\n >\n <IconScreenShare className=\"size-3.5\" />\n {labels.screenRecordStart}\n </Button>\n )}\n {recording ? (\n <span className=\"inline-flex min-w-0 flex-1 items-center gap-1.5 text-xs text-destructive\">\n <span className=\"size-2 shrink-0 animate-pulse rounded-full bg-destructive\" />\n Grabando pantalla\n </span>\n ) : null}\n {(recording || blob) && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"h-8 text-xs text-muted-foreground\"\n onClick={cancelSession}\n >\n {labels.screenRecordCancel}\n </Button>\n )}\n <Button\n type=\"button\"\n size=\"sm\"\n className=\"ml-auto h-8 shrink-0\"\n disabled={sending || !canSend}\n onClick={() => void sendRecording()}\n >\n {labels.screenRecordSend}\n </Button>\n </div>\n </div>\n </div>\n );\n}\n\nfunction renderMessageTextWithMentions(text: string): ReactNode {\n const parts = text.split(/(@\\[[^\\]]+\\])/g);\n return parts.map((part, index) => {\n const mention = /^@\\[(.+)\\]$/.exec(part);\n if (mention) {\n return (\n <span\n key={index}\n className=\"inline-flex max-w-full items-center rounded-md border border-primary/35 bg-primary/12 px-1.5 py-0.5 font-medium text-primary\"\n >\n {mention[1]}\n </span>\n );\n }\n return <span key={index}>{part}</span>;\n });\n}\n\nexport type InternalChatPanelProps = {\n title: string;\n rooms: InternalChatRoom[];\n activeRoomId: string | null;\n onActiveRoomChange: (roomId: string) => void;\n messages: InternalChatMessageLine[];\n messagesStatus: InternalChatMessagesStatus;\n messagesError?: string | null;\n onLoadOlderMessages?: () => void | Promise<void>;\n isLoadingOlder?: boolean;\n hasOlderMessages?: boolean;\n mentionGuideItems?: InternalChatMentionItem[];\n mentionPackageItems?: InternalChatMentionItem[];\n mentionConfig?: InternalChatMentionConfig;\n /** Catálogo de ítems ya filtrado en servidor (guías/paquetes). */\n mentionServerFilteredItems?: boolean;\n mentionCatalogLoading?: boolean;\n onMentionCatalogChange?: (state: InternalChatMentionCatalogState) => void;\n typingLine?: string | null;\n onSendMessage: (text: string) => void | Promise<void>;\n onCreateRoom: (name: string) => void | Promise<void>;\n onDeleteRoom: (roomId: string) => void | Promise<void>;\n onOpenChange?: (open: boolean) => void;\n onDraftChange?: (draft: string) => void;\n onUserGesture?: () => void;\n isSending?: boolean;\n isCreatingRoom?: boolean;\n isDeletingRoom?: boolean;\n onDeleteRoomBlocked?: (reason: string) => void;\n labels?: Partial<InternalChatPanelLabels>;\n className?: string;\n /** Mensajes no vistos (icono del header: badge rojo, hasta «9+»). */\n unreadTotal?: number;\n /** No leídos por sala (badge en el rail de salas). */\n unreadByRoomId?: Record<string, number>;\n /** Se invoca al abrir el panel o al cambiar de sala con el chat visible. */\n onMarkRoomRead?: (roomId: string) => void;\n /** Conexión BFF: el contenedor sube el archivo. */\n onAttachPhotos?: (file: File) => void | Promise<void>;\n onAttachFiles?: (file: File) => void | Promise<void>;\n /** Tras grabar en el panel (conexión BFF / subida). */\n onSendAudioBlob?: (\n blob: Blob,\n durationMs: number,\n ) => void | Promise<void>;\n /** Atajo legacy: el host abre su propio flujo sin el grabador integrado. */\n onSendAudio?: () => void | Promise<void>;\n onAttachAudioFile?: (file: File) => void | Promise<void>;\n /** Video de captura de pantalla (máx. 1 min en el grabador integrado). */\n onSendScreenRecordingBlob?: (\n blob: Blob,\n durationMs: number,\n ) => void | Promise<void>;\n /** Abre el drawer al montar (p. ej. `/admin/.../chat`). */\n defaultOpen?: boolean;\n /** Oculta el botón flotante; usar con `defaultOpen` en página dedicada. */\n hideTrigger?: boolean;\n};\n\nfunction InternalChatPanel({\n title,\n rooms,\n activeRoomId,\n onActiveRoomChange,\n messages,\n messagesStatus,\n messagesError = null,\n onLoadOlderMessages,\n isLoadingOlder = false,\n hasOlderMessages = false,\n mentionGuideItems = [],\n mentionPackageItems = [],\n mentionConfig = DEFAULT_MENTION_CONFIG,\n mentionServerFilteredItems = false,\n mentionCatalogLoading = false,\n onMentionCatalogChange,\n typingLine = null,\n onSendMessage,\n onCreateRoom,\n onDeleteRoom,\n onOpenChange,\n onDraftChange,\n onUserGesture,\n isSending = false,\n isCreatingRoom = false,\n isDeletingRoom = false,\n onDeleteRoomBlocked,\n labels: labelsPartial,\n className,\n unreadTotal = 0,\n unreadByRoomId,\n onMarkRoomRead,\n onAttachPhotos,\n onAttachFiles,\n onSendAudioBlob,\n onSendAudio,\n onSendScreenRecordingBlob,\n defaultOpen = false,\n hideTrigger = false,\n}: InternalChatPanelProps) {\n const labels = { ...DEFAULT_LABELS, ...labelsPartial };\n\n const [open, setOpen] = useState(defaultOpen);\n const [draft, setDraft] = useState(\"\");\n const [createOpen, setCreateOpen] = useState(false);\n const [newRoomName, setNewRoomName] = useState(\"\");\n const [deleteOpen, setDeleteOpen] = useState(false);\n const [voiceRecorderOpen, setVoiceRecorderOpen] = useState(false);\n const [screenRecorderOpen, setScreenRecorderOpen] = useState(false);\n const screenRecorderSession = useInternalChatScreenRecorderSession(\n labels,\n onSendScreenRecordingBlob,\n useCallback((isRecording: boolean) => {\n if (isRecording) setScreenRecorderOpen(true);\n }, []),\n );\n const [mentionStart, setMentionStart] = useState<number | null>(null);\n const [mentionQuery, setMentionQuery] = useState(\"\");\n const [mentionHighlight, setMentionHighlight] = useState(0);\n const lastMentionQueryRef = useRef(\"\");\n const loadingOlderMessagesRef = useRef(false);\n\n const formRef = useRef<HTMLFormElement>(null);\n const scrollerRef = useRef<HTMLDivElement>(null);\n const messagesListRef = useRef<HTMLDivElement>(null);\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n const photoInputRef = useRef<HTMLInputElement>(null);\n const fileInputRef = useRef<HTMLInputElement>(null);\n\n const supportRoom = useMemo(\n () => rooms.find((r) => r.isSupport) ?? null,\n [rooms],\n );\n\n const teamRooms = useMemo(\n () => rooms.filter((r) => !r.isSupport),\n [rooms],\n );\n\n const activeRoom = useMemo(\n () => rooms.find((r) => r.id === activeRoomId) ?? null,\n [rooms, activeRoomId],\n );\n\n const mentionListOptions = useMemo((): BuildMentionListOptions | undefined => {\n if (!mentionServerFilteredItems) return undefined;\n const seg = parseMentionSegment(mentionQuery, mentionConfig);\n if (seg.phase !== \"item\") return undefined;\n return { serverFilteredItems: true };\n }, [mentionServerFilteredItems, mentionQuery, mentionConfig]);\n\n const mentionEntries = useMemo(\n () =>\n buildMentionList(\n mentionQuery,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n mentionListOptions,\n ),\n [\n mentionQuery,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n mentionListOptions,\n ],\n );\n\n const mentionSeg = useMemo(\n () => parseMentionSegment(mentionQuery, mentionConfig),\n [mentionQuery, mentionConfig],\n );\n\n const mentionOpen =\n Boolean(activeRoom) &&\n mentionStart !== null &&\n (mentionEntries.length > 0 ||\n mentionCatalogLoading ||\n (mentionServerFilteredItems && mentionSeg.phase === \"item\"));\n\n const canSend =\n draft.trim().length > 0 &&\n !isSending &&\n Boolean(activeRoomId);\n\n const messagesScrollTail = useMemo(() => {\n if (messages.length === 0) {\n return `empty:${messagesStatus}`;\n }\n const last = messages[messages.length - 1]!;\n const attachCount = last.attachments?.length ?? 0;\n const hasAudio = last.audio ? 1 : 0;\n return [\n messages.length,\n last.id,\n last.text.length,\n attachCount,\n hasAudio,\n last.receiptStatus ?? \"\",\n messagesStatus,\n ].join(\"|\");\n }, [messages, messagesStatus]);\n\n const scrollMessagesToBottom = useCallback(() => {\n const node = scrollerRef.current;\n if (!node) return;\n node.scrollTop = node.scrollHeight;\n }, []);\n\n const scheduleScrollMessagesToBottom = useCallback(() => {\n scrollMessagesToBottom();\n let rafNested = 0;\n const rafOuter = requestAnimationFrame(() => {\n scrollMessagesToBottom();\n rafNested = requestAnimationFrame(scrollMessagesToBottom);\n });\n const timeouts = [50, 150, 320].map((ms) =>\n window.setTimeout(scrollMessagesToBottom, ms),\n );\n return () => {\n cancelAnimationFrame(rafOuter);\n if (rafNested) cancelAnimationFrame(rafNested);\n timeouts.forEach(clearTimeout);\n };\n }, [scrollMessagesToBottom]);\n\n const setOpenWithNotify = useCallback(\n (next: boolean) => {\n if (next) onUserGesture?.();\n setOpen(next);\n onOpenChange?.(next);\n },\n [onOpenChange, onUserGesture],\n );\n\n useEffect(() => {\n if (!open || !activeRoomId) return;\n onMarkRoomRead?.(activeRoomId);\n }, [open, activeRoomId, onMarkRoomRead]);\n\n useEffect(() => {\n if (!open) return;\n if (screenRecorderSession.active) {\n setScreenRecorderOpen(true);\n }\n }, [open, screenRecorderSession.active]);\n\n const handleMessagesScroll = useCallback(() => {\n const node = scrollerRef.current;\n if (!node || !activeRoomId || !onLoadOlderMessages) return;\n if (loadingOlderMessagesRef.current) return;\n if (node.scrollTop > 120) return;\n if (!hasOlderMessages || isLoadingOlder) return;\n loadingOlderMessagesRef.current = true;\n const prevHeight = node.scrollHeight;\n const prevTop = node.scrollTop;\n void Promise.resolve(onLoadOlderMessages()).finally(() => {\n requestAnimationFrame(() => {\n const n = scrollerRef.current;\n if (!n) return;\n const delta = n.scrollHeight - prevHeight;\n n.scrollTop = prevTop + delta;\n loadingOlderMessagesRef.current = false;\n });\n });\n }, [\n activeRoomId,\n hasOlderMessages,\n isLoadingOlder,\n onLoadOlderMessages,\n ]);\n\n useLayoutEffect(() => {\n if (!open || !activeRoomId) return;\n if (loadingOlderMessagesRef.current) return;\n return scheduleScrollMessagesToBottom();\n }, [\n open,\n activeRoomId,\n messagesScrollTail,\n typingLine,\n draft,\n voiceRecorderOpen,\n screenRecorderOpen,\n scheduleScrollMessagesToBottom,\n ]);\n\n useEffect(() => {\n if (!open || !activeRoomId) return;\n const scroller = scrollerRef.current;\n const list = messagesListRef.current;\n const textarea = textareaRef.current;\n if (!scroller) return;\n\n const pinBottom = () => {\n if (loadingOlderMessagesRef.current) return;\n scrollMessagesToBottom();\n };\n\n const ro = new ResizeObserver(pinBottom);\n ro.observe(scroller);\n if (list) ro.observe(list);\n if (textarea) ro.observe(textarea);\n const form = formRef.current;\n if (form) ro.observe(form);\n\n return () => ro.disconnect();\n }, [\n open,\n activeRoomId,\n messagesScrollTail,\n scrollMessagesToBottom,\n ]);\n\n useEffect(() => {\n if (!mentionOpen || mentionEntries.length === 0) return;\n setMentionHighlight((h) => Math.min(h, mentionEntries.length - 1));\n }, [mentionOpen, mentionEntries.length]);\n\n useEffect(() => {\n setMentionStart(null);\n setMentionQuery(\"\");\n lastMentionQueryRef.current = \"\";\n }, [activeRoomId]);\n\n useEffect(() => {\n if (!onMentionCatalogChange) return;\n if (mentionStart === null) {\n onMentionCatalogChange({\n open: false,\n phase: \"category\",\n filter: \"\",\n });\n return;\n }\n if (mentionSeg.phase === \"category\") {\n onMentionCatalogChange({\n open: true,\n phase: \"category\",\n filter: mentionSeg.filter,\n });\n return;\n }\n onMentionCatalogChange({\n open: true,\n phase: \"item\",\n kind: mentionSeg.kind,\n filter: mentionSeg.filter,\n });\n }, [mentionStart, mentionQuery, mentionConfig, onMentionCatalogChange]);\n\n function closeMentionMenu() {\n setMentionStart(null);\n setMentionQuery(\"\");\n lastMentionQueryRef.current = \"\";\n }\n\n function syncMentionMenu(nextDraft: string, cursor: number) {\n const m = parseSlashMention(nextDraft, cursor);\n if (!m) {\n closeMentionMenu();\n return;\n }\n const seg = parseMentionSegment(m.query, mentionConfig);\n const listOptions: BuildMentionListOptions | undefined =\n mentionServerFilteredItems && seg.phase === \"item\"\n ? { serverFilteredItems: true }\n : undefined;\n const entries = buildMentionList(\n m.query,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n listOptions,\n );\n const keepOpenOnEmpty =\n mentionCatalogLoading ||\n (mentionServerFilteredItems && seg.phase === \"item\");\n if (entries.length === 0 && !keepOpenOnEmpty) {\n closeMentionMenu();\n return;\n }\n if (m.query !== lastMentionQueryRef.current) {\n setMentionHighlight(0);\n lastMentionQueryRef.current = m.query;\n }\n setMentionStart(m.start);\n setMentionQuery(m.query);\n }\n\n function replaceSlashSegment(newSegment: string) {\n const el = textareaRef.current;\n if (el === null || mentionStart === null) return;\n const cursor = el.selectionStart ?? draft.length;\n const before = draft.slice(0, mentionStart);\n const after = draft.slice(cursor);\n const newDraft = `${before}/${newSegment}${after}`;\n const slashEnd = before.length + 1 + newSegment.length;\n setDraft(newDraft);\n onDraftChange?.(newDraft);\n setMentionQuery(newSegment);\n lastMentionQueryRef.current = newSegment;\n requestAnimationFrame(() => {\n el.focus();\n el.setSelectionRange(slashEnd, slashEnd);\n });\n }\n\n function applyCategoryPick(kind: InternalChatMentionKind) {\n if (mentionConfig.categoryMeta[kind]?.disabled) return;\n replaceSlashSegment(`${kind}:`);\n setMentionHighlight(0);\n }\n\n function applyItemPick(item: InternalChatMentionItem) {\n const el = textareaRef.current;\n if (el === null || mentionStart === null) return;\n const cursor = el.selectionStart ?? draft.length;\n const before = draft.slice(0, mentionStart);\n const after = draft.slice(cursor);\n const token = `@[${item.insertLabel}]`;\n const needsSpace =\n after.length > 0 && !/^\\s/.test(after) && !after.startsWith(\"\\n\");\n const newDraft = `${before}${token}${needsSpace ? \" \" : \"\"}${after}`;\n setDraft(newDraft);\n onDraftChange?.(newDraft);\n closeMentionMenu();\n const newPos = before.length + token.length + (needsSpace ? 1 : 0);\n requestAnimationFrame(() => {\n el.focus();\n el.setSelectionRange(newPos, newPos);\n });\n }\n\n async function onSubmit(event: FormEvent<HTMLFormElement>) {\n event.preventDefault();\n if (mentionOpen) return;\n const text = draft.trim();\n if (!text || !activeRoomId) return;\n onUserGesture?.();\n try {\n await onSendMessage(text);\n setDraft(\"\");\n onDraftChange?.(\"\");\n scheduleScrollMessagesToBottom();\n } catch {\n /* errores vía contenedor */\n }\n }\n\n async function handleCreateRoom() {\n const name = newRoomName.trim();\n if (!name) return;\n try {\n await onCreateRoom(name);\n setNewRoomName(\"\");\n setCreateOpen(false);\n } catch {\n /* contenedor */\n }\n }\n\n async function handleConfirmDeleteRoom() {\n if (!activeRoom) return;\n try {\n await onDeleteRoom(activeRoom.id);\n setDeleteOpen(false);\n } catch {\n /* contenedor */\n }\n }\n\n function requestDeleteRoom() {\n if (!activeRoom) return;\n if (activeRoom.isOrganization) {\n onDeleteRoomBlocked?.(\"La sala Organización no se puede eliminar.\");\n return;\n }\n if (activeRoom.isSupport) {\n onDeleteRoomBlocked?.(\"La sala de soporte no se puede eliminar.\");\n return;\n }\n if (teamRooms.length <= 1) {\n onDeleteRoomBlocked?.(\"Debe existir al menos una sala.\");\n return;\n }\n setDeleteOpen(true);\n }\n\n return (\n <>\n {!hideTrigger ? (\n <Button\n type=\"button\"\n size=\"icon\"\n className={cn(\"relative size-9 text-primary-foreground\", className)}\n aria-label={\n unreadTotal > 0\n ? `${labels.openChat} · ${unreadTotal} sin leer`\n : labels.openChat\n }\n onClick={() => setOpenWithNotify(true)}\n >\n <IconMessages className=\"size-4\" />\n {unreadTotal > 0 ? (\n <span\n className={unreadCountBadgeClasses(formatUnreadBadge(unreadTotal))}\n aria-hidden\n >\n {formatUnreadBadge(unreadTotal)}\n </span>\n ) : null}\n {screenRecorderSession.recording ? (\n <span\n className=\"absolute -top-0.5 -left-0.5 size-2.5 animate-pulse rounded-full bg-destructive ring-2 ring-background\"\n aria-hidden\n />\n ) : null}\n </Button>\n ) : !open ? (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n className=\"gap-2\"\n onClick={() => setOpenWithNotify(true)}\n >\n <IconMessages className=\"size-4\" />\n {labels.openChat}\n </Button>\n ) : null}\n\n {!open && screenRecorderSession.active ? (\n <Button\n type=\"button\"\n variant=\"destructive\"\n size=\"sm\"\n className=\"fixed bottom-6 right-6 z-[200] h-9 gap-2 shadow-lg\"\n onClick={() => setOpenWithNotify(true)}\n >\n <span className=\"size-2 animate-pulse rounded-full bg-destructive-foreground\" />\n <span className=\"text-xs font-medium tabular-nums\">\n {screenRecorderSession.recording\n ? `REC ${screenRecorderSession.timerLabel}`\n : screenRecorderSession.blob\n ? labels.screenRecordOpenChatToFinish\n : labels.screenRecordTitle}\n </span>\n </Button>\n ) : null}\n\n <Dialog open={createOpen} onOpenChange={setCreateOpen}>\n <DialogContent className=\"sm:max-w-md\">\n <DialogHeader>\n <DialogTitle>{labels.newRoomTitle}</DialogTitle>\n <DialogDescription>{labels.newRoomDescription}</DialogDescription>\n </DialogHeader>\n <Input\n value={newRoomName}\n onChange={(e) => setNewRoomName(e.target.value)}\n placeholder={labels.newRoomPlaceholder}\n autoFocus\n onKeyDown={(e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n void handleCreateRoom();\n }\n }}\n />\n <DialogFooter className=\"gap-2 sm:gap-0\">\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={() => {\n setCreateOpen(false);\n setNewRoomName(\"\");\n }}\n >\n {labels.cancel}\n </Button>\n <Button\n type=\"button\"\n onClick={() => void handleCreateRoom()}\n disabled={isCreatingRoom}\n >\n {labels.create}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n\n <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>{labels.deleteRoomTitle}</AlertDialogTitle>\n <AlertDialogDescription>\n {labels.deleteRoomDescription(activeRoom?.name ?? \"la sala\")}\n </AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel>{labels.cancel}</AlertDialogCancel>\n <AlertDialogAction\n className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n onClick={() => void handleConfirmDeleteRoom()}\n disabled={isDeletingRoom}\n >\n {labels.deleteConfirm}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n\n <Drawer\n open={open}\n onOpenChange={setOpenWithNotify}\n direction=\"right\"\n shouldScaleBackground={false}\n >\n <DrawerContent\n className={cn(\n \"gap-0 overflow-hidden p-0 shadow-xl\",\n \"data-[vaul-drawer-direction=right]:inset-y-3\",\n \"data-[vaul-drawer-direction=right]:right-3\",\n \"data-[vaul-drawer-direction=right]:left-auto\",\n \"data-[vaul-drawer-direction=right]:h-[calc(100%-1.5rem)]\",\n \"data-[vaul-drawer-direction=right]:max-h-[calc(100%-1.5rem)]\",\n \"data-[vaul-drawer-direction=right]:w-[min(36rem,calc(100vw-1.5rem))]\",\n \"data-[vaul-drawer-direction=right]:max-w-[min(36rem,calc(100vw-1.5rem))]\",\n \"data-[vaul-drawer-direction=right]:sm:max-w-[min(36rem,calc(100vw-1.5rem))]\",\n \"data-[vaul-drawer-direction=right]:rounded-2xl\",\n \"data-[vaul-drawer-direction=right]:border\",\n )}\n >\n <div className=\"flex h-full min-h-0 flex-row\">\n <div className=\"flex w-[3.25rem] shrink-0 flex-col items-center gap-2 border-r border-border py-4\">\n <Tooltip delayDuration={300}>\n <TooltipTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-9 shrink-0 rounded-full\"\n aria-label={labels.newRoom}\n onClick={() => setCreateOpen(true)}\n >\n <IconPlus className=\"size-4\" />\n </Button>\n </TooltipTrigger>\n <TooltipContent side=\"left\">{labels.newRoom}</TooltipContent>\n </Tooltip>\n\n <div className=\"flex min-h-0 flex-1 flex-col items-center gap-2 overflow-y-auto px-1 py-1\">\n {teamRooms.map((room) => {\n const selected = room.id === activeRoomId;\n return (\n <Tooltip key={room.id} delayDuration={300}>\n <TooltipTrigger asChild>\n <button\n type=\"button\"\n onClick={() => onActiveRoomChange(room.id)}\n className={cn(\n \"relative flex size-9 shrink-0 items-center justify-center rounded-full border-2 transition-colors\",\n selected\n ? \"border-primary bg-primary/15\"\n : \"border-transparent bg-muted hover:bg-muted/80\",\n )}\n aria-label={`Sala ${room.name}${room.isOrganization ? \" (toda la organización)\" : \"\"}`}\n aria-current={selected ? \"true\" : undefined}\n >\n <span className=\"text-xs font-semibold leading-none\">\n {roomInitialLetter(room.name)}\n </span>\n {(unreadByRoomId?.[room.id] ?? 0) > 0 ? (\n <span\n className={cn(\n unreadCountBadgeClasses(\n formatUnreadBadge(unreadByRoomId![room.id]!),\n ),\n \"ring-2 ring-background\",\n )}\n aria-hidden\n >\n {formatUnreadBadge(unreadByRoomId![room.id]!)}\n </span>\n ) : null}\n </button>\n </TooltipTrigger>\n <TooltipContent side=\"left\">\n {room.isOrganization\n ? `${room.name}${labels.orgRoomTooltip}`\n : room.name}\n </TooltipContent>\n </Tooltip>\n );\n })}\n </div>\n\n {supportRoom ? (\n <Tooltip delayDuration={300}>\n <TooltipTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className={cn(\n \"relative size-9 shrink-0 rounded-full border shadow-sm\",\n activeRoomId === supportRoom.id\n ? \"border-sky-600 bg-sky-600 text-white hover:bg-sky-600/90 hover:text-white dark:border-sky-500 dark:bg-sky-500\"\n : \"border-sky-500/70 bg-sky-500/20 text-sky-800 hover:bg-sky-500/30 dark:border-sky-400/60 dark:bg-sky-500/25 dark:text-sky-100\",\n )}\n aria-label={labels.supportChatTooltip}\n aria-current={\n activeRoomId === supportRoom.id ? \"true\" : undefined\n }\n onClick={() => onActiveRoomChange(supportRoom.id)}\n >\n <IconHeadset className=\"size-4 stroke-[2.25]\" />\n {(unreadByRoomId?.[supportRoom.id] ?? 0) > 0 ? (\n <span\n className={cn(\n unreadCountBadgeClasses(\n formatUnreadBadge(\n unreadByRoomId![supportRoom.id]!,\n ),\n ),\n \"ring-2 ring-background\",\n )}\n aria-hidden\n >\n {formatUnreadBadge(unreadByRoomId![supportRoom.id]!)}\n </span>\n ) : null}\n </Button>\n </TooltipTrigger>\n <TooltipContent side=\"left\">\n {labels.supportChatTooltip}\n </TooltipContent>\n </Tooltip>\n ) : null}\n\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-9 shrink-0 rounded-full\"\n aria-label={labels.roomMenu}\n >\n <IconDotsVertical className=\"size-4\" />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent side=\"left\" align=\"end\" className=\"w-48\">\n <DropdownMenuItem onClick={() => setCreateOpen(true)}>\n <IconPlus className=\"size-4\" />\n {labels.newRoom}\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem\n disabled={\n teamRooms.length <= 1 ||\n activeRoom?.isOrganization ||\n activeRoom?.isSupport\n }\n className=\"text-destructive focus:text-destructive\"\n onClick={requestDeleteRoom}\n >\n <IconTrash className=\"size-4\" />\n {labels.deleteRoom}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </div>\n\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n <div className=\"flex items-start justify-between gap-4 px-5 pt-5 pb-3\">\n <div className=\"flex min-w-0 flex-col gap-1\">\n <DrawerTitle className=\"flex items-center gap-2 font-medium\">\n <IconMessages className=\"size-5 shrink-0\" aria-hidden />\n <span className=\"min-w-0 truncate\">{title}</span>\n </DrawerTitle>\n <DrawerDescription className=\"line-clamp-2\">\n {activeRoom ? (\n activeRoom.isSupport ? (\n <span>{labels.supportRoomDescription}</span>\n ) : (\n <>\n <span className=\"font-medium text-foreground\">\n {activeRoom.name}\n </span>\n {activeRoom.isOrganization ? (\n <>{labels.orgRoomSuffix}</>\n ) : null}\n {\" · \"}\n <kbd className=\"pointer-events-none rounded border bg-muted px-0.5 font-mono text-[0.65rem]\">\n /\n </kbd>{\" \"}\n {labels.slashMentionHint}\n </>\n )\n ) : (\n labels.selectRoomHint\n )}\n </DrawerDescription>\n </div>\n <DrawerClose asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n className=\"size-8 shrink-0\"\n aria-label={labels.closeChat}\n >\n <IconX className=\"size-4\" />\n </Button>\n </DrawerClose>\n </div>\n\n <div\n ref={scrollerRef}\n className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\"\n onScroll={handleMessagesScroll}\n >\n {!activeRoom ? (\n <Empty className=\"h-full border-0\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <IconMessages />\n </EmptyMedia>\n <EmptyTitle>{labels.noRoomTitle}</EmptyTitle>\n <EmptyDescription>\n {labels.noRoomDescription}\n </EmptyDescription>\n </EmptyHeader>\n </Empty>\n ) : messagesStatus === \"loading\" ? (\n <div className=\"flex h-full flex-col items-center justify-center gap-2 text-muted-foreground\">\n <IconLoader2 className=\"size-6 animate-spin\" aria-hidden />\n <span className=\"text-sm\">{labels.loadingMessages}</span>\n </div>\n ) : messagesStatus === \"error\" ? (\n <Empty className=\"h-full border-0\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <IconMessages />\n </EmptyMedia>\n <EmptyTitle>{labels.loadMessagesErrorTitle}</EmptyTitle>\n <EmptyDescription className=\"text-left\">\n {messagesError ?? labels.loadMessagesErrorFallback}\n </EmptyDescription>\n </EmptyHeader>\n </Empty>\n ) : messagesStatus === \"empty\" ? (\n <Empty className=\"h-full border-0\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <IconMessages />\n </EmptyMedia>\n <EmptyTitle>{labels.emptyThreadTitle}</EmptyTitle>\n <EmptyDescription>\n {labels.emptyThreadDescription(activeRoom.name)}\n </EmptyDescription>\n </EmptyHeader>\n </Empty>\n ) : (\n <div\n ref={messagesListRef}\n className=\"flex flex-col gap-4 px-5 pt-2 pb-6\"\n >\n {isLoadingOlder ? (\n <div className=\"flex items-center justify-center gap-2 py-2 text-xs text-muted-foreground\">\n <IconLoader2\n className=\"size-3.5 shrink-0 animate-spin\"\n aria-hidden\n />\n {labels.loadingOlderMessages}\n </div>\n ) : null}\n {messages.map((message) => {\n const mine = message.role === \"me\";\n return (\n <Message\n key={message.id}\n align={mine ? \"end\" : \"start\"}\n >\n {mine ? null : (\n <MessageAvatar className=\"size-8 text-xs font-medium\">\n {message.initials}\n </MessageAvatar>\n )}\n <MessageContent className=\"gap-0.5\">\n {mine ? null : (\n <MessageHeader className=\"px-3 pb-0 leading-snug\">\n {message.author}\n </MessageHeader>\n )}\n {message.attachments?.length ? (\n <InternalChatAttachmentStack\n items={message.attachments}\n labels={labels}\n />\n ) : null}\n {message.audio ? (\n <Bubble\n align={mine ? \"end\" : \"start\"}\n variant={mine ? \"muted\" : \"outline\"}\n >\n <BubbleContent className=\"py-2\">\n <InternalChatAudioBubble\n audio={message.audio}\n seed={message.id}\n mine={mine}\n />\n </BubbleContent>\n </Bubble>\n ) : null}\n {message.text.trim() ? (\n <Bubble\n align={mine ? \"end\" : \"start\"}\n variant={mine ? \"muted\" : \"outline\"}\n >\n <BubbleContent className=\"break-words whitespace-pre-wrap\">\n {renderMessageTextWithMentions(message.text)}\n </BubbleContent>\n </Bubble>\n ) : null}\n <MessageFooter className=\"flex items-center justify-end gap-1 px-3 pt-0 leading-none\">\n <span className=\"text-[0.65rem] tabular-nums text-muted-foreground\">\n {formatMessageTime(message.at)}\n </span>\n {mine && message.receiptStatus ? (\n <span\n className=\"inline-flex shrink-0\"\n title={\n message.receiptStatus === \"pending\"\n ? \"Enviando\"\n : message.receiptStatus === \"sent\"\n ? \"Enviado\"\n : message.receiptStatus === \"delivered\"\n ? \"Entregado\"\n : \"Leído\"\n }\n >\n <MessageReceiptTicks\n status={message.receiptStatus}\n />\n </span>\n ) : null}\n </MessageFooter>\n </MessageContent>\n </Message>\n );\n })}\n </div>\n )}\n {activeRoom && typingLine ? (\n <div\n className=\"border-t border-border px-5 py-2 text-xs text-muted-foreground\"\n aria-live=\"polite\"\n >\n {typingLine}\n </div>\n ) : null}\n </div>\n\n {voiceRecorderOpen && activeRoom ? (\n <InternalChatVoiceRecorderBar\n labels={labels}\n onClose={() => setVoiceRecorderOpen(false)}\n onSendBlob={onSendAudioBlob}\n onSendLegacy={onSendAudio}\n />\n ) : null}\n\n {activeRoom && screenRecorderOpen ? (\n <InternalChatScreenRecorderBar\n labels={labels}\n session={screenRecorderSession}\n onDismiss={() => setScreenRecorderOpen(false)}\n />\n ) : null}\n\n <form\n ref={formRef}\n onSubmit={onSubmit}\n className=\"relative px-5 pt-2 pb-5\"\n >\n {mentionOpen ? (\n <div\n role=\"listbox\"\n aria-label={labels.mentionListLabel}\n className=\"absolute bottom-full left-5 right-5 z-50 mb-1 flex max-h-60 flex-col overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-md\"\n >\n <div className=\"shrink-0 border-b border-border px-2 py-1.5 text-[0.7rem] text-muted-foreground\">\n {mentionSeg.phase === \"category\" ? (\n <>\n <span className=\"font-medium text-foreground\">\n Tipo\n </span>\n {\" · \"}\n <kbd className=\"rounded bg-muted px-1 font-mono text-[0.65rem]\">\n Tab\n </kbd>\n /\n <kbd className=\"rounded bg-muted px-1 font-mono text-[0.65rem]\">\n ↵\n </kbd>\n </>\n ) : (\n <>\n <span className=\"font-medium text-foreground\">\n {DEFAULT_MENTION_KIND_LABEL[mentionSeg.kind]}\n </span>\n {\" · \"}\n <kbd className=\"rounded bg-muted px-1 font-mono text-[0.65rem]\">\n ←\n </kbd>\n </>\n )}\n </div>\n <div className=\"min-h-0 flex-1 overflow-y-auto p-1\">\n {mentionCatalogLoading && mentionEntries.length === 0 ? (\n <div className=\"flex items-center justify-center gap-2 px-2 py-6 text-sm text-muted-foreground\">\n <IconLoader2 className=\"size-4 animate-spin\" />\n Buscando…\n </div>\n ) : null}\n {!mentionCatalogLoading &&\n mentionEntries.length === 0 &&\n mentionSeg.phase === \"item\" ? (\n <p className=\"px-2 py-4 text-center text-sm text-muted-foreground\">\n Sin resultados\n </p>\n ) : null}\n {mentionEntries.map((entry, index) => {\n if (entry.type === \"category\") {\n const IconComp = entry.icon;\n const categoryDisabled = isMentionEntryDisabled(\n entry,\n mentionConfig,\n );\n return (\n <button\n key={`cat-${entry.kind}`}\n type=\"button\"\n role=\"option\"\n aria-selected={index === mentionHighlight}\n aria-disabled={categoryDisabled}\n disabled={categoryDisabled}\n className={cn(\n \"flex w-full items-start gap-2 rounded-lg px-2 py-2 text-left text-sm\",\n categoryDisabled &&\n \"cursor-not-allowed opacity-50\",\n !categoryDisabled &&\n (index === mentionHighlight\n ? \"bg-accent text-accent-foreground\"\n : \"hover:bg-muted/80\"),\n )}\n onMouseEnter={() => {\n if (!categoryDisabled) setMentionHighlight(index);\n }}\n onMouseDown={(event) => {\n event.preventDefault();\n if (categoryDisabled) return;\n applyCategoryPick(entry.kind);\n }}\n >\n <IconComp className=\"mt-0.5 size-4 shrink-0 opacity-80\" />\n <span className=\"min-w-0 flex-1\">\n <span className=\"block font-medium leading-tight\">\n {entry.label}\n </span>\n <span className=\"mt-0.5 block text-xs text-muted-foreground\">\n {entry.hint}\n </span>\n </span>\n </button>\n );\n }\n const item = entry.data;\n const IconComp = item.icon;\n return (\n <button\n key={item.id}\n type=\"button\"\n role=\"option\"\n aria-selected={index === mentionHighlight}\n className={cn(\n \"flex w-full items-start gap-2 rounded-lg px-2 py-2 text-left text-sm\",\n index === mentionHighlight\n ? \"bg-accent text-accent-foreground\"\n : \"hover:bg-muted/80\",\n )}\n onMouseEnter={() => setMentionHighlight(index)}\n onMouseDown={(event) => {\n event.preventDefault();\n applyItemPick(item);\n }}\n >\n <IconComp className=\"mt-0.5 size-4 shrink-0 opacity-80\" />\n <span className=\"min-w-0 flex-1\">\n <span className=\"block font-medium leading-tight\">\n {item.label}\n </span>\n <span className=\"mt-0.5 block text-xs text-muted-foreground\">\n {item.subtitle}\n </span>\n </span>\n </button>\n );\n })}\n </div>\n </div>\n ) : null}\n\n <InputGroup>\n <InputGroupTextarea\n ref={textareaRef}\n value={draft}\n onChange={(event) => {\n const v = event.target.value;\n const cursor =\n event.target.selectionStart ?? v.length;\n setDraft(v);\n onDraftChange?.(v);\n syncMentionMenu(v, cursor);\n }}\n onSelect={(event) => {\n const t = event.target as HTMLTextAreaElement;\n syncMentionMenu(\n t.value,\n t.selectionStart ?? t.value.length,\n );\n }}\n placeholder={\n activeRoom\n ? labels.messagePlaceholder\n : labels.messagePlaceholderNoRoom\n }\n aria-label={labels.messageAriaLabel}\n className=\"min-h-14\"\n disabled={!activeRoom}\n onKeyDown={(event) => {\n onUserGesture?.();\n if (mentionOpen) {\n if (event.key === \"ArrowDown\") {\n event.preventDefault();\n setMentionHighlight((h) =>\n stepMentionHighlight(\n mentionEntries,\n mentionConfig,\n h,\n 1,\n ),\n );\n return;\n }\n if (event.key === \"ArrowUp\") {\n event.preventDefault();\n setMentionHighlight((h) =>\n stepMentionHighlight(\n mentionEntries,\n mentionConfig,\n h,\n -1,\n ),\n );\n return;\n }\n if (event.key === \"Backspace\") {\n const seg = parseMentionSegment(\n mentionQuery,\n mentionConfig,\n );\n if (seg.phase === \"item\" && seg.filter === \"\") {\n event.preventDefault();\n replaceSlashSegment(\"\");\n setMentionHighlight(0);\n return;\n }\n }\n if (event.key === \"ArrowLeft\") {\n const seg = parseMentionSegment(\n mentionQuery,\n mentionConfig,\n );\n if (seg.phase === \"item\" && seg.filter === \"\") {\n event.preventDefault();\n replaceSlashSegment(\"\");\n setMentionHighlight(0);\n return;\n }\n }\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n const pick = mentionEntries[mentionHighlight];\n if (!pick || isMentionEntryDisabled(pick, mentionConfig)) {\n return;\n }\n if (pick.type === \"category\") {\n applyCategoryPick(pick.kind);\n } else {\n applyItemPick(pick.data);\n }\n return;\n }\n if (event.key === \"Tab\") {\n event.preventDefault();\n const pick = mentionEntries[mentionHighlight];\n if (!pick || isMentionEntryDisabled(pick, mentionConfig)) {\n return;\n }\n if (pick.type === \"category\") {\n applyCategoryPick(pick.kind);\n } else {\n applyItemPick(pick.data);\n }\n return;\n }\n if (event.key === \"Escape\") {\n event.preventDefault();\n closeMentionMenu();\n return;\n }\n }\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n formRef.current?.requestSubmit();\n }\n }}\n />\n <InputGroupAddon align=\"block-end\" className=\"pt-1\">\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <InputGroupButton\n aria-label={labels.attachAriaLabel}\n type=\"button\"\n size=\"icon-sm\"\n variant=\"outline\"\n disabled={!activeRoom}\n >\n <IconPlus className=\"size-4\" />\n </InputGroupButton>\n </DropdownMenuTrigger>\n <DropdownMenuContent\n align=\"start\"\n side=\"top\"\n className=\"w-48\"\n >\n <DropdownMenuItem\n onClick={() => {\n onUserGesture?.();\n photoInputRef.current?.click();\n }}\n >\n <IconPhoto className=\"size-4\" />\n {labels.attachPhotos}\n </DropdownMenuItem>\n <DropdownMenuItem\n onClick={() => {\n onUserGesture?.();\n fileInputRef.current?.click();\n }}\n >\n <IconPaperclip className=\"size-4\" />\n {labels.attachFiles}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n <InputGroupButton\n type=\"button\"\n variant={\n screenRecorderOpen || screenRecorderSession.recording\n ? \"default\"\n : \"outline\"\n }\n size=\"icon-sm\"\n disabled={!activeRoom}\n aria-label={labels.screenCaptureAriaLabel}\n aria-pressed={\n screenRecorderOpen || screenRecorderSession.recording\n }\n className={cn(\n !screenRecorderOpen &&\n !screenRecorderSession.recording &&\n \"text-violet-600 hover:text-violet-700 dark:text-violet-400 dark:hover:text-violet-300\",\n )}\n onClick={() => {\n onUserGesture?.();\n setVoiceRecorderOpen(false);\n setScreenRecorderOpen((prev) => !prev);\n }}\n >\n <IconScreenShare className=\"size-4\" />\n </InputGroupButton>\n <input\n ref={photoInputRef}\n type=\"file\"\n accept=\"image/*\"\n className=\"hidden\"\n onChange={(e) => {\n const file = e.target.files?.[0];\n if (file && onAttachPhotos) void onAttachPhotos(file);\n e.target.value = \"\";\n }}\n />\n <input\n ref={fileInputRef}\n type=\"file\"\n className=\"hidden\"\n onChange={(e) => {\n const file = e.target.files?.[0];\n if (file && onAttachFiles) void onAttachFiles(file);\n e.target.value = \"\";\n }}\n />\n <div className=\"ml-auto flex shrink-0 items-center gap-1\">\n <InputGroupButton\n type=\"button\"\n variant={voiceRecorderOpen ? \"default\" : \"outline\"}\n size=\"icon-sm\"\n disabled={!activeRoom}\n aria-label={labels.attachAudio}\n aria-pressed={voiceRecorderOpen}\n className={cn(\n !voiceRecorderOpen &&\n \"text-sky-600 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300\",\n )}\n onClick={() => {\n onUserGesture?.();\n setScreenRecorderOpen(false);\n setVoiceRecorderOpen((open) => !open);\n }}\n >\n <IconMicrophone className=\"size-4\" />\n </InputGroupButton>\n <InputGroupButton\n type=\"submit\"\n variant=\"default\"\n size=\"icon-sm\"\n disabled={!canSend || !activeRoom}\n >\n <IconArrowUp className=\"size-4\" />\n <span className=\"sr-only\">{labels.sendSrOnly}</span>\n </InputGroupButton>\n </div>\n </InputGroupAddon>\n </InputGroup>\n </form>\n </div>\n </div>\n </DrawerContent>\n </Drawer>\n </>\n );\n}\n\nexport {\n InternalChatPanel,\n DEFAULT_MENTION_CONFIG,\n DEFAULT_LABELS as DEFAULT_INTERNAL_CHAT_PANEL_LABELS,\n};\n\nexport { initialsFromDisplayName } from \"@/components/ui/internal-chat-mention\";\n\nexport type {\n BuildMentionListOptions,\n InternalChatMentionCatalogState,\n InternalChatMentionConfig,\n InternalChatMentionItem,\n InternalChatMentionKind,\n} from \"@/components/ui/internal-chat-mention\";\n",
|
|
27
27
|
"type": "registry:ui",
|
|
28
28
|
"target": ""
|
|
29
29
|
}
|
|
@@ -10,20 +10,24 @@
|
|
|
10
10
|
"alert-dialog",
|
|
11
11
|
"button",
|
|
12
12
|
"card",
|
|
13
|
+
"city-select",
|
|
14
|
+
"country-select",
|
|
13
15
|
"entity-icon",
|
|
14
16
|
"google-maps-address-picker",
|
|
15
17
|
"input",
|
|
16
18
|
"label",
|
|
19
|
+
"lib-geo-locations",
|
|
17
20
|
"lib-google-maps-address",
|
|
18
21
|
"select",
|
|
19
22
|
"spinner",
|
|
23
|
+
"state-select",
|
|
20
24
|
"switch",
|
|
21
25
|
"textarea"
|
|
22
26
|
],
|
|
23
27
|
"files": [
|
|
24
28
|
{
|
|
25
29
|
"path": "ui/office-address-form.tsx",
|
|
26
|
-
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport { IconEdit, IconMapPin, IconPlus } from \"@tabler/icons-react\"\nimport type { Icon } from \"@tabler/icons-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\"\nimport { Button } from \"@/components/ui/button\"\nimport { Spinner } from \"@/components/ui/spinner\"\nimport { googleMapsSelectionToFormPatch } from \"@/lib/google-maps-address\"\nimport {\n Card,\n CardContent,\n CardDescription,\n CardFooter,\n CardHeader,\n CardTitle,\n} from \"@/components/ui/card\"\nimport { EntityIcon } from \"@/components/ui/entity-icon\"\nimport { GoogleMapsAddressPicker } from \"@/components/ui/google-maps-address-picker\"\nimport { Input } from \"@/components/ui/input\"\nimport { Label } from \"@/components/ui/label\"\nimport {\n Select,\n SelectContent,\n SelectGroup,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\"\nimport { Switch } from \"@/components/ui/switch\"\nimport { Textarea } from \"@/components/ui/textarea\"\n\nexport type OfficeAddressEntryMode = \"manual\" | \"gps\"\n\nfunction OfficeAddressEntryModeSwitch({\n mode,\n onModeChange,\n disabled = false,\n className,\n}: {\n mode: OfficeAddressEntryMode\n onModeChange: (mode: OfficeAddressEntryMode) => void\n disabled?: boolean\n className?: string\n}) {\n const isGps = mode === \"gps\"\n\n return (\n <div\n className={cn(\n \"flex shrink-0 items-center gap-2 text-xs text-muted-foreground\",\n className,\n )}\n role=\"group\"\n aria-label=\"Modo de ingreso de dirección\"\n >\n <span className={cn(!isGps && \"font-semibold text-foreground\")}>\n Manual\n </span>\n <Switch\n checked={isGps}\n disabled={disabled}\n aria-label={isGps ? \"Modo GPS\" : \"Modo manual\"}\n onCheckedChange={(checked) =>\n onModeChange(checked ? \"gps\" : \"manual\")\n }\n />\n <span className={cn(isGps && \"font-semibold text-foreground\")}>GPS</span>\n </div>\n )\n}\n\n/** `customer` = dirección de envío (contacto/portal). `enterprise` = sucursal o bodega. */\nexport type OfficeAddressFormVariant = \"customer\" | \"enterprise\"\n\nconst OFFICE_ADDRESS_COUNTRY_OPTIONS: { code: string; label: string }[] = [\n { code: \"EC\", label: \"Ecuador\" },\n { code: \"US\", label: \"Estados Unidos\" },\n { code: \"MX\", label: \"México\" },\n { code: \"CO\", label: \"Colombia\" },\n { code: \"PE\", label: \"Perú\" },\n { code: \"CL\", label: \"Chile\" },\n { code: \"PA\", label: \"Panamá\" },\n { code: \"CR\", label: \"Costa Rica\" },\n]\n\nconst FAX_COUNTRY_CODES = new Set([\"US\", \"CA\", \"PR\"])\n\nexport function isOfficeAddressFaxCountry(countryCode: string | undefined) {\n return FAX_COUNTRY_CODES.has(countryCode?.trim().toUpperCase() ?? \"\")\n}\n\nexport type OfficeAddressFormValues = {\n countryCode: string\n address: string\n suiteOrApt: string\n city: string\n stateOrProvince: string\n postalCode: string\n phoneNumber: string\n fax: string\n note: string\n isActive: boolean\n isMain: boolean\n isWarehouseOnly: boolean\n latitude: number | null\n longitude: number | null\n addressGeocodeSource: string | null\n}\n\nexport const EMPTY_OFFICE_ADDRESS_FORM_VALUES: OfficeAddressFormValues = {\n countryCode: \"\",\n address: \"\",\n suiteOrApt: \"\",\n city: \"\",\n stateOrProvince: \"\",\n postalCode: \"\",\n phoneNumber: \"\",\n fax: \"\",\n note: \"\",\n isActive: true,\n isMain: false,\n isWarehouseOnly: false,\n latitude: null,\n longitude: null,\n addressGeocodeSource: null,\n}\n\nexport function mergeOfficeAddressFormValues(\n initial?: Partial<OfficeAddressFormValues>,\n): OfficeAddressFormValues {\n return {\n ...EMPTY_OFFICE_ADDRESS_FORM_VALUES,\n ...initial,\n isActive: initial?.isActive ?? true,\n isMain: initial?.isMain ?? false,\n }\n}\n\nexport function resolveOfficeAddressEntryMode(\n initial?: Partial<OfficeAddressFormValues>,\n initialEntryMode?: OfficeAddressEntryMode,\n): OfficeAddressEntryMode {\n if (initialEntryMode) return initialEntryMode\n return initial?.addressGeocodeSource === \"google_maps\" ? \"gps\" : \"manual\"\n}\n\nexport function useOfficeAddressForm(options?: {\n initialValues?: Partial<OfficeAddressFormValues>\n initialEntryMode?: OfficeAddressEntryMode\n}) {\n const [form, setForm] = React.useState<OfficeAddressFormValues>(() =>\n mergeOfficeAddressFormValues(options?.initialValues),\n )\n const [addressEntryMode, setAddressEntryMode] =\n React.useState<OfficeAddressEntryMode>(() =>\n resolveOfficeAddressEntryMode(\n options?.initialValues,\n options?.initialEntryMode,\n ),\n )\n\n const reset = React.useCallback(\n (\n nextInitial?: Partial<OfficeAddressFormValues>,\n nextMode?: OfficeAddressEntryMode,\n ) => {\n setForm(mergeOfficeAddressFormValues(nextInitial))\n setAddressEntryMode(\n resolveOfficeAddressEntryMode(nextInitial, nextMode),\n )\n },\n [],\n )\n\n const setAddressEntryModeAndClearGps = React.useCallback(\n (mode: OfficeAddressEntryMode) => {\n setAddressEntryMode(mode)\n if (mode === \"manual\") {\n setForm((prev) => ({\n ...prev,\n latitude: null,\n longitude: null,\n addressGeocodeSource: null,\n }))\n }\n },\n [],\n )\n\n return {\n form,\n setForm,\n addressEntryMode,\n setAddressEntryMode: setAddressEntryModeAndClearGps,\n reset,\n }\n}\n\nexport type OfficeAddressFormCardProps = {\n title: string\n description?: string\n icon?: Icon\n form: OfficeAddressFormValues\n onFormChange: React.Dispatch<React.SetStateAction<OfficeAddressFormValues>>\n addressEntryMode: OfficeAddressEntryMode\n onAddressEntryModeChange: (mode: OfficeAddressEntryMode) => void\n disabled?: boolean\n regionCode?: string\n googleMapsApiKey?: string\n /** Estable para remontar GoogleMaps al editar. */\n pickerKey?: string\n footer?: React.ReactNode\n className?: string\n /**\n * `dialog`: card plana dentro de AlertDialog (sin borde/sombra exterior).\n * `page`: card completa para rutas de edición.\n */\n layout?: \"dialog\" | \"page\"\n contentClassName?: string\n /** customer = envío; enterprise = sucursal/bodega (campos extra). */\n variant?: OfficeAddressFormVariant\n /** Reemplaza el selector de país por defecto (p. ej. CountrySelect en la app). */\n countryField?: React.ReactNode\n phoneField?: React.ReactNode\n faxField?: React.ReactNode\n}\n\nexport function OfficeAddressFormCard({\n title,\n description,\n icon: HeaderIcon = IconMapPin,\n form,\n onFormChange,\n addressEntryMode,\n onAddressEntryModeChange,\n disabled = false,\n regionCode,\n googleMapsApiKey,\n pickerKey = \"office-address-gps\",\n footer,\n className,\n layout = \"page\",\n contentClassName,\n variant = \"customer\",\n countryField,\n phoneField,\n faxField,\n}: OfficeAddressFormCardProps) {\n const busy = disabled\n const isCustomer = variant === \"customer\"\n const mapRegion =\n form.countryCode?.trim().toUpperCase() ||\n regionCode?.trim().toUpperCase() ||\n undefined\n const showFax =\n Boolean(faxField) ||\n isOfficeAddressFaxCountry(form.countryCode) ||\n form.fax.trim().length > 0\n\n const handleChange = (\n e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,\n ) => {\n const { name, value } = e.target\n onFormChange((prev) => ({ ...prev, [name]: value }))\n }\n\n const embedded = layout === \"dialog\"\n\n const countryControl =\n countryField ?? (\n <Select\n value={form.countryCode || undefined}\n disabled={busy}\n onValueChange={(code) =>\n onFormChange((prev) => ({ ...prev, countryCode: code }))\n }\n >\n <SelectTrigger className=\"w-full\" aria-label=\"País\">\n <SelectValue placeholder=\"Selecciona el país\" />\n </SelectTrigger>\n <SelectContent>\n <SelectGroup>\n {OFFICE_ADDRESS_COUNTRY_OPTIONS.map((c) => (\n <SelectItem key={c.code} value={c.code}>\n {c.label}\n </SelectItem>\n ))}\n </SelectGroup>\n </SelectContent>\n </Select>\n )\n\n return (\n <Card\n className={cn(\n \"gap-0 py-0\",\n embedded\n ? \"border-0 bg-transparent shadow-none\"\n : \"border-border shadow-sm\",\n className,\n )}\n >\n <CardHeader\n className={cn(\n \"flex flex-row flex-wrap items-center justify-between gap-3 space-y-0 px-5 pt-5 pb-3.5 sm:px-6\",\n embedded && \"border-b border-border/60\",\n !embedded && \"border-b border-border/60\",\n )}\n >\n <div className=\"flex min-w-0 flex-1 items-start gap-3\">\n <EntityIcon icon={HeaderIcon} size=\"sm\" className=\"mb-0\" />\n <div className=\"min-w-0 space-y-0.5\">\n <CardTitle className=\"text-sm font-semibold tracking-tight\">\n {title}\n </CardTitle>\n {description ? (\n <CardDescription className=\"text-xs\">{description}</CardDescription>\n ) : null}\n </div>\n </div>\n <OfficeAddressEntryModeSwitch\n mode={addressEntryMode}\n disabled={busy}\n onModeChange={onAddressEntryModeChange}\n />\n </CardHeader>\n\n <CardContent\n className={cn(\n \"grid min-h-0 grid-cols-1 gap-4 px-5 py-4 sm:grid-cols-2 sm:px-6\",\n embedded && \"max-h-[min(60dvh,28rem)] overflow-y-auto overscroll-contain\",\n contentClassName,\n )}\n >\n {addressEntryMode === \"gps\" ? (\n <div className=\"col-span-1 sm:col-span-2\">\n <GoogleMapsAddressPicker\n key={pickerKey}\n apiKey={googleMapsApiKey}\n regionCode={mapRegion}\n initialLatitude={form.latitude ?? undefined}\n initialLongitude={form.longitude ?? undefined}\n initialFormattedAddress={form.address}\n onLocationSelect={(selection) => {\n const patch = googleMapsSelectionToFormPatch(selection)\n onFormChange((prev) => ({\n ...prev,\n address: patch.address,\n city: patch.city,\n stateOrProvince: patch.stateOrProvince,\n postalCode: patch.postalCode,\n countryCode: patch.countryCode || prev.countryCode,\n latitude: patch.latitude,\n longitude: patch.longitude,\n addressGeocodeSource: patch.addressGeocodeSource,\n }))\n }}\n onLocationClear={() => {\n onFormChange((prev) => ({\n ...prev,\n address: \"\",\n city: \"\",\n stateOrProvince: \"\",\n postalCode: \"\",\n latitude: null,\n longitude: null,\n addressGeocodeSource: null,\n }))\n }}\n />\n </div>\n ) : null}\n\n <div className=\"col-span-1 space-y-1 sm:col-span-2\">\n <Label htmlFor=\"office-country\">País</Label>\n {countryControl}\n </div>\n\n {addressEntryMode === \"manual\" ? (\n <>\n <div className=\"col-span-1 space-y-1 sm:col-span-2\">\n <Label htmlFor=\"office-address\">Dirección</Label>\n <Input\n id=\"office-address\"\n name=\"address\"\n value={form.address}\n onChange={handleChange}\n placeholder=\"Calle y número\"\n disabled={busy}\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-city\">Ciudad</Label>\n <Input\n id=\"office-city\"\n name=\"city\"\n value={form.city}\n onChange={handleChange}\n disabled={busy}\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-state\">Estado / Provincia</Label>\n <Input\n id=\"office-state\"\n name=\"stateOrProvince\"\n value={form.stateOrProvince}\n onChange={handleChange}\n disabled={busy}\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-postal\">Código postal</Label>\n <Input\n id=\"office-postal\"\n name=\"postalCode\"\n value={form.postalCode}\n onChange={handleChange}\n disabled={busy}\n />\n </div>\n </>\n ) : null}\n\n {addressEntryMode === \"gps\" && form.address.trim() ? (\n <div className=\"col-span-1 rounded-lg border border-border bg-muted/30 px-3 py-2.5 text-sm sm:col-span-2\">\n <p className=\"font-medium text-foreground\">{form.address}</p>\n <p className=\"text-muted-foreground\">\n {[form.city, form.stateOrProvince, form.postalCode, form.countryCode]\n .filter(Boolean)\n .join(\" · \")}\n </p>\n </div>\n ) : null}\n\n <div className=\"col-span-1 space-y-1 sm:col-span-2\">\n <Label htmlFor=\"office-suite\">Suite / apt</Label>\n <Input\n id=\"office-suite\"\n name=\"suiteOrApt\"\n value={form.suiteOrApt}\n onChange={handleChange}\n disabled={busy}\n />\n </div>\n\n {phoneField ?? (\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-phone\">Teléfono</Label>\n <Input\n id=\"office-phone\"\n name=\"phoneNumber\"\n type=\"tel\"\n value={form.phoneNumber}\n onChange={handleChange}\n placeholder=\"+593 …\"\n disabled={busy}\n />\n </div>\n )}\n\n {showFax\n ? (faxField ?? (\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-fax\">Fax</Label>\n <Input\n id=\"office-fax\"\n name=\"fax\"\n type=\"tel\"\n value={form.fax}\n onChange={handleChange}\n disabled={busy}\n />\n </div>\n ))\n : null}\n\n <div className=\"col-span-1 space-y-1 sm:col-span-2\">\n <Label htmlFor=\"office-note\">Nota</Label>\n <Textarea\n id=\"office-note\"\n name=\"note\"\n value={form.note}\n onChange={handleChange}\n placeholder=\"Información adicional sobre esta dirección\"\n className=\"min-h-20 resize-y\"\n disabled={busy}\n />\n </div>\n\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-active\">Estado</Label>\n <Select\n value={form.isActive.toString()}\n disabled={busy}\n onValueChange={(value) =>\n onFormChange((prev) => ({\n ...prev,\n isActive: value === \"true\",\n }))\n }\n >\n <SelectTrigger id=\"office-active\" className=\"w-full\">\n <SelectValue placeholder=\"Seleccione el estado\" />\n </SelectTrigger>\n <SelectContent>\n <SelectGroup>\n <SelectItem value=\"true\">Activo</SelectItem>\n <SelectItem value=\"false\">Inactivo</SelectItem>\n </SelectGroup>\n </SelectContent>\n </Select>\n </div>\n\n {!isCustomer ? (\n <>\n <div className=\"col-span-1 space-y-1 sm:col-span-2\">\n <Label>Tipo de ubicación</Label>\n <Select\n value={form.isWarehouseOnly ? \"warehouse\" : \"agency\"}\n disabled={busy}\n onValueChange={(value) => {\n const isWarehouseOnly = value === \"warehouse\"\n onFormChange((prev) => ({\n ...prev,\n isWarehouseOnly,\n isMain: isWarehouseOnly ? false : prev.isMain,\n }))\n }}\n >\n <SelectTrigger className=\"w-full\">\n <SelectValue placeholder=\"Selecciona el tipo\" />\n </SelectTrigger>\n <SelectContent>\n <SelectGroup>\n <SelectItem value=\"agency\">Es sucursal</SelectItem>\n <SelectItem value=\"warehouse\">Es solo bodega</SelectItem>\n </SelectGroup>\n </SelectContent>\n </Select>\n </div>\n <div className=\"space-y-1\">\n <Label>Dirección principal</Label>\n <Select\n value={form.isMain.toString()}\n disabled={busy || form.isWarehouseOnly}\n onValueChange={(value) =>\n onFormChange((prev) => ({\n ...prev,\n isMain: value === \"true\",\n }))\n }\n >\n <SelectTrigger className=\"w-full\">\n <SelectValue placeholder=\"¿Es dirección principal?\" />\n </SelectTrigger>\n <SelectContent>\n <SelectGroup>\n <SelectItem value=\"true\">Sí</SelectItem>\n <SelectItem value=\"false\">No</SelectItem>\n </SelectGroup>\n </SelectContent>\n </Select>\n </div>\n </>\n ) : null}\n </CardContent>\n\n {footer ? (\n <CardFooter\n className={cn(\n \"border-t border-border/60 px-5 py-3 sm:px-6\",\n embedded && \"bg-background\",\n )}\n >\n {footer}\n </CardFooter>\n ) : null}\n </Card>\n )\n}\n\ntype OfficeAddressFormSharedProps = {\n title?: string\n titleCreate?: string\n titleEdit?: string\n description?: string\n initialValues?: Partial<OfficeAddressFormValues>\n initialEntryMode?: OfficeAddressEntryMode\n regionCode?: string\n googleMapsApiKey?: string\n pickerKey?: string\n className?: string\n contentClassName?: string\n icon?: Icon\n disabled?: boolean\n variant?: OfficeAddressFormVariant\n countryField?: React.ReactNode\n phoneField?: React.ReactNode\n faxField?: React.ReactNode\n}\n\ntype OfficeAddressFormInlineProps = OfficeAddressFormSharedProps & {\n /** Card en página o sección (por defecto). */\n surface?: \"inline\"\n form?: OfficeAddressFormValues\n onFormChange?: React.Dispatch<React.SetStateAction<OfficeAddressFormValues>>\n addressEntryMode?: OfficeAddressEntryMode\n onAddressEntryModeChange?: (mode: OfficeAddressEntryMode) => void\n footer?: React.ReactNode\n}\n\ntype OfficeAddressFormModalProps = OfficeAddressFormSharedProps & {\n /** Misma card dentro de AlertDialog (contactos, selectores, etc.). */\n surface: \"modal\"\n type?: \"create\" | \"edit\"\n open?: boolean\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n onSubmit?: (values: OfficeAddressFormValues) => void | Promise<void>\n trigger?: React.ReactNode\n hideTrigger?: boolean\n loading?: boolean\n dialogClassName?: string\n form?: OfficeAddressFormValues\n onFormChange?: React.Dispatch<React.SetStateAction<OfficeAddressFormValues>>\n addressEntryMode?: OfficeAddressEntryMode\n onAddressEntryModeChange?: (mode: OfficeAddressEntryMode) => void\n}\n\nexport type OfficeAddressFormProps =\n | OfficeAddressFormInlineProps\n | OfficeAddressFormModalProps\n\n/**\n * Formulario de dirección unificado: card en página (`surface=\"inline\"`) o en modal (`surface=\"modal\"`).\n * Usa `OfficeAddressFormCard` + `useOfficeAddressForm` por debajo.\n */\nexport function OfficeAddressForm(props: OfficeAddressFormProps) {\n if (props.surface === \"modal\") {\n return <OfficeAddressFormModal {...props} />\n }\n return <OfficeAddressFormInline {...props} />\n}\n\nfunction OfficeAddressFormInline({\n title = \"Dirección\",\n description,\n initialValues,\n initialEntryMode,\n form: controlledForm,\n onFormChange: controlledOnFormChange,\n addressEntryMode: controlledMode,\n onAddressEntryModeChange: controlledOnModeChange,\n footer,\n regionCode,\n googleMapsApiKey,\n pickerKey = \"office-address-inline\",\n className,\n contentClassName,\n icon,\n disabled,\n variant,\n countryField,\n phoneField,\n faxField,\n}: OfficeAddressFormInlineProps) {\n const internal = useOfficeAddressForm({ initialValues, initialEntryMode })\n const form = controlledForm ?? internal.form\n const setForm = controlledOnFormChange ?? internal.setForm\n const addressEntryMode = controlledMode ?? internal.addressEntryMode\n const setAddressEntryMode =\n controlledOnModeChange ?? internal.setAddressEntryMode\n\n return (\n <OfficeAddressFormCard\n layout=\"page\"\n title={title}\n description={description}\n icon={icon}\n form={form}\n onFormChange={setForm}\n addressEntryMode={addressEntryMode}\n onAddressEntryModeChange={setAddressEntryMode}\n disabled={disabled}\n regionCode={regionCode}\n googleMapsApiKey={googleMapsApiKey}\n pickerKey={pickerKey}\n footer={footer}\n className={className}\n contentClassName={contentClassName}\n variant={variant}\n countryField={countryField}\n phoneField={phoneField}\n faxField={faxField}\n />\n )\n}\n\nfunction OfficeAddressFormModal({\n type = \"create\",\n open: controlledOpen,\n defaultOpen = false,\n onOpenChange,\n initialValues,\n initialEntryMode,\n onSubmit,\n trigger,\n hideTrigger = false,\n loading = false,\n titleCreate = \"Crear dirección\",\n titleEdit = \"Editar dirección\",\n description,\n regionCode,\n googleMapsApiKey,\n className,\n dialogClassName,\n variant,\n countryField,\n phoneField,\n faxField,\n icon,\n form: controlledForm,\n onFormChange: controlledOnFormChange,\n addressEntryMode: controlledMode,\n onAddressEntryModeChange: controlledOnModeChange,\n}: OfficeAddressFormModalProps) {\n const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)\n const isControlled = controlledOpen !== undefined\n const open = isControlled ? Boolean(controlledOpen) : uncontrolledOpen\n\n const internal = useOfficeAddressForm({ initialValues, initialEntryMode })\n const form = controlledForm ?? internal.form\n const setForm = controlledOnFormChange ?? internal.setForm\n const addressEntryMode = controlledMode ?? internal.addressEntryMode\n const setAddressEntryMode =\n controlledOnModeChange ?? internal.setAddressEntryMode\n const reset = internal.reset\n\n const [submitting, setSubmitting] = React.useState(false)\n\n const setOpen = (next: boolean) => {\n if (!isControlled) setUncontrolledOpen(next)\n onOpenChange?.(next)\n }\n\n React.useEffect(() => {\n if (!open) return\n reset(initialValues, initialEntryMode)\n }, [open, initialValues, initialEntryMode, reset])\n\n const modalTitle = type === \"edit\" ? titleEdit : titleCreate\n const busy = loading || submitting\n\n const handleSubmit = async () => {\n setSubmitting(true)\n try {\n await onSubmit?.(form)\n setOpen(false)\n } finally {\n setSubmitting(false)\n }\n }\n\n const pickerKey =\n type === \"edit\" && initialValues\n ? `gps-modal-${String((initialValues as { id?: string }).id ?? \"edit\")}`\n : \"gps-modal-create\"\n\n return (\n <AlertDialog open={open} onOpenChange={setOpen}>\n {!hideTrigger ? (\n <AlertDialogTrigger asChild>\n {trigger ??\n (type === \"edit\" ? (\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"outline\"\n disabled={busy}\n aria-label=\"Editar dirección\"\n className={className}\n >\n {busy ? <Spinner className=\"size-4\" /> : <IconEdit size={16} />}\n </Button>\n ) : (\n <Button type=\"button\" disabled={busy} className={className}>\n {busy ? <Spinner /> : <IconPlus />}\n Nueva dirección\n </Button>\n ))}\n </AlertDialogTrigger>\n ) : null}\n\n <AlertDialogContent\n className={cn(\n \"flex max-h-[min(90dvh,48rem)] w-[calc(100%-1.5rem)] max-w-2xl flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl\",\n dialogClassName,\n )}\n >\n <OfficeAddressFormCard\n layout=\"dialog\"\n title={modalTitle}\n description={description}\n icon={icon}\n variant={variant}\n countryField={countryField}\n phoneField={phoneField}\n faxField={faxField}\n form={form}\n onFormChange={setForm}\n addressEntryMode={addressEntryMode}\n onAddressEntryModeChange={setAddressEntryMode}\n disabled={busy}\n regionCode={regionCode}\n googleMapsApiKey={googleMapsApiKey}\n pickerKey={pickerKey}\n footer={\n <div className=\"flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end\">\n <AlertDialogCancel type=\"button\" disabled={busy} className=\"mt-0\">\n Cancelar\n </AlertDialogCancel>\n <AlertDialogAction\n type=\"button\"\n disabled={busy}\n className=\"mt-0\"\n onClick={(e) => {\n e.preventDefault()\n void handleSubmit()\n }}\n >\n {busy ? (\n <>\n <Spinner className=\"mr-2 size-4\" />\n Guardando…\n </>\n ) : type === \"edit\" ? (\n \"Guardar cambios\"\n ) : (\n \"Crear dirección\"\n )}\n </AlertDialogAction>\n </div>\n }\n />\n </AlertDialogContent>\n </AlertDialog>\n )\n}\n\n/** @deprecated Usa `OfficeAddressForm` con `surface=\"modal\"`. */\nexport const OfficeAddressFormDialog = (\n props: Omit<OfficeAddressFormModalProps, \"surface\">,\n) => <OfficeAddressForm {...props} surface=\"modal\" />\n\nexport type OfficeAddressFormDialogProps = Omit<\n OfficeAddressFormModalProps,\n \"surface\"\n>\n",
|
|
30
|
+
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport { IconEdit, IconMapPin, IconPlus } from \"@tabler/icons-react\"\nimport type { Icon } from \"@tabler/icons-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { fetchStates } from \"@/lib/geo-locations\"\nimport { googleMapsSelectionToFormPatch } from \"@/lib/google-maps-address\"\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\"\nimport { Button } from \"@/components/ui/button\"\nimport { Spinner } from \"@/components/ui/spinner\"\nimport { StateSelect } from \"@/components/ui/state-select\"\nimport {\n Card,\n CardContent,\n CardDescription,\n CardFooter,\n CardHeader,\n CardTitle,\n} from \"@/components/ui/card\"\nimport { EntityIcon } from \"@/components/ui/entity-icon\"\nimport { CitySelect } from \"@/components/ui/city-select\"\nimport { CountrySelect } from \"@/components/ui/country-select\"\nimport { GoogleMapsAddressPicker } from \"@/components/ui/google-maps-address-picker\"\nimport { Input } from \"@/components/ui/input\"\nimport { Label } from \"@/components/ui/label\"\nimport {\n Select,\n SelectContent,\n SelectGroup,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\"\nimport { Switch } from \"@/components/ui/switch\"\nimport { Textarea } from \"@/components/ui/textarea\"\n\nexport type OfficeAddressEntryMode = \"manual\" | \"gps\"\n\nfunction OfficeAddressEntryModeSwitch({\n mode,\n onModeChange,\n disabled = false,\n className,\n}: {\n mode: OfficeAddressEntryMode\n onModeChange: (mode: OfficeAddressEntryMode) => void\n disabled?: boolean\n className?: string\n}) {\n const isGps = mode === \"gps\"\n\n return (\n <div\n className={cn(\n \"flex shrink-0 items-center gap-2 text-xs text-muted-foreground\",\n className,\n )}\n role=\"group\"\n aria-label=\"Modo de ingreso de dirección\"\n >\n <span className={cn(!isGps && \"font-semibold text-foreground\")}>\n Manual\n </span>\n <Switch\n checked={isGps}\n disabled={disabled}\n aria-label={isGps ? \"Modo GPS\" : \"Modo manual\"}\n onCheckedChange={(checked) =>\n onModeChange(checked ? \"gps\" : \"manual\")\n }\n />\n <span className={cn(isGps && \"font-semibold text-foreground\")}>GPS</span>\n </div>\n )\n}\n\n/** `customer` = dirección de envío (contacto/portal). `enterprise` = sucursal o bodega. */\nexport type OfficeAddressFormVariant = \"customer\" | \"enterprise\"\n\nconst FAX_COUNTRY_CODES = new Set([\"US\", \"CA\", \"PR\"])\n\nexport function isOfficeAddressFaxCountry(countryCode: string | undefined) {\n return FAX_COUNTRY_CODES.has(countryCode?.trim().toUpperCase() ?? \"\")\n}\n\nexport type OfficeAddressFormValues = {\n countryCode: string\n address: string\n suiteOrApt: string\n city: string\n stateOrProvince: string\n postalCode: string\n phoneNumber: string\n fax: string\n note: string\n isActive: boolean\n isMain: boolean\n isWarehouseOnly: boolean\n latitude: number | null\n longitude: number | null\n addressGeocodeSource: string | null\n}\n\nexport const EMPTY_OFFICE_ADDRESS_FORM_VALUES: OfficeAddressFormValues = {\n countryCode: \"\",\n address: \"\",\n suiteOrApt: \"\",\n city: \"\",\n stateOrProvince: \"\",\n postalCode: \"\",\n phoneNumber: \"\",\n fax: \"\",\n note: \"\",\n isActive: true,\n isMain: false,\n isWarehouseOnly: false,\n latitude: null,\n longitude: null,\n addressGeocodeSource: null,\n}\n\nexport function mergeOfficeAddressFormValues(\n initial?: Partial<OfficeAddressFormValues>,\n): OfficeAddressFormValues {\n return {\n ...EMPTY_OFFICE_ADDRESS_FORM_VALUES,\n ...initial,\n isActive: initial?.isActive ?? true,\n isMain: initial?.isMain ?? false,\n }\n}\n\nexport function resolveOfficeAddressEntryMode(\n initial?: Partial<OfficeAddressFormValues>,\n initialEntryMode?: OfficeAddressEntryMode,\n): OfficeAddressEntryMode {\n if (initialEntryMode) return initialEntryMode\n return initial?.addressGeocodeSource === \"google_maps\" ? \"gps\" : \"manual\"\n}\n\nexport function useOfficeAddressForm(options?: {\n initialValues?: Partial<OfficeAddressFormValues>\n initialEntryMode?: OfficeAddressEntryMode\n}) {\n const [form, setForm] = React.useState<OfficeAddressFormValues>(() =>\n mergeOfficeAddressFormValues(options?.initialValues),\n )\n const [addressEntryMode, setAddressEntryMode] =\n React.useState<OfficeAddressEntryMode>(() =>\n resolveOfficeAddressEntryMode(\n options?.initialValues,\n options?.initialEntryMode,\n ),\n )\n\n const reset = React.useCallback(\n (\n nextInitial?: Partial<OfficeAddressFormValues>,\n nextMode?: OfficeAddressEntryMode,\n ) => {\n setForm(mergeOfficeAddressFormValues(nextInitial))\n setAddressEntryMode(\n resolveOfficeAddressEntryMode(nextInitial, nextMode),\n )\n },\n [],\n )\n\n const setAddressEntryModeAndClearGps = React.useCallback(\n (mode: OfficeAddressEntryMode) => {\n setAddressEntryMode(mode)\n if (mode === \"manual\") {\n setForm((prev) => ({\n ...prev,\n latitude: null,\n longitude: null,\n addressGeocodeSource: null,\n }))\n }\n },\n [],\n )\n\n return {\n form,\n setForm,\n addressEntryMode,\n setAddressEntryMode: setAddressEntryModeAndClearGps,\n reset,\n }\n}\n\nexport type OfficeAddressFormCardProps = {\n title: string\n description?: string\n icon?: Icon\n form: OfficeAddressFormValues\n onFormChange: React.Dispatch<React.SetStateAction<OfficeAddressFormValues>>\n addressEntryMode: OfficeAddressEntryMode\n onAddressEntryModeChange: (mode: OfficeAddressEntryMode) => void\n disabled?: boolean\n regionCode?: string\n googleMapsApiKey?: string\n /** Estable para remontar GoogleMaps al editar. */\n pickerKey?: string\n footer?: React.ReactNode\n className?: string\n /**\n * `dialog`: card plana dentro de AlertDialog (sin borde/sombra exterior).\n * `page`: card completa para rutas de edición.\n */\n layout?: \"dialog\" | \"page\"\n contentClassName?: string\n /** customer = envío; enterprise = sucursal/bodega (campos extra). */\n variant?: OfficeAddressFormVariant\n /** Reemplaza el selector de país por defecto (p. ej. CountrySelect en la app). */\n countryField?: React.ReactNode\n phoneField?: React.ReactNode\n faxField?: React.ReactNode\n manualFields?: React.ReactNode\n}\n\nexport function OfficeAddressFormCard({\n title,\n description,\n icon: HeaderIcon = IconMapPin,\n form,\n onFormChange,\n addressEntryMode,\n onAddressEntryModeChange,\n disabled = false,\n regionCode,\n googleMapsApiKey,\n pickerKey = \"office-address-gps\",\n footer,\n className,\n layout = \"page\",\n contentClassName,\n variant = \"customer\",\n countryField,\n phoneField,\n faxField,\n manualFields,\n}: OfficeAddressFormCardProps) {\n const busy = disabled\n const isCustomer = variant === \"customer\"\n const mapRegion =\n form.countryCode?.trim().toUpperCase() ||\n regionCode?.trim().toUpperCase() ||\n undefined\n const showFax =\n Boolean(faxField) ||\n isOfficeAddressFaxCountry(form.countryCode) ||\n form.fax.trim().length > 0\n\n const handleChange = (\n e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,\n ) => {\n const { name, value } = e.target\n onFormChange((prev) => ({ ...prev, [name]: value }))\n }\n\n const embedded = layout === \"dialog\"\n const countryCode = form.countryCode?.trim().toUpperCase() ?? \"\"\n const [stateCode, setStateCode] = React.useState(\"\")\n\n React.useEffect(() => {\n const stateName = form.stateOrProvince?.trim() ?? \"\"\n if (!countryCode || !stateName) {\n setStateCode(\"\")\n return\n }\n\n let cancelled = false\n void fetchStates(countryCode).then((list) => {\n if (cancelled) return\n const wanted = stateName.toLowerCase()\n const match = list.find(\n (item) =>\n item.name.toLowerCase() === wanted ||\n item.code.toLowerCase() === wanted,\n )\n setStateCode(match?.code ?? \"\")\n })\n\n return () => {\n cancelled = true\n }\n }, [countryCode, form.stateOrProvince])\n\n const countryControl =\n countryField ?? (\n <CountrySelect\n className=\"w-full\"\n size=\"lg\"\n value={form.countryCode}\n disabled={busy}\n searchPlaceholder=\"Escribe el país…\"\n onChange={(country) => {\n setStateCode(\"\")\n onFormChange((prev) => ({\n ...prev,\n countryCode: country.code,\n stateOrProvince: \"\",\n city: \"\",\n }))\n }}\n />\n )\n\n return (\n <Card\n className={cn(\n \"gap-0 py-0\",\n embedded\n ? \"border-0 bg-transparent shadow-none\"\n : \"border-border shadow-sm\",\n className,\n )}\n >\n <CardHeader\n className={cn(\n \"flex flex-row flex-wrap items-center justify-between gap-3 space-y-0 px-5 pt-5 pb-3.5 sm:px-6\",\n embedded && \"border-b border-border/60\",\n !embedded && \"border-b border-border/60\",\n )}\n >\n <div className=\"flex min-w-0 flex-1 items-start gap-3\">\n <EntityIcon icon={HeaderIcon} size=\"sm\" className=\"mb-0\" />\n <div className=\"min-w-0 space-y-0.5\">\n <CardTitle className=\"text-sm font-semibold tracking-tight\">\n {title}\n </CardTitle>\n {description ? (\n <CardDescription className=\"text-xs\">{description}</CardDescription>\n ) : null}\n </div>\n </div>\n <OfficeAddressEntryModeSwitch\n mode={addressEntryMode}\n disabled={busy}\n onModeChange={onAddressEntryModeChange}\n />\n </CardHeader>\n\n <CardContent\n className={cn(\n \"grid min-h-0 grid-cols-1 gap-4 px-5 py-4 sm:grid-cols-2 sm:px-6\",\n embedded && \"max-h-[min(60dvh,28rem)] overflow-y-auto overscroll-contain\",\n contentClassName,\n )}\n >\n {addressEntryMode === \"gps\" ? (\n <div className=\"col-span-1 sm:col-span-2\">\n <GoogleMapsAddressPicker\n key={pickerKey}\n apiKey={googleMapsApiKey}\n regionCode={mapRegion}\n initialLatitude={form.latitude ?? undefined}\n initialLongitude={form.longitude ?? undefined}\n initialFormattedAddress={form.address}\n onLocationSelect={(selection) => {\n const patch = googleMapsSelectionToFormPatch(selection)\n onFormChange((prev) => ({\n ...prev,\n address: patch.address,\n city: patch.city,\n stateOrProvince: patch.stateOrProvince,\n postalCode: patch.postalCode,\n countryCode: patch.countryCode || prev.countryCode,\n latitude: patch.latitude,\n longitude: patch.longitude,\n addressGeocodeSource: patch.addressGeocodeSource,\n }))\n }}\n onLocationClear={() => {\n onFormChange((prev) => ({\n ...prev,\n address: \"\",\n city: \"\",\n stateOrProvince: \"\",\n postalCode: \"\",\n latitude: null,\n longitude: null,\n addressGeocodeSource: null,\n }))\n }}\n />\n </div>\n ) : null}\n\n <div className=\"col-span-1 space-y-1 sm:col-span-2\">\n <Label htmlFor=\"office-country\">País</Label>\n {countryControl}\n </div>\n\n {addressEntryMode === \"manual\"\n ? (manualFields ?? (\n <>\n <div className=\"col-span-1 space-y-1 sm:col-span-2\">\n <Label htmlFor=\"office-address\">Dirección</Label>\n <Input\n id=\"office-address\"\n name=\"address\"\n value={form.address}\n onChange={handleChange}\n placeholder=\"Calle y número\"\n disabled={busy}\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-state\">\n Estado / Región / Municipio\n </Label>\n <StateSelect\n id=\"office-state\"\n className=\"w-full\"\n countryCode={countryCode}\n value={form.stateOrProvince}\n disabled={busy}\n onChange={(option) => {\n setStateCode(option?.code ?? \"\")\n onFormChange((prev) => ({\n ...prev,\n stateOrProvince: option?.name ?? \"\",\n city: \"\",\n }))\n }}\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-city\">\n Ciudad / Pueblo / Distrito\n </Label>\n <CitySelect\n id=\"office-city\"\n className=\"w-full\"\n countryCode={countryCode}\n stateCode={stateCode}\n value={form.city}\n disabled={busy}\n onChange={(option) => {\n onFormChange((prev) => ({\n ...prev,\n city: option?.name ?? \"\",\n ...(option?.stateName\n ? { stateOrProvince: option.stateName }\n : {}),\n }))\n }}\n />\n </div>\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-postal\">Código postal</Label>\n <Input\n id=\"office-postal\"\n name=\"postalCode\"\n value={form.postalCode}\n onChange={handleChange}\n disabled={busy}\n />\n </div>\n </>\n ))\n : null}\n\n {addressEntryMode === \"gps\" && form.address.trim() ? (\n <div className=\"col-span-1 rounded-lg border border-border bg-muted/30 px-3 py-2.5 text-sm sm:col-span-2\">\n <p className=\"font-medium text-foreground\">{form.address}</p>\n <p className=\"text-muted-foreground\">\n {[form.city, form.stateOrProvince, form.postalCode, form.countryCode]\n .filter(Boolean)\n .join(\" · \")}\n </p>\n </div>\n ) : null}\n\n <div className=\"col-span-1 space-y-1 sm:col-span-2\">\n <Label htmlFor=\"office-suite\">Suite / apt</Label>\n <Input\n id=\"office-suite\"\n name=\"suiteOrApt\"\n value={form.suiteOrApt}\n onChange={handleChange}\n disabled={busy}\n />\n </div>\n\n {phoneField ?? (\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-phone\">Teléfono</Label>\n <Input\n id=\"office-phone\"\n name=\"phoneNumber\"\n type=\"tel\"\n value={form.phoneNumber}\n onChange={handleChange}\n placeholder=\"+593 …\"\n disabled={busy}\n />\n </div>\n )}\n\n {showFax\n ? (faxField ?? (\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-fax\">Fax</Label>\n <Input\n id=\"office-fax\"\n name=\"fax\"\n type=\"tel\"\n value={form.fax}\n onChange={handleChange}\n disabled={busy}\n />\n </div>\n ))\n : null}\n\n <div className=\"col-span-1 space-y-1 sm:col-span-2\">\n <Label htmlFor=\"office-note\">Nota</Label>\n <Textarea\n id=\"office-note\"\n name=\"note\"\n value={form.note}\n onChange={handleChange}\n placeholder=\"Información adicional sobre esta dirección\"\n className=\"min-h-20 resize-y\"\n disabled={busy}\n />\n </div>\n\n <div className=\"space-y-1\">\n <Label htmlFor=\"office-active\">Estado</Label>\n <Select\n value={form.isActive.toString()}\n disabled={busy}\n onValueChange={(value) =>\n onFormChange((prev) => ({\n ...prev,\n isActive: value === \"true\",\n }))\n }\n >\n <SelectTrigger id=\"office-active\" className=\"w-full\">\n <SelectValue placeholder=\"Seleccione el estado\" />\n </SelectTrigger>\n <SelectContent>\n <SelectGroup>\n <SelectItem value=\"true\">Activo</SelectItem>\n <SelectItem value=\"false\">Inactivo</SelectItem>\n </SelectGroup>\n </SelectContent>\n </Select>\n </div>\n\n {!isCustomer ? (\n <>\n <div className=\"col-span-1 space-y-1 sm:col-span-2\">\n <Label>Tipo de ubicación</Label>\n <Select\n value={form.isWarehouseOnly ? \"warehouse\" : \"agency\"}\n disabled={busy}\n onValueChange={(value) => {\n const isWarehouseOnly = value === \"warehouse\"\n onFormChange((prev) => ({\n ...prev,\n isWarehouseOnly,\n isMain: isWarehouseOnly ? false : prev.isMain,\n }))\n }}\n >\n <SelectTrigger className=\"w-full\">\n <SelectValue placeholder=\"Selecciona el tipo\" />\n </SelectTrigger>\n <SelectContent>\n <SelectGroup>\n <SelectItem value=\"agency\">Es sucursal</SelectItem>\n <SelectItem value=\"warehouse\">Es solo bodega</SelectItem>\n </SelectGroup>\n </SelectContent>\n </Select>\n </div>\n <div className=\"space-y-1\">\n <Label>Dirección principal</Label>\n <Select\n value={form.isMain.toString()}\n disabled={busy || form.isWarehouseOnly}\n onValueChange={(value) =>\n onFormChange((prev) => ({\n ...prev,\n isMain: value === \"true\",\n }))\n }\n >\n <SelectTrigger className=\"w-full\">\n <SelectValue placeholder=\"¿Es dirección principal?\" />\n </SelectTrigger>\n <SelectContent>\n <SelectGroup>\n <SelectItem value=\"true\">Sí</SelectItem>\n <SelectItem value=\"false\">No</SelectItem>\n </SelectGroup>\n </SelectContent>\n </Select>\n </div>\n </>\n ) : null}\n </CardContent>\n\n {footer ? (\n <CardFooter\n className={cn(\n \"border-t border-border/60 px-5 py-3 sm:px-6\",\n embedded && \"bg-background\",\n )}\n >\n {footer}\n </CardFooter>\n ) : null}\n </Card>\n )\n}\n\ntype OfficeAddressFormSharedProps = {\n title?: string\n titleCreate?: string\n titleEdit?: string\n description?: string\n initialValues?: Partial<OfficeAddressFormValues>\n initialEntryMode?: OfficeAddressEntryMode\n regionCode?: string\n googleMapsApiKey?: string\n pickerKey?: string\n className?: string\n contentClassName?: string\n icon?: Icon\n disabled?: boolean\n variant?: OfficeAddressFormVariant\n countryField?: React.ReactNode\n phoneField?: React.ReactNode\n faxField?: React.ReactNode\n manualFields?: React.ReactNode\n}\n\ntype OfficeAddressFormInlineProps = OfficeAddressFormSharedProps & {\n /** Card en página o sección (por defecto). */\n surface?: \"inline\"\n form?: OfficeAddressFormValues\n onFormChange?: React.Dispatch<React.SetStateAction<OfficeAddressFormValues>>\n addressEntryMode?: OfficeAddressEntryMode\n onAddressEntryModeChange?: (mode: OfficeAddressEntryMode) => void\n footer?: React.ReactNode\n}\n\ntype OfficeAddressFormModalProps = OfficeAddressFormSharedProps & {\n /** Misma card dentro de AlertDialog (contactos, selectores, etc.). */\n surface: \"modal\"\n type?: \"create\" | \"edit\"\n open?: boolean\n defaultOpen?: boolean\n onOpenChange?: (open: boolean) => void\n onSubmit?: (values: OfficeAddressFormValues) => void | Promise<void>\n trigger?: React.ReactNode\n hideTrigger?: boolean\n loading?: boolean\n dialogClassName?: string\n form?: OfficeAddressFormValues\n onFormChange?: React.Dispatch<React.SetStateAction<OfficeAddressFormValues>>\n addressEntryMode?: OfficeAddressEntryMode\n onAddressEntryModeChange?: (mode: OfficeAddressEntryMode) => void\n}\n\nexport type OfficeAddressFormProps =\n | OfficeAddressFormInlineProps\n | OfficeAddressFormModalProps\n\n/**\n * Formulario de dirección unificado: card en página (`surface=\"inline\"`) o en modal (`surface=\"modal\"`).\n * Usa `OfficeAddressFormCard` + `useOfficeAddressForm` por debajo.\n */\nexport function OfficeAddressForm(props: OfficeAddressFormProps) {\n if (props.surface === \"modal\") {\n return <OfficeAddressFormModal {...props} />\n }\n return <OfficeAddressFormInline {...props} />\n}\n\nfunction OfficeAddressFormInline({\n title = \"Dirección\",\n description,\n initialValues,\n initialEntryMode,\n form: controlledForm,\n onFormChange: controlledOnFormChange,\n addressEntryMode: controlledMode,\n onAddressEntryModeChange: controlledOnModeChange,\n footer,\n regionCode,\n googleMapsApiKey,\n pickerKey = \"office-address-inline\",\n className,\n contentClassName,\n icon,\n disabled,\n variant,\n countryField,\n phoneField,\n faxField,\n manualFields,\n}: OfficeAddressFormInlineProps) {\n const internal = useOfficeAddressForm({ initialValues, initialEntryMode })\n const form = controlledForm ?? internal.form\n const setForm = controlledOnFormChange ?? internal.setForm\n const addressEntryMode = controlledMode ?? internal.addressEntryMode\n const setAddressEntryMode =\n controlledOnModeChange ?? internal.setAddressEntryMode\n\n return (\n <OfficeAddressFormCard\n layout=\"page\"\n title={title}\n description={description}\n icon={icon}\n form={form}\n onFormChange={setForm}\n addressEntryMode={addressEntryMode}\n onAddressEntryModeChange={setAddressEntryMode}\n disabled={disabled}\n regionCode={regionCode}\n googleMapsApiKey={googleMapsApiKey}\n pickerKey={pickerKey}\n footer={footer}\n className={className}\n contentClassName={contentClassName}\n variant={variant}\n countryField={countryField}\n phoneField={phoneField}\n faxField={faxField}\n manualFields={manualFields}\n />\n )\n}\n\nfunction OfficeAddressFormModal({\n type = \"create\",\n open: controlledOpen,\n defaultOpen = false,\n onOpenChange,\n initialValues,\n initialEntryMode,\n onSubmit,\n trigger,\n hideTrigger = false,\n loading = false,\n titleCreate = \"Crear dirección\",\n titleEdit = \"Editar dirección\",\n description,\n regionCode,\n googleMapsApiKey,\n className,\n dialogClassName,\n variant,\n countryField,\n phoneField,\n faxField,\n manualFields,\n icon,\n form: controlledForm,\n onFormChange: controlledOnFormChange,\n addressEntryMode: controlledMode,\n onAddressEntryModeChange: controlledOnModeChange,\n}: OfficeAddressFormModalProps) {\n const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)\n const isControlled = controlledOpen !== undefined\n const open = isControlled ? Boolean(controlledOpen) : uncontrolledOpen\n\n const internal = useOfficeAddressForm({ initialValues, initialEntryMode })\n const form = controlledForm ?? internal.form\n const setForm = controlledOnFormChange ?? internal.setForm\n const addressEntryMode = controlledMode ?? internal.addressEntryMode\n const setAddressEntryMode =\n controlledOnModeChange ?? internal.setAddressEntryMode\n const reset = internal.reset\n\n const [submitting, setSubmitting] = React.useState(false)\n\n const setOpen = (next: boolean) => {\n if (!isControlled) setUncontrolledOpen(next)\n onOpenChange?.(next)\n }\n\n React.useEffect(() => {\n if (!open) return\n reset(initialValues, initialEntryMode)\n }, [open, initialValues, initialEntryMode, reset])\n\n const modalTitle = type === \"edit\" ? titleEdit : titleCreate\n const busy = loading || submitting\n\n const handleSubmit = async () => {\n setSubmitting(true)\n try {\n await onSubmit?.(form)\n setOpen(false)\n } finally {\n setSubmitting(false)\n }\n }\n\n const pickerKey =\n type === \"edit\" && initialValues\n ? `gps-modal-${String((initialValues as { id?: string }).id ?? \"edit\")}`\n : \"gps-modal-create\"\n\n return (\n <AlertDialog open={open} onOpenChange={setOpen}>\n {!hideTrigger ? (\n <AlertDialogTrigger asChild>\n {trigger ??\n (type === \"edit\" ? (\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"outline\"\n disabled={busy}\n aria-label=\"Editar dirección\"\n className={className}\n >\n {busy ? <Spinner className=\"size-4\" /> : <IconEdit size={16} />}\n </Button>\n ) : (\n <Button type=\"button\" disabled={busy} className={className}>\n {busy ? <Spinner /> : <IconPlus />}\n Nueva dirección\n </Button>\n ))}\n </AlertDialogTrigger>\n ) : null}\n\n <AlertDialogContent\n className={cn(\n \"flex max-h-[min(90dvh,48rem)] w-[calc(100%-1.5rem)] max-w-2xl flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl\",\n dialogClassName,\n )}\n >\n <OfficeAddressFormCard\n layout=\"dialog\"\n title={modalTitle}\n description={description}\n icon={icon}\n variant={variant}\n countryField={countryField}\n phoneField={phoneField}\n faxField={faxField}\n manualFields={manualFields}\n form={form}\n onFormChange={setForm}\n addressEntryMode={addressEntryMode}\n onAddressEntryModeChange={setAddressEntryMode}\n disabled={busy}\n regionCode={regionCode}\n googleMapsApiKey={googleMapsApiKey}\n pickerKey={pickerKey}\n footer={\n <div className=\"flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end\">\n <AlertDialogCancel type=\"button\" disabled={busy} className=\"mt-0\">\n Cancelar\n </AlertDialogCancel>\n <AlertDialogAction\n type=\"button\"\n disabled={busy}\n className=\"mt-0\"\n onClick={(e) => {\n e.preventDefault()\n void handleSubmit()\n }}\n >\n {busy ? (\n <>\n <Spinner className=\"mr-2 size-4\" />\n Guardando…\n </>\n ) : type === \"edit\" ? (\n \"Guardar cambios\"\n ) : (\n \"Crear dirección\"\n )}\n </AlertDialogAction>\n </div>\n }\n />\n </AlertDialogContent>\n </AlertDialog>\n )\n}\n\n/** @deprecated Usa `OfficeAddressForm` con `surface=\"modal\"`. */\nexport const OfficeAddressFormDialog = (\n props: Omit<OfficeAddressFormModalProps, \"surface\">,\n) => <OfficeAddressForm {...props} surface=\"modal\" />\n\nexport type OfficeAddressFormDialogProps = Omit<\n OfficeAddressFormModalProps,\n \"surface\"\n>\n",
|
|
27
31
|
"type": "registry:ui",
|
|
28
32
|
"target": ""
|
|
29
33
|
}
|