---
plan_id: 05-04
phase: 5
wave: 4
title: "Client dashboard offer card + /admin/forecast revenue forecast page (12 months)"
type: execute
depends_on: [05-01, 05-03]
files_modified:
  - 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
requirements_addressed: [OFFER-05, OFFER-06]
autonomous: true
must_haves:
  truths:
    - "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"
  artifacts:
    - path: "src/lib/client-view.ts"
      provides: "Extended ProjectView interface with activeOffers field"
      contains: "activeOffers"
    - path: "src/components/client/OffersSection.tsx"
      provides: "Client-facing offer card showing public_name, cumulative_price, accepted_total"
      contains: "OffersSection"
    - path: "src/lib/forecast-queries.ts"
      provides: "getRevenueForecast12Months() server function"
      contains: "ForecastMonth, getRevenueForecast12Months"
    - path: "src/app/admin/forecast/page.tsx"
      provides: "Admin revenue forecast page at /admin/forecast"
      contains: "ForecastTable"
  key_links:
    - from: "src/lib/client-view.ts"
      to: "src/db/schema.ts"
      via: "project_offers + offer_micros + offer_micro_services + offer_services JOIN queries"
      pattern: "activeOffers"
    - from: "src/app/admin/forecast/page.tsx"
      to: "src/lib/forecast-queries.ts"
      via: "getRevenueForecast12Months() server import"
      pattern: "getRevenueForecast12Months"
---

<objective>
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.
</objective>

<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>

<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

<interfaces>
<!-- From src/lib/client-view.ts — ProjectView interface to extend (SECURITY: never expose internal_name): -->
```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
  // }>;
}
```

<!-- SECURITY RULE: getProjectView() must NEVER select offer_micros.internal_name -->
<!-- Use explicit column selection: offer_micros.public_name, project_offers.accepted_total -->
<!-- See client-view.ts line 89: "amount intentionally excluded" — same discipline for offer internal data -->

<!-- From src/db/schema.ts (after 05-01): -->
```typescript
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
});
```

<!-- From src/lib/analytics-queries.ts — JS computation pattern to mirror for forecast: -->
```typescript
// 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);
}
```

<!-- From src/components/client-dashboard.tsx — where to inject OffersSection: -->
```tsx
// 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
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Extend client-view.ts + forecast-queries.ts</name>
  <files>
    src/lib/client-view.ts
    src/lib/forecast-queries.ts
  </files>

  <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>

  <action>
**Extend `src/lib/client-view.ts`:**

1. Add these imports to the existing import block at the top:
```typescript
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
```

2. Add `activeOffers` to `ProjectView` interface (AFTER the existing `global_progress_pct` field):
```typescript
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;
}>;
```

3. Also extend `ClientView` interface (AFTER `global_progress_pct` field):
```typescript
activeOffers?: Array<{
  id: string;
  public_name: string;
  cumulative_price: string;
  accepted_total: string | null;
}>;
```

4. In `getProjectView()`, after the existing `notesRows` query and before the `commentsRows` query, add two new queries in a parallel `Promise.all`:

```typescript
// 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,
}));
```

5. In the return statement of `getProjectView()`, add `activeOffers` to the returned object:
```typescript
return {
  // ...existing fields...
  global_progress_pct,
  activeOffers: activeOffers.length > 0 ? activeOffers : undefined,
};
```

**Create `src/lib/forecast-queries.ts`:**

```typescript
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;
}
```
  </action>

  <verify>
    <automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
  </verify>

  <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>

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

<task type="auto">
  <name>Task 2: OffersSection component + client dashboard wiring + /admin/forecast page</name>
  <files>
    src/components/client/OffersSection.tsx
    src/components/client-dashboard.tsx
    src/app/client/[token]/page.tsx
    src/app/admin/forecast/page.tsx
  </files>

  <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>

  <action>
**Create `src/components/client/OffersSection.tsx`:**

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

```typescript
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:
```typescript
import { OffersSection } from './client/OffersSection';
```

2. In the sidebar `<aside>` section, add an "Offerte Attive" block after the Pagamenti block, conditionally rendered when `view.activeOffers` exists and is non-empty:

```tsx
{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`:

```typescript
// 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:

```typescript
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>
  );
}
```
  </action>

  <verify>
    <automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
  </verify>

  <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>

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

</tasks>

<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>

<verification>
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()
</verification>

<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>

<output>
After completion, create `.planning/phases/05-offer-system/05-04-SUMMARY.md` using the summary template.
</output>
