feat: HQ Toast + Confirm dialog primitives, replace browser alert()/confirm()
Deploy to VPS / deploy (push) Has been cancelled
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:
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user