feat: inbox UX polish — auto-save routing, search filter, email badges
Deploy to VPS / deploy (push) Has been cancelled
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:
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } 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, Check,
|
||||
Search, MailQuestion,
|
||||
} from "lucide-react";
|
||||
import { getSignals, updateSignalStatus, resolveAndCleanSignal, getNotificationRoutes, updateNotificationRoute, deleteSignal, resendSignalEmail, getClients, approveAccessRequest, deleteClient } from "./actions";
|
||||
import { useHqUi } from "@/components/hq/Toast";
|
||||
@@ -25,6 +26,11 @@ export default function OperationsInbox() {
|
||||
|
||||
const [filterType, setFilterType] = 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 () => {
|
||||
setIsLoading(true);
|
||||
@@ -41,10 +47,26 @@ export default function OperationsInbox() {
|
||||
|
||||
useEffect(() => { fetchInitialData(); }, []);
|
||||
|
||||
const handleSaveRoute = async (type: string, emails: string) => {
|
||||
// Auto-save a route after 800ms of inactivity
|
||||
const autoSaveRoute = useCallback((type: string, emails: string) => {
|
||||
if (debounceTimers.current[type]) clearTimeout(debounceTimers.current[type]);
|
||||
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) ui.toast(`Failed to save ${type} routing: ${(res as any).error}`, "error");
|
||||
else ui.toast(`${type} routing saved.`, "success");
|
||||
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) => {
|
||||
@@ -156,6 +178,12 @@ export default function OperationsInbox() {
|
||||
const filtered = signals.filter(s => {
|
||||
if (filterType !== "ALL" && s.type !== filterType) 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;
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</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 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>
|
||||
@@ -216,7 +248,11 @@ export default function OperationsInbox() {
|
||||
<span title={signal.emailError} className="flex items-center">
|
||||
<MailX size={11} className="text-red-400" />
|
||||
</span>
|
||||
) : null}
|
||||
) : (
|
||||
<span title="No email sent" className="flex items-center">
|
||||
<MailQuestion size={11} className="text-[#86868B]/50" />
|
||||
</span>
|
||||
)}
|
||||
{getStatusIcon(signal.status)}
|
||||
</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>
|
||||
<p className="text-sm text-[#86868B] mb-6">Configure which team members receive alerts. Separate emails with commas.</p>
|
||||
<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 => {
|
||||
const status = routeSaveStatus[route.id] || "idle";
|
||||
return (
|
||||
<div key={route.id} className="bg-black/40 border border-white/5 rounded-xl p-4">
|
||||
<label className={`block text-[10px] uppercase tracking-widest font-bold mb-2 ${route.color}`}>{route.label}</label>
|
||||
<div className="flex gap-2">
|
||||
<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" />
|
||||
<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>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user