--- phase: 05-offer-system plan: 04 subsystem: ui tags: [offer-system, client-dashboard, forecast, rsc, drizzle] requires: - phase: 05-01 provides: [project_offers, offer_micros, offer_micro_services, offer_services tables and TypeScript types] - phase: 05-03 provides: [project offer assignment, getClientActiveOffers query pattern] provides: - activeOffers field in ProjectView and ClientView (public_name only — never internal_name) - OffersSection client-facing component - getRevenueForecast12Months() 12-bucket JS algorithm in forecast-queries.ts - /admin/forecast RSC page with 12-month revenue table affects: [client-dashboard, client-view, forecast-queries, admin-forecast] tech-stack: added: [] patterns: [explicit-column-selection for security (T-05-09/T-05-10), 12-bucket JS forecast algorithm mirroring getMonthlyCollected pattern, RSC data fetch + table render for admin forecast] key-files: created: - src/lib/forecast-queries.ts - src/components/client/OffersSection.tsx - src/app/admin/forecast/page.tsx modified: - src/lib/client-view.ts - src/components/client-dashboard.tsx - src/app/client/[token]/page.tsx key-decisions: - "activeOffers query uses explicit .select({ public_name: offer_micros.public_name }) — internal_name never appears in the SELECT list (T-05-09 mitigation)" - "activeOffers is undefined (not []) when no offers assigned — dashboard section conditionally renders only when truthy and non-empty (T-05-10 mitigation)" - "Forecast algorithm runs entirely server-side in RSC (getRevenueForecast12Months called in page.tsx) — no client-side computation (T-05-12 mitigation)" - "ForecastMonth label uses it-IT locale for Italian month abbreviations consistent with admin UI language" - "cumulative_price computed via SQL SUM on offer_micro_services JOIN offer_services — not persisted, always fresh from DB" patterns-established: - "Pattern: Security-critical column selection in client-facing queries — explicit .select() with only public fields, comment naming excluded field" - "Pattern: Conditional sidebar blocks using undefined (not []) — view.activeOffers && view.activeOffers.length > 0" - "Pattern: 12-bucket JS array for time-series data initialized with Array.from then filled in a for loop (mirrors getMonthlyCollected)" requirements-completed: [OFFER-05, OFFER-06] duration: ~10 minutes completed: 2026-05-30 --- # Phase 5 Plan 4: Client dashboard offer card + /admin/forecast revenue forecast page Summary **Client dashboard now shows active offers per project (public_name + cumulative service price + accepted_total), and /admin/forecast displays a 12-month server-side revenue projection spread from project_offers.accepted_total across duration_months.** ## Performance - **Duration:** ~10 minutes - **Started:** 2026-05-30T00:00:00Z - **Completed:** 2026-05-30T00:00:00Z - **Tasks:** 2 - **Files modified:** 6 ## Accomplishments - Extended `ProjectView` and `ClientView` interfaces with `activeOffers` field (public_name, cumulative_price, accepted_total only — never internal_name) - Created `forecast-queries.ts` with `getRevenueForecast12Months()` — builds 12 monthly buckets from current month and distributes each offer's accepted_total evenly across its duration_months window - Built `OffersSection.tsx` client component showing per-offer card with public name and prices — zero internal data exposed - Wired OffersSection into client-dashboard sidebar (conditionally rendered when offers exist), and mapped activeOffers through the projectViewToClientView adapter - Delivered `/admin/forecast` RSC page — table with month, projected revenue, proportional bar; totals row; Italian locale labels; empty-state message ## Task Commits Each task was committed atomically: 1. **Task 1: Extend client-view.ts + forecast-queries.ts** - `c398d6d` (feat) 2. **Task 2: OffersSection + client dashboard wiring + /admin/forecast page** - `745f8a7` (feat) ## Files Created/Modified - `src/lib/client-view.ts` — Added offer table imports, sql import; activeOffers to ProjectView and ClientView interfaces; projectOfferRows + cumulativePriceRows queries in getProjectView(); activeOffers in return - `src/lib/forecast-queries.ts` — New file: ForecastMonth type, getRevenueForecast12Months() with 12-bucket JS algorithm - `src/components/client/OffersSection.tsx` — New RSC-compatible component: renders offer card per entry with public_name, cumulative_price, accepted_total - `src/components/client-dashboard.tsx` — Added OffersSection import; Offerte Attive sidebar block between Pagamenti and Documenti - `src/app/client/[token]/page.tsx` — Extended projectViewToClientView adapter to map activeOffers - `src/app/admin/forecast/page.tsx` — New RSC page at /admin/forecast with table, bar column, totals row, empty state ## Decisions Made - `activeOffers` returns `undefined` (not `[]`) when empty — allows simple truthy check in dashboard (`view.activeOffers && view.activeOffers.length > 0`) - Security comment in `client-view.ts` offer query explicitly names `internal_name` as excluded — this is a documentation discipline from the existing payment amount comment pattern - Forecast uses `offer_micros.duration_months` not a hardcoded value — ties forecast accuracy directly to micro-offer configuration - `maxTotal` for bar width computed inside the `map` on each row iteration (idiomatic React, acceptable for 12 rows) ## Deviations from Plan None — plan executed exactly as written. ## Issues Encountered None. ## Known Stubs None — all data paths wire to live DB queries. OffersSection receives real project_offers data. getRevenueForecast12Months reads from production DB. ## Threat Flags No new trust boundaries beyond those in the plan's threat model. T-05-09 mitigated by explicit column selection (no internal_name in SELECT). T-05-10 mitigated by OffersSection receiving only public_name prop (confirmed by grep: 0 internal_name occurrences). T-05-12 mitigated by server-side RSC execution of getRevenueForecast12Months. ## Self-Check: PASSED - `src/lib/forecast-queries.ts` exists: FOUND (c398d6d) - `src/components/client/OffersSection.tsx` exists: FOUND (745f8a7) - `src/app/admin/forecast/page.tsx` exists: FOUND (745f8a7) - `src/lib/client-view.ts` activeOffers count: 4 matches (interface×2 + query + return) - `grep "internal_name" src/components/client/OffersSection.tsx`: 0 matches - `grep "internal_name" src/app/admin/forecast/page.tsx`: 0 matches - `npx tsc --noEmit`: PASSED (no errors) - Task 1 commit c398d6d: FOUND - Task 2 commit 745f8a7: FOUND --- *Phase: 05-offer-system* *Completed: 2026-05-30*