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