feat: inbox UX polish — auto-save routing, search filter, email badges
Deploy to VPS / deploy (push) Has been cancelled

Three improvements to the Operations Inbox:

1. Auto-save email routing: typing in the routing config auto-saves
   after 800ms with inline "Saving…" / "✓ Saved" indicator. No more
   manual Save button per route.

2. Search filter: instant client-side search across name, company,
   email, and ticket ID. Composes with existing type/status filters.

3. No-email badge: signals without email delivery now show a dim
   MailQuestion icon in the list view instead of nothing, making it
   clear at a glance which tickets need attention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 14:52:57 -05:00
parent 7502c9c674
commit 9c1e0cce01
+55 -14
View File
@@ -1,11 +1,12 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useCallback } 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, Check, Trash2, Loader2, Settings, X, MailCheck, MailX, Users, Key, Check,
Search, MailQuestion,
} 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"; import { useHqUi } from "@/components/hq/Toast";
@@ -25,6 +26,11 @@ export default function OperationsInbox() {
const [filterType, setFilterType] = useState<string>("ALL"); const [filterType, setFilterType] = useState<string>("ALL");
const [filterStatus, setFilterStatus] = useState<string>("ALL"); const [filterStatus, setFilterStatus] = useState<string>("ALL");
const [searchQuery, setSearchQuery] = useState("");
// Auto-save routing: track per-route save state
const [routeSaveStatus, setRouteSaveStatus] = useState<Record<string, "idle" | "saving" | "saved" | "error">>({});
const debounceTimers = useRef<Record<string, NodeJS.Timeout>>({});
const fetchInitialData = async () => { const fetchInitialData = async () => {
setIsLoading(true); setIsLoading(true);
@@ -41,10 +47,26 @@ export default function OperationsInbox() {
useEffect(() => { fetchInitialData(); }, []); useEffect(() => { fetchInitialData(); }, []);
const handleSaveRoute = async (type: string, emails: string) => { // Auto-save a route after 800ms of inactivity
const res = await updateNotificationRoute(type, emails); const autoSaveRoute = useCallback((type: string, emails: string) => {
if ((res as any)?.error) ui.toast(`Failed to save ${type} routing: ${(res as any).error}`, "error"); if (debounceTimers.current[type]) clearTimeout(debounceTimers.current[type]);
else ui.toast(`${type} routing saved.`, "success"); setRouteSaveStatus(prev => ({ ...prev, [type]: "idle" }));
debounceTimers.current[type] = setTimeout(async () => {
setRouteSaveStatus(prev => ({ ...prev, [type]: "saving" }));
const res = await updateNotificationRoute(type, emails);
if ((res as any)?.error) {
setRouteSaveStatus(prev => ({ ...prev, [type]: "error" }));
ui.toast(`Failed to save ${type} routing: ${(res as any).error}`, "error");
} else {
setRouteSaveStatus(prev => ({ ...prev, [type]: "saved" }));
setTimeout(() => setRouteSaveStatus(prev => ({ ...prev, [type]: "idle" })), 2000);
}
}, 800);
}, [ui]);
const handleRouteChange = (type: string, emails: string) => {
setRoutes(prev => ({ ...prev, [type]: emails }));
autoSaveRoute(type, emails);
}; };
const handleStatusChange = async (id: string, status: string) => { const handleStatusChange = async (id: string, status: string) => {
@@ -156,6 +178,12 @@ export default function OperationsInbox() {
const filtered = signals.filter(s => { const filtered = signals.filter(s => {
if (filterType !== "ALL" && s.type !== filterType) return false; if (filterType !== "ALL" && s.type !== filterType) return false;
if (filterStatus !== "ALL" && s.status !== filterStatus) return false; if (filterStatus !== "ALL" && s.status !== filterStatus) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
const match = [s.clientName, s.clientCompany, s.clientEmail, s.ticketId]
.some(field => field?.toLowerCase().includes(q));
if (!match) return false;
}
return true; return true;
}); });
@@ -198,6 +226,10 @@ export default function OperationsInbox() {
<button key={s} onClick={() => setFilterStatus(s)} className={`text-[9px] uppercase tracking-widest font-bold px-2 py-1 rounded-lg border transition-all ${filterStatus === s ? "bg-white/10 border-white/20 text-white" : "border-transparent text-[#86868B] hover:text-white"}`}>{s === "ALL" ? "All Status" : s}</button> <button key={s} onClick={() => setFilterStatus(s)} className={`text-[9px] uppercase tracking-widest font-bold px-2 py-1 rounded-lg border transition-all ${filterStatus === s ? "bg-white/10 border-white/20 text-white" : "border-transparent text-[#86868B] hover:text-white"}`}>{s === "ALL" ? "All Status" : s}</button>
))} ))}
</div> </div>
<div className="relative mt-3">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#86868B]" />
<input type="text" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} placeholder="Search name, company, email, ticket…" className="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-3 py-2 text-xs text-white placeholder-[#86868B]/60 outline-none focus:border-white/30 transition-colors" />
</div>
</div> </div>
<div className="flex-1 overflow-y-auto [scrollbar-width:none] p-3 space-y-2"> <div className="flex-1 overflow-y-auto [scrollbar-width:none] p-3 space-y-2">
{isLoading ? <div className="p-10 text-center text-[#86868B]"><Loader2 size={24} className="animate-spin mx-auto mb-2" /></div> {isLoading ? <div className="p-10 text-center text-[#86868B]"><Loader2 size={24} className="animate-spin mx-auto mb-2" /></div>
@@ -216,7 +248,11 @@ export default function OperationsInbox() {
<span title={signal.emailError} className="flex items-center"> <span title={signal.emailError} className="flex items-center">
<MailX size={11} className="text-red-400" /> <MailX size={11} className="text-red-400" />
</span> </span>
) : null} ) : (
<span title="No email sent" className="flex items-center">
<MailQuestion size={11} className="text-[#86868B]/50" />
</span>
)}
{getStatusIcon(signal.status)} {getStatusIcon(signal.status)}
</div> </div>
</div> </div>
@@ -363,15 +399,20 @@ export default function OperationsInbox() {
<div className="flex items-center gap-3 mb-2"><Mail className="text-rose-500" size={24} /><h3 className="text-2xl font-light text-white">Email Routing</h3></div> <div className="flex items-center gap-3 mb-2"><Mail className="text-rose-500" size={24} /><h3 className="text-2xl font-light text-white">Email Routing</h3></div>
<p className="text-sm text-[#86868B] mb-6">Configure which team members receive alerts. Separate emails with commas.</p> <p className="text-sm text-[#86868B] mb-6">Configure which team members receive alerts. Separate emails with commas.</p>
<div className="space-y-5"> <div className="space-y-5">
{[{ id: "ORDER", label: "Spare Part Orders", color: "text-amber-500" }, { id: "DIAGNOSTIC", label: "Tech Support / Diagnostics", color: "text-rose-500" }, { id: "CONSULTATION", label: "Engineering Consultations", color: "text-[#00F0FF]" }].map(route => ( {[{ id: "ORDER", label: "Spare Part Orders", color: "text-amber-500" }, { id: "DIAGNOSTIC", label: "Tech Support / Diagnostics", color: "text-rose-500" }, { id: "CONSULTATION", label: "Engineering Consultations", color: "text-[#00F0FF]" }].map(route => {
<div key={route.id} className="bg-black/40 border border-white/5 rounded-xl p-4"> const status = routeSaveStatus[route.id] || "idle";
<label className={`block text-[10px] uppercase tracking-widest font-bold mb-2 ${route.color}`}>{route.label}</label> return (
<div className="flex gap-2"> <div key={route.id} className="bg-black/40 border border-white/5 rounded-xl p-4">
<input type="text" value={(routes as any)[route.id]} onChange={e => setRoutes({...routes, [route.id]: e.target.value})} className="flex-1 bg-transparent border-b border-white/20 text-white text-sm pb-1 outline-none focus:border-white font-mono" placeholder="e.g. sales@flux.com, ceo@flux.com" /> <div className="flex justify-between items-center mb-2">
<button onClick={() => handleSaveRoute(route.id, (routes as any)[route.id])} className="text-xs bg-white/10 hover:bg-white/20 text-white px-3 py-1 rounded-lg font-medium">Save</button> <label className={`text-[10px] uppercase tracking-widest font-bold ${route.color}`}>{route.label}</label>
<span className={`text-[9px] font-medium transition-opacity duration-300 ${status === "idle" ? "opacity-0" : "opacity-100"} ${status === "saving" ? "text-[#86868B]" : status === "saved" ? "text-emerald-400" : status === "error" ? "text-red-400" : ""}`}>
{status === "saving" ? "Saving…" : status === "saved" ? "✓ Saved" : status === "error" ? "✗ Failed" : ""}
</span>
</div>
<input type="text" value={(routes as any)[route.id]} onChange={e => handleRouteChange(route.id, e.target.value)} className="w-full bg-transparent border-b border-white/20 text-white text-sm pb-1 outline-none focus:border-white font-mono" placeholder="e.g. sales@flux.com, ceo@flux.com" />
</div> </div>
</div> );
))} })}
</div> </div>
</div> </div>
</div> </div>