From ea1300bfdce28db3d8a45332734fa33d265b0afd Mon Sep 17 00:00:00 2001 From: DavidHerran Date: Tue, 5 May 2026 19:02:41 -0500 Subject: [PATCH] feat: HQ Toast + Confirm dialog primitives, replace browser alert()/confirm() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Operations Inbox panel was using browser-native alert() and confirm() — those popups break out of the dark-themed CMS aesthetic and look like a 1998 form validation. Worse, they're modal blocking, so the editor can't see the surrounding context (the ticket card, the activity feed) while the dialog is up. NEW PRIMITIVES (src/components/hq/Toast.tsx) - mounts a global toast stack (bottom-right) and a global confirm dialog. Mounted in src/app/hq-command/layout.tsx so every panel under /hq-command/* can use it. - useHqUi() returns { toast, confirm }: ui.toast("Saved", "success") // ephemeral, 3s ui.toast("Save failed: ...", "error") // 5s await ui.confirm({ // returns boolean title: "Delete ticket", message: "This permanently...", confirmLabel: "Delete", destructive: true, }) - Toasts auto-dismiss with a manual close button. Confirm dialog uses red accent for destructive actions. - Zero deps. ~140 lines total. INBOX (src/app/hq-command/dashboard/inbox/page.tsx) - All 5 alert() calls replaced with ui.toast() — success / error toned and persisting just long enough to read. - All 4 confirm() calls replaced with ui.confirm() — destructive ones (delete ticket, purge files, delete client) get the red accent + 'cannot be undone' copy. - Action descriptions are richer ('Resolve & purge attachments' instead of 'Resolve') so the editor knows exactly what fires. NO BACKEND CHANGES. Pure UX layer on top of the existing actions. --- src/app/hq-command/dashboard/inbox/page.tsx | 109 ++++++++++--- src/app/hq-command/layout.tsx | 17 +- src/components/hq/Toast.tsx | 166 ++++++++++++++++++++ 3 files changed, 266 insertions(+), 26 deletions(-) create mode 100644 src/components/hq/Toast.tsx diff --git a/src/app/hq-command/dashboard/inbox/page.tsx b/src/app/hq-command/dashboard/inbox/page.tsx index 4ed3c7b..7828052 100644 --- a/src/app/hq-command/dashboard/inbox/page.tsx +++ b/src/app/hq-command/dashboard/inbox/page.tsx @@ -1,15 +1,17 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import Link from "next/link"; -import { - ArrowLeft, Radar, Inbox, CheckCircle2, Clock, AlertCircle, - Package, Video, Sparkles, Building2, Mail, Phone, ShieldAlert, - Trash2, Loader2, Settings, X, MailCheck, MailX, Users, Key +import { + ArrowLeft, Radar, Inbox, CheckCircle2, Clock, AlertCircle, + Package, Video, Sparkles, Building2, Mail, Phone, ShieldAlert, + Trash2, Loader2, Settings, X, MailCheck, MailX, Users, Key, Check, } from "lucide-react"; import { getSignals, updateSignalStatus, resolveAndCleanSignal, getNotificationRoutes, updateNotificationRoute, deleteSignal, resendSignalEmail, getClients, approveAccessRequest, deleteClient } from "./actions"; +import { useHqUi } from "@/components/hq/Toast"; export default function OperationsInbox() { + const ui = useHqUi(); const [viewMode, setViewMode] = useState<"SIGNALS" | "CLIENTS">("SIGNALS"); const [signals, setSignals] = useState([]); @@ -39,36 +41,105 @@ export default function OperationsInbox() { useEffect(() => { fetchInitialData(); }, []); - const handleSaveRoute = async (type: string, emails: string) => { await updateNotificationRoute(type, emails); alert(`Routing for ${type} updated.`); }; - const handleStatusChange = async (id: string, status: string) => { setIsProcessing(true); await updateSignalStatus(id, status); await fetchInitialData(); if (activeSignal?.id === id) setActiveSignal((p: any) => ({ ...p, status })); setIsProcessing(false); }; - const handleResolveAndClean = async (id: string) => { if (!confirm("Permanently delete attached files and mark as resolved?")) return; setIsProcessing(true); const res = await resolveAndCleanSignal(id); if (res.success) { await fetchInitialData(); setActiveSignal(null); } setIsProcessing(false); }; - const handleDelete = async (id: string) => { if (!confirm("Permanently delete this ticket?")) return; setIsProcessing(true); await deleteSignal(id); await fetchInitialData(); setActiveSignal(null); setIsProcessing(false); }; - const handleResendEmail = async (id: string) => { setIsProcessing(true); const res = await resendSignalEmail(id); if (res.success) { alert("Email reminder sent."); await fetchInitialData(); if (activeSignal?.id === id) setActiveSignal((p: any) => ({ ...p, emailSentAt: new Date(), emailError: null })); } else { alert("Failed: " + res.error); } setIsProcessing(false); }; - - // APROBAR CLIENTE - const handleApproveClient = async (signalId: string) => { - if (!confirm("Approve this client for B2B portal access? They will receive an email.")) return; + const handleSaveRoute = async (type: string, emails: string) => { + const res = await updateNotificationRoute(type, emails); + if ((res as any)?.error) ui.toast(`Failed to save ${type} routing: ${(res as any).error}`, "error"); + else ui.toast(`${type} routing saved.`, "success"); + }; + + const handleStatusChange = async (id: string, status: string) => { setIsProcessing(true); - const res = await approveAccessRequest(signalId); + await updateSignalStatus(id, status); + await fetchInitialData(); + if (activeSignal?.id === id) setActiveSignal((p: any) => ({ ...p, status })); + setIsProcessing(false); + }; + + const handleResolveAndClean = async (id: string) => { + const ok = await ui.confirm({ + title: "Resolve & purge attachments", + message: "Mark this ticket as resolved and permanently delete the attached diagnostic files. This cannot be undone.", + confirmLabel: "Resolve & purge", + destructive: true, + }); + if (!ok) return; + setIsProcessing(true); + const res = await resolveAndCleanSignal(id); if (res.success) { - alert("Client approved and email sent!"); + ui.toast("Ticket resolved. Attachments purged.", "success"); await fetchInitialData(); setActiveSignal(null); } else { - alert("Failed: " + res.error); + ui.toast("Failed to resolve ticket.", "error"); + } + setIsProcessing(false); + }; + + const handleDelete = async (id: string) => { + const ok = await ui.confirm({ + title: "Delete ticket", + message: "This permanently removes the ticket from the database. The action cannot be undone.", + confirmLabel: "Delete ticket", + destructive: true, + }); + if (!ok) return; + setIsProcessing(true); + await deleteSignal(id); + ui.toast("Ticket deleted.", "success"); + await fetchInitialData(); + setActiveSignal(null); + setIsProcessing(false); + }; + + const handleResendEmail = async (id: string) => { + setIsProcessing(true); + const res = await resendSignalEmail(id); + if (res.success) { + ui.toast("Email reminder sent.", "success"); + await fetchInitialData(); + if (activeSignal?.id === id) setActiveSignal((p: any) => ({ ...p, emailSentAt: new Date(), emailError: null })); + } else { + ui.toast(`Resend failed: ${res.error}`, "error"); + } + setIsProcessing(false); + }; + + // APROBAR CLIENTE + const handleApproveClient = async (signalId: string) => { + const ok = await ui.confirm({ + title: "Approve B2B access", + message: "Grant this client access to the B2B portal? They'll receive an approval email immediately.", + confirmLabel: "Approve & notify", + }); + if (!ok) return; + setIsProcessing(true); + const res = await approveAccessRequest(signalId); + if (res.success) { + ui.toast("Client approved. Email sent.", "success"); + await fetchInitialData(); + setActiveSignal(null); + } else { + ui.toast(`Approval failed: ${res.error}`, "error"); } setIsProcessing(false); }; // BORRAR CLIENTE const handleDeleteClient = async (clientId: string) => { - if (!confirm("Permanently delete this client? Their past tickets will be kept but unlinked from their account.")) return; + const ok = await ui.confirm({ + title: "Delete B2B client", + message: "Permanently delete this client account. Past tickets are preserved but unlinked from them.", + confirmLabel: "Delete client", + destructive: true, + }); + if (!ok) return; setIsProcessing(true); const res = await deleteClient(clientId); if (res.success) { + ui.toast("Client deleted.", "success"); await fetchInitialData(); } else { - alert("Failed: " + res.error); + ui.toast(`Delete failed: ${res.error}`, "error"); } setIsProcessing(false); }; diff --git a/src/app/hq-command/layout.tsx b/src/app/hq-command/layout.tsx index 8082c61..c6a408a 100644 --- a/src/app/hq-command/layout.tsx +++ b/src/app/hq-command/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "@/app/globals.css"; +import { HqUiProvider } from "@/components/hq/Toast"; export const metadata: Metadata = { title: "FLUX Command Center", @@ -13,16 +14,18 @@ export default function HQLayout({ children }: { children: React.ReactNode }) { {/* Mantenemos tu fondo negro absoluto, texto blanco y el color de selección cyan */} - + {/* Patrón de puntos sutil en el fondo para dar aspecto técnico */} -
- -
- {children} -
+ + +
+ {children} +
+
diff --git a/src/components/hq/Toast.tsx b/src/components/hq/Toast.tsx new file mode 100644 index 0000000..d5b96f8 --- /dev/null +++ b/src/components/hq/Toast.tsx @@ -0,0 +1,166 @@ +"use client"; + +// ───────────────────────────────────────────────────────────────────────────── +// Tiny toast + confirm dialog primitives for HQ Command panels. +// Replaces the browser-native alert()/confirm() that were jumping users +// out of the dark CMS aesthetic. Zero deps, ~80 lines of state. +// ───────────────────────────────────────────────────────────────────────────── + +import { createContext, useCallback, useContext, useState } from "react"; +import { CheckCircle2, AlertCircle, Info, X, AlertTriangle } from "lucide-react"; + +type ToastTone = "success" | "error" | "info"; + +interface Toast { + id: number; + tone: ToastTone; + message: string; +} + +interface ConfirmRequest { + id: number; + title: string; + message: string; + confirmLabel: string; + cancelLabel: string; + destructive: boolean; + resolve: (ok: boolean) => void; +} + +interface HqUiCtx { + toast: (message: string, tone?: ToastTone) => void; + confirm: (opts: { + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + destructive?: boolean; + }) => Promise; +} + +const Ctx = createContext(null); + +let nextToastId = 1; +let nextConfirmId = 1; + +export function HqUiProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + const [pending, setPending] = useState(null); + + const toast = useCallback((message: string, tone: ToastTone = "info") => { + const id = nextToastId++; + setToasts((prev) => [...prev, { id, tone, message }]); + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, tone === "error" ? 5000 : 3000); + }, []); + + const confirm = useCallback( + (opts: { + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + destructive?: boolean; + }) => + new Promise((resolve) => { + const id = nextConfirmId++; + setPending({ + id, + title: opts.title, + message: opts.message, + confirmLabel: opts.confirmLabel || "Confirm", + cancelLabel: opts.cancelLabel || "Cancel", + destructive: !!opts.destructive, + resolve, + }); + }), + [] + ); + + const respond = (ok: boolean) => { + if (!pending) return; + pending.resolve(ok); + setPending(null); + }; + + return ( + + {children} + + {/* Toast stack — bottom right */} +
+ {toasts.map((t) => { + const Icon = t.tone === "success" ? CheckCircle2 : t.tone === "error" ? AlertCircle : Info; + const accent = + t.tone === "success" ? "text-emerald-400 border-emerald-500/30 bg-emerald-500/10" + : t.tone === "error" ? "text-rose-400 border-rose-500/30 bg-rose-500/10" + : "text-[#00F0FF] border-[#00F0FF]/30 bg-[#00F0FF]/10"; + return ( +
+ +
{t.message}
+ +
+ ); + })} +
+ + {/* Confirm dialog — centered modal */} + {pending && ( +
+
+
+
+ {pending.destructive ? : } +
+
+

{pending.title}

+

{pending.message}

+
+
+ +
+ + +
+
+
+ )} +
+ ); +} + +export function useHqUi(): HqUiCtx { + const ctx = useContext(Ctx); + if (!ctx) throw new Error("useHqUi must be used inside "); + return ctx; +}