feat: offer badges, unified dashboard, income forecast, Pipedrive-style follow-up

- Client detail: category + tier badges on active offers
- Dashboard: remove top KPI cards + recent activity; fold current KPIs into
  bottom MetricCard style; add 12-month income forecast chart
- Forecast query: branch by offer_type (retainer = monthly fee, una_tantum =
  spread over duration), filter archived projects
- Payments: set the month a payment was collected (paid_at) from PaymentsTab
- Redesign FollowUpWidget in clean Pipedrive style (brand green, no orange)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 22:28:12 +02:00
parent fc766ca1ee
commit ae355c33a6
9 changed files with 296 additions and 245 deletions
@@ -1,46 +1,96 @@
import { getLeadsNeedingFollowUp } from "@/lib/lead-service";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { AlertCircle } from "lucide-react";
import Link from "next/link";
const MAX_ROWS = 4;
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function relativeDays(date: Date | string | null | undefined): string | null {
if (!date) return "Mai contattato";
const d = date instanceof Date ? date : new Date(date);
const diffMs = Date.now() - d.getTime();
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (days <= 0) return "Contattato oggi";
if (days === 1) return "Contattato ieri";
return `Contattato ${days} giorni fa`;
}
export async function FollowUpWidget() {
const leadsNeedingFollowUp = await getLeadsNeedingFollowUp(7);
const leads = await getLeadsNeedingFollowUp(7);
if (leads.length === 0) {
return (
<div className="bg-white rounded-xl border border-[#e5e7eb] px-5 py-4">
<div className="flex items-center gap-2">
<h2 className="text-sm font-bold text-[#1a1a1a]">Follow-up</h2>
</div>
<p className="text-sm text-[#71717a] mt-2">Tutti i lead sono aggiornati </p>
</div>
);
}
const visible = leads.slice(0, MAX_ROWS);
return (
<Card className="border-orange-200 bg-orange-50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-orange-600" />
Richiedi Follow-up
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{leadsNeedingFollowUp.length > 0 ? (
<>
<p className="text-lg font-bold text-orange-900">
{leadsNeedingFollowUp.length} lead da contattare
</p>
<ul className="space-y-2 text-sm">
{leadsNeedingFollowUp.slice(0, 3).map((lead) => (
<li key={lead.id} className="flex justify-between items-center">
<span>{lead.name}</span>
<Button variant="ghost" size="sm" asChild>
<Link href={`/admin/leads/${lead.id}`}>Contatta</Link>
</Button>
</li>
))}
</ul>
{leadsNeedingFollowUp.length > 3 && (
<Button variant="outline" className="w-full" asChild>
<Link href="/admin/leads">Vedi Tutto ({leadsNeedingFollowUp.length})</Link>
</Button>
)}
</>
) : (
<p className="text-gray-600 text-sm">Tutti i lead sono aggiornati!</p>
)}
</CardContent>
</Card>
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[#f4f4f5]">
<div className="flex items-center gap-2.5">
<h2 className="text-sm font-bold text-[#1a1a1a]">Follow-up</h2>
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-[#1A463C] text-white text-[11px] font-bold">
{leads.length}
</span>
</div>
<Link
href="/admin/leads"
className="text-xs font-medium text-[#71717a] hover:text-[#1A463C] transition-colors"
>
Vedi tutti
</Link>
</div>
{/* Lead rows */}
<ul className="divide-y divide-[#f4f4f5]">
{visible.map((lead) => (
<li key={lead.id}>
<Link
href={`/admin/leads/${lead.id}`}
className="flex items-center gap-3 px-5 py-3 hover:bg-[#f9f9f9] transition-colors group"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#1A463C]/10 text-[#1A463C] text-xs font-bold">
{initials(lead.name)}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[#1a1a1a] truncate">{lead.name}</p>
<p className="text-xs text-[#71717a] truncate">
{lead.next_action
? lead.next_action
: [lead.company, relativeDays(lead.last_contact_date)]
.filter(Boolean)
.join(" · ")}
</p>
</div>
<span className="text-xs font-medium text-[#71717a] group-hover:text-[#1A463C] transition-colors shrink-0">
Contatta
</span>
</Link>
</li>
))}
</ul>
{leads.length > MAX_ROWS && (
<Link
href="/admin/leads"
className="block px-5 py-2.5 text-center text-xs font-medium text-[#71717a] hover:text-[#1A463C] border-t border-[#f4f4f5] transition-colors"
>
Vedi tutti i {leads.length} lead
</Link>
)}
</div>
);
}