From ae355c33a60a5e4d94fb18de37c8aca0882395a9 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Thu, 25 Jun 2026 22:28:12 +0200 Subject: [PATCH] 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 --- src/app/admin/clients/[id]/actions.ts | 19 +++ src/app/admin/clients/[id]/page.tsx | 45 ++++-- src/app/admin/page.tsx | 135 +++++------------- src/components/admin/ForecastChart.tsx | 61 ++++++++ .../admin/dashboard/FollowUpWidget.tsx | 126 +++++++++++----- src/components/admin/tabs/PaymentsTab.tsx | 37 +++++ src/lib/admin-queries.ts | 10 ++ src/lib/dashboard-queries.ts | 89 +----------- src/lib/forecast-queries.ts | 19 ++- 9 files changed, 296 insertions(+), 245 deletions(-) create mode 100644 src/components/admin/ForecastChart.tsx diff --git a/src/app/admin/clients/[id]/actions.ts b/src/app/admin/clients/[id]/actions.ts index a5b8229..24bdece 100644 --- a/src/app/admin/clients/[id]/actions.ts +++ b/src/app/admin/clients/[id]/actions.ts @@ -289,6 +289,25 @@ export async function updatePaymentStatus(paymentId: string, id: string, status: revalidatePath(path); } +// Imposta il mese in cui un pagamento è stato incassato (formato "YYYY-MM"). +// Mappa al primo giorno del mese (mezzogiorno UTC per evitare drift di fuso) e +// porta lo stato a "saldato" così l'incasso viene attribuito a quel mese nelle analytics. +export async function setPaymentPaidAt(paymentId: string, id: string, monthStr: string) { + await requireAdmin(); + const m = /^(\d{4})-(\d{2})$/.exec(monthStr); + if (!m) throw new Error("Mese non valido"); + const year = parseInt(m[1], 10); + const month = parseInt(m[2], 10); + if (month < 1 || month > 12) throw new Error("Mese non valido"); + const paid_at = new Date(Date.UTC(year, month - 1, 1, 12, 0, 0)); + await db + .update(payments) + .set({ paid_at, status: "saldato" }) + .where(eq(payments.id, paymentId)); + const { path } = await resolveEntity(id); + revalidatePath(path); +} + // Rescales payment amounts when the total changes. // If the payment has a `percent` field, use it (new plan rows). // Legacy rows (percent null) fall back to equal split across all rows. diff --git a/src/app/admin/clients/[id]/page.tsx b/src/app/admin/clients/[id]/page.tsx index b3f8794..c69b636 100644 --- a/src/app/admin/clients/[id]/page.tsx +++ b/src/app/admin/clients/[id]/page.tsx @@ -130,19 +130,40 @@ export default async function ClientDetailPage({ Offerte Attive ({activeOffers.length})

- {activeOffers.map((offer) => ( -
-
-

{offer.public_name}

-

{offer.project_name}

+ {activeOffers.map((offer) => { + const categoryLabel = + offer.category ?? + (offer.offer_type === "retainer" + ? "Retainer" + : offer.offer_type === "una_tantum" + ? "Una tantum" + : null); + return ( +
+
+
+

{offer.public_name}

+ {categoryLabel && ( + + {categoryLabel} + + )} + {offer.tier_letter && ( + + Tier {offer.tier_letter} + + )} +
+

{offer.project_name}

+
+ {offer.accepted_total && ( + + €{parseFloat(offer.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })} + + )}
- {offer.accepted_total && ( - - €{parseFloat(offer.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })} - - )} -
- ))} + ); + })}
)} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index dfe4b41..b7f225e 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -8,38 +8,12 @@ import { getTimeByClient, getTotalTrackedHours, } from "@/lib/analytics-queries"; +import { getRevenueForecast12Months } from "@/lib/forecast-queries"; import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector"; -import { Users, FolderOpen, Euro, Clock } from "lucide-react"; +import { ForecastChart } from "@/components/admin/ForecastChart"; export const revalidate = 0; -function KpiCard({ - label, - value, - sub, - icon: Icon, - color, -}: { - label: string; - value: string | number; - sub?: string; - icon: React.ElementType; - color: string; -}) { - return ( -
-
- -
-
-

{label}

-

{value}

- {sub &&

{sub}

} -
-
- ); -} - function MetricCard({ label, value, @@ -78,22 +52,6 @@ function MetricCard({ ); } -const ACTIVITY_ICONS: Record = { - nuovo_cliente: "πŸ‘€", - nuovo_progetto: "πŸ“", - deliverable_approvato: "βœ…", - timer_stoppato: "⏱", -}; - -function fmt(ts: Date) { - return new Intl.DateTimeFormat("it-IT", { - day: "2-digit", - month: "short", - hour: "2-digit", - minute: "2-digit", - }).format(new Date(ts)); -} - function fmtEur(n: number) { return n.toLocaleString("it-IT", { style: "currency", @@ -117,14 +75,15 @@ export default async function AdminDashboard({ const { year: yearParam } = await searchParams; const year = parseInt(yearParam ?? "") || new Date().getFullYear(); - const { kpi, activity } = await getDashboardStats(); + const { kpi } = await getDashboardStats(); - const [data, monthly, availableYears, timeByClient, totalHours] = await Promise.all([ + const [data, monthly, availableYears, timeByClient, totalHours, forecast] = await Promise.all([ getAnalyticsByYear(year), getMonthlyCollected(year), getAvailableYears(), getTimeByClient(year), getTotalTrackedHours(year), + getRevenueForecast12Months(), ]); const collectedPct = @@ -143,63 +102,8 @@ export default async function AdminDashboard({ - {/* KPI cards */} -
- - - - -
- - {/* Activity feed */} -
-
-

AttivitΓ  recente

-
- {activity.length === 0 ? ( -

Nessuna attivitΓ  recente

- ) : ( -
    - {activity.map((item, i) => ( -
  • -
    - {ACTIVITY_ICONS[item.type]} -
    -

    {item.label}

    -

    {item.detail}

    -
    -
    - {fmt(item.timestamp)} -
  • - ))} -
- )} -
- - {/* ── SEZIONE STATISTICHE ANNUALI ── */} -
+ {/* ── SEZIONE STATISTICHE ── */} +

Statistiche

@@ -208,6 +112,31 @@ export default async function AdminDashboard({
+ {/* Panoramica corrente (non per-anno) */} +
+

Panoramica

+
+ + +
+
+ + {/* Previsione incassi */} +
+

+ Previsione incassi (12 mesi) +

+ +

+ Stima dalle offerte attive: i retainer contribuiscono il canone mensile, le una-tantum + ripartite sulla durata. +

+
+ {/* Sezione economica */}

Fatturato

diff --git a/src/components/admin/ForecastChart.tsx b/src/components/admin/ForecastChart.tsx new file mode 100644 index 0000000..75683ec --- /dev/null +++ b/src/components/admin/ForecastChart.tsx @@ -0,0 +1,61 @@ +import type { ForecastMonth } from "@/lib/forecast-queries"; + +function fmtEur(n: number) { + return n.toLocaleString("it-IT", { + style: "currency", + currency: "EUR", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }); +} + +export function ForecastChart({ data }: { data: ForecastMonth[] }) { + const max = Math.max(...data.map((d) => d.total), 1); + // index 0 = mese corrente, index 1 = mese prossimo (evidenziato) + const nextMonth = data[1]; + + return ( +
+ {nextMonth && ( +
+
+

+ Previsto il mese prossimo +

+

{nextMonth.label}

+
+

+ {fmtEur(nextMonth.total)} +

+
+ )} + +
+ {data.map((m, i) => { + const pct = Math.round((m.total / max) * 100); + const isNext = i === 1; + return ( +
+
+
0 ? "4px" : "0" }} + title={`${m.label}: ${m.total.toLocaleString("it-IT", { style: "currency", currency: "EUR", minimumFractionDigits: 2 })}`} + /> +
+ + {m.label.split(" ")[0]} + +
+ ); + })} +
+
+ ); +} diff --git a/src/components/admin/dashboard/FollowUpWidget.tsx b/src/components/admin/dashboard/FollowUpWidget.tsx index 24722f5..4b8b9d7 100644 --- a/src/components/admin/dashboard/FollowUpWidget.tsx +++ b/src/components/admin/dashboard/FollowUpWidget.tsx @@ -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 ( +
+
+

Follow-up

+
+

Tutti i lead sono aggiornati βœ“

+
+ ); + } + + const visible = leads.slice(0, MAX_ROWS); return ( - - - - - Richiedi Follow-up - - - - {leadsNeedingFollowUp.length > 0 ? ( - <> -

- {leadsNeedingFollowUp.length} lead da contattare -

-
    - {leadsNeedingFollowUp.slice(0, 3).map((lead) => ( -
  • - {lead.name} - -
  • - ))} -
- {leadsNeedingFollowUp.length > 3 && ( - - )} - - ) : ( -

Tutti i lead sono aggiornati!

- )} -
-
+
+ {/* Header */} +
+
+

Follow-up

+ + {leads.length} + +
+ + Vedi tutti β†’ + +
+ + {/* Lead rows */} +
    + {visible.map((lead) => ( +
  • + + + {initials(lead.name)} + +
    +

    {lead.name}

    +

    + {lead.next_action + ? lead.next_action + : [lead.company, relativeDays(lead.last_contact_date)] + .filter(Boolean) + .join(" Β· ")} +

    +
    + + Contatta β†’ + + +
  • + ))} +
+ + {leads.length > MAX_ROWS && ( + + Vedi tutti i {leads.length} lead + + )} +
); } diff --git a/src/components/admin/tabs/PaymentsTab.tsx b/src/components/admin/tabs/PaymentsTab.tsx index a9fb0f2..e5d7bf0 100644 --- a/src/components/admin/tabs/PaymentsTab.tsx +++ b/src/components/admin/tabs/PaymentsTab.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { updatePaymentStatus, updateAcceptedTotal, + setPaymentPaidAt, } from "@/app/admin/clients/[id]/actions"; import { setPaymentPlan } from "@/app/admin/projects/project-actions"; import { Button } from "@/components/ui/button"; @@ -27,6 +28,16 @@ const statusLabels: Record = { saldato: "Saldato", }; +// paid_at (Date | string | null, serializzato sul confine RSC) β†’ "YYYY-MM" per +function toMonthValue(paidAt: Date | string | null | undefined): string { + if (!paidAt) { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; + } + const d = paidAt instanceof Date ? paidAt : new Date(paidAt); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} + type PlanMode = "single" | "two" | "three"; const planOptions: { mode: PlanMode; label: string; description: string }[] = [ @@ -90,6 +101,17 @@ export function PaymentsTab({ } } + async function handlePaidMonthUpdate(paymentId: string, monthStr: string) { + if (!monthStr) return; + setStatusLoading(paymentId); + try { + await setPaymentPaidAt(paymentId, clientId, monthStr); + router.refresh(); + } finally { + setStatusLoading(null); + } + } + return (
{/* Totale preventivo */} @@ -240,6 +262,21 @@ export function PaymentsTab({ ... )}
+ {p.status === "saldato" && ( +
+ + handlePaidMonthUpdate(p.id, e.target.value)} + className="text-sm border border-gray-200 rounded px-2 py-1 bg-white" + /> +
+ )}
); })} diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts index 9a92edd..7e78a82 100644 --- a/src/lib/admin-queries.ts +++ b/src/lib/admin-queries.ts @@ -836,6 +836,9 @@ export type ClientActiveOfferSummary = { project_id: string; project_name: string; public_name: string; // offer_micros.public_name β€” NEVER internal_name + tier_letter: string | null; // A | B | C + category: string | null; // Entry | Signature | Retainer (offer_macros.category) + offer_type: string; // una_tantum | retainer (fallback for category) accepted_total: string | null; }; @@ -856,10 +859,14 @@ export async function getClientActiveOffers(clientId: string): Promise { @@ -59,80 +51,5 @@ export async function getDashboardStats(): Promise { progettiInCorso: progettiRow?.count ?? 0, }; - // ── Activity feed ───────────────────────────────────────────────────────────── - // Fetch recent events from multiple tables, merge and sort - const [recentClients, recentProjects, recentApprovals, recentTimers] = await Promise.all([ - db - .select({ id: clients.id, name: clients.name, created_at: clients.created_at }) - .from(clients) - .orderBy(desc(clients.created_at)) - .limit(5), - - db - .select({ id: projects.id, name: projects.name, created_at: projects.created_at }) - .from(projects) - .orderBy(desc(projects.created_at)) - .limit(5), - - db - .select({ id: deliverables.id, title: deliverables.title, approved_at: deliverables.approved_at }) - .from(deliverables) - .where(isNotNull(deliverables.approved_at)) - .orderBy(desc(deliverables.approved_at)) - .limit(5), - - db - .select({ - id: time_entries.id, - ended_at: time_entries.ended_at, - duration_seconds: time_entries.duration_seconds, - }) - .from(time_entries) - .where( - and( - isNotNull(time_entries.ended_at), - isNotNull(time_entries.duration_seconds) - ) - ) - .orderBy(desc(time_entries.ended_at)) - .limit(5), - ]); - - const activity: ActivityItem[] = [ - ...recentClients.map((c) => ({ - type: "nuovo_cliente" as const, - label: "Nuovo cliente", - detail: c.name, - timestamp: c.created_at, - })), - ...recentProjects.map((p) => ({ - type: "nuovo_progetto" as const, - label: "Nuovo progetto", - detail: p.name, - timestamp: p.created_at, - })), - ...recentApprovals - .filter((d): d is typeof d & { approved_at: Date } => d.approved_at !== null) - .map((d) => ({ - type: "deliverable_approvato" as const, - label: "Deliverable approvato", - detail: d.title, - timestamp: d.approved_at, - })), - ...recentTimers - .filter( - (t): t is typeof t & { ended_at: Date; duration_seconds: number } => - t.ended_at !== null && t.duration_seconds !== null - ) - .map((t) => ({ - type: "timer_stoppato" as const, - label: "Timer stoppato", - detail: `${Math.round(t.duration_seconds / 60)} min`, - timestamp: t.ended_at, - })), - ] - .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()) - .slice(0, 10); - - return { kpi, activity }; + return { kpi }; } diff --git a/src/lib/forecast-queries.ts b/src/lib/forecast-queries.ts index 22d8fad..ff0bccc 100644 --- a/src/lib/forecast-queries.ts +++ b/src/lib/forecast-queries.ts @@ -1,5 +1,5 @@ import { db } from "@/db"; -import { project_offers, offer_micros } from "@/db/schema"; +import { project_offers, offer_micros, offer_macros, projects } from "@/db/schema"; import { eq } from "drizzle-orm"; export type ForecastMonth = { @@ -10,15 +10,21 @@ export type ForecastMonth = { }; export async function getRevenueForecast12Months(): Promise { - // Load all project offers with micro duration info + // Active offers only (non-archived projects). offer_type drives the math: + // - retainer: accepted_total Γ¨ il CANONE MENSILE β†’ ogni mese coperto riceve l'intero importo + // - una_tantum: accepted_total Γ¨ il prezzo TOTALE β†’ spalmato su duration_months const offers = await db .select({ start_date: project_offers.start_date, duration_months: offer_micros.duration_months, accepted_total: project_offers.accepted_total, + offer_type: offer_macros.offer_type, }) .from(project_offers) - .innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id)); + .innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id)) + .innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id)) + .innerJoin(projects, eq(project_offers.project_id, projects.id)) + .where(eq(projects.archived, false)); // Build 12-month bucket array starting from current month const now = new Date(); @@ -33,15 +39,16 @@ export async function getRevenueForecast12Months(): Promise { }); for (const offer of offers) { - // Skip offers with no accepted_total β€” they contribute 0 to forecast if (!offer.accepted_total) continue; const total = parseFloat(String(offer.accepted_total)); if (isNaN(total) || total <= 0) continue; - const perMonth = total / offer.duration_months; + const months = offer.duration_months > 0 ? offer.duration_months : 1; + // Retainer: canone mensile pieno; una_tantum: prezzo totale ripartito sui mesi. + const perMonth = offer.offer_type === "retainer" ? total : total / months; const start = new Date(offer.start_date); - for (let m = 0; m < offer.duration_months; m++) { + for (let m = 0; m < months; m++) { const offerMonth = new Date(start.getFullYear(), start.getMonth() + m, 1); const bucket = buckets.find( (b) => b.year === offerMonth.getFullYear() && b.month === offerMonth.getMonth() + 1