feat: pagina Conversazioni — inbox unificata messaggi clienti

Nuova pagina admin /admin/conversazioni: vista WhatsApp-style (lista
conversazioni a sinistra, thread + risposta a destra) che aggrega i
messaggi di tutti i clienti dalla tabella comments, cross-cliente.

- Migration additiva 0014: clients.admin_last_read_at per tracciare
  letto/non-letto (pallini + badge in sidebar). Applicata a prod.
- conversations-queries.ts: aggregazione entity_id → cliente
  (general/phase/task/deliverable), getConversations /
  getConversationThread / getUnreadConversationsCount.
- Risposte admin salvate come commento "general" (visibili anche nella
  chat cliente e nel CommentsTab della scheda).
- Voce di menu + badge non-letti (AdminSidebar/AdminShell/layout).
- Token semantici per dual light/dark; refresh manuale (no polling).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 16:10:14 +02:00
parent a20a9de2d7
commit c1cc13a99a
10 changed files with 994 additions and 10 deletions
+8 -2
View File
@@ -12,7 +12,13 @@ const SIDEBAR_STORAGE_KEY = "iamcavalli:sidebar";
* (starts expanded on server/first paint to avoid a hydration mismatch,
* then reconciles client-side — mirrors the pattern used by useTheme).
*/
export function AdminShell({ children }: { children: React.ReactNode }) {
export function AdminShell({
children,
unreadConversations = 0,
}: {
children: React.ReactNode;
unreadConversations?: number;
}) {
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
@@ -30,7 +36,7 @@ export function AdminShell({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen bg-background">
<AdminSidebar collapsed={collapsed} />
<AdminSidebar collapsed={collapsed} unreadConversations={unreadConversations} />
<div className="flex-1 flex flex-col min-w-0">
{/* Top header bar */}
+29 -7
View File
@@ -13,12 +13,14 @@ import {
LogOut,
Zap,
FileText,
MessageSquare,
} from "lucide-react";
import ThemeToggle from "@/components/ThemeToggle";
import { cn } from "@/lib/utils";
const NAV_ITEMS = [
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
{ href: "/admin/conversazioni", label: "Conversazioni", icon: MessageSquare },
{ href: "/admin/pipeline", label: "Pipeline", icon: Zap },
{ href: "/admin/clients", label: "Clienti", icon: Users },
{ href: "/admin/projects", label: "Progetti", icon: FolderOpen },
@@ -28,7 +30,13 @@ const NAV_ITEMS = [
{ href: "/admin/impostazioni", label: "Impostazioni", icon: Settings },
];
export function AdminSidebar({ collapsed = false }: { collapsed?: boolean }) {
export function AdminSidebar({
collapsed = false,
unreadConversations = 0,
}: {
collapsed?: boolean;
unreadConversations?: number;
}) {
const pathname = usePathname();
const isActive = (href: string, exact?: boolean) =>
@@ -64,6 +72,10 @@ export function AdminSidebar({ collapsed = false }: { collapsed?: boolean }) {
<nav className="flex-1 px-2 py-4 flex flex-col gap-0.5">
{NAV_ITEMS.map(({ href, label, icon: Icon, exact }) => {
const active = isActive(href, exact);
const badge =
href === "/admin/conversazioni" && unreadConversations > 0
? unreadConversations
: 0;
return (
<Link
key={href}
@@ -77,12 +89,22 @@ export function AdminSidebar({ collapsed = false }: { collapsed?: boolean }) {
: "text-white/65 hover:text-white hover:bg-white/10"
)}
>
<Icon
size={16}
strokeWidth={1.8}
className="shrink-0"
/>
{!collapsed && <span className="whitespace-nowrap">{label}</span>}
<span className="relative shrink-0">
<Icon size={16} strokeWidth={1.8} className="shrink-0" />
{badge > 0 && collapsed && (
<span className="absolute -top-1.5 -right-1.5 w-2 h-2 rounded-full bg-emerald-500 ring-2 ring-[#1A463C]" />
)}
</span>
{!collapsed && (
<span className="flex-1 flex items-center justify-between gap-2 min-w-0">
<span className="whitespace-nowrap">{label}</span>
{badge > 0 && (
<span className="shrink-0 min-w-[18px] h-[18px] px-1.5 inline-flex items-center justify-center rounded-full bg-emerald-500 text-white text-[10px] font-bold">
{badge}
</span>
)}
</span>
)}
</Link>
);
})}
@@ -0,0 +1,291 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ExternalLink, IdCard, MessageSquare } from "lucide-react";
import { SearchInput } from "@/components/ui/SearchInput";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
markConversationRead,
replyToConversation,
} from "@/app/admin/conversazioni/actions";
import type {
ConversationSummary,
ConversationThread,
} from "@/lib/conversations-queries";
type Props = {
conversations: ConversationSummary[];
activeThread: ConversationThread | null;
activeClientId: string | null;
};
export function ConversationsView({
conversations,
activeThread,
activeClientId,
}: Props) {
const router = useRouter();
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return conversations;
return conversations.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.brand_name.toLowerCase().includes(q) ||
c.lastMessage.toLowerCase().includes(q)
);
}, [conversations, query]);
const activeSummary = conversations.find((c) => c.clientId === activeClientId);
// Mark the active conversation as read when it is opened and still unread.
// Guarded on `unread` so the refresh (which flips it to read) won't re-fire.
useEffect(() => {
if (!activeClientId || !activeSummary?.unread) return;
(async () => {
await markConversationRead(activeClientId);
router.refresh();
})();
}, [activeClientId, activeSummary?.unread, router]);
return (
<div className="flex gap-4 h-[calc(100vh-15rem)] min-h-[520px]">
{/* ── Left: conversation list ─────────────────────────────── */}
<aside className="w-80 lg:w-96 shrink-0 flex flex-col bg-card border border-border rounded-xl shadow-card overflow-hidden">
<div className="p-4 border-b border-border">
<SearchInput
placeholder="Cerca conversazione..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<div className="flex-1 overflow-y-auto divide-y divide-border">
{filtered.length === 0 ? (
<p className="p-6 text-sm text-muted-foreground text-center">
Nessuna conversazione.
</p>
) : (
filtered.map((c) => (
<ConversationListItem
key={c.clientId}
conv={c}
active={c.clientId === activeClientId}
/>
))
)}
</div>
</aside>
{/* ── Right: active thread ────────────────────────────────── */}
<section className="flex-1 min-w-0 flex flex-col bg-card border border-border rounded-xl shadow-card overflow-hidden">
{activeThread ? (
<ActiveThread thread={activeThread} />
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center gap-3 text-muted-foreground">
<MessageSquare className="w-10 h-10 opacity-40" strokeWidth={1.5} />
<p className="text-sm">Nessun messaggio dai clienti ancora.</p>
</div>
)}
</section>
</div>
);
}
function ConversationListItem({
conv,
active,
}: {
conv: ConversationSummary;
active: boolean;
}) {
return (
<Link
href={`/admin/conversazioni?c=${conv.clientId}`}
className={cn(
"block p-4 transition-colors hover:bg-muted/40",
active && "bg-muted/60 border-l-2 border-primary",
!active && "border-l-2 border-transparent"
)}
>
<div className="flex items-center justify-between gap-2">
<span
className={cn(
"text-sm truncate",
conv.unread ? "font-bold text-foreground" : "font-semibold text-foreground"
)}
>
{conv.name}
</span>
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
{formatListTime(conv.lastMessageAt)}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground truncate">
{conv.lastMessageAuthor === "admin" && (
<span className="text-muted-foreground/70">Tu: </span>
)}
{conv.lastMessage}
</p>
<div className="mt-2 flex items-center justify-between gap-2">
<EntityBadge label={conv.lastEntityLabel} />
{conv.unread && (
<span className="flex items-center gap-1.5 shrink-0">
{conv.unreadCount > 1 && (
<span className="text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
{conv.unreadCount}
</span>
)}
<span className="w-2 h-2 rounded-full bg-emerald-500" />
</span>
)}
</div>
</Link>
);
}
function ActiveThread({ thread }: { thread: ConversationThread }) {
const scrollRef = useRef<HTMLDivElement>(null);
const portalHref = `/client/${thread.slug ?? thread.token}`;
// Keep the thread pinned to the latest message.
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [thread.clientId, thread.messages.length]);
return (
<>
{/* Header */}
<div className="p-4 border-b border-border flex items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-bold text-foreground truncate">{thread.name}</h2>
<p className="text-[11px] text-muted-foreground truncate">{thread.brand_name}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Link
href={`/admin/clients/${thread.clientId}`}
className="inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<IdCard className="w-3.5 h-3.5" /> Scheda
</Link>
<Link
href={portalHref}
target="_blank"
className="inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="w-3.5 h-3.5" /> Portale
</Link>
</div>
</div>
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-6 bg-muted/20 space-y-4">
{thread.messages.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
Nessun messaggio in questa conversazione.
</p>
) : (
thread.messages.map((m) => <MessageBubble key={m.id} message={m} />)
)}
</div>
{/* Reply box */}
<form
action={replyToConversation.bind(null, thread.clientId)}
className="p-4 border-t border-border bg-card flex items-end gap-3"
>
<Textarea
name="body"
rows={2}
required
placeholder="Scrivi una risposta..."
className="flex-1 resize-none"
/>
<Button type="submit" size="sm" className="shrink-0">
Invia
</Button>
</form>
</>
);
}
function MessageBubble({
message,
}: {
message: ConversationThread["messages"][number];
}) {
const isAdmin = message.author === "admin";
const showEntity = message.entityType !== "general";
return (
<div className={cn("flex", isAdmin && "justify-end")}>
<div
className={cn(
"max-w-[75%] p-3.5 rounded-2xl shadow-sm space-y-1",
isAdmin
? "bg-primary text-primary-foreground rounded-tr-sm"
: "bg-background border border-border text-foreground rounded-tl-sm"
)}
>
<div className="flex items-center gap-2">
<span
className={cn(
"text-[10px] font-bold",
isAdmin ? "text-primary-foreground/70" : "text-muted-foreground"
)}
>
{isAdmin ? "Tu (Admin)" : "Cliente"}
</span>
{showEntity && <EntityBadge label={message.entityLabel} />}
</div>
<p className="text-xs leading-relaxed whitespace-pre-wrap">{message.body}</p>
<p
className={cn(
"text-[9px] font-mono",
isAdmin ? "text-primary-foreground/50" : "text-muted-foreground/60"
)}
>
{formatMessageTime(message.created_at)}
</p>
</div>
</div>
);
}
function EntityBadge({ label }: { label: string }) {
return (
<span className="inline-block text-[9px] font-semibold px-1.5 py-0.5 rounded border border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-400 truncate max-w-[140px]">
{label}
</span>
);
}
// ── Date helpers ──────────────────────────────────────────────────────────────
function formatListTime(date: Date): string {
const d = new Date(date);
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
if (sameDay) {
return d.toLocaleTimeString("it-IT", { hour: "2-digit", minute: "2-digit" });
}
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (d.toDateString() === yesterday.toDateString()) return "Ieri";
return d.toLocaleDateString("it-IT", { day: "numeric", month: "short" });
}
function formatMessageTime(date: Date): string {
const d = new Date(date);
return d.toLocaleString("it-IT", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
}