Add `offer_micros` and `project_offers` to the existing schema imports at the top (they were added in Task 1 of this plan). Then add the following new exported type and function after `getClientWithProjects()`:
```typescript
exporttypeClientActiveOfferSummary={
offer_id: string;
project_id: string;
project_name: string;
public_name: string;// offer_micros.public_name — NEVER internal_name
2. Replace the single `await getClientWithProjects(id)` call with a parallel fetch:
```typescript
const[data,activeOffers]=awaitPromise.all([
getClientWithProjects(id),
getClientActiveOffers(id),
]);
if(!data)notFound();
```
3. Add the "Offerte Attive" summary section at the bottom of the JSX return, after the `archivedProjects` block and before the closing `</div>` of the root element:
-`grep "getClientActiveOffers" "src/app/admin/clients/[id]/page.tsx"` matches (function called on page)
-`grep "activeOffers" "src/app/admin/clients/[id]/page.tsx"` returns at least 2 matches (fetch + render)
-`grep "internal_name" src/lib/admin-queries.ts` — must NOT appear in the new getClientActiveOffers query block
</acceptance_criteria>
<done>getClientActiveOffers() exported from admin-queries.ts; /admin/clients/[id] page shows active offers summary with public_name and project name; TypeScript compiles</done>
</task>
</tasks>
</tasks>
<threat_model>
<threat_model>
@@ -537,13 +671,15 @@ And after the last `<TabsContent>`:
</threat_model>
</threat_model>
<verification>
<verification>
After both tasks complete:
After all three tasks complete:
-`npx tsc --noEmit` exits 0
-`npx tsc --noEmit` exits 0
- Visit `/admin/projects/[id]` → Offerte tab is visible
- Visit `/admin/projects/[id]` → Offerte tab is visible
- Offerte tab shows empty state when no offers assigned
- Offerte tab shows empty state when no offers assigned
- Assign a micro-offer → appears in the active list
- Assign a micro-offer → appears in the active list
- Set accepted_total by blurring the input → value persists on refresh
- Set accepted_total by blurring the input → value persists on refresh
- Remove an assignment → disappears from list
- Remove an assignment → disappears from list
- Visit `/admin/clients/[id]` for a client with active project offers → "Offerte Attive" section appears at the bottom with public_name and project name
- Client with no active offers → no Offerte Attive section visible
</verification>
</verification>
<success_criteria>
<success_criteria>
@@ -552,6 +688,7 @@ After both tasks complete:
3. Admin can update the accepted_total inline (onBlur save)
3. Admin can update the accepted_total inline (onBlur save)
4. Admin can remove a project offer assignment
4. Admin can remove a project offer assignment
5. All changes revalidate /admin/forecast path
5. All changes revalidate /admin/forecast path
6. Client detail page /admin/clients/[id] shows active offers summary (public_name + project name) for all projects belonging to that client
**Confidence:** HIGH (codebase fully inspected; all patterns verified against existing code)
---
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| OFFER-01 | Admin can create macro-offers with internal name, public name, macro transformation promise | New table `offer_macros`; CRUD follows catalog/actions.ts pattern |
| OFFER-02 | Each macro-offer has child micro-offers with internal name, public name, micro transformation promise, duration in months | New table `offer_micros` with FK to `offer_macros`; `duration_months` integer column |
| OFFER-03 | Admin can create services with name, price, transformation description; each service assignable to multiple micro-offers via multi-select (many-to-many) | New table `offer_services` (separate from `service_catalog`) + junction table `offer_micro_services`; multi-select via checkbox list pattern |
| OFFER-04 | Admin can assign one or more micro-offers to a project; admin sees active offers per project/client | New table `project_offers` with `project_id`, `micro_offer_id`, `start_date`, `accepted_total` columns |
| OFFER-05 | Client dashboard shows active offers with public name, cumulative service price, accepted final price; multiple offers shown | Extend `getProjectView()` to include offer data; render in `ClientDashboard` component; NO internal names or individual prices |
| OFFER-06 | Admin revenue forecast for next 12 months based on active offers, duration, accepted_total; monthly breakdown | Pure JS computation in a new query function; no new DB tables required |
</phase_requirements>
---
## Summary
Phase 5 adds an Offer System on top of the existing multi-project structure. The work divides cleanly into three layers: (1) a new schema with four tables, (2) admin CRUD UI for the offers catalog and project assignment, and (3) two read surfaces — the client dashboard and an admin revenue forecast page.
The most important architectural decision is that Offer Services (`offer_services`) are a NEW entity, distinct from the existing `service_catalog`. The existing `service_catalog` is used for quote line items (internal pricing). Offer services carry a "transformation description" for marketing language and are bundled into micro-offers. The two entities serve different purposes and must not be merged.
The revenue forecast algorithm (OFFER-06) requires no extra DB tables: given `project_offers.start_date` and `offer_micros.duration_months`, each project_offer generates revenue in months `[start_month, start_month + duration_months)`. The `accepted_total` from the `project_offers` row (not from the project) drives the monthly amount — this is the "offer-level accepted total", distinct from `projects.accepted_total`.
**Primary recommendation:** Follow the established pattern — `"use server"` actions + `revalidatePath` + `router.refresh()` in client components. Add no new libraries for this phase; the existing shadcn/ui `select.tsx` is sufficient for the single-select assignment form, and a controlled checkbox list covers the multi-select service assignment. Radix `@radix-ui/react-checkbox` (v1.3.3 available) is the only optional new install if a styled checkbox component is desired.
| @radix-ui/react-checkbox | 1.3.3 | Styled checkbox primitive | Only if the bare HTML `<input type="checkbox">` looks too unstyled; the project uses Radix primitives for all interactive elements |
[VERIFIED: npm registry]
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Checkbox list for multi-select | `cmdk` Command palette | Command palette adds a dependency and is overkill for a 5-20 item list; checkbox list is simpler and consistent with project style |
| Separate offer_services table | Re-use service_catalog | Wrong: service_catalog is for quoted line-item prices; offer_services carry marketing transformation descriptions — different semantics |
The `getProjectFullDetail()` query needs one additional parallel query for `project_offers`. [VERIFIED: existing tab pattern in /admin/projects/[id]/page.tsx]
### Anti-Patterns to Avoid
- **Re-using `service_catalog` for offer services:** `service_catalog` has `unit_price` semantics for quote line items. Offer services have a `transformation_description` for marketing copy and are bundled into packages. Different entities, different tables.
- **Storing cumulative price in DB:** Compute `cumulative_price` at query time (SUM of `offer_services.price` joined via `offer_micro_services`). Never denormalize into `offer_micros` — it would go stale when services are edited.
- **Exposing `internal_name` to client API:** Every client-side query must select `public_name` explicitly, never `SELECT *` on offer tables.
- **Computing forecast in the browser:** The `getRevenueForecast12Months()` function runs server-side as a Server Component prop. No client-side fetch or useEffect.
- **Using `onDelete: "cascade"` on `project_offers.micro_id`:** Use `onDelete: "restrict"` — if an admin tries to delete a micro-offer that is actively assigned to projects, the DB should reject it with an error rather than silently destroying assignment history.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Multi-select UI | Custom dropdown with search | Checkbox list (plain HTML or Radix Checkbox) | 5-20 items max; no search needed at this scale |
**Key insight:** This phase is data-model heavy but UI-light. The hardest part is the schema design (junction table, composite PK, the `start_date` + `duration_months` forecast model), not the UI.
---
## Common Pitfalls
### Pitfall 1: Drizzle Composite PK import
**What goes wrong:** `primaryKey` is not imported from `drizzle-orm` — it must be imported from `drizzle-orm/pg-core`.
**Why it happens:**`drizzle-orm` re-exports many things but `primaryKey` for table definitions is from the pg-core package.
**How to avoid:**`import { pgTable, primaryKey, text, ... } from "drizzle-orm/pg-core"` — add `primaryKey` to the existing import in schema.ts.
**Warning signs:** TypeScript error "primaryKey is not exported from drizzle-orm".
### Pitfall 2: Offer accepted_total vs. project accepted_total
**What goes wrong:** Revenue forecast uses `projects.accepted_total` instead of `project_offers.accepted_total`.
**Why it happens:**`projects.accepted_total` is the quote-builder total (from Phase 3 CRUD). Offer assignments have their own accepted totals.
**How to avoid:**`project_offers` table has its own `accepted_total` column. Forecast queries MUST join to `project_offers.accepted_total`, not `projects.accepted_total`.
**Warning signs:** Forecast totals match the quote totals instead of offer totals.
**Why it happens:** If `start_date` is nullable, active offers with no start date contribute nothing to forecast.
**How to avoid:** Make `start_date` NOT NULL with `.defaultNow()` — admin sets it at assignment time. If needed, allow admin to edit it after assignment.
**Warning signs:** Forecast shows 0 even with active offers.
### Pitfall 4: Client API security — internal names leaking
**What goes wrong:** A query accidentally includes `internal_name` in the client-side response.
**Why it happens:** Using `...spread` or `SELECT *` on offer tables in `getProjectView()`.
**How to avoid:** In `getProjectView()`, always use explicit column selection: `offer_micros.public_name`, `project_offers.accepted_total` — never `SELECT *` on tables with both internal and public name columns.
**Warning signs:** Client dashboard shows internal names like "Entry A" instead of "Entry Offer — Starter".
### Pitfall 5: Drizzle push order — tables with circular deps
**What goes wrong:** `drizzle-kit push` fails if tables reference each other out of order.
**Why it happens:**`offer_micro_services` references both `offer_micros` and `offer_services` — both must exist first.
**How to avoid:** Define in schema.ts in this order: `offer_macros` → `offer_micros` → `offer_services` → `offer_micro_services` → `project_offers`. drizzle-kit push respects definition order.
**Warning signs:**`relation "offer_micros" does not exist` error during push.
### Pitfall 6: Forecast page route collision with existing /admin/analytics
**What goes wrong:** Naming the forecast page `/admin/analytics` when that route already exists.
**Why it happens:** Existing `/admin/analytics/page.tsx` is the financial statistics page.
**How to avoid:** Use `/admin/forecast` or add a tab to the existing analytics page. Recommended: new route `/admin/forecast` to keep concerns separated.
**Warning signs:** The analytics page gets replaced.
**Recommended addition:** Add "Offerte" between "Catalogo" and "Impostazioni", and "Forecast" after "Statistiche". This keeps catalog-type items together.
Alternatively, "Forecast" can live under "Statistiche" as a tab, avoiding NavBar clutter. Since the analytics page already uses year-based filtering, adding a "Forecast" tab to `/admin/analytics` is a viable option.
[ASSUMED] — Which navigation placement is preferable is a product decision. Both options work technically.
---
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Offer-level `accepted_total` on `project_offers` is separate from `projects.accepted_total` | Schema Design | If the intent is that `projects.accepted_total` also covers offers, the schema design changes; the revenue forecast would use a different source |
| A2 | "Forecast" gets its own page at `/admin/forecast` rather than a tab in `/admin/analytics` | Navigation Integration | If admin prefers a tab, the page structure changes but not the algorithm |
| A3 | Offer services have a flat price (not quantity × unit_price like quote_items) | Schema Design | If offer services need quantity support, `offer_micro_services` needs a `quantity` column |
---
## Open Questions (RESOLVED)
1.**Should `project_offers.accepted_total` be mandatory or optional?**
- What we know: `projects.accepted_total` defaults to "0"; offer assignment may happen before price negotiation
- What's unclear: Can admin assign a micro-offer with no accepted total yet? Does it appear in forecast as 0?
- RESOLVED: nullable — exclude from forecast if null
- Recommendation: Make it nullable (`accepted_total: numeric(...)`), exclude null rows from forecast computation
2.**Can a project have the same micro-offer assigned twice (e.g., a retainer renewed)?**
- What we know: The `project_offers` table as designed allows duplicate (`project_id`, `micro_id`) combinations
- What's unclear: Is renewal a new row (different `start_date`) or an update?
- RESOLVED: allowed — differentiated by start_date, no unique constraint
- Recommendation: Allow duplicate rows (multiple assignments of same micro to same project), differentiated by `start_date`; no unique constraint on (`project_id`, `micro_id`)
3.**Where does the "Offerte" catalog page live — merged with existing /admin/catalog, or separate?**
- What we know: `/admin/catalog` handles `service_catalog`; offer entities are distinct
- What's unclear: Admin preference for navigation
- RESOLVED: separate /admin/offers page
- Recommendation: Separate page `/admin/offers` keeps concerns clean; existing `/admin/catalog` stays for quote service catalog
---
## Environment Availability
Step 2.6: SKIPPED — no new external dependencies identified. All required tools (Node.js, npm, Postgres, drizzle-kit) are already verified operational from Phase 4.
---
## Validation Architecture
`nyquist_validation: false` in `.planning/config.json` — section omitted per config.
| V4 Access Control | yes | Client API (`getProjectView`) must never return `internal_name` or individual `offer_services.price` |
| V5 Input Validation | yes | zod schemas in all server actions (established pattern) |
| V6 Cryptography | no | No new cryptographic operations |
### Known Threat Patterns
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Client reads internal offer names via /api/client/* | Information Disclosure | Explicit column selection in `getProjectView()` — never `SELECT *` on offer tables |
| Admin accesses offer CRUD without session | Elevation of Privilege | `requireAdmin()` as first call in every server action |
| Cascade delete of micro-offer deletes project_offer history | Tampering | `onDelete: "restrict"` on `project_offers.micro_id` — prevents deletion of in-use micros |
---
## Sources
### Primary (HIGH confidence)
-`/Users/simonecavalli/IAMCAVALLI/src/db/schema.ts` — full schema inspected
-`/Users/simonecavalli/IAMCAVALLI/src/lib/admin-queries.ts` — all query patterns verified
-`/Users/simonecavalli/IAMCAVALLI/src/lib/client-view.ts` — client API security model verified
-`/Users/simonecavalli/IAMCAVALLI/src/app/admin/catalog/actions.ts` — server action pattern verified
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.