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:
@@ -289,6 +289,25 @@ export async function updatePaymentStatus(paymentId: string, id: string, status:
|
|||||||
revalidatePath(path);
|
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.
|
// Rescales payment amounts when the total changes.
|
||||||
// If the payment has a `percent` field, use it (new plan rows).
|
// If the payment has a `percent` field, use it (new plan rows).
|
||||||
// Legacy rows (percent null) fall back to equal split across all rows.
|
// Legacy rows (percent null) fall back to equal split across all rows.
|
||||||
|
|||||||
@@ -130,19 +130,40 @@ export default async function ClientDetailPage({
|
|||||||
Offerte Attive ({activeOffers.length})
|
Offerte Attive ({activeOffers.length})
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-white rounded-xl border border-[#e5e7eb] divide-y divide-[#e5e7eb]">
|
<div className="bg-white rounded-xl border border-[#e5e7eb] divide-y divide-[#e5e7eb]">
|
||||||
{activeOffers.map((offer) => (
|
{activeOffers.map((offer) => {
|
||||||
<div key={offer.offer_id} className="flex items-center justify-between px-4 py-3">
|
const categoryLabel =
|
||||||
<div>
|
offer.category ??
|
||||||
<p className="text-sm font-medium text-[#1a1a1a]">{offer.public_name}</p>
|
(offer.offer_type === "retainer"
|
||||||
<p className="text-xs text-[#71717a]">{offer.project_name}</p>
|
? "Retainer"
|
||||||
|
: offer.offer_type === "una_tantum"
|
||||||
|
? "Una tantum"
|
||||||
|
: null);
|
||||||
|
return (
|
||||||
|
<div key={offer.offer_id} className="flex items-center justify-between px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm font-medium text-[#1a1a1a]">{offer.public_name}</p>
|
||||||
|
{categoryLabel && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-[#1A463C]/10 px-2 py-0.5 text-[11px] font-semibold text-[#1A463C]">
|
||||||
|
{categoryLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{offer.tier_letter && (
|
||||||
|
<span className="inline-flex items-center rounded-full border border-[#e5e7eb] px-2 py-0.5 text-[11px] font-semibold text-[#71717a]">
|
||||||
|
Tier {offer.tier_letter}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[#71717a] mt-0.5">{offer.project_name}</p>
|
||||||
|
</div>
|
||||||
|
{offer.accepted_total && (
|
||||||
|
<span className="text-sm font-mono text-[#1a1a1a] shrink-0">
|
||||||
|
€{parseFloat(offer.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{offer.accepted_total && (
|
);
|
||||||
<span className="text-sm font-mono text-[#1a1a1a]">
|
})}
|
||||||
€{parseFloat(offer.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+32
-103
@@ -8,38 +8,12 @@ import {
|
|||||||
getTimeByClient,
|
getTimeByClient,
|
||||||
getTotalTrackedHours,
|
getTotalTrackedHours,
|
||||||
} from "@/lib/analytics-queries";
|
} from "@/lib/analytics-queries";
|
||||||
|
import { getRevenueForecast12Months } from "@/lib/forecast-queries";
|
||||||
import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
|
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;
|
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 (
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-5 flex items-start gap-4">
|
|
||||||
<div className={`p-2 rounded-lg ${color}`}>
|
|
||||||
<Icon size={20} strokeWidth={1.8} className="text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-gray-500 font-medium uppercase tracking-wide">{label}</p>
|
|
||||||
<p className="text-2xl font-bold text-gray-900 mt-0.5">{value}</p>
|
|
||||||
{sub && <p className="text-xs text-gray-400 mt-0.5">{sub}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricCard({
|
function MetricCard({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -78,22 +52,6 @@ function MetricCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTIVITY_ICONS: Record<string, string> = {
|
|
||||||
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) {
|
function fmtEur(n: number) {
|
||||||
return n.toLocaleString("it-IT", {
|
return n.toLocaleString("it-IT", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
@@ -117,14 +75,15 @@ export default async function AdminDashboard({
|
|||||||
const { year: yearParam } = await searchParams;
|
const { year: yearParam } = await searchParams;
|
||||||
const year = parseInt(yearParam ?? "") || new Date().getFullYear();
|
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),
|
getAnalyticsByYear(year),
|
||||||
getMonthlyCollected(year),
|
getMonthlyCollected(year),
|
||||||
getAvailableYears(),
|
getAvailableYears(),
|
||||||
getTimeByClient(year),
|
getTimeByClient(year),
|
||||||
getTotalTrackedHours(year),
|
getTotalTrackedHours(year),
|
||||||
|
getRevenueForecast12Months(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const collectedPct =
|
const collectedPct =
|
||||||
@@ -143,63 +102,8 @@ export default async function AdminDashboard({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI cards */}
|
{/* ── SEZIONE STATISTICHE ── */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
<div className="space-y-10">
|
||||||
<KpiCard
|
|
||||||
label="Clienti attivi"
|
|
||||||
value={kpi.clientiAttivi}
|
|
||||||
icon={Users}
|
|
||||||
color="bg-[#1A463C]"
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Revenue totale"
|
|
||||||
value={fmtEur(parseFloat(kpi.revenueTotale))}
|
|
||||||
sub="progetti non archiviati"
|
|
||||||
icon={Euro}
|
|
||||||
color="bg-emerald-600"
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Progetti in corso"
|
|
||||||
value={kpi.progettiInCorso}
|
|
||||||
icon={FolderOpen}
|
|
||||||
color="bg-blue-600"
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Pagamenti in sospeso"
|
|
||||||
value={fmtEur(parseFloat(kpi.pagamentiInSospeso))}
|
|
||||||
sub="da_saldare + inviata"
|
|
||||||
icon={Clock}
|
|
||||||
color="bg-amber-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Activity feed */}
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200">
|
|
||||||
<div className="px-5 py-4 border-b border-gray-100">
|
|
||||||
<h2 className="text-sm font-semibold text-gray-700">Attività recente</h2>
|
|
||||||
</div>
|
|
||||||
{activity.length === 0 ? (
|
|
||||||
<p className="px-5 py-8 text-sm text-gray-400 text-center">Nessuna attività recente</p>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-gray-50">
|
|
||||||
{activity.map((item, i) => (
|
|
||||||
<li key={i} className="px-5 py-3 flex items-center justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-base">{ACTIVITY_ICONS[item.type]}</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-800">{item.label}</p>
|
|
||||||
<p className="text-xs text-gray-500">{item.detail}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-400 shrink-0">{fmt(item.timestamp)}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── SEZIONE STATISTICHE ANNUALI ── */}
|
|
||||||
<div className="mt-10 space-y-10">
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-gray-900">Statistiche</h2>
|
<h2 className="text-xl font-bold text-gray-900">Statistiche</h2>
|
||||||
@@ -208,6 +112,31 @@ export default async function AdminDashboard({
|
|||||||
<YearSelector currentYear={year} availableYears={availableYears} />
|
<YearSelector currentYear={year} availableYears={availableYears} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Panoramica corrente (non per-anno) */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Panoramica</h3>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<MetricCard label="Clienti attivi" value={String(kpi.clientiAttivi)} sub="Totale corrente" />
|
||||||
|
<MetricCard
|
||||||
|
label="Progetti in corso"
|
||||||
|
value={String(kpi.progettiInCorso)}
|
||||||
|
sub="Non archiviati"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Previsione incassi */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">
|
||||||
|
Previsione incassi (12 mesi)
|
||||||
|
</h3>
|
||||||
|
<ForecastChart data={forecast} />
|
||||||
|
<p className="text-xs text-[#a1a1aa] italic">
|
||||||
|
Stima dalle offerte attive: i retainer contribuiscono il canone mensile, le una-tantum
|
||||||
|
ripartite sulla durata.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sezione economica */}
|
{/* Sezione economica */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Fatturato</h3>
|
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Fatturato</h3>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 space-y-5">
|
||||||
|
{nextMonth && (
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider text-[#71717a]">
|
||||||
|
Previsto il mese prossimo
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#a1a1aa] mt-0.5 capitalize">{nextMonth.label}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold tracking-tight text-[#1A463C]">
|
||||||
|
{fmtEur(nextMonth.total)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2 h-44 border-t border-[#f4f4f5] pt-4">
|
||||||
|
{data.map((m, i) => {
|
||||||
|
const pct = Math.round((m.total / max) * 100);
|
||||||
|
const isNext = i === 1;
|
||||||
|
return (
|
||||||
|
<div key={`${m.year}-${m.month}`} className="flex-1 flex flex-col items-center gap-1">
|
||||||
|
<div className="w-full flex flex-col justify-end" style={{ height: "120px" }}>
|
||||||
|
<div
|
||||||
|
className={`w-full rounded-t-md transition-all ${
|
||||||
|
isNext ? "bg-[#1A463C]" : "bg-[#1A463C]/35"
|
||||||
|
}`}
|
||||||
|
style={{ height: `${pct}%`, minHeight: m.total > 0 ? "4px" : "0" }}
|
||||||
|
title={`${m.label}: ${m.total.toLocaleString("it-IT", { style: "currency", currency: "EUR", minimumFractionDigits: 2 })}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] capitalize ${
|
||||||
|
isNext ? "font-bold text-[#1A463C]" : "text-[#71717a]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.label.split(" ")[0]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,46 +1,96 @@
|
|||||||
import { getLeadsNeedingFollowUp } from "@/lib/lead-service";
|
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";
|
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() {
|
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 (
|
return (
|
||||||
<Card className="border-orange-200 bg-orange-50">
|
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
|
||||||
<CardHeader>
|
{/* Header */}
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[#f4f4f5]">
|
||||||
<AlertCircle className="h-5 w-5 text-orange-600" />
|
<div className="flex items-center gap-2.5">
|
||||||
Richiedi Follow-up
|
<h2 className="text-sm font-bold text-[#1a1a1a]">Follow-up</h2>
|
||||||
</CardTitle>
|
<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">
|
||||||
</CardHeader>
|
{leads.length}
|
||||||
<CardContent className="space-y-4">
|
</span>
|
||||||
{leadsNeedingFollowUp.length > 0 ? (
|
</div>
|
||||||
<>
|
<Link
|
||||||
<p className="text-lg font-bold text-orange-900">
|
href="/admin/leads"
|
||||||
{leadsNeedingFollowUp.length} lead da contattare
|
className="text-xs font-medium text-[#71717a] hover:text-[#1A463C] transition-colors"
|
||||||
</p>
|
>
|
||||||
<ul className="space-y-2 text-sm">
|
Vedi tutti →
|
||||||
{leadsNeedingFollowUp.slice(0, 3).map((lead) => (
|
</Link>
|
||||||
<li key={lead.id} className="flex justify-between items-center">
|
</div>
|
||||||
<span>{lead.name}</span>
|
|
||||||
<Button variant="ghost" size="sm" asChild>
|
{/* Lead rows */}
|
||||||
<Link href={`/admin/leads/${lead.id}`}>Contatta</Link>
|
<ul className="divide-y divide-[#f4f4f5]">
|
||||||
</Button>
|
{visible.map((lead) => (
|
||||||
</li>
|
<li key={lead.id}>
|
||||||
))}
|
<Link
|
||||||
</ul>
|
href={`/admin/leads/${lead.id}`}
|
||||||
{leadsNeedingFollowUp.length > 3 && (
|
className="flex items-center gap-3 px-5 py-3 hover:bg-[#f9f9f9] transition-colors group"
|
||||||
<Button variant="outline" className="w-full" asChild>
|
>
|
||||||
<Link href="/admin/leads">Vedi Tutto ({leadsNeedingFollowUp.length})</Link>
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#1A463C]/10 text-[#1A463C] text-xs font-bold">
|
||||||
</Button>
|
{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-gray-600 text-sm">Tutti i lead sono aggiornati!</p>
|
<p className="text-xs text-[#71717a] truncate">
|
||||||
)}
|
{lead.next_action
|
||||||
</CardContent>
|
? lead.next_action
|
||||||
</Card>
|
: [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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import {
|
import {
|
||||||
updatePaymentStatus,
|
updatePaymentStatus,
|
||||||
updateAcceptedTotal,
|
updateAcceptedTotal,
|
||||||
|
setPaymentPaidAt,
|
||||||
} from "@/app/admin/clients/[id]/actions";
|
} from "@/app/admin/clients/[id]/actions";
|
||||||
import { setPaymentPlan } from "@/app/admin/projects/project-actions";
|
import { setPaymentPlan } from "@/app/admin/projects/project-actions";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -27,6 +28,16 @@ const statusLabels: Record<string, string> = {
|
|||||||
saldato: "Saldato",
|
saldato: "Saldato",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// paid_at (Date | string | null, serializzato sul confine RSC) → "YYYY-MM" per <input type="month">
|
||||||
|
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";
|
type PlanMode = "single" | "two" | "three";
|
||||||
|
|
||||||
const planOptions: { mode: PlanMode; label: string; description: string }[] = [
|
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 (
|
return (
|
||||||
<div className="space-y-6 max-w-md">
|
<div className="space-y-6 max-w-md">
|
||||||
{/* Totale preventivo */}
|
{/* Totale preventivo */}
|
||||||
@@ -240,6 +262,21 @@ export function PaymentsTab({
|
|||||||
<span className="text-xs text-[#71717a]">...</span>
|
<span className="text-xs text-[#71717a]">...</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{p.status === "saldato" && (
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Label htmlFor={`paid-${p.id}`} className="text-xs text-[#71717a] shrink-0">
|
||||||
|
Incassato nel mese
|
||||||
|
</Label>
|
||||||
|
<input
|
||||||
|
id={`paid-${p.id}`}
|
||||||
|
type="month"
|
||||||
|
defaultValue={toMonthValue(p.paid_at)}
|
||||||
|
disabled={statusLoading === p.id}
|
||||||
|
onChange={(e) => handlePaidMonthUpdate(p.id, e.target.value)}
|
||||||
|
className="text-sm border border-gray-200 rounded px-2 py-1 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -836,6 +836,9 @@ export type ClientActiveOfferSummary = {
|
|||||||
project_id: string;
|
project_id: string;
|
||||||
project_name: string;
|
project_name: string;
|
||||||
public_name: string; // offer_micros.public_name — NEVER internal_name
|
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;
|
accepted_total: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -856,10 +859,14 @@ export async function getClientActiveOffers(clientId: string): Promise<ClientAct
|
|||||||
offer_id: project_offers.id,
|
offer_id: project_offers.id,
|
||||||
project_id: project_offers.project_id,
|
project_id: project_offers.project_id,
|
||||||
public_name: offer_micros.public_name,
|
public_name: offer_micros.public_name,
|
||||||
|
tier_letter: offer_micros.tier_letter,
|
||||||
|
category: offer_macros.category,
|
||||||
|
offer_type: offer_macros.offer_type,
|
||||||
accepted_total: project_offers.accepted_total,
|
accepted_total: project_offers.accepted_total,
|
||||||
})
|
})
|
||||||
.from(project_offers)
|
.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))
|
||||||
.where(inArray(project_offers.project_id, projectIds))
|
.where(inArray(project_offers.project_id, projectIds))
|
||||||
.orderBy(asc(project_offers.created_at));
|
.orderBy(asc(project_offers.created_at));
|
||||||
|
|
||||||
@@ -868,6 +875,9 @@ export async function getClientActiveOffers(clientId: string): Promise<ClientAct
|
|||||||
project_id: r.project_id,
|
project_id: r.project_id,
|
||||||
project_name: projectNameMap.get(r.project_id) ?? "—",
|
project_name: projectNameMap.get(r.project_id) ?? "—",
|
||||||
public_name: r.public_name,
|
public_name: r.public_name,
|
||||||
|
tier_letter: r.tier_letter,
|
||||||
|
category: r.category,
|
||||||
|
offer_type: r.offer_type,
|
||||||
accepted_total: r.accepted_total ? String(r.accepted_total) : null,
|
accepted_total: r.accepted_total ? String(r.accepted_total) : null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { clients, projects, payments, deliverables, time_entries } from "@/db/schema";
|
import { clients, projects, payments } from "@/db/schema";
|
||||||
import { eq, and, isNotNull, isNull, desc, sum, count } from "drizzle-orm";
|
import { eq, sum, count } from "drizzle-orm";
|
||||||
|
|
||||||
export type KpiStats = {
|
export type KpiStats = {
|
||||||
clientiAttivi: number;
|
clientiAttivi: number;
|
||||||
@@ -9,16 +9,8 @@ export type KpiStats = {
|
|||||||
progettiInCorso: number; // projects not archived
|
progettiInCorso: number; // projects not archived
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ActivityItem = {
|
|
||||||
type: "nuovo_cliente" | "nuovo_progetto" | "deliverable_approvato" | "timer_stoppato";
|
|
||||||
label: string;
|
|
||||||
detail: string;
|
|
||||||
timestamp: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DashboardStats = {
|
export type DashboardStats = {
|
||||||
kpi: KpiStats;
|
kpi: KpiStats;
|
||||||
activity: ActivityItem[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getDashboardStats(): Promise<DashboardStats> {
|
export async function getDashboardStats(): Promise<DashboardStats> {
|
||||||
@@ -59,80 +51,5 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
|||||||
progettiInCorso: progettiRow?.count ?? 0,
|
progettiInCorso: progettiRow?.count ?? 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Activity feed ─────────────────────────────────────────────────────────────
|
return { kpi };
|
||||||
// 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 };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from "@/db";
|
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";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export type ForecastMonth = {
|
export type ForecastMonth = {
|
||||||
@@ -10,15 +10,21 @@ export type ForecastMonth = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
|
export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
|
||||||
// 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
|
const offers = await db
|
||||||
.select({
|
.select({
|
||||||
start_date: project_offers.start_date,
|
start_date: project_offers.start_date,
|
||||||
duration_months: offer_micros.duration_months,
|
duration_months: offer_micros.duration_months,
|
||||||
accepted_total: project_offers.accepted_total,
|
accepted_total: project_offers.accepted_total,
|
||||||
|
offer_type: offer_macros.offer_type,
|
||||||
})
|
})
|
||||||
.from(project_offers)
|
.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
|
// Build 12-month bucket array starting from current month
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -33,15 +39,16 @@ export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const offer of offers) {
|
for (const offer of offers) {
|
||||||
// Skip offers with no accepted_total — they contribute 0 to forecast
|
|
||||||
if (!offer.accepted_total) continue;
|
if (!offer.accepted_total) continue;
|
||||||
const total = parseFloat(String(offer.accepted_total));
|
const total = parseFloat(String(offer.accepted_total));
|
||||||
if (isNaN(total) || total <= 0) continue;
|
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);
|
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 offerMonth = new Date(start.getFullYear(), start.getMonth() + m, 1);
|
||||||
const bucket = buckets.find(
|
const bucket = buckets.find(
|
||||||
(b) => b.year === offerMonth.getFullYear() && b.month === offerMonth.getMonth() + 1
|
(b) => b.year === offerMonth.getFullYear() && b.month === offerMonth.getMonth() + 1
|
||||||
|
|||||||
Reference in New Issue
Block a user