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:
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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" });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user