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
+292
View File
@@ -0,0 +1,292 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Conversazioni — Luxury Admin CRM</title>
<!-- Google Fonts: Plus Jakarta Sans -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Plus Jakarta Sans', 'sans-serif'],
},
colors: {
brand: {
dark: '#1A463C', /* Verde scuro lusso */
darkHover: '#13342D',
active: 'rgba(255, 255, 255, 0.08)',
bg: '#F8F9FA',
}
}
}
}
}
</script>
<style>
.sidebar-transition {
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
</style>
</head>
<body class="bg-brand-bg font-sans text-slate-800 antialiased min-h-screen flex overflow-x-hidden">
<!-- SIDEBAR -->
<aside id="sidebar" class="w-64 bg-brand-dark text-white flex flex-col justify-between p-6 border-r border-emerald-950/20 shrink-0 sidebar-transition relative z-10">
<div class="overflow-hidden">
<!-- Logo e Intestazione -->
<div class="mb-10 px-2 flex items-center justify-between">
<span id="sidebar-logo-text" class="text-lg font-bold tracking-wider text-emerald-50 whitespace-nowrap transition-opacity duration-300">iamcavalli</span>
</div>
<!-- Navigazione Principale -->
<nav class="space-y-1">
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2v-4zM14 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2v-4z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Dashboard</span>
</a>
<!-- NUOVA VOCE: Conversazioni (Con Badge Notifica) -->
<a href="#" class="flex items-center justify-between px-3 py-2.5 rounded-lg text-sm text-white bg-brand-active font-medium transition-all duration-200 shadow-sm">
<div class="flex items-center gap-3">
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a.598.598 0 01-.73-.578 5.672 5.672 0 011.02-3.155A8.72 8.72 0 013 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Conversazioni</span>
</div>
<span class="bg-emerald-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full sidebar-text">2</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Lead</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Clienti</span>
</a>
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Progetti</span>
</a>
</nav>
</div>
<!-- Area Inferiore Sidebar -->
<div class="border-t border-white/10 pt-4 space-y-3 overflow-hidden">
<div class="flex items-center justify-between px-3 py-1 text-xs text-emerald-200/50 whitespace-nowrap">
<span class="sidebar-text transition-opacity duration-300">Tema</span>
<button class="p-1 rounded-full bg-white/5 hover:bg-white/10 text-emerald-200 hover:text-white transition-all duration-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M14 12a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
</button>
</div>
<a href="#" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-red-300/80 hover:text-red-200 hover:bg-red-950/20 transition-all duration-200 whitespace-nowrap">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
<span class="sidebar-text transition-opacity duration-300">Esci</span>
</a>
</div>
</aside>
<!-- SEZIONE PRINCIPALE -->
<div class="flex-1 flex flex-col min-w-0">
<!-- BARRA SUPERIORE -->
<header class="h-16 border-b border-slate-100 bg-white px-8 flex items-center justify-between shrink-0">
<div class="flex items-center gap-4">
<button id="sidebar-toggle" class="p-2 -ml-2 rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-800 transition-colors focus:outline-none" title="Espandi/Comprimi Sidebar">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 6h16M4 12h12M4 18h16"/></svg>
</button>
<span class="text-xs text-slate-400 font-medium">Centro Messaggi Clienti</span>
</div>
<div class="flex items-center gap-4">
<span class="text-xs font-semibold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-full">Admin Panel</span>
</div>
</header>
<!-- AREA CHAT INBOX (LAYOUT A 2 COLONNE STILE WHATSAPP / INTERCOM) -->
<main class="flex-1 p-8 max-w-[1400px] w-full mx-auto flex gap-6 h-[calc(100vh-4rem)] overflow-hidden">
<!-- COLONNA SINISTRA (1/3): Lista Progetti / Chat Clienti -->
<div class="w-80 lg:w-96 bg-white border border-slate-100 rounded-xl flex flex-col shadow-[0_4px_20px_rgba(0,0,0,0.01)] shrink-0">
<!-- Header Filtro Lista -->
<div class="p-4 border-b border-slate-100">
<h2 class="text-xs font-bold uppercase tracking-wider text-slate-400 mb-3">Conversazioni Attive</h2>
<div class="relative">
<input
type="text"
placeholder="Cerca cliente o progetto..."
class="w-full bg-slate-50 border border-slate-200 rounded-lg pl-8 pr-3 py-2 text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-brand-dark transition-all"
/>
<svg class="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-2.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</div>
</div>
<!-- Lista Thread Clienti -->
<div class="flex-1 overflow-y-auto divide-y divide-slate-50">
<!-- Thread 1 (Selezionato / Non letto) -->
<div onclick="selectThread('Rossi Inc')" class="p-4 bg-emerald-50/30 border-l-4 border-brand-dark cursor-pointer transition-colors flex justify-between items-start gap-3">
<div class="flex-1 min-w-0">
<div class="flex justify-between items-center mb-1">
<h3 class="text-xs font-bold text-slate-900 truncate">Rossi Inc</h3>
<span class="text-[10px] text-slate-400 font-mono">10:42</span>
</div>
<p class="text-[11px] text-slate-600 font-medium truncate mb-1">In merito all'analisi competitor della Fase 2...</p>
<span class="text-[9px] font-semibold text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded border border-amber-100">Fase 2: Analisi</span>
</div>
<span class="w-2 h-2 rounded-full bg-emerald-500 shrink-0 mt-1"></span>
</div>
<!-- Thread 2 (Non letto) -->
<div onclick="selectThread('Azienda Srl')" class="p-4 hover:bg-slate-50 cursor-pointer transition-colors flex justify-between items-start gap-3">
<div class="flex-1 min-w-0">
<div class="flex justify-between items-center mb-1">
<h3 class="text-xs font-bold text-slate-800 truncate">Azienda Srl</h3>
<span class="text-[10px] text-slate-400 font-mono">Ieri</span>
</div>
<p class="text-[11px] text-slate-500 truncate mb-1">Abbiamo caricato i file di onboarding!</p>
<span class="text-[9px] font-semibold text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded border border-emerald-100">Fase 1: Onboarding</span>
</div>
<span class="w-2 h-2 rounded-full bg-emerald-500 shrink-0 mt-1"></span>
</div>
<!-- Thread 3 (Letto) -->
<div onclick="selectThread('Caruso Speaker')" class="p-4 hover:bg-slate-50 cursor-pointer transition-colors flex justify-between items-start gap-3 opacity-60">
<div class="flex-1 min-w-0">
<div class="flex justify-between items-center mb-1">
<h3 class="text-xs font-semibold text-slate-800 truncate">Caruso Speaker</h3>
<span class="text-[10px] text-slate-400 font-mono">12 Lug</span>
</div>
<p class="text-[11px] text-slate-500 truncate mb-1">Perfetto, attendo aggiornamenti.</p>
<span class="text-[9px] font-semibold text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded">Generale</span>
</div>
</div>
</div>
</div>
<!-- COLONNA DESTRA (2/3): Area Chat con Thread Attivo -->
<div class="flex-1 bg-white border border-slate-100 rounded-xl flex flex-col shadow-[0_4px_20px_rgba(0,0,0,0.01)] min-w-0">
<!-- Header Chat Selezionata -->
<div class="p-4 border-b border-slate-100 flex items-center justify-between">
<div>
<h2 id="active-chat-title" class="text-sm font-bold text-slate-900">Rossi Inc</h2>
<p class="text-[10px] text-slate-400 mt-0.5">Progetto: Web Domination — Pagina Pubblica Client Portal</p>
</div>
<a href="#" class="text-xs font-medium text-brand-dark hover:text-brand-darkHover inline-flex items-center gap-1">
Vedi Portal Cliente ➔
</a>
</div>
<!-- Thread Messaggi -->
<div id="admin-chat-messages" class="flex-1 p-6 overflow-y-auto bg-slate-50/30 space-y-4">
<!-- Messaggio Cliente -->
<div class="max-w-[75%] bg-white border border-slate-200/80 p-3.5 rounded-2xl rounded-tl-sm shadow-sm space-y-1">
<div class="flex justify-between items-center gap-4 text-[9px] font-bold text-slate-400">
<span>Cliente (Rossi Inc)</span>
<span class="text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded">Riferito a: Fase 2 - Analisi</span>
</div>
<p class="text-xs text-slate-700 leading-relaxed">
Buongiorno, abbiamo revisionato l'analisi dei competitor. Vorremmo aggiungere un paio di riferimenti al report prima della meeting di martedì.
</p>
<span class="text-[9px] text-slate-300 font-mono block text-right">10:42</span>
</div>
<!-- Risposta Admin -->
<div class="max-w-[75%] ml-auto bg-brand-dark text-white p-3.5 rounded-2xl rounded-tr-sm shadow-sm space-y-1">
<div class="flex justify-between items-center gap-4 text-[9px] font-bold text-emerald-200/80">
<span>Tu (Admin)</span>
<span>10:45</span>
</div>
<p class="text-xs leading-relaxed">
Ricevuto! Potete caricare i documenti direttamente nella sezione Documenti della vostra area riservata, così li integriamo nella Fase 2.
</p>
</div>
</div>
<!-- Box Risposta Admin -->
<div class="p-4 border-t border-slate-100 bg-white">
<div class="flex gap-2">
<textarea
id="admin-chat-input"
rows="2"
placeholder="Scrivi una risposta per il cliente..."
class="flex-1 bg-slate-50 border border-slate-200 rounded-lg p-3 text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-brand-dark focus:bg-white transition-all resize-none"
></textarea>
<button
onclick="sendAdminReply()"
class="bg-brand-dark hover:bg-brand-darkHover text-white text-xs font-semibold px-5 py-2 rounded-lg transition-colors shrink-0 self-end h-10"
>
Invia Risposta
</button>
</div>
</div>
</div>
</main>
</div>
<!-- SCRIPT INTERATTIVO CHAT -->
<script>
// Toggle Sidebar
const sidebar = document.getElementById('sidebar');
const toggleButton = document.getElementById('sidebar-toggle');
const logoText = document.getElementById('sidebar-logo-text');
const sidebarTexts = document.querySelectorAll('.sidebar-text');
toggleButton.addEventListener('click', () => {
const isCollapsed = sidebar.classList.contains('w-20');
if (isCollapsed) {
sidebar.classList.remove('w-20');
sidebar.classList.add('w-64');
setTimeout(() => {
logoText.classList.remove('opacity-0');
sidebarTexts.forEach(text => text.classList.remove('opacity-0'));
}, 150);
} else {
logoText.classList.add('opacity-0');
sidebarTexts.forEach(text => text.classList.add('opacity-0'));
sidebar.classList.remove('w-64');
sidebar.classList.add('w-20');
}
});
// Cambio Thread Cliente
function selectThread(clientName) {
document.getElementById('active-chat-title').innerText = clientName;
}
// Invio Risposta
function sendAdminReply() {
const input = document.getElementById('admin-chat-input');
const text = input.value.trim();
if (!text) return;
const container = document.getElementById('admin-chat-messages');
const replyBubble = document.createElement('div');
replyBubble.className = "max-w-[75%] ml-auto bg-brand-dark text-white p-3.5 rounded-2xl rounded-tr-sm shadow-sm space-y-1";
replyBubble.innerHTML = `
<div class="flex justify-between items-center gap-4 text-[9px] font-bold text-emerald-200/80">
<span>Tu (Admin)</span>
<span>Adesso</span>
</div>
<p class="text-xs leading-relaxed">${text}</p>
`;
container.appendChild(replyBubble);
input.value = '';
container.scrollTop = container.scrollHeight;
}
</script>
</body>
</html>
+59
View File
@@ -0,0 +1,59 @@
"use server";
import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth";
import { eq } from "drizzle-orm";
import { authOptions } from "@/lib/auth";
import { db } from "@/db";
import { clients, comments } from "@/db/schema";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
/**
* Admin reply from the Conversazioni inbox. Per project decision, replies are
* saved as a "general" comment on the client (entity_id = clientId), so they
* surface in the client's general chat and in the client detail CommentsTab.
*/
export async function replyToConversation(clientId: string, formData: FormData) {
await requireAdmin();
const body = (formData.get("body") as string)?.trim();
if (!clientId || !body) throw new Error("Dati mancanti");
// Validate the client exists (entity_id integrity).
const rows = await db
.select({ id: clients.id })
.from(clients)
.where(eq(clients.id, clientId))
.limit(1);
if (rows.length === 0) throw new Error("Cliente non trovato");
await db.insert(comments).values({
entity_type: "general",
entity_id: clientId,
author: "admin",
body,
});
// Replying implies the admin has read the incoming messages.
await db
.update(clients)
.set({ admin_last_read_at: new Date() })
.where(eq(clients.id, clientId));
revalidatePath("/admin/conversazioni");
revalidatePath(`/admin/clients/${clientId}`);
}
/** Mark a client's conversation as read up to now (clears unread dot/badge). */
export async function markConversationRead(clientId: string) {
await requireAdmin();
if (!clientId) return;
await db
.update(clients)
.set({ admin_last_read_at: new Date() })
.where(eq(clients.id, clientId));
revalidatePath("/admin/conversazioni");
}
+41
View File
@@ -0,0 +1,41 @@
import { PageHeader } from "@/components/admin/PageHeader";
import { ConversationsView } from "@/components/admin/conversazioni/ConversationsView";
import {
getConversations,
getConversationThread,
} from "@/lib/conversations-queries";
export const revalidate = 0;
export default async function ConversazioniPage({
searchParams,
}: {
searchParams: Promise<{ c?: string }>;
}) {
const { c } = await searchParams;
const conversations = await getConversations();
// Default selection: the requested client, else the most recent conversation.
const activeClientId =
(c && conversations.some((conv) => conv.clientId === c) ? c : null) ??
conversations[0]?.clientId ??
null;
const activeThread = activeClientId
? await getConversationThread(activeClientId)
: null;
return (
<div className="space-y-6">
<PageHeader
title="Conversazioni"
subtitle="Centro messaggi clienti — tutte le richieste in un unico posto"
/>
<ConversationsView
conversations={conversations}
activeThread={activeThread}
activeClientId={activeClientId}
/>
</div>
);
}
+3 -1
View File
@@ -1,6 +1,7 @@
import { AdminShell } from "@/components/admin/AdminShell"; import { AdminShell } from "@/components/admin/AdminShell";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { getUnreadConversationsCount } from "@/lib/conversations-queries";
export default async function AdminLayout({ export default async function AdminLayout({
children, children,
@@ -11,5 +12,6 @@ export default async function AdminLayout({
if (!session) { if (!session) {
return <div className="min-h-screen bg-background">{children}</div>; return <div className="min-h-screen bg-background">{children}</div>;
} }
return <AdminShell>{children}</AdminShell>; const unreadConversations = await getUnreadConversationsCount();
return <AdminShell unreadConversations={unreadConversations}>{children}</AdminShell>;
} }
+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, * (starts expanded on server/first paint to avoid a hydration mismatch,
* then reconciles client-side — mirrors the pattern used by useTheme). * 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); const [collapsed, setCollapsed] = useState(false);
useEffect(() => { useEffect(() => {
@@ -30,7 +36,7 @@ export function AdminShell({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex min-h-screen bg-background"> <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"> <div className="flex-1 flex flex-col min-w-0">
{/* Top header bar */} {/* Top header bar */}
+29 -7
View File
@@ -13,12 +13,14 @@ import {
LogOut, LogOut,
Zap, Zap,
FileText, FileText,
MessageSquare,
} from "lucide-react"; } from "lucide-react";
import ThemeToggle from "@/components/ThemeToggle"; import ThemeToggle from "@/components/ThemeToggle";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true }, { href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
{ href: "/admin/conversazioni", label: "Conversazioni", icon: MessageSquare },
{ href: "/admin/pipeline", label: "Pipeline", icon: Zap }, { href: "/admin/pipeline", label: "Pipeline", icon: Zap },
{ href: "/admin/clients", label: "Clienti", icon: Users }, { href: "/admin/clients", label: "Clienti", icon: Users },
{ href: "/admin/projects", label: "Progetti", icon: FolderOpen }, { href: "/admin/projects", label: "Progetti", icon: FolderOpen },
@@ -28,7 +30,13 @@ const NAV_ITEMS = [
{ href: "/admin/impostazioni", label: "Impostazioni", icon: Settings }, { 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 pathname = usePathname();
const isActive = (href: string, exact?: boolean) => 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 className="flex-1 px-2 py-4 flex flex-col gap-0.5">
{NAV_ITEMS.map(({ href, label, icon: Icon, exact }) => { {NAV_ITEMS.map(({ href, label, icon: Icon, exact }) => {
const active = isActive(href, exact); const active = isActive(href, exact);
const badge =
href === "/admin/conversazioni" && unreadConversations > 0
? unreadConversations
: 0;
return ( return (
<Link <Link
key={href} key={href}
@@ -77,12 +89,22 @@ export function AdminSidebar({ collapsed = false }: { collapsed?: boolean }) {
: "text-white/65 hover:text-white hover:bg-white/10" : "text-white/65 hover:text-white hover:bg-white/10"
)} )}
> >
<Icon <span className="relative shrink-0">
size={16} <Icon size={16} strokeWidth={1.8} className="shrink-0" />
strokeWidth={1.8} {badge > 0 && collapsed && (
className="shrink-0" <span className="absolute -top-1.5 -right-1.5 w-2 h-2 rounded-full bg-emerald-500 ring-2 ring-[#1A463C]" />
/> )}
{!collapsed && <span className="whitespace-nowrap">{label}</span>} </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> </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",
});
}
@@ -0,0 +1,8 @@
-- Additive: track when the admin last read a client's conversation (Conversazioni inbox).
-- A conversation is "unread" when the latest client-authored comment created_at is
-- greater than admin_last_read_at (or admin_last_read_at IS NULL).
-- nullable — existing clients keep NULL (treated as fully unread until first open).
-- Apply to prod via SSH+docker exec BEFORE pushing schema-dependent code.
-- No drops, no truncates, no data loss.
ALTER TABLE clients ADD COLUMN IF NOT EXISTS admin_last_read_at timestamptz;
+3
View File
@@ -37,6 +37,9 @@ export const clients = pgTable("clients", {
"0" "0"
), ),
archived: boolean("archived").notNull().default(false), archived: boolean("archived").notNull().default(false),
// Conversazioni inbox: timestamp of the admin's last read of this client's
// conversation. NULL = never read (treated as unread). Set via markConversationRead.
admin_last_read_at: timestamp("admin_last_read_at", { withTimezone: true }),
created_at: timestamp("created_at", { withTimezone: true }) created_at: timestamp("created_at", { withTimezone: true })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
+260
View File
@@ -0,0 +1,260 @@
import { db } from "@/db";
import { clients, projects, phases, tasks, deliverables, comments } from "@/db/schema";
import { eq, inArray, asc } from "drizzle-orm";
import type { Comment } from "@/db/schema";
// ── Types ─────────────────────────────────────────────────────────────────────
export type ConversationSummary = {
clientId: string;
name: string;
brand_name: string;
token: string;
slug: string | null;
lastMessage: string;
lastMessageAuthor: "client" | "admin";
lastMessageAt: Date;
lastEntityLabel: string;
unread: boolean;
unreadCount: number;
};
export type ThreadMessage = {
id: string;
author: "client" | "admin";
body: string;
created_at: Date;
entityLabel: string;
entityType: string;
};
export type ConversationThread = {
clientId: string;
name: string;
brand_name: string;
token: string;
slug: string | null;
messages: ThreadMessage[];
};
type ClientMeta = {
id: string;
name: string;
brand_name: string;
token: string;
slug: string | null;
admin_last_read_at: Date | null;
};
type EntityInfo = { clientId: string; label: string; type: string };
// ── Shared aggregation ──────────────────────────────────────────────────────────
// Builds the entity_id → owning client map by walking
// clients → projects → phases → tasks → deliverables, then groups every comment
// under its owning client. Same pattern as admin-queries.getClientFullDetail,
// but across ALL non-archived clients at once (cross-client inbox).
async function buildEntityMap(): Promise<{
clientMeta: Map<string, ClientMeta>;
entityMap: Map<string, EntityInfo>;
allEntityIds: string[];
}> {
const clientMeta = new Map<string, ClientMeta>();
const entityMap = new Map<string, EntityInfo>();
const clientRows = await db
.select()
.from(clients)
.where(eq(clients.archived, false));
for (const c of clientRows) {
clientMeta.set(c.id, {
id: c.id,
name: c.name,
brand_name: c.brand_name,
token: c.token,
slug: c.slug,
admin_last_read_at: c.admin_last_read_at,
});
// A client's own id is the entity_id for "general" messages.
entityMap.set(c.id, { clientId: c.id, label: "Generale", type: "general" });
}
const clientIds = clientRows.map((c) => c.id);
if (clientIds.length === 0) {
return { clientMeta, entityMap, allEntityIds: [] };
}
const projectRows = await db
.select({ id: projects.id, client_id: projects.client_id })
.from(projects)
.where(inArray(projects.client_id, clientIds));
const projectToClient = new Map(projectRows.map((p) => [p.id, p.client_id]));
const projectIds = projectRows.map((p) => p.id);
if (projectIds.length > 0) {
const phaseRows = await db
.select({ id: phases.id, project_id: phases.project_id, title: phases.title })
.from(phases)
.where(inArray(phases.project_id, projectIds));
const phaseToClient = new Map<string, string>();
for (const ph of phaseRows) {
const clientId = projectToClient.get(ph.project_id);
if (!clientId) continue;
phaseToClient.set(ph.id, clientId);
entityMap.set(ph.id, { clientId, label: `Fase: ${ph.title}`, type: "phase" });
}
const phaseIds = phaseRows.map((p) => p.id);
if (phaseIds.length > 0) {
const taskRows = await db
.select({ id: tasks.id, phase_id: tasks.phase_id, title: tasks.title })
.from(tasks)
.where(inArray(tasks.phase_id, phaseIds));
const taskToClient = new Map<string, string>();
for (const t of taskRows) {
const clientId = phaseToClient.get(t.phase_id);
if (!clientId) continue;
taskToClient.set(t.id, clientId);
entityMap.set(t.id, { clientId, label: `Task: ${t.title}`, type: "task" });
}
const taskIds = taskRows.map((t) => t.id);
if (taskIds.length > 0) {
const delivRows = await db
.select({ id: deliverables.id, task_id: deliverables.task_id, title: deliverables.title })
.from(deliverables)
.where(inArray(deliverables.task_id, taskIds));
for (const d of delivRows) {
const clientId = taskToClient.get(d.task_id);
if (!clientId) continue;
entityMap.set(d.id, {
clientId,
label: `Deliverable: ${d.title}`,
type: "deliverable",
});
}
}
}
}
return { clientMeta, entityMap, allEntityIds: [...entityMap.keys()] };
}
// Groups comments (already sorted asc) by owning client id.
function groupCommentsByClient(
commentRows: Comment[],
entityMap: Map<string, EntityInfo>
): Map<string, Comment[]> {
const byClient = new Map<string, Comment[]>();
for (const c of commentRows) {
const info = entityMap.get(c.entity_id);
if (!info) continue; // orphaned comment (deleted entity) — skip
const arr = byClient.get(info.clientId) ?? [];
arr.push(c);
byClient.set(info.clientId, arr);
}
return byClient;
}
async function fetchComments(allEntityIds: string[]): Promise<Comment[]> {
if (allEntityIds.length === 0) return [];
return db
.select()
.from(comments)
.where(inArray(comments.entity_id, allEntityIds))
.orderBy(asc(comments.created_at));
}
// ── Public API ──────────────────────────────────────────────────────────────────
/** One entry per non-archived client that has at least one comment, newest first. */
export async function getConversations(): Promise<ConversationSummary[]> {
const { clientMeta, entityMap, allEntityIds } = await buildEntityMap();
const commentRows = await fetchComments(allEntityIds);
const byClient = groupCommentsByClient(commentRows, entityMap);
const summaries: ConversationSummary[] = [];
for (const [clientId, msgs] of byClient) {
const meta = clientMeta.get(clientId);
if (!meta || msgs.length === 0) continue;
const last = msgs[msgs.length - 1];
const lastReadAt = meta.admin_last_read_at?.getTime() ?? 0;
const clientMsgsAfterRead = msgs.filter(
(m) => m.author === "client" && m.created_at.getTime() > lastReadAt
);
summaries.push({
clientId,
name: meta.name,
brand_name: meta.brand_name,
token: meta.token,
slug: meta.slug,
lastMessage: last.body,
lastMessageAuthor: last.author as "client" | "admin",
lastMessageAt: last.created_at,
lastEntityLabel: entityMap.get(last.entity_id)?.label ?? "Generale",
unread: clientMsgsAfterRead.length > 0,
unreadCount: clientMsgsAfterRead.length,
});
}
summaries.sort((a, b) => b.lastMessageAt.getTime() - a.lastMessageAt.getTime());
return summaries;
}
/** Full ordered thread for a single client, with per-message entity labels. */
export async function getConversationThread(
clientId: string
): Promise<ConversationThread | null> {
const { clientMeta, entityMap, allEntityIds } = await buildEntityMap();
const meta = clientMeta.get(clientId);
if (!meta) return null;
// Restrict to entity ids owned by this client.
const ownEntityIds = allEntityIds.filter(
(id) => entityMap.get(id)?.clientId === clientId
);
const commentRows = await fetchComments(ownEntityIds);
const messages: ThreadMessage[] = commentRows.map((c) => {
const info = entityMap.get(c.entity_id);
return {
id: c.id,
author: c.author as "client" | "admin",
body: c.body,
created_at: c.created_at,
entityLabel: info?.label ?? "Generale",
entityType: info?.type ?? "general",
};
});
return {
clientId: meta.id,
name: meta.name,
brand_name: meta.brand_name,
token: meta.token,
slug: meta.slug,
messages,
};
}
/** Number of clients whose conversation has unread client messages (sidebar badge). */
export async function getUnreadConversationsCount(): Promise<number> {
const { clientMeta, entityMap, allEntityIds } = await buildEntityMap();
const commentRows = await fetchComments(allEntityIds);
const byClient = groupCommentsByClient(commentRows, entityMap);
let count = 0;
for (const [clientId, msgs] of byClient) {
const meta = clientMeta.get(clientId);
if (!meta) continue;
const lastReadAt = meta.admin_last_read_at?.getTime() ?? 0;
const hasUnread = msgs.some(
(m) => m.author === "client" && m.created_at.getTime() > lastReadAt
);
if (hasUnread) count++;
}
return count;
}