feat: HQ Toast + Confirm dialog primitives, replace browser alert()/confirm()
Deploy to VPS / deploy (push) Has been cancelled

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)
- <HqUiProvider> 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.
This commit is contained in:
2026-05-05 19:02:41 -05:00
parent aebcabd767
commit ea1300bfdc
3 changed files with 266 additions and 26 deletions
+86 -15
View File
@@ -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
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<any[]>([]);
@@ -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); };
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");
};
// APROBAR CLIENTE
const handleApproveClient = async (signalId: string) => {
if (!confirm("Approve this client for B2B portal access? They will receive an email.")) return;
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);
};
+6 -3
View File
@@ -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",
@@ -20,9 +21,11 @@ export default function HQLayout({ children }: { children: React.ReactNode }) {
style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '32px 32px' }}
></div>
<main className="relative z-10">
{children}
</main>
<HqUiProvider>
<main className="relative z-10">
{children}
</main>
</HqUiProvider>
</body>
</html>
+166
View File
@@ -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<boolean>;
}
const Ctx = createContext<HqUiCtx | null>(null);
let nextToastId = 1;
let nextConfirmId = 1;
export function HqUiProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const [pending, setPending] = useState<ConfirmRequest | null>(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<boolean>((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 (
<Ctx.Provider value={{ toast, confirm }}>
{children}
{/* Toast stack — bottom right */}
<div className="fixed bottom-6 right-6 z-[200] flex flex-col gap-2 pointer-events-none">
{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 (
<div
key={t.id}
className={`pointer-events-auto flex items-start gap-3 ${accent} backdrop-blur-md border rounded-xl px-4 py-3 max-w-sm shadow-2xl animate-in slide-in-from-right-4 duration-200`}
>
<Icon size={16} className="mt-0.5 flex-shrink-0" />
<div className="text-sm leading-relaxed flex-1">{t.message}</div>
<button
onClick={() => setToasts((prev) => prev.filter((x) => x.id !== t.id))}
className="text-current opacity-50 hover:opacity-100"
>
<X size={14} />
</button>
</div>
);
})}
</div>
{/* Confirm dialog — centered modal */}
{pending && (
<div className="fixed inset-0 z-[150] bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-[#111] border border-white/10 rounded-3xl p-6 max-w-md w-full shadow-2xl">
<div className="flex items-start gap-3 mb-4">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${
pending.destructive
? "bg-rose-500/10 text-rose-400"
: "bg-[#00F0FF]/10 text-[#00F0FF]"
}`}
>
{pending.destructive ? <AlertTriangle size={18} /> : <Info size={18} />}
</div>
<div className="flex-1">
<h3 className="text-base font-medium text-white mb-1">{pending.title}</h3>
<p className="text-sm text-[#86868B] leading-relaxed">{pending.message}</p>
</div>
</div>
<div className="flex gap-2 justify-end mt-6">
<button
onClick={() => respond(false)}
className="px-4 py-2 text-sm font-medium text-[#86868B] hover:text-white transition-colors"
>
{pending.cancelLabel}
</button>
<button
onClick={() => respond(true)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
pending.destructive
? "bg-rose-500 text-white hover:bg-rose-400"
: "bg-[#00F0FF] text-black hover:bg-[#00F0FF]/80"
}`}
>
{pending.confirmLabel}
</button>
</div>
</div>
</div>
)}
</Ctx.Provider>
);
}
export function useHqUi(): HqUiCtx {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useHqUi must be used inside <HqUiProvider>");
return ctx;
}