feat(dashboard): l'inbox sale in cima e dice da quanto aspettano

Il widget dei messaggi esisteva gia', ma era il terzo riquadro della colonna
stretta: sotto la piega, cioe' invisibile. Un messaggio non letto e' la cosa
piu' urgente della giornata e ora sta in testa alla dashboard, a piena
larghezza, due colonne.

Due dati erano gia' in ConversationSummary e non venivano mostrati: da quanto
aspetta il messaggio e su cosa e' stato scritto (fase, task, deliverable o
generale). Sono esattamente i due che dicono con che fretta rispondere. Il
tempo relativo si ferma alla settimana e poi passa alla data: oltre, "23 giorni
fa" non aiuta piu' nessuno.

La fascia sparisce del tutto quando non c'e' niente da leggere, invece di
lasciare a video un riquadro vuoto che si impara a saltare — e con lui si
imparerebbe a saltare anche quello pieno.

relativeTime va in src/lib/dates.ts perche' serve anche altrove. FollowUpWidget
ha ancora la sua copia locale con il prefisso "Contattato": la si unifica
quando la si tocca, non oggi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 22:42:52 +02:00
parent d6e95ef66a
commit 1115bb2265
4 changed files with 129 additions and 71 deletions
+7 -6
View File
@@ -1,7 +1,7 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { getDashboardStats } from "@/lib/dashboard-queries"; import { getDashboardStats } from "@/lib/dashboard-queries";
import { FollowUpWidget } from "@/components/admin/dashboard/FollowUpWidget"; import { FollowUpWidget } from "@/components/admin/dashboard/FollowUpWidget";
import { MessagesWidget } from "@/components/admin/dashboard/MessagesWidget"; import { InboxBand } from "@/components/admin/dashboard/InboxBand";
import { import {
getAnalyticsByYear, getAnalyticsByYear,
getTotalTrackedHours, getTotalTrackedHours,
@@ -39,6 +39,12 @@ export default async function AdminDashboard({
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
{/* Messaggi in attesa — in cima perché è la cosa più urgente della
giornata. Non renderizza nulla quando non c'è niente da leggere. */}
<Suspense fallback={null}>
<InboxBand />
</Suspense>
{/* KPI strip */} {/* KPI strip */}
<section className="grid grid-cols-2 lg:grid-cols-4 gap-6"> <section className="grid grid-cols-2 lg:grid-cols-4 gap-6">
<MetricCard <MetricCard
@@ -76,11 +82,6 @@ export default async function AdminDashboard({
> >
<FollowUpWidget /> <FollowUpWidget />
</Suspense> </Suspense>
<Suspense
fallback={<div className="animate-pulse h-40 bg-muted rounded-xl" />}
>
<MessagesWidget />
</Suspense>
<OffersSoldChart data={offersSold} /> <OffersSoldChart data={offersSold} />
</div> </div>
</div> </div>
@@ -0,0 +1,93 @@
import { getConversations } from "@/lib/conversations-queries";
import { relativeTime } from "@/lib/dates";
import Link from "next/link";
const MAX_ROWS = 6;
/**
* I messaggi che aspettano una risposta, in cima alla dashboard.
*
* Prima era un riquadro stretto in fondo alla terza colonna: c'era, ma sotto la
* piega. Un messaggio non letto è la cosa più urgente della giornata e va vista
* per prima — per questo occupa tutta la larghezza, e per questo sparisce del
* tutto quando non c'è niente da leggere invece di lasciare a video una fascia
* vuota che si impara a saltare.
*
* Si legge qui, si risponde in /admin/conversazioni.
*/
export async function InboxBand() {
const conversations = await getConversations();
const unread = conversations.filter((c) => c.unread);
if (unread.length === 0) return null;
const visible = unread.slice(0, MAX_ROWS);
const totalMessages = unread.reduce((sum, c) => sum + c.unreadCount, 0);
return (
<section className="bg-card rounded-xl border border-border-light shadow-card p-5">
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Da rispondere
</h2>
<span className="text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-100 px-2 py-0.5 rounded-full dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20">
{totalMessages} {totalMessages === 1 ? "messaggio" : "messaggi"} ·{" "}
{unread.length} {unread.length === 1 ? "cliente" : "clienti"}
</span>
</div>
<Link
href="/admin/conversazioni"
className="text-xs font-medium text-muted-foreground hover:text-primary transition-colors"
>
Apri conversazioni
</Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{visible.map((conv) => (
<Link
key={conv.clientId}
href={`/admin/conversazioni?c=${conv.clientId}`}
className="flex items-start justify-between gap-3 p-3 border border-border-light rounded-lg hover:border-border transition-colors group"
>
<div className="min-w-0">
<div className="flex items-baseline gap-2 flex-wrap">
<h3 className="text-xs font-bold text-foreground truncate">
{conv.brand_name}
</h3>
<span className="text-[10px] text-muted-foreground font-mono tabular-nums shrink-0">
{relativeTime(conv.lastMessageAt)}
</span>
{conv.unreadCount > 1 && (
<span className="text-[10px] font-bold text-emerald-700 dark:text-emerald-400 shrink-0">
{conv.unreadCount} nuovi
</span>
)}
</div>
<p className="text-[11px] text-muted-foreground mt-1 line-clamp-2">
{conv.lastMessage}
</p>
<p className="text-[10px] text-muted-foreground/70 mt-1 truncate">
{conv.lastEntityLabel}
</p>
</div>
<span className="text-[11px] font-bold text-primary group-hover:opacity-70 transition-opacity shrink-0 mt-0.5">
Rispondi
</span>
</Link>
))}
</div>
{unread.length > MAX_ROWS && (
<Link
href="/admin/conversazioni"
className="block mt-3 pt-3 text-center text-xs font-medium text-muted-foreground hover:text-primary border-t border-border-light transition-colors"
>
Altri {unread.length - MAX_ROWS} clienti in attesa
</Link>
)}
</section>
);
}
@@ -1,65 +0,0 @@
import { getConversations } from "@/lib/conversations-queries";
import Link from "next/link";
const MAX_ROWS = 4;
export async function MessagesWidget() {
const conversations = await getConversations();
const unread = conversations.filter((c) => c.unread);
if (unread.length === 0) {
return (
<div className="bg-card rounded-xl border border-border-light shadow-card p-5">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Messaggi Clienti
</h3>
<p className="text-sm text-muted-foreground mt-3">Nessun messaggio in attesa </p>
</div>
);
}
const visible = unread.slice(0, MAX_ROWS);
return (
<div className="bg-card rounded-xl border border-border-light shadow-card p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Messaggi Clienti
</h3>
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
</div>
<span className="text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-100 px-2 py-0.5 rounded-full dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20">
{unread.length} {unread.length === 1 ? "Nuovo" : "Nuovi"}
</span>
</div>
<div className="space-y-3">
{visible.map((conv) => (
<Link
key={conv.clientId}
href={`/admin/conversazioni?c=${conv.clientId}`}
className="flex items-center justify-between gap-3 p-3 border border-border-light rounded-lg hover:border-border transition-colors group"
>
<div className="min-w-0">
<h4 className="text-xs font-bold text-foreground truncate">{conv.brand_name}</h4>
<p className="text-[10px] text-muted-foreground truncate">{conv.lastMessage}</p>
</div>
<span className="text-[11px] font-bold text-primary group-hover:opacity-70 transition-opacity shrink-0 inline-flex items-center gap-0.5">
Rispondi
</span>
</Link>
))}
</div>
{unread.length > MAX_ROWS && (
<Link
href="/admin/conversazioni"
className="block mt-3 pt-3 text-center text-xs font-medium text-muted-foreground hover:text-primary border-t border-border-light transition-colors"
>
Vedi tutti i messaggi
</Link>
)}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Distanza da adesso, in italiano e senza prefissi: "adesso", "12 min fa",
* "3 ore fa", "ieri", "5 giorni fa", "12 mar".
*
* Sui messaggi la scala che conta è quella delle ore: sapere che un cliente
* scrive da due ore o da due giorni cambia con che fretta gli si risponde, e
* una data assoluta lo direbbe soltanto dopo un calcolo mentale.
* Oltre la settimana la distanza smette di essere utile e si passa alla data.
*/
export function relativeTime(date: Date | string | null | undefined): string {
if (!date) return "—";
const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return "—";
const seconds = Math.floor((Date.now() - d.getTime()) / 1000);
if (seconds < 60) return "adesso";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} min fa`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} ${hours === 1 ? "ora" : "ore"} fa`;
const days = Math.floor(hours / 24);
if (days === 1) return "ieri";
if (days < 7) return `${days} giorni fa`;
return d.toLocaleDateString("it-IT", { day: "numeric", month: "short" });
}