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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Radar, Inbox, CheckCircle2, Clock, AlertCircle,
|
ArrowLeft, Radar, Inbox, CheckCircle2, Clock, AlertCircle,
|
||||||
Package, Video, Sparkles, Building2, Mail, Phone, ShieldAlert,
|
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";
|
} from "lucide-react";
|
||||||
import { getSignals, updateSignalStatus, resolveAndCleanSignal, getNotificationRoutes, updateNotificationRoute, deleteSignal, resendSignalEmail, getClients, approveAccessRequest, deleteClient } from "./actions";
|
import { getSignals, updateSignalStatus, resolveAndCleanSignal, getNotificationRoutes, updateNotificationRoute, deleteSignal, resendSignalEmail, getClients, approveAccessRequest, deleteClient } from "./actions";
|
||||||
|
import { useHqUi } from "@/components/hq/Toast";
|
||||||
|
|
||||||
export default function OperationsInbox() {
|
export default function OperationsInbox() {
|
||||||
|
const ui = useHqUi();
|
||||||
const [viewMode, setViewMode] = useState<"SIGNALS" | "CLIENTS">("SIGNALS");
|
const [viewMode, setViewMode] = useState<"SIGNALS" | "CLIENTS">("SIGNALS");
|
||||||
|
|
||||||
const [signals, setSignals] = useState<any[]>([]);
|
const [signals, setSignals] = useState<any[]>([]);
|
||||||
@@ -39,36 +41,105 @@ export default function OperationsInbox() {
|
|||||||
|
|
||||||
useEffect(() => { fetchInitialData(); }, []);
|
useEffect(() => { fetchInitialData(); }, []);
|
||||||
|
|
||||||
const handleSaveRoute = async (type: string, emails: string) => { await updateNotificationRoute(type, emails); alert(`Routing for ${type} updated.`); };
|
const handleSaveRoute = async (type: string, emails: string) => {
|
||||||
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 res = await updateNotificationRoute(type, emails);
|
||||||
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); };
|
if ((res as any)?.error) ui.toast(`Failed to save ${type} routing: ${(res as any).error}`, "error");
|
||||||
const handleDelete = async (id: string) => { if (!confirm("Permanently delete this ticket?")) return; setIsProcessing(true); await deleteSignal(id); await fetchInitialData(); setActiveSignal(null); setIsProcessing(false); };
|
else ui.toast(`${type} routing saved.`, "success");
|
||||||
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 handleStatusChange = async (id: string, status: string) => {
|
||||||
const handleApproveClient = async (signalId: string) => {
|
|
||||||
if (!confirm("Approve this client for B2B portal access? They will receive an email.")) return;
|
|
||||||
setIsProcessing(true);
|
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) {
|
if (res.success) {
|
||||||
alert("Client approved and email sent!");
|
ui.toast("Ticket resolved. Attachments purged.", "success");
|
||||||
await fetchInitialData();
|
await fetchInitialData();
|
||||||
setActiveSignal(null);
|
setActiveSignal(null);
|
||||||
} else {
|
} 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);
|
setIsProcessing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// BORRAR CLIENTE
|
// BORRAR CLIENTE
|
||||||
const handleDeleteClient = async (clientId: string) => {
|
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);
|
setIsProcessing(true);
|
||||||
const res = await deleteClient(clientId);
|
const res = await deleteClient(clientId);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
|
ui.toast("Client deleted.", "success");
|
||||||
await fetchInitialData();
|
await fetchInitialData();
|
||||||
} else {
|
} else {
|
||||||
alert("Failed: " + res.error);
|
ui.toast(`Delete failed: ${res.error}`, "error");
|
||||||
}
|
}
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import "@/app/globals.css";
|
import "@/app/globals.css";
|
||||||
|
import { HqUiProvider } from "@/components/hq/Toast";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "FLUX Command Center",
|
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' }}
|
style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '32px 32px' }}
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<main className="relative z-10">
|
<HqUiProvider>
|
||||||
{children}
|
<main className="relative z-10">
|
||||||
</main>
|
{children}
|
||||||
|
</main>
|
||||||
|
</HqUiProvider>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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