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
+10
View File
@@ -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<ClientAct
offer_id: project_offers.id,
project_id: project_offers.project_id,
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,
})
.from(project_offers)
.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))
.orderBy(asc(project_offers.created_at));
@@ -868,6 +875,9 @@ export async function getClientActiveOffers(clientId: string): Promise<ClientAct
project_id: r.project_id,
project_name: projectNameMap.get(r.project_id) ?? "—",
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,
}));
}
+3 -86
View File
@@ -1,6 +1,6 @@
import { db } from "@/db";
import { clients, projects, payments, deliverables, time_entries } from "@/db/schema";
import { eq, and, isNotNull, isNull, desc, sum, count } from "drizzle-orm";
import { clients, projects, payments } from "@/db/schema";
import { eq, sum, count } from "drizzle-orm";
export type KpiStats = {
clientiAttivi: number;
@@ -9,16 +9,8 @@ export type KpiStats = {
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 = {
kpi: KpiStats;
activity: ActivityItem[];
};
export async function getDashboardStats(): Promise<DashboardStats> {
@@ -59,80 +51,5 @@ export async function getDashboardStats(): Promise<DashboardStats> {
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 };
}
+13 -6
View File
@@ -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<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
.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<ForecastMonth[]> {
});
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