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,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
|
||||
|
||||
Reference in New Issue
Block a user