diff --git a/.planning/phases/05-offer-system/05-04-SUMMARY.md b/.planning/phases/05-offer-system/05-04-SUMMARY.md new file mode 100644 index 0000000..1df2784 --- /dev/null +++ b/.planning/phases/05-offer-system/05-04-SUMMARY.md @@ -0,0 +1,124 @@ +--- +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*