Files
clienthub/.planning/milestones/v1.0-phases/05-offer-system/05-04-PLAN.md
T
simone 23cb057f0b docs(07): phase 7 planning complete — 2 plans (schema migration + admin crud)
Wave 1: 07-01-PLAN.md — Unified services table (audit trail, backfill, validation)
Wave 2: 07-02-PLAN.md — Admin catalog CRUD rewire (preserve historical references)

Ready for execution: /gsd-execute-phase 7

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-11 06:17:29 +02:00

22 KiB

plan_id, phase, wave, title, type, depends_on, files_modified, requirements_addressed, autonomous, must_haves
plan_id phase wave title type depends_on files_modified requirements_addressed autonomous must_haves
05-04 5 4 Client dashboard offer card + /admin/forecast revenue forecast page (12 months) execute
05-01
05-03
src/lib/client-view.ts
src/components/client-dashboard.tsx
src/components/client/OffersSection.tsx
src/lib/forecast-queries.ts
src/app/admin/forecast/page.tsx
OFFER-05
OFFER-06
true
truths artifacts key_links
Client dashboard shows active offers for a project with public_name, cumulative_price, accepted_total — never internal_name or individual service prices
If a project has no active offers, the client dashboard does not show an offer section
Admin can visit /admin/forecast and see a 12-month revenue breakdown table based on active project_offers
Forecast uses project_offers.accepted_total (not projects.accepted_total) spread over duration_months starting from start_date
path provides contains
src/lib/client-view.ts Extended ProjectView interface with activeOffers field activeOffers
path provides contains
src/components/client/OffersSection.tsx Client-facing offer card showing public_name, cumulative_price, accepted_total OffersSection
path provides contains
src/lib/forecast-queries.ts getRevenueForecast12Months() server function ForecastMonth, getRevenueForecast12Months
path provides contains
src/app/admin/forecast/page.tsx Admin revenue forecast page at /admin/forecast ForecastTable
from to via pattern
src/lib/client-view.ts src/db/schema.ts project_offers + offer_micros + offer_micro_services + offer_services JOIN queries activeOffers
from to via pattern
src/app/admin/forecast/page.tsx src/lib/forecast-queries.ts getRevenueForecast12Months() server import getRevenueForecast12Months
Extend the client dashboard to display active offers per project, and build the admin revenue forecast page at `/admin/forecast`.

Purpose: These are the two read surfaces that give value to the offer data collected in Plans 01-03. The client sees what they are getting; the admin sees projected revenue. Output: ProjectView extended with activeOffers; OffersSection client component; forecast-queries.ts with computation function; /admin/forecast page.

<execution_context> @/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/workflows/execute-plan.md @/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/templates/summary.md </execution_context>

@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md @/Users/simonecavalli/IAMCAVALLI/.planning/STATE.md @/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-RESEARCH.md @/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-01-SUMMARY.md @/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-03-SUMMARY.md ```typescript export interface ProjectView { project: { id: string; name: string; client_id: string; accepted_total: string; }; phases: Array<...>; payments: Array<{ id: string; label: string; status: string; }>; // ... other fields ... global_progress_pct: number; // ADD: // activeOffers?: Array<{ // id: string; // public_name: string; // offer_micros.public_name ONLY — NEVER internal_name // cumulative_price: string; // SUM of offer_services.price for this micro's services // accepted_total: string | null; // project_offers.accepted_total // }>; } ```
export const project_offers = pgTable("project_offers", {
  id, project_id, micro_id, start_date, accepted_total, created_at
});
export const offer_micros = pgTable("offer_micros", {
  id, macro_id, internal_name, public_name, transformation_promise, duration_months, sort_order
});
export const offer_micro_services = pgTable("offer_micro_services", {
  micro_id, service_id  // composite PK
});
export const offer_services = pgTable("offer_services", {
  id, name, price, transformation_description, active
});
// getMonthlyCollected() pattern: query → JS array fill → return
const byMonth: number[] = Array(12).fill(0);
for (const row of rows) {
  byMonth[(row.month as number) - 1] = parseFloat(row.total);
}
// In the sidebar <aside> after the Documenti section, or in main content
// The dashboard receives: view: ClientView, token: string, comments: Comment[]
// ClientView wraps ProjectView data via projectViewToClientView() adapter
// THEREFORE: extend ClientView to include activeOffers, and extend the adapter in client/[token]/page.tsx
Task 1: Extend client-view.ts + forecast-queries.ts src/lib/client-view.ts src/lib/forecast-queries.ts

<read_first> - src/lib/client-view.ts — read the FULL file; understand ProjectView interface, getProjectView() function structure, import block - src/lib/analytics-queries.ts — read lines 1-60 for the JS computation pattern - src/db/schema.ts — confirm project_offers, offer_micros, offer_micro_services, offer_services column names </read_first>

**Extend `src/lib/client-view.ts`:**
  1. Add these imports to the existing import block at the top:
import { project_offers, offer_micros, offer_micro_services, offer_services } from "@/db/schema";
import { sql } from "drizzle-orm";  // sql may already be imported — check first
  1. Add activeOffers to ProjectView interface (AFTER the existing global_progress_pct field):
activeOffers?: Array<{
  id: string;
  public_name: string;        // offer_micros.public_name — NEVER internal_name
  cumulative_price: string;   // sum of assigned offer_services.price
  accepted_total: string | null;
}>;
  1. Also extend ClientView interface (AFTER global_progress_pct field):
activeOffers?: Array<{
  id: string;
  public_name: string;
  cumulative_price: string;
  accepted_total: string | null;
}>;
  1. In getProjectView(), after the existing notesRows query and before the commentsRows query, add two new queries in a parallel Promise.all:
// Fetch active offers for this project (client-safe fields only — never internal_name)
const projectOfferRows = await db
  .select({
    id: project_offers.id,
    public_name: offer_micros.public_name,   // EXPLICITLY public_name, never internal_name
    accepted_total: project_offers.accepted_total,
    micro_id: project_offers.micro_id,
  })
  .from(project_offers)
  .innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
  .where(eq(project_offers.project_id, projectId));

// Cumulative price per micro (sum of assigned services)
const microIds = projectOfferRows.map((o) => o.micro_id);
const cumulativePriceRows = microIds.length === 0 ? [] : await db
  .select({
    micro_id: offer_micro_services.micro_id,
    cumulative_price: sql<string>`coalesce(sum(${offer_services.price}::numeric), 0)`,
  })
  .from(offer_micro_services)
  .innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id))
  .where(inArray(offer_micro_services.micro_id, microIds))
  .groupBy(offer_micro_services.micro_id);

const cumulMap = new Map(cumulativePriceRows.map((r) => [r.micro_id, r.cumulative_price]));

const activeOffers = projectOfferRows.map((o) => ({
  id: o.id,
  public_name: o.public_name,
  cumulative_price: cumulMap.get(o.micro_id) ?? "0",
  accepted_total: o.accepted_total ? String(o.accepted_total) : null,
}));
  1. In the return statement of getProjectView(), add activeOffers to the returned object:
return {
  // ...existing fields...
  global_progress_pct,
  activeOffers: activeOffers.length > 0 ? activeOffers : undefined,
};

Create src/lib/forecast-queries.ts:

import { db } from "@/db";
import { project_offers, offer_micros } from "@/db/schema";
import { eq } from "drizzle-orm";

export type ForecastMonth = {
  year: number;
  month: number;    // 1-12
  label: string;   // "Giu 2026"
  total: number;
};

export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
  // Load all project offers with micro duration info
  const offers = await db
    .select({
      start_date: project_offers.start_date,
      duration_months: offer_micros.duration_months,
      accepted_total: project_offers.accepted_total,
    })
    .from(project_offers)
    .innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id));

  // Build 12-month bucket array starting from current month
  const now = new Date();
  const buckets: ForecastMonth[] = Array.from({ length: 12 }, (_, i) => {
    const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
    return {
      year: d.getFullYear(),
      month: d.getMonth() + 1,
      label: d.toLocaleDateString("it-IT", { month: "short", year: "numeric" }),
      total: 0,
    };
  });

  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 start = new Date(offer.start_date);

    for (let m = 0; m < offer.duration_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
      );
      if (bucket) bucket.total += perMonth;
    }
  }

  // Round totals to 2 decimal places
  buckets.forEach((b) => { b.total = Math.round(b.total * 100) / 100; });

  return buckets;
}
cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20

<acceptance_criteria> - npx tsc --noEmit exits 0 - grep "activeOffers" src/lib/client-view.ts returns at least 3 matches (interface + query + return) - grep -v "internal_name" src/lib/client-view.ts | grep "offer_micros" — offer_micros is only referenced for public_name (SECURITY: verify internal_name never appears in getProjectView query context for offers) - grep "internal_name" src/lib/client-view.ts returns 0 matches related to offer queries (it should NOT appear anywhere in the client-view offer code) - File src/lib/forecast-queries.ts exists - grep "getRevenueForecast12Months\|ForecastMonth" src/lib/forecast-queries.ts returns 2 matches - grep "project_offers.accepted_total\|offer.accepted_total" src/lib/forecast-queries.ts matches (forecast uses offer-level total, not projects.accepted_total) </acceptance_criteria>

client-view.ts extended with activeOffers (public_name only); forecast-queries.ts created with JS bucket algorithm; TypeScript compiles

Task 2: OffersSection component + client dashboard wiring + /admin/forecast page src/components/client/OffersSection.tsx src/components/client-dashboard.tsx src/app/client/[token]/page.tsx src/app/admin/forecast/page.tsx

<read_first> - src/components/client-dashboard.tsx — read FULL file (understand ClientDashboardProps, ClientView shape, sidebar layout) - src/app/client/[token]/page.tsx — read FULL file (understand projectViewToClientView adapter — must extend it) - src/components/payment-status.tsx — read first 30 lines for the sidebar card pattern (styling reference) - src/lib/forecast-queries.ts — confirm ForecastMonth shape (just created in Task 1) </read_first>

**Create `src/components/client/OffersSection.tsx`:**

This is a pure presentational RSC-compatible component (no client directives needed):

interface ActiveOffer {
  id: string;
  public_name: string;       // micro offer public name — never internal_name
  cumulative_price: string;  // sum of service prices
  accepted_total: string | null;
}

interface OffersSectionProps {
  offers: ActiveOffer[];
}

export function OffersSection({ offers }: OffersSectionProps) {
  if (offers.length === 0) return null;

  return (
    <div className="space-y-3">
      {offers.map((offer) => (
        <div key={offer.id} className="bg-white rounded-lg border border-[#e5e5e5] p-4">
          <p className="text-sm font-semibold text-[#1a1a1a]">{offer.public_name}</p>
          <div className="mt-2 space-y-1">
            <div className="flex items-center justify-between">
              <span className="text-xs text-[#71717a]">Valore incluso</span>
              <span className="text-xs font-mono text-[#1a1a1a]">
                {parseFloat(offer.cumulative_price).toFixed(2)}
              </span>
            </div>
            {offer.accepted_total && (
              <div className="flex items-center justify-between">
                <span className="text-xs font-semibold text-[#1a1a1a]">Prezzo finale</span>
                <span className="text-sm font-bold text-[#1A463C]">
                  {parseFloat(offer.accepted_total).toFixed(2)}
                </span>
              </div>
            )}
          </div>
        </div>
      ))}
    </div>
  );
}

Extend src/components/client-dashboard.tsx:

Read the full file. Make two targeted changes:

  1. Add import at the top:
import { OffersSection } from './client/OffersSection';
  1. In the sidebar <aside> section, add an "Offerte Attive" block after the Pagamenti block, conditionally rendered when view.activeOffers exists and is non-empty:
{view.activeOffers && view.activeOffers.length > 0 && (
  <div>
    <h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Offerte Attive</h2>
    <OffersSection offers={view.activeOffers} />
  </div>
)}

Insert this block between the Pagamenti block and the Documenti block in the sidebar.

Extend src/app/client/[token]/page.tsx:

Read the full file. Extend the projectViewToClientView adapter function to map activeOffers from ProjectView to ClientView:

// In projectViewToClientView() function, add to the return object:
activeOffers: view.activeOffers,

No other changes to this file are needed.

Create src/app/admin/forecast/page.tsx:

RSC page — no "use client". Fetches forecast data server-side and renders a table:

import { getRevenueForecast12Months } from "@/lib/forecast-queries";

export const revalidate = 0;

export default async function ForecastPage() {
  const forecast = await getRevenueForecast12Months();
  const totalForecast = forecast.reduce((sum, m) => sum + m.total, 0);

  return (
    <div className="max-w-3xl mx-auto py-8 px-4">
      <h1 className="text-2xl font-bold text-[#1a1a1a] mb-2">Revenue Forecast</h1>
      <p className="text-sm text-[#71717a] mb-6">
        Proiezione 12 mesi basata sulle offerte attive, i loro accepted_total e le durate in mesi.
        Ogni offerta distribuisce il suo totale equamente per i mesi di durata a partire dalla data di inizio.
      </p>

      <div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden">
        <table className="w-full text-sm">
          <thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
            <tr>
              <th className="px-4 py-3 text-left text-xs font-semibold text-[#71717a] uppercase tracking-wider">
                Mese
              </th>
              <th className="px-4 py-3 text-right text-xs font-semibold text-[#71717a] uppercase tracking-wider">
                Fatturato previsto
              </th>
              <th className="px-4 py-3 text-right text-xs font-semibold text-[#71717a] uppercase tracking-wider">
                Barra
              </th>
            </tr>
          </thead>
          <tbody className="divide-y divide-[#e5e7eb]">
            {forecast.map((month) => {
              const maxTotal = Math.max(...forecast.map((m) => m.total), 1);
              const pct = Math.round((month.total / maxTotal) * 100);
              return (
                <tr key={`${month.year}-${month.month}`} className="hover:bg-[#f9f9f9]">
                  <td className="px-4 py-3 text-[#1a1a1a] capitalize">{month.label}</td>
                  <td className="px-4 py-3 text-right font-mono text-[#1a1a1a]">
                    {month.total > 0 ? `€${month.total.toFixed(2)}` : <span className="text-[#71717a]"></span>}
                  </td>
                  <td className="px-4 py-3">
                    {month.total > 0 && (
                      <div className="flex justify-end items-center">
                        <div
                          className="bg-[#1A463C] h-2 rounded"
                          style={{ width: `${pct}%`, minWidth: "4px", maxWidth: "120px" }}
                        />
                      </div>
                    )}
                  </td>
                </tr>
              );
            })}
          </tbody>
          <tfoot className="border-t-2 border-[#e5e7eb] bg-[#f9f9f9]">
            <tr>
              <td className="px-4 py-3 text-sm font-semibold text-[#1a1a1a]">Totale 12 mesi</td>
              <td className="px-4 py-3 text-right font-mono font-bold text-[#1a1a1a]">
                {totalForecast.toFixed(2)}
              </td>
              <td />
            </tr>
          </tfoot>
        </table>
      </div>

      {forecast.every((m) => m.total === 0) && (
        <p className="text-sm text-[#71717a] mt-4">
          Nessuna offerta con accepted_total trovata. Assegna micro-offerte ai progetti e imposta il totale accettato.
        </p>
      )}
    </div>
  );
}
cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20

<acceptance_criteria> - npx tsc --noEmit exits 0 - grep "OffersSection" src/components/client-dashboard.tsx returns 2 matches (import + usage) - grep "activeOffers" src/app/client/\[token\]/page.tsx returns at least 1 match (adapter wiring) - File src/components/client/OffersSection.tsx exists - File src/app/admin/forecast/page.tsx exists - grep "getRevenueForecast12Months" src/app/admin/forecast/page.tsx returns 1 match - grep "internal_name" src/components/client/OffersSection.tsx returns 0 matches (security: internal_name never in client component) - grep "internal_name" src/app/admin/forecast/page.tsx returns 0 matches (forecast page is admin-only but still uses only aggregates) </acceptance_criteria>

OffersSection renders public_name + cumulative_price + accepted_total; client dashboard wired; /admin/forecast shows 12-month table; TypeScript compiles

<threat_model>

Trust Boundaries

Boundary Description
DB → getProjectView() → client browser Client-facing data path — must never include internal names or individual prices
DB → getRevenueForecast12Months() → admin browser Admin-only read path — acceptable to show all offer data

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-05-09 Information Disclosure getProjectView() activeOffers query mitigate Explicit column selection: offer_micros.public_name only — never offer_micros.internal_name; enforce via acceptance criteria grep check
T-05-10 Information Disclosure OffersSection component mitigate Component receives public_name only; no prop for internal_name; grep gate in acceptance_criteria confirms 0 occurrences
T-05-11 Information Disclosure /admin/forecast page accept Route is under /admin/* — Auth.js middleware session guard; low risk
T-05-12 Tampering Forecast computation (JS client-side alternative) mitigate getRevenueForecast12Months() runs server-side in RSC — no client-side computation; forecast data never sent for client-side calculation
</threat_model>
After both tasks complete: - `npx tsc --noEmit` exits 0 - Client dashboard: If a project has active offers with accepted_total set → "Offerte Attive" section appears in sidebar with public name and price - Client dashboard: Project with no offers → no offer section visible - `/admin/forecast` → 12-month table renders; months with active offers show totals; empty-state message shown if no accepted_total set - Security check: `grep "internal_name" src/lib/client-view.ts` — must not appear in the offer-related query section of getProjectView()

<success_criteria>

  1. Client dashboard shows active offers with public_name, cumulative_price, accepted_total — never internal_name
  2. Client sees nothing when no offers are assigned to their project
  3. Admin forecast page shows 12-month breakdown with totals
  4. Forecast correctly uses project_offers.accepted_total (not projects.accepted_total)
  5. Months outside active offer windows show 0/empty </success_criteria>
After completion, create `.planning/phases/05-offer-system/05-04-SUMMARY.md` using the summary template.