docs(05-04): complete client dashboard offer card + admin forecast plan

- SUMMARY.md: 2 tasks, 6 files, commits c398d6d and 745f8a7
- activeOffers in ProjectView/ClientView (public_name only, T-05-09/10 mitigated)
- /admin/forecast 12-month RSC table complete
This commit is contained in:
2026-05-30 16:44:25 +02:00
parent 745f8a78c0
commit 00783c4c15
@@ -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*