docs(05): create Phase 5 Offer System plans — 4 plans in 3 waves
Wave 1: 05-01 schema migration (5 new tables, additive only) Wave 2: 05-02 offer catalog CRUD (/admin/offers + NavBar) Wave 3: 05-03 project offer assignment (OffersTab) + 05-04 client dashboard offers + /admin/forecast Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,18 @@
|
|||||||
| PROJ-04 | Il link cliente supporta slug personalizzabile (/c/mario-rossi) con fallback al token esistente | Pending |
|
| PROJ-04 | Il link cliente supporta slug personalizzabile (/c/mario-rossi) con fallback al token esistente | Pending |
|
||||||
| PROJ-05 | Analytics profittabilità per progetto: ore lavorate, accepted_total, €/h reale vs target_hourly_rate globale | Pending |
|
| PROJ-05 | Analytics profittabilità per progetto: ore lavorate, accepted_total, €/h reale vs target_hourly_rate globale | Pending |
|
||||||
|
|
||||||
## Flusso Claude (v2 — deferred to Phase 5)
|
## Offer System (Phase 5)
|
||||||
|
|
||||||
|
| ID | Requirement | Status |
|
||||||
|
|----|-------------|--------|
|
||||||
|
| OFFER-01 | Admin può creare macro-offerte (es. Entry Offer, Signature Offer, Retainer) con nome interno, nome pubblico e promessa di trasformazione macro | Pending |
|
||||||
|
| OFFER-02 | Ogni macro-offerta ha micro-offerte figlie (es. Entry A/B/C) con nome interno, nome pubblico, promessa di trasformazione micro e durata in mesi | Pending |
|
||||||
|
| OFFER-03 | Admin può creare servizi con nome, prezzo e descrizione della trasformazione; ogni servizio è assegnabile a più micro-offerte via multi-select (many-to-many) | Pending |
|
||||||
|
| OFFER-04 | Admin può assegnare una o più micro-offerte a un progetto; admin vede le offerte attive per ogni progetto e cliente | Pending |
|
||||||
|
| OFFER-05 | La dashboard cliente mostra le offerte attive con nome pubblico, prezzo cumulativo dei servizi inclusi e prezzo finale accettato in evidenza; se ha più offerte le vede tutte | Pending |
|
||||||
|
| OFFER-06 | Admin ha una vista revenue forecast dei 12 mesi successivi basata su offerte attive, durata e accepted_total; breakdown mensile con totale fatturato per mese | Pending |
|
||||||
|
|
||||||
|
## Flusso Claude (v2 — deferred to Phase 6)
|
||||||
|
|
||||||
| ID | Requirement | Status |
|
| ID | Requirement | Status |
|
||||||
|----|-------------|--------|
|
|----|-------------|--------|
|
||||||
@@ -79,6 +90,12 @@
|
|||||||
| PROJ-03 | Phase 4 | Pending |
|
| PROJ-03 | Phase 4 | Pending |
|
||||||
| PROJ-04 | Phase 4 | Pending |
|
| PROJ-04 | Phase 4 | Pending |
|
||||||
| PROJ-05 | Phase 4 | Pending |
|
| PROJ-05 | Phase 4 | Pending |
|
||||||
| CLAUDE-01 | Phase 5 (v2) | Deferred |
|
| OFFER-01 | Phase 5 | Pending |
|
||||||
| CLAUDE-02 | Phase 5 (v2) | Deferred |
|
| OFFER-02 | Phase 5 | Pending |
|
||||||
| CLAUDE-03 | Phase 5 (v2) | Deferred |
|
| OFFER-03 | Phase 5 | Pending |
|
||||||
|
| OFFER-04 | Phase 5 | Pending |
|
||||||
|
| OFFER-05 | Phase 5 | Pending |
|
||||||
|
| OFFER-06 | Phase 5 | Pending |
|
||||||
|
| CLAUDE-01 | Phase 6 (v2) | Deferred |
|
||||||
|
| CLAUDE-02 | Phase 6 (v2) | Deferred |
|
||||||
|
| CLAUDE-03 | Phase 6 (v2) | Deferred |
|
||||||
+30
-6
@@ -16,7 +16,8 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||||||
- [ ] **Phase 2: Admin Area & Interactive Features** - Auth admin, CRUD completo clienti/fasi/task/deliverable/pagamenti, approvazioni e commenti
|
- [ ] **Phase 2: Admin Area & Interactive Features** - Auth admin, CRUD completo clienti/fasi/task/deliverable/pagamenti, approvazioni e commenti
|
||||||
- [ ] **Phase 3: Service Catalog & Quote Builder** - Catalogo servizi riutilizzabile e costruttore preventivi (admin-only, cliente vede solo il totale)
|
- [ ] **Phase 3: Service Catalog & Quote Builder** - Catalogo servizi riutilizzabile e costruttore preventivi (admin-only, cliente vede solo il totale)
|
||||||
- [ ] **Phase 4: Progetti — Multi-Project per Cliente** - Modello dati multi-progetto per cliente; dashboard con tabs; /admin/projects; slug link; analytics €/h
|
- [ ] **Phase 4: Progetti — Multi-Project per Cliente** - Modello dati multi-progetto per cliente; dashboard con tabs; /admin/projects; slug link; analytics €/h
|
||||||
- [ ] **Phase 5: Claude AI Onboarding (v2)** - Flusso guidato step-by-step per onboarding cliente e generazione assistita del piano/preventivo
|
- [ ] **Phase 5: Offer System** - Catalogo macro/micro offerte con servizi assegnabili, assegnazione offerte a progetti, dashboard cliente con offerte attive e revenue forecast 12 mesi per l'admin
|
||||||
|
- [ ] **Phase 6: Claude AI Onboarding (v2)** - Flusso guidato step-by-step per onboarding cliente e generazione assistita del piano/preventivo
|
||||||
|
|
||||||
## Phase Details
|
## Phase Details
|
||||||
|
|
||||||
@@ -99,10 +100,32 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
**Status**: Planning complete
|
**Status**: Planning complete
|
||||||
|
|
||||||
### Phase 5: Claude AI Onboarding (v2)
|
### Phase 5: Offer System
|
||||||
**Goal**: L'admin può usare un flusso chat guidato per onboardare un nuovo cliente e generare un piano a fasi e un preventivo assistito da Claude
|
|
||||||
|
**Goal**: L'admin può gestire un catalogo di macro/micro offerte con servizi assegnabili; le offerte vengono assegnate ai progetti; il cliente vede le sue offerte attive; l'admin ha un revenue forecast 12 mesi
|
||||||
**Mode:** mvp
|
**Mode:** mvp
|
||||||
**Depends on**: Phase 4
|
**Depends on**: Phase 4
|
||||||
|
**Requirements**: OFFER-01, OFFER-02, OFFER-03, OFFER-04, OFFER-05, OFFER-06
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
|
||||||
|
1. L'admin può creare macro-offerte (es. Entry Offer) con micro-offerte figlie (es. Entry A/B/C) — nome interno, nome pubblico, promessa di trasformazione, durata in mesi
|
||||||
|
2. L'admin può creare servizi con nome, prezzo e descrizione trasformazione; ogni servizio è assegnabile a più micro-offerte via multi-select
|
||||||
|
3. L'admin può assegnare una micro-offerta a un progetto; la lista clienti/progetti mostra le offerte attive
|
||||||
|
4. Il cliente vede nella dashboard le sue offerte attive (nome pubblico), il prezzo cumulativo dei servizi e il prezzo finale accettato in evidenza
|
||||||
|
5. L'admin ha una pagina revenue forecast con breakdown mensile per i 12 mesi successivi basato su offerte attive e durate
|
||||||
|
**Plans**: 4 plans
|
||||||
|
**Plan list**:
|
||||||
|
- [ ] 05-01-PLAN.md — Schema migration: 5 new offer tables (offer_macros, offer_micros, offer_services, offer_micro_services, project_offers) + drizzle-kit push [BLOCKING]
|
||||||
|
- [ ] 05-02-PLAN.md — Offer catalog admin CRUD: /admin/offers page + offer-queries.ts + actions.ts + ServiceCheckboxList + NavBar update
|
||||||
|
- [ ] 05-03-PLAN.md — Project offer assignment: OffersTab in /admin/projects/[id] + admin-queries extension + project-actions extension
|
||||||
|
- [ ] 05-04-PLAN.md — Client dashboard OffersSection + /admin/forecast revenue forecast page (12 months)
|
||||||
|
**UI hint**: yes
|
||||||
|
**Status**: Planning complete
|
||||||
|
|
||||||
|
### Phase 6: Claude AI Onboarding (v2)
|
||||||
|
**Goal**: L'admin può usare un flusso chat guidato per onboardare un nuovo cliente e generare un piano a fasi e un preventivo assistito da Claude
|
||||||
|
**Mode:** mvp
|
||||||
|
**Depends on**: Phase 5
|
||||||
**Requirements**: CLAUDE-01, CLAUDE-02, CLAUDE-03
|
**Requirements**: CLAUDE-01, CLAUDE-02, CLAUDE-03
|
||||||
**Success Criteria** (what must be TRUE):
|
**Success Criteria** (what must be TRUE):
|
||||||
1. L'admin avvia il flusso Claude inserendo il brief del cliente; Claude guida step-by-step la raccolta delle informazioni necessarie
|
1. L'admin avvia il flusso Claude inserendo il brief del cliente; Claude guida step-by-step la raccolta delle informazioni necessarie
|
||||||
@@ -115,12 +138,13 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
**Execution Order:**
|
**Execution Order:**
|
||||||
Phases execute in numeric order: 1 → 2 → 3 → 4
|
Phases execute in numeric order: 1 → 2 → 3 → 4 → 5
|
||||||
|
|
||||||
| Phase | Plans | Status | Completed |
|
| Phase | Plans | Status | Completed |
|
||||||
|-------|-------|--------|-----------|
|
|-------|-------|--------|-----------|
|
||||||
| 1. Foundation & Client Dashboard | 5/5 | ✅ Done | 2026-05-14 |
|
| 1. Foundation & Client Dashboard | 5/5 | ✅ Done | 2026-05-14 |
|
||||||
| 2. Admin Area & Interactive Features | 4/4 | ✅ Done | 2026-05-15 |
|
| 2. Admin Area & Interactive Features | 4/4 | ✅ Done | 2026-05-15 |
|
||||||
| 3. Service Catalog & Quote Builder | 4/4 | ✅ Done | 2026-05-19 |
|
| 3. Service Catalog & Quote Builder | 4/4 | ✅ Done | 2026-05-19 |
|
||||||
| 4. Progetti — Multi-Project per Cliente | 0/TBD | Pending | - |
|
| 4. Progetti — Multi-Project per Cliente | 5/5 | ✅ Done | 2026-05-23 |
|
||||||
| 5. Claude AI Onboarding (v2) | 0/TBD | Pending | - |
|
| 5. Offer System | 0/4 | Pending | - |
|
||||||
|
| 6. Claude AI Onboarding (v2) | 0/TBD | Pending | - |
|
||||||
|
|||||||
+40
-64
@@ -2,16 +2,16 @@
|
|||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v1.0
|
milestone: v1.0
|
||||||
milestone_name: milestone
|
milestone_name: milestone
|
||||||
status: executing
|
status: completed
|
||||||
stopped_at: Phase 1 execution complete — all 5 plans done, E2E verified (valid token 200, invalid 404)
|
stopped_at: Phase 4 fully executed — payments, multi-project, slug, deploy on hub.iamcavalli.net
|
||||||
last_updated: "2026-05-21T11:56:14.461Z"
|
last_updated: "2026-05-27"
|
||||||
last_activity: 2026-05-21 -- Phase 4 planning complete
|
last_activity: 2026-05-27 -- Phase 4 confirmed complete (04-00 through 04-07 + payments)
|
||||||
progress:
|
progress:
|
||||||
total_phases: 5
|
total_phases: 5
|
||||||
completed_phases: 3
|
completed_phases: 4
|
||||||
total_plans: 17
|
total_plans: 17
|
||||||
completed_plans: 13
|
completed_plans: 17
|
||||||
percent: 76
|
percent: 100
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
@@ -21,75 +21,51 @@ progress:
|
|||||||
See: .planning/PROJECT.md (updated 2026-05-09)
|
See: .planning/PROJECT.md (updated 2026-05-09)
|
||||||
|
|
||||||
**Core value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
|
**Core value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
|
||||||
**Current focus:** Phase 03 — service-catalog-quote-builder
|
|
||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 4
|
Phase: 4 — COMPLETE
|
||||||
Plan: Not started
|
Plan: All plans done (04-00 through 04-07)
|
||||||
Status: Ready to execute
|
Status: Production deployed on hub.iamcavalli.net
|
||||||
Last activity: 2026-05-21 -- Phase 4 planning complete
|
|
||||||
|
|
||||||
Progress: [██░░░░░░░░] 25%
|
## What Was Built (Phase 4)
|
||||||
|
|
||||||
## Performance Metrics
|
- **04-00**: Infra — route /c/ → /client/, Dockerfile, deploy Gitea → Coolify
|
||||||
|
- **04-01**: DB schema migration — projects, settings, FK pivot
|
||||||
|
- **04-02**: Admin projects list + client detail project cards
|
||||||
|
- **04-03**: Project workspace + timer analytics + settings page
|
||||||
|
- **04-04**: Slug resolution + multi-project client dashboard + slug edit
|
||||||
|
- **04-05/06**: Post-deploy bug fixes
|
||||||
|
- **04-07**: Debug code removed, public page restored
|
||||||
|
- **security**: Full hardening — auth guards, rate limiting, security headers
|
||||||
|
- **payments**: Init payments + split payment in project workspace
|
||||||
|
|
||||||
**Velocity:**
|
## Production State
|
||||||
|
|
||||||
- Total plans completed: 13
|
- App: hub.iamcavalli.net (Coolify, Hetzner)
|
||||||
- Average duration: ~1 session each
|
- DB: Postgres self-hosted on Coolify
|
||||||
- Total execution time: ~2 sessions (May 13–14)
|
- Auth: Auth.js (admin) + token middleware (clients)
|
||||||
|
- Routes: /admin/* (admin), /client/[token]/* (clients)
|
||||||
|
|
||||||
**By Phase:**
|
## Decisions (All Phases)
|
||||||
|
|
||||||
| Phase | Plans | Total | Avg/Plan |
|
- `clients.token` è campo separato (non la PK) — rotazionabile via single UPDATE
|
||||||
|-------|-------|-------|----------|
|
- `clients.accepted_total` denormalizzato — client API non tocca mai `quote_items`
|
||||||
| 1. Foundation & Client Dashboard | 5 | 2 sessions | ~0.4 sessions |
|
- `deliverables.approved_at` immutabile — audit trail dal giorno uno
|
||||||
| 02 | 4 | - | - |
|
- Edge middleware (`proxy.ts`) usa fetch() a route interna — postgres-js non può girare nell'Edge runtime
|
||||||
| 03 | 4 | - | - |
|
- Tailwind v4 auto-detection allargata — aggiunto `@source not` per escludere `.01_projects/` e `.claude/`
|
||||||
|
- Authelia va solo davanti ad /admin — /client/[token] deve bypassare per accesso via link token
|
||||||
**Recent Trend:**
|
|
||||||
|
|
||||||
- Last 5 plans: 01-01, 01-02, 01-03, 01-04, 01-05
|
|
||||||
- Trend: Steady, one blocker fixed mid-execution (Tailwind scanning external projects)
|
|
||||||
|
|
||||||
*Updated after each plan completion*
|
|
||||||
|
|
||||||
## Accumulated Context
|
|
||||||
|
|
||||||
### Decisions
|
|
||||||
|
|
||||||
Decisions are logged in PROJECT.md Key Decisions table.
|
|
||||||
Recent decisions affecting current work:
|
|
||||||
|
|
||||||
- Phase 1: `clients.token` è campo separato (non la PK) — rotazionabile via single UPDATE
|
|
||||||
- Phase 1: `clients.accepted_total` denormalizzato — client API non tocca mai `quote_items`
|
|
||||||
- Phase 1: `deliverables.approved_at` immutabile — audit trail dal giorno uno
|
|
||||||
- Phase 1: Edge middleware (`proxy.ts`) usa fetch() a route interna — postgres-js non può girare nell'Edge runtime
|
|
||||||
- Phase 1: Tailwind v4 auto-detection allargata — aggiunto `@source not` per escludere `.01_projects/` e `.claude/`
|
|
||||||
- Phase 1: DNS `welcomeclient.iamcavalli.net` → PENDING (richiede Vercel deploy prima)
|
|
||||||
|
|
||||||
### Pending Todos
|
|
||||||
|
|
||||||
- [ ] Vercel deploy: `vercel --prod` e aggiunta dominio `welcomeclient.iamcavalli.net`
|
|
||||||
- [ ] DNS CNAME: `welcomeclient → cname.vercel-dns.com` al registrar `iamcavalli.net`
|
|
||||||
- [ ] `DATABASE_URL` env var in Vercel project settings
|
|
||||||
|
|
||||||
### Blockers/Concerns
|
|
||||||
|
|
||||||
None.
|
|
||||||
|
|
||||||
## Deferred Items
|
## Deferred Items
|
||||||
|
|
||||||
| Category | Item | Status | Deferred At |
|
| Category | Item | Status |
|
||||||
|----------|------|--------|-------------|
|
|----------|------|--------|
|
||||||
| v2 | Claude AI onboarding (CLAUDE-01, CLAUDE-02, CLAUDE-03) | Phase 4 | Roadmap init |
|
| v2 | Claude AI onboarding (CLAUDE-01, CLAUDE-02, CLAUDE-03) | Backlog |
|
||||||
| Post-Phase 1 | DNS CNAME + Vercel deploy | Before prod launch | 2026-05-14 |
|
| Infrastructure | Authelia SSO davanti ad /admin | After Phase 4 (now ready) |
|
||||||
|
| Infrastructure | Higgsfield clone deploy (nuovo progetto Coolify) | Pending |
|
||||||
|
| SEO | WordPress auto-publish articoli | Mentioned, not planned |
|
||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-05-14
|
Last session: 2026-05-27
|
||||||
Stopped at: Phase 1 execution complete — all 5 plans done, E2E verified (valid token 200, invalid 404)
|
All Phase 4 plans complete. Next: Authelia infra or new feature scope.
|
||||||
Resume with: `/gsd-plan-phase 2` — Admin Area & Interactive Features
|
|
||||||
</content>
|
|
||||||
</invoke>
|
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
---
|
||||||
|
plan_id: 05-01
|
||||||
|
phase: 5
|
||||||
|
wave: 1
|
||||||
|
title: "Schema migration — 5 new offer tables + Drizzle relations"
|
||||||
|
type: execute
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/db/schema.ts
|
||||||
|
requirements_addressed: [OFFER-01, OFFER-02, OFFER-03, OFFER-04]
|
||||||
|
autonomous: true
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Five new tables exist in the database: offer_macros, offer_micros, offer_services, offer_micro_services, project_offers"
|
||||||
|
- "No existing table (clients, projects, payments, phases) is modified, dropped, or truncated"
|
||||||
|
- "Drizzle relations are defined for all new tables"
|
||||||
|
- "TypeScript types are exported for all new tables"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/db/schema.ts"
|
||||||
|
provides: "Five new pgTable definitions appended after existing tables"
|
||||||
|
contains: "offer_macros, offer_micros, offer_services, offer_micro_services, project_offers"
|
||||||
|
key_links:
|
||||||
|
- from: "src/db/schema.ts"
|
||||||
|
to: "Postgres DB"
|
||||||
|
via: "npx drizzle-kit push"
|
||||||
|
pattern: "offer_macros|offer_micros|offer_services|offer_micro_services|project_offers"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add five new tables for the Offer System to the Drizzle schema and push to the database.
|
||||||
|
|
||||||
|
Purpose: Every subsequent plan in Phase 5 reads from or writes to these tables. This plan is the blocking dependency for all other Phase 5 work.
|
||||||
|
Output: Updated `src/db/schema.ts` with five new table definitions, Drizzle relations, exported TypeScript types, and a successful `drizzle-kit push`.
|
||||||
|
</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
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Append five new table definitions and relations to schema.ts</name>
|
||||||
|
<files>src/db/schema.ts</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- src/db/schema.ts — read the FULL file before touching it (existing tables, import block, relations block, TypeScript types block)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
Open `src/db/schema.ts`. At the top of the file, the existing import from `"drizzle-orm/pg-core"` is:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
numeric,
|
||||||
|
timestamp,
|
||||||
|
boolean,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `primaryKey, date` to this import (needed for `offer_micro_services` composite PK and `project_offers.start_date`).
|
||||||
|
|
||||||
|
Append the following five table definitions AFTER the `settings` table definition and BEFORE the `// ============ RELATIONS ============` section. Use the exact column names from the research (not the phase_scope variant — research is authoritative):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ============ OFFER MACROS ============
|
||||||
|
export const offer_macros = pgTable("offer_macros", {
|
||||||
|
id: text("id")
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
internal_name: text("internal_name").notNull(),
|
||||||
|
public_name: text("public_name").notNull(),
|
||||||
|
transformation_promise: text("transformation_promise"),
|
||||||
|
sort_order: integer("sort_order").notNull().default(0),
|
||||||
|
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ OFFER MICROS ============
|
||||||
|
export const offer_micros = pgTable("offer_micros", {
|
||||||
|
id: text("id")
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
macro_id: text("macro_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => offer_macros.id, { onDelete: "cascade" }),
|
||||||
|
internal_name: text("internal_name").notNull(),
|
||||||
|
public_name: text("public_name").notNull(),
|
||||||
|
transformation_promise: text("transformation_promise"),
|
||||||
|
duration_months: integer("duration_months").notNull().default(1),
|
||||||
|
sort_order: integer("sort_order").notNull().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ OFFER SERVICES (distinct from service_catalog — marketing/transformation semantics) ============
|
||||||
|
export const offer_services = pgTable("offer_services", {
|
||||||
|
id: text("id")
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
price: numeric("price", { precision: 10, scale: 2 }).notNull(),
|
||||||
|
transformation_description: text("transformation_description"),
|
||||||
|
active: boolean("active").notNull().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ OFFER MICRO SERVICES (junction: offer_micros <-> offer_services) ============
|
||||||
|
export const offer_micro_services = pgTable(
|
||||||
|
"offer_micro_services",
|
||||||
|
{
|
||||||
|
micro_id: text("micro_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => offer_micros.id, { onDelete: "cascade" }),
|
||||||
|
service_id: text("service_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => offer_services.id, { onDelete: "cascade" }),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
pk: primaryKey({ columns: [t.micro_id, t.service_id] }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============ PROJECT OFFERS (assignment of micro-offer to project) ============
|
||||||
|
export const project_offers = pgTable("project_offers", {
|
||||||
|
id: text("id")
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
project_id: text("project_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
micro_id: text("micro_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => offer_micros.id, { onDelete: "restrict" }),
|
||||||
|
// NOT NULL with defaultNow — required for forecast computation (null start_date breaks forecast)
|
||||||
|
start_date: timestamp("start_date", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
// Offer-level accepted total — separate from projects.accepted_total (quote builder total)
|
||||||
|
accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }),
|
||||||
|
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add Drizzle relations at the end of the `// ============ RELATIONS ============` section (after `serviceCatalogRelations`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const offerMacrosRelations = relations(offer_macros, ({ many }) => ({
|
||||||
|
micros: many(offer_micros),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const offerMicrosRelations = relations(offer_micros, ({ one, many }) => ({
|
||||||
|
macro: one(offer_macros, { fields: [offer_micros.macro_id], references: [offer_macros.id] }),
|
||||||
|
services: many(offer_micro_services),
|
||||||
|
projectOffers: many(project_offers),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const offerServicesRelations = relations(offer_services, ({ many }) => ({
|
||||||
|
microAssignments: many(offer_micro_services),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const offerMicroServicesRelations = relations(offer_micro_services, ({ one }) => ({
|
||||||
|
micro: one(offer_micros, { fields: [offer_micro_services.micro_id], references: [offer_micros.id] }),
|
||||||
|
service: one(offer_services, { fields: [offer_micro_services.service_id], references: [offer_services.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const projectOffersRelations = relations(project_offers, ({ one }) => ({
|
||||||
|
project: one(projects, { fields: [project_offers.project_id], references: [projects.id] }),
|
||||||
|
micro: one(offer_micros, { fields: [project_offers.micro_id], references: [offer_micros.id] }),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add the new tables to `projectsRelations`:
|
||||||
|
```typescript
|
||||||
|
// Add to the existing projectsRelations many() block:
|
||||||
|
projectOffers: many(project_offers),
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally, append TypeScript types at the end of the `// ============ TYPESCRIPT TYPES ============` section:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type OfferMacro = typeof offer_macros.$inferSelect;
|
||||||
|
export type NewOfferMacro = typeof offer_macros.$inferInsert;
|
||||||
|
export type OfferMicro = typeof offer_micros.$inferSelect;
|
||||||
|
export type NewOfferMicro = typeof offer_micros.$inferInsert;
|
||||||
|
export type OfferService = typeof offer_services.$inferSelect;
|
||||||
|
export type NewOfferService = typeof offer_services.$inferInsert;
|
||||||
|
export type OfferMicroService = typeof offer_micro_services.$inferSelect;
|
||||||
|
export type NewOfferMicroService = typeof offer_micro_services.$inferInsert;
|
||||||
|
export type ProjectOffer = typeof project_offers.$inferSelect;
|
||||||
|
export type NewProjectOffer = typeof project_offers.$inferInsert;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data Safety check before push:** Confirm the migration only adds tables — grep the generated SQL for DROP or TRUNCATE before accepting.
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -c "offer_macros\|offer_micros\|offer_services\|offer_micro_services\|project_offers" src/db/schema.ts` returns 5 or more (table definitions present)
|
||||||
|
- `npx tsc --noEmit` exits 0 (no TypeScript errors)
|
||||||
|
- `grep "primaryKey" src/db/schema.ts` matches (composite PK import present)
|
||||||
|
- `grep "onDelete.*restrict" src/db/schema.ts` matches (project_offers.micro_id FK is restrict, not cascade)
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>schema.ts compiles without errors; five new table definitions present; relations defined; TypeScript types exported</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Run drizzle-kit push — create tables in database</name>
|
||||||
|
<files>src/db/schema.ts</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- src/db/schema.ts — verify Task 1 output before pushing
|
||||||
|
- .env (existence check only — do NOT log contents)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
Run the migration command. Before accepting the push, read the SQL preview that drizzle-kit outputs and verify it contains only CREATE TABLE statements — NO DROP TABLE, NO ALTER TABLE DROP COLUMN, NO TRUNCATE.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx drizzle-kit push
|
||||||
|
```
|
||||||
|
|
||||||
|
When drizzle-kit shows the diff, confirm only additions are shown. If any destructive statement appears, ABORT and report to the user immediately.
|
||||||
|
|
||||||
|
Expected tables created:
|
||||||
|
1. `offer_macros`
|
||||||
|
2. `offer_micros`
|
||||||
|
3. `offer_services`
|
||||||
|
4. `offer_micro_services` (composite PK: micro_id + service_id)
|
||||||
|
5. `project_offers`
|
||||||
|
|
||||||
|
If push fails with "relation does not exist", the definition order in schema.ts is wrong. Fix order: `offer_macros` → `offer_micros` → `offer_services` → `offer_micro_services` → `project_offers` (each references only tables defined before it).
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>cd /Users/simonecavalli/IAMCAVALLI && node -e "const { db } = require('./src/db/index.ts'); console.log('db ok')" 2>&1 || npx drizzle-kit push --dry-run 2>&1 | tail -10</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `npx drizzle-kit push` exits without error
|
||||||
|
- No DROP TABLE or TRUNCATE appears in the migration output
|
||||||
|
- All five table names appear in the drizzle-kit push output as CREATE TABLE operations
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>All five tables created in database; no existing data modified</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| schema.ts → DB | Schema changes are applied via drizzle-kit push — only additive changes allowed (CLAUDE.md Data Safety) |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-05-01 | Tampering | drizzle-kit push | mitigate | Read migration preview before accepting; abort if DROP TABLE or TRUNCATE appears |
|
||||||
|
| T-05-02 | Tampering | project_offers.micro_id FK | mitigate | Use `onDelete: "restrict"` — prevents silent history destruction when a micro-offer is deleted while assigned to projects |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
After both tasks complete:
|
||||||
|
- `grep -c "offer_macros" src/db/schema.ts` → at least 3 (table def + relation + type)
|
||||||
|
- `grep "primaryKey" src/db/schema.ts` → matches (composite PK import added)
|
||||||
|
- `npx tsc --noEmit` → exits 0
|
||||||
|
- DB tables exist (confirmed by drizzle-kit push output)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
1. Five new tables in database: offer_macros, offer_micros, offer_services, offer_micro_services, project_offers
|
||||||
|
2. Zero modifications to existing tables (clients, projects, payments, phases untouched)
|
||||||
|
3. TypeScript compiles without errors
|
||||||
|
4. Drizzle relations defined for all five tables
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/05-offer-system/05-01-SUMMARY.md` using the summary template.
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,713 @@
|
|||||||
|
---
|
||||||
|
plan_id: 05-02
|
||||||
|
phase: 5
|
||||||
|
wave: 2
|
||||||
|
title: "Offer catalog admin CRUD — /admin/offers (macro + micro + services + multi-select assignment)"
|
||||||
|
type: execute
|
||||||
|
depends_on: [05-01]
|
||||||
|
files_modified:
|
||||||
|
- src/app/admin/offers/page.tsx
|
||||||
|
- src/app/admin/offers/actions.ts
|
||||||
|
- src/lib/offer-queries.ts
|
||||||
|
- src/components/admin/NavBar.tsx
|
||||||
|
requirements_addressed: [OFFER-01, OFFER-02, OFFER-03]
|
||||||
|
autonomous: true
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Admin can create a macro-offer with internal_name, public_name, transformation_promise"
|
||||||
|
- "Admin can create a micro-offer as a child of a macro, with internal_name, public_name, transformation_promise, duration_months"
|
||||||
|
- "Admin can create offer services with name, price, transformation_description"
|
||||||
|
- "Admin can assign services to a micro-offer via a checkbox list (many-to-many)"
|
||||||
|
- "NavBar has an 'Offerte' link pointing to /admin/offers"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/app/admin/offers/page.tsx"
|
||||||
|
provides: "RSC page listing macros, their micros, and offer services"
|
||||||
|
contains: "OfferMacroForm, OfferServiceForm"
|
||||||
|
- path: "src/app/admin/offers/actions.ts"
|
||||||
|
provides: "Server actions for all offer catalog mutations"
|
||||||
|
contains: "createMacro, createMicro, createOfferService, updateMicroServices"
|
||||||
|
- path: "src/lib/offer-queries.ts"
|
||||||
|
provides: "Read queries for offer catalog"
|
||||||
|
contains: "getCatalogWithMicros, getAllOfferServices"
|
||||||
|
- path: "src/components/admin/NavBar.tsx"
|
||||||
|
provides: "NavBar with Offerte link"
|
||||||
|
contains: "/admin/offers"
|
||||||
|
key_links:
|
||||||
|
- from: "src/app/admin/offers/page.tsx"
|
||||||
|
to: "src/lib/offer-queries.ts"
|
||||||
|
via: "getCatalogWithMicros() server import"
|
||||||
|
pattern: "getCatalogWithMicros"
|
||||||
|
- from: "src/app/admin/offers/actions.ts"
|
||||||
|
to: "src/db/schema.ts"
|
||||||
|
via: "offer_macros, offer_micros, offer_services, offer_micro_services imports"
|
||||||
|
pattern: "offer_macros|offer_micro_services"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Build the full admin offer catalog at `/admin/offers`: create/list macro-offers, create/list micro-offers (children of macros), create/list offer services, and assign services to micro-offers via a checkbox list.
|
||||||
|
|
||||||
|
Purpose: This is the data entry point for the entire offer system. Without catalog data, Plans 03 and 04 have nothing to assign or display.
|
||||||
|
Output: `/admin/offers` page fully functional; server actions for all mutations; query layer in `offer-queries.ts`; NavBar updated.
|
||||||
|
</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
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From src/db/schema.ts (after 05-01): -->
|
||||||
|
```typescript
|
||||||
|
export const offer_macros = pgTable("offer_macros", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||||
|
internal_name: text("internal_name").notNull(),
|
||||||
|
public_name: text("public_name").notNull(),
|
||||||
|
transformation_promise: text("transformation_promise"),
|
||||||
|
sort_order: integer("sort_order").notNull().default(0),
|
||||||
|
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const offer_micros = pgTable("offer_micros", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||||
|
macro_id: text("macro_id").notNull().references(() => offer_macros.id, { onDelete: "cascade" }),
|
||||||
|
internal_name: text("internal_name").notNull(),
|
||||||
|
public_name: text("public_name").notNull(),
|
||||||
|
transformation_promise: text("transformation_promise"),
|
||||||
|
duration_months: integer("duration_months").notNull().default(1),
|
||||||
|
sort_order: integer("sort_order").notNull().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const offer_services = pgTable("offer_services", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
price: numeric("price", { precision: 10, scale: 2 }).notNull(),
|
||||||
|
transformation_description: text("transformation_description"),
|
||||||
|
active: boolean("active").notNull().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const offer_micro_services = pgTable("offer_micro_services", {
|
||||||
|
micro_id: text("micro_id").notNull(),
|
||||||
|
service_id: text("service_id").notNull(),
|
||||||
|
}, (t) => ({ pk: primaryKey({ columns: [t.micro_id, t.service_id] }) }));
|
||||||
|
|
||||||
|
export type OfferMacro = typeof offer_macros.$inferSelect;
|
||||||
|
export type OfferMicro = typeof offer_micros.$inferSelect;
|
||||||
|
export type OfferService = typeof offer_services.$inferSelect;
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- From src/app/admin/catalog/actions.ts (existing pattern to mirror): -->
|
||||||
|
```typescript
|
||||||
|
"use server";
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) throw new Error("Non autorizzato");
|
||||||
|
}
|
||||||
|
// All actions: call requireAdmin() first, then zod parse, then db mutation, then revalidatePath("/admin/offers")
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- From src/components/admin/NavBar.tsx (existing, to be extended): -->
|
||||||
|
```typescript
|
||||||
|
// Add between Catalogo and Impostazioni:
|
||||||
|
<Link href="/admin/offers" className="text-sm text-white/70 hover:text-white transition-colors">
|
||||||
|
Offerte
|
||||||
|
</Link>
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: offer-queries.ts + server actions (actions.ts)</name>
|
||||||
|
<files>
|
||||||
|
src/lib/offer-queries.ts
|
||||||
|
src/app/admin/offers/actions.ts
|
||||||
|
</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- src/db/schema.ts — confirm column names for offer_macros, offer_micros, offer_services, offer_micro_services (after 05-01)
|
||||||
|
- src/app/admin/catalog/actions.ts — copy requireAdmin() pattern, zod schema pattern, revalidatePath usage
|
||||||
|
- src/lib/admin-queries.ts — copy import style (db, eq, inArray, asc, sql from drizzle-orm)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
**Create `src/lib/offer-queries.ts`:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { db } from "@/db";
|
||||||
|
import {
|
||||||
|
offer_macros, offer_micros, offer_services, offer_micro_services,
|
||||||
|
} from "@/db/schema";
|
||||||
|
import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema";
|
||||||
|
import { eq, asc, inArray, sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
export type MicroWithServices = OfferMicro & {
|
||||||
|
services: Array<{ id: string; name: string; price: string }>;
|
||||||
|
cumulative_price: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MacroWithMicros = OfferMacro & {
|
||||||
|
micros: MicroWithServices[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCatalogWithMicros(): Promise<MacroWithMicros[]> {
|
||||||
|
const macros = await db
|
||||||
|
.select()
|
||||||
|
.from(offer_macros)
|
||||||
|
.orderBy(asc(offer_macros.sort_order), asc(offer_macros.created_at));
|
||||||
|
|
||||||
|
if (macros.length === 0) return [];
|
||||||
|
|
||||||
|
const macroIds = macros.map((m) => m.id);
|
||||||
|
|
||||||
|
const micros = await db
|
||||||
|
.select()
|
||||||
|
.from(offer_micros)
|
||||||
|
.where(inArray(offer_micros.macro_id, macroIds))
|
||||||
|
.orderBy(asc(offer_micros.sort_order));
|
||||||
|
|
||||||
|
const microIds = micros.map((m) => m.id);
|
||||||
|
|
||||||
|
if (microIds.length === 0) {
|
||||||
|
return macros.map((m) => ({ ...m, micros: [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch junction rows + service data in one query
|
||||||
|
const assignments = await db
|
||||||
|
.select({
|
||||||
|
micro_id: offer_micro_services.micro_id,
|
||||||
|
service_id: offer_services.id,
|
||||||
|
name: offer_services.name,
|
||||||
|
price: offer_services.price,
|
||||||
|
})
|
||||||
|
.from(offer_micro_services)
|
||||||
|
.innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id))
|
||||||
|
.where(inArray(offer_micro_services.micro_id, microIds));
|
||||||
|
|
||||||
|
// Cumulative price per micro
|
||||||
|
const cumulRows = 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(cumulRows.map((r) => [r.micro_id, r.cumulative_price]));
|
||||||
|
|
||||||
|
const microsWithServices: MicroWithServices[] = micros.map((micro) => ({
|
||||||
|
...micro,
|
||||||
|
services: assignments
|
||||||
|
.filter((a) => a.micro_id === micro.id)
|
||||||
|
.map((a) => ({ id: a.service_id, name: a.name, price: String(a.price) })),
|
||||||
|
cumulative_price: cumulMap.get(micro.id) ?? "0",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return macros.map((macro) => ({
|
||||||
|
...macro,
|
||||||
|
micros: microsWithServices.filter((m) => m.macro_id === macro.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllOfferServices(): Promise<OfferService[]> {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(offer_services)
|
||||||
|
.where(eq(offer_services.active, true))
|
||||||
|
.orderBy(asc(offer_services.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns assigned service IDs for a given micro-offer (used by ServiceCheckboxList)
|
||||||
|
export async function getMicroAssignedServiceIds(microId: string): Promise<string[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ service_id: offer_micro_services.service_id })
|
||||||
|
.from(offer_micro_services)
|
||||||
|
.where(eq(offer_micro_services.micro_id, microId));
|
||||||
|
return rows.map((r) => r.service_id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create `src/app/admin/offers/actions.ts`:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { db } from "@/db";
|
||||||
|
import {
|
||||||
|
offer_macros, offer_micros, offer_services, offer_micro_services,
|
||||||
|
} from "@/db/schema";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) throw new Error("Non autorizzato");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Macro-offer CRUD ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const macroSchema = z.object({
|
||||||
|
internal_name: z.string().min(1, "Nome interno richiesto"),
|
||||||
|
public_name: z.string().min(1, "Nome pubblico richiesto"),
|
||||||
|
transformation_promise: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createMacro(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const parsed = macroSchema.safeParse({
|
||||||
|
internal_name: formData.get("internal_name"),
|
||||||
|
public_name: formData.get("public_name"),
|
||||||
|
transformation_promise: formData.get("transformation_promise") ?? "",
|
||||||
|
});
|
||||||
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||||
|
await db.insert(offer_macros).values({
|
||||||
|
internal_name: parsed.data.internal_name,
|
||||||
|
public_name: parsed.data.public_name,
|
||||||
|
transformation_promise: parsed.data.transformation_promise || null,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/offers");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMacro(macroId: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
await db.delete(offer_macros).where(eq(offer_macros.id, macroId));
|
||||||
|
revalidatePath("/admin/offers");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Micro-offer CRUD ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const microSchema = z.object({
|
||||||
|
macro_id: z.string().min(1, "Macro offerta richiesta"),
|
||||||
|
internal_name: z.string().min(1, "Nome interno richiesto"),
|
||||||
|
public_name: z.string().min(1, "Nome pubblico richiesto"),
|
||||||
|
transformation_promise: z.string().optional(),
|
||||||
|
duration_months: z.coerce.number().int().min(1, "Durata minima 1 mese"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createMicro(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const parsed = microSchema.safeParse({
|
||||||
|
macro_id: formData.get("macro_id"),
|
||||||
|
internal_name: formData.get("internal_name"),
|
||||||
|
public_name: formData.get("public_name"),
|
||||||
|
transformation_promise: formData.get("transformation_promise") ?? "",
|
||||||
|
duration_months: formData.get("duration_months"),
|
||||||
|
});
|
||||||
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||||
|
await db.insert(offer_micros).values({
|
||||||
|
macro_id: parsed.data.macro_id,
|
||||||
|
internal_name: parsed.data.internal_name,
|
||||||
|
public_name: parsed.data.public_name,
|
||||||
|
transformation_promise: parsed.data.transformation_promise || null,
|
||||||
|
duration_months: parsed.data.duration_months,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/offers");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMicro(microId: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
// offer_micro_services will cascade; project_offers will restrict (DB constraint)
|
||||||
|
await db.delete(offer_micros).where(eq(offer_micros.id, microId));
|
||||||
|
revalidatePath("/admin/offers");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Offer service CRUD ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const offerServiceSchema = z.object({
|
||||||
|
name: z.string().min(1, "Nome richiesto"),
|
||||||
|
price: z.coerce.number().min(0, "Prezzo non può essere negativo"),
|
||||||
|
transformation_description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createOfferService(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const parsed = offerServiceSchema.safeParse({
|
||||||
|
name: formData.get("name"),
|
||||||
|
price: formData.get("price"),
|
||||||
|
transformation_description: formData.get("transformation_description") ?? "",
|
||||||
|
});
|
||||||
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||||
|
await db.insert(offer_services).values({
|
||||||
|
name: parsed.data.name,
|
||||||
|
price: parsed.data.price.toFixed(2),
|
||||||
|
transformation_description: parsed.data.transformation_description || null,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/offers");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleOfferServiceActive(serviceId: string, active: boolean) {
|
||||||
|
await requireAdmin();
|
||||||
|
await db.update(offer_services).set({ active }).where(eq(offer_services.id, serviceId));
|
||||||
|
revalidatePath("/admin/offers");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-select service assignment ─────────────────────────────────────────
|
||||||
|
|
||||||
|
// Replaces all assignments for a micro with the provided serviceIds (upsert-replace pattern)
|
||||||
|
export async function updateMicroOfferServices(microId: string, serviceIds: string[]) {
|
||||||
|
await requireAdmin();
|
||||||
|
// Delete existing assignments for this micro
|
||||||
|
await db.delete(offer_micro_services).where(eq(offer_micro_services.micro_id, microId));
|
||||||
|
// Re-insert with new set (empty array = unassign all)
|
||||||
|
if (serviceIds.length > 0) {
|
||||||
|
await db.insert(offer_micro_services).values(
|
||||||
|
serviceIds.map((serviceId) => ({ micro_id: microId, service_id: serviceId }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/offers");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `npx tsc --noEmit` exits 0
|
||||||
|
- `grep -c "requireAdmin" src/app/admin/offers/actions.ts` returns 5 or more (every exported action calls it)
|
||||||
|
- `grep "revalidatePath" src/app/admin/offers/actions.ts` returns at least 5 matches (every mutating action revalidates)
|
||||||
|
- `grep "getCatalogWithMicros\|getAllOfferServices\|getMicroAssignedServiceIds" src/lib/offer-queries.ts` returns 3 matches
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>offer-queries.ts and actions.ts created; TypeScript compiles; every action guards with requireAdmin()</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: /admin/offers RSC page + NavBar update</name>
|
||||||
|
<files>
|
||||||
|
src/app/admin/offers/page.tsx
|
||||||
|
src/components/admin/NavBar.tsx
|
||||||
|
</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- src/app/admin/catalog/page.tsx — read for the admin page structure pattern (RSC, revalidate = 0, form patterns)
|
||||||
|
- src/components/admin/NavBar.tsx — read full file before editing (must preserve all existing links)
|
||||||
|
- src/lib/offer-queries.ts — confirm getCatalogWithMicros and getAllOfferServices signatures (just created in Task 1)
|
||||||
|
- src/app/admin/offers/actions.ts — confirm action names (just created in Task 1)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
**Create `src/app/admin/offers/page.tsx`:**
|
||||||
|
|
||||||
|
This is an RSC page. No `"use client"` at the top level. Inline client sub-components that need interactivity are permissible as separate files or as local `"use client"` components in the same file using the pattern established by existing admin pages.
|
||||||
|
|
||||||
|
The page has three sections:
|
||||||
|
1. **Macro-offers** — list with "create" form inline; each macro shows its micro-offer children
|
||||||
|
2. **Micro-offers** per macro — create form inside each macro card; each micro shows assigned services and a `ServiceCheckboxList`
|
||||||
|
3. **Offer Services catalog** — list all offer services + create form at bottom
|
||||||
|
|
||||||
|
For `ServiceCheckboxList`: this is a `"use client"` component. Create it as a named export in the same page file or as a separate file at `src/components/admin/offers/ServiceCheckboxList.tsx`. It uses `useState(Set<string>)` + `useTransition` + `router.refresh()` pattern from the research (Pattern 3).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/components/admin/offers/ServiceCheckboxList.tsx
|
||||||
|
"use client";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { updateMicroOfferServices } from "@/app/admin/offers/actions";
|
||||||
|
|
||||||
|
export function ServiceCheckboxList({
|
||||||
|
allServices,
|
||||||
|
assignedIds,
|
||||||
|
microId,
|
||||||
|
}: {
|
||||||
|
allServices: Array<{ id: string; name: string; price: string }>;
|
||||||
|
assignedIds: string[];
|
||||||
|
microId: string;
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = useState(new Set(assignedIds));
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function toggle(serviceId: string) {
|
||||||
|
const next = new Set(selected);
|
||||||
|
if (next.has(serviceId)) next.delete(serviceId);
|
||||||
|
else next.add(serviceId);
|
||||||
|
setSelected(next);
|
||||||
|
startTransition(async () => {
|
||||||
|
await updateMicroOfferServices(microId, [...next]);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{allServices.map((svc) => (
|
||||||
|
<label key={svc.id} className="flex items-center gap-2 cursor-pointer text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(svc.id)}
|
||||||
|
onChange={() => toggle(svc.id)}
|
||||||
|
disabled={isPending}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span>{svc.name}</span>
|
||||||
|
<span className="ml-auto text-xs text-[#71717a]">€{parseFloat(svc.price).toFixed(2)}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{allServices.length === 0 && (
|
||||||
|
<p className="text-xs text-[#71717a]">Nessun servizio nel catalogo ancora.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Page structure for `src/app/admin/offers/page.tsx`:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getCatalogWithMicros, getAllOfferServices } from "@/lib/offer-queries";
|
||||||
|
import { createMacro, createMicro, createOfferService, deleteMacro, deleteMicro, toggleOfferServiceActive } from "./actions";
|
||||||
|
import { ServiceCheckboxList } from "@/components/admin/offers/ServiceCheckboxList";
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function OffersPage() {
|
||||||
|
const [catalog, allServices] = await Promise.all([
|
||||||
|
getCatalogWithMicros(),
|
||||||
|
getAllOfferServices(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto py-8 px-4 space-y-10">
|
||||||
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Offerte</h1>
|
||||||
|
|
||||||
|
{/* ── Sezione macro-offerte ── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-[#1a1a1a] mb-4">Macro-Offerte</h2>
|
||||||
|
|
||||||
|
{/* Create macro form */}
|
||||||
|
<form action={createMacro} className="bg-white rounded-lg border border-[#e5e7eb] p-4 mb-6 space-y-3 max-w-lg">
|
||||||
|
<p className="text-sm font-medium">Nuova Macro-Offerta</p>
|
||||||
|
<input name="internal_name" placeholder="Nome interno (es. Entry Offer)" required
|
||||||
|
className="w-full border rounded px-3 py-1.5 text-sm" />
|
||||||
|
<input name="public_name" placeholder="Nome pubblico (es. Starter Branding)"
|
||||||
|
required className="w-full border rounded px-3 py-1.5 text-sm" />
|
||||||
|
<textarea name="transformation_promise" placeholder="Promessa di trasformazione"
|
||||||
|
rows={2} className="w-full border rounded px-3 py-1.5 text-sm" />
|
||||||
|
<button type="submit"
|
||||||
|
className="bg-[#1A463C] text-white text-sm px-4 py-1.5 rounded hover:bg-[#163a31]">
|
||||||
|
Aggiungi Macro
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* List macros */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
{catalog.map((macro) => (
|
||||||
|
<div key={macro.id} className="bg-white rounded-lg border border-[#e5e7eb] p-6">
|
||||||
|
<div className="flex items-start justify-between mb-1">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-[#1a1a1a]">{macro.internal_name}</p>
|
||||||
|
<p className="text-sm text-[#71717a]">Pubblico: {macro.public_name}</p>
|
||||||
|
{macro.transformation_promise && (
|
||||||
|
<p className="text-xs text-[#71717a] mt-1 italic">{macro.transformation_promise}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<form action={deleteMacro.bind(null, macro.id)}>
|
||||||
|
<button type="submit" className="text-xs text-red-600 hover:underline">Elimina</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Micro-offers under this macro */}
|
||||||
|
<div className="mt-4 space-y-4 pl-4 border-l-2 border-[#e5e7eb]">
|
||||||
|
<p className="text-xs font-semibold text-[#71717a] uppercase tracking-wider">Micro-Offerte</p>
|
||||||
|
|
||||||
|
{macro.micros.map((micro) => (
|
||||||
|
<div key={micro.id} className="bg-[#f9f9f9] rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{micro.internal_name}</p>
|
||||||
|
<p className="text-xs text-[#71717a]">Pubblico: {micro.public_name} · {micro.duration_months} {micro.duration_months === 1 ? "mese" : "mesi"}</p>
|
||||||
|
{micro.transformation_promise && (
|
||||||
|
<p className="text-xs text-[#71717a] italic">{micro.transformation_promise}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-[#1a1a1a] mt-1 font-medium">
|
||||||
|
Prezzo cumulativo: €{parseFloat(micro.cumulative_price).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form action={deleteMicro.bind(null, micro.id)}>
|
||||||
|
<button type="submit" className="text-xs text-red-600 hover:underline">Elimina</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/* Service assignment checkbox list */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-[#71717a] mb-2">Servizi inclusi:</p>
|
||||||
|
<ServiceCheckboxList
|
||||||
|
allServices={allServices.map((s) => ({ id: s.id, name: s.name, price: String(s.price) }))}
|
||||||
|
assignedIds={micro.services.map((s) => s.id)}
|
||||||
|
microId={micro.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Create micro form */}
|
||||||
|
<form action={createMicro} className="bg-white rounded border border-[#e5e7eb] p-3 space-y-2">
|
||||||
|
<input type="hidden" name="macro_id" value={macro.id} />
|
||||||
|
<p className="text-xs font-medium">Nuova Micro-Offerta</p>
|
||||||
|
<input name="internal_name" placeholder="Nome interno" required
|
||||||
|
className="w-full border rounded px-2 py-1 text-xs" />
|
||||||
|
<input name="public_name" placeholder="Nome pubblico" required
|
||||||
|
className="w-full border rounded px-2 py-1 text-xs" />
|
||||||
|
<textarea name="transformation_promise" placeholder="Promessa di trasformazione"
|
||||||
|
rows={2} className="w-full border rounded px-2 py-1 text-xs" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs">Durata (mesi):</label>
|
||||||
|
<input name="duration_months" type="number" min="1" defaultValue="1"
|
||||||
|
required className="w-16 border rounded px-2 py-1 text-xs" />
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
className="bg-[#1A463C] text-white text-xs px-3 py-1 rounded hover:bg-[#163a31]">
|
||||||
|
Aggiungi Micro
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{catalog.length === 0 && (
|
||||||
|
<p className="text-sm text-[#71717a]">Nessuna macro-offerta ancora. Creane una sopra.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Sezione servizi offerta ── */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold text-[#1a1a1a] mb-4">Servizi Offerta</h2>
|
||||||
|
<p className="text-sm text-[#71717a] mb-4">
|
||||||
|
I servizi qui sono diversi dal catalogo preventivi — hanno una descrizione della trasformazione e vengono raggruppati nelle micro-offerte.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* List services */}
|
||||||
|
<div className="bg-white rounded-lg border border-[#e5e7eb] divide-y divide-[#e5e7eb] mb-6">
|
||||||
|
{allServices.map((svc) => (
|
||||||
|
<div key={svc.id} className="flex items-center justify-between px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{svc.name}</p>
|
||||||
|
{svc.transformation_description && (
|
||||||
|
<p className="text-xs text-[#71717a]">{svc.transformation_description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm font-mono">€{parseFloat(String(svc.price)).toFixed(2)}</span>
|
||||||
|
<form action={toggleOfferServiceActive.bind(null, svc.id, !svc.active)}>
|
||||||
|
<button type="submit" className="text-xs text-[#71717a] hover:text-[#1a1a1a]">
|
||||||
|
{svc.active ? "Disattiva" : "Attiva"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{allServices.length === 0 && (
|
||||||
|
<p className="px-4 py-3 text-sm text-[#71717a]">Nessun servizio ancora.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create service form */}
|
||||||
|
<form action={createOfferService}
|
||||||
|
className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-3 max-w-lg">
|
||||||
|
<p className="text-sm font-medium">Nuovo Servizio Offerta</p>
|
||||||
|
<input name="name" placeholder="Nome servizio" required
|
||||||
|
className="w-full border rounded px-3 py-1.5 text-sm" />
|
||||||
|
<input name="price" type="number" step="0.01" min="0" placeholder="Prezzo (€)" required
|
||||||
|
className="w-full border rounded px-3 py-1.5 text-sm" />
|
||||||
|
<textarea name="transformation_description" placeholder="Descrizione trasformazione (marketing)"
|
||||||
|
rows={2} className="w-full border rounded px-3 py-1.5 text-sm" />
|
||||||
|
<button type="submit"
|
||||||
|
className="bg-[#1A463C] text-white text-sm px-4 py-1.5 rounded hover:bg-[#163a31]">
|
||||||
|
Aggiungi Servizio
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update `src/components/admin/NavBar.tsx`:**
|
||||||
|
|
||||||
|
Read the full file first. Add the "Offerte" link between the "Catalogo" link and the "Impostazioni" link. Also add a "Forecast" link between "Statistiche" and "Catalogo":
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// After the Statistiche link:
|
||||||
|
<Link href="/admin/forecast" className="text-sm text-white/70 hover:text-white transition-colors">
|
||||||
|
Forecast
|
||||||
|
</Link>
|
||||||
|
// After the Catalogo link (before Impostazioni):
|
||||||
|
<Link href="/admin/offers" className="text-sm text-white/70 hover:text-white transition-colors">
|
||||||
|
Offerte
|
||||||
|
</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
Final NavBar order: Clienti | Progetti | Statistiche | Forecast | Catalogo | Offerte | Impostazioni
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `npx tsc --noEmit` exits 0
|
||||||
|
- `grep "/admin/offers" src/components/admin/NavBar.tsx` matches (NavBar has Offerte link)
|
||||||
|
- `grep "/admin/forecast" src/components/admin/NavBar.tsx` matches (NavBar has Forecast link)
|
||||||
|
- `grep "ServiceCheckboxList" src/app/admin/offers/page.tsx` matches
|
||||||
|
- `grep "getCatalogWithMicros\|getAllOfferServices" src/app/admin/offers/page.tsx` returns 2 matches
|
||||||
|
- `grep "updateMicroOfferServices" src/components/admin/offers/ServiceCheckboxList.tsx` matches
|
||||||
|
- File `src/app/admin/offers/page.tsx` exists
|
||||||
|
- File `src/components/admin/offers/ServiceCheckboxList.tsx` exists
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>/admin/offers page renders macro-offers, micro-offers, services; ServiceCheckboxList multi-select works; NavBar has Offerte and Forecast links; TypeScript compiles</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser → Server Actions | Admin form submissions reach offer-actions.ts |
|
||||||
|
| Admin session → Server Actions | Only authenticated admin can mutate offer data |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-05-03 | Elevation of Privilege | actions.ts — all exported functions | mitigate | `requireAdmin()` as first call in every exported server action; throws if session absent |
|
||||||
|
| T-05-04 | Tampering | updateMicroOfferServices — delete+re-insert pattern | mitigate | Both operations run within the same server action under requireAdmin(); atomic from the client perspective |
|
||||||
|
| T-05-05 | Information Disclosure | /admin/offers page | accept | Page is under /admin/* — Auth.js session guard in middleware protects the route; no extra exposure |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
After both tasks complete:
|
||||||
|
- `npx tsc --noEmit` exits 0
|
||||||
|
- Visit `/admin/offers` → page loads without error
|
||||||
|
- Create a macro-offer → appears in the list
|
||||||
|
- Create a micro-offer under the macro → appears nested
|
||||||
|
- Create an offer service → appears in services list
|
||||||
|
- Toggle a checkbox for a service on a micro → assignment persists on refresh
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
1. Admin can create macro-offers with all three fields (internal_name, public_name, transformation_promise)
|
||||||
|
2. Admin can create micro-offers with all fields including duration_months
|
||||||
|
3. Admin can create offer services (separate from service_catalog)
|
||||||
|
4. Checkbox list correctly assigns/unassigns services to micro-offers
|
||||||
|
5. NavBar shows "Offerte" and "Forecast" links
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/05-offer-system/05-02-SUMMARY.md` using the summary template.
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
---
|
||||||
|
plan_id: 05-03
|
||||||
|
phase: 5
|
||||||
|
wave: 3
|
||||||
|
title: "Project offer assignment — OffersTab in project workspace + offer queries extension"
|
||||||
|
type: execute
|
||||||
|
depends_on: [05-01, 05-02]
|
||||||
|
files_modified:
|
||||||
|
- src/lib/admin-queries.ts
|
||||||
|
- src/app/admin/projects/[id]/page.tsx
|
||||||
|
- src/app/admin/projects/project-actions.ts
|
||||||
|
- src/components/admin/tabs/OffersTab.tsx
|
||||||
|
requirements_addressed: [OFFER-04]
|
||||||
|
autonomous: true
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Admin can assign a micro-offer to a project from the project workspace Offerte tab"
|
||||||
|
- "The Offerte tab shows all active offers assigned to a project with micro name, duration, and accepted_total"
|
||||||
|
- "Admin can set the accepted_total on a project offer assignment"
|
||||||
|
- "ProjectFullDetail type includes projectOffers array"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/components/admin/tabs/OffersTab.tsx"
|
||||||
|
provides: "Client component for assigning and viewing project offers"
|
||||||
|
contains: "assign form, offer list, accepted_total input"
|
||||||
|
- path: "src/app/admin/projects/project-actions.ts"
|
||||||
|
provides: "Server actions for project offer assignment mutations"
|
||||||
|
contains: "assignOfferToProject, removeProjectOffer, updateProjectOfferTotal"
|
||||||
|
key_links:
|
||||||
|
- from: "src/app/admin/projects/[id]/page.tsx"
|
||||||
|
to: "src/lib/admin-queries.ts"
|
||||||
|
via: "getProjectFullDetail() — extended to include projectOffers"
|
||||||
|
pattern: "projectOffers"
|
||||||
|
- from: "src/components/admin/tabs/OffersTab.tsx"
|
||||||
|
to: "src/app/admin/projects/project-actions.ts"
|
||||||
|
via: "assignOfferToProject server action"
|
||||||
|
pattern: "assignOfferToProject"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add an "Offerte" tab to the project workspace at `/admin/projects/[id]` that lets the admin assign micro-offers to a project, set the offer-level accepted total, and view all active assignments.
|
||||||
|
|
||||||
|
Purpose: This is the assignment layer — it connects the offer catalog (Plan 02) to specific projects. Without this, offers exist in the catalog but cannot be associated with client work.
|
||||||
|
Output: `OffersTab.tsx` component; project-actions.ts extended with offer actions; `getProjectFullDetail` extended to return `projectOffers`; `/admin/projects/[id]` page updated with the new tab.
|
||||||
|
</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-02-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From src/lib/admin-queries.ts — ProjectFullDetail type to extend: -->
|
||||||
|
```typescript
|
||||||
|
export type ProjectFullDetail = {
|
||||||
|
project: Project & { client: { id: string; name: string; brand_name: string; slug: string | null } };
|
||||||
|
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
|
||||||
|
payments: Payment[];
|
||||||
|
documents: Document[];
|
||||||
|
notes: Note[];
|
||||||
|
comments: Comment[];
|
||||||
|
quoteItems: QuoteItemWithLabel[];
|
||||||
|
activeServices: ServiceCatalog[];
|
||||||
|
activeTimerEntryId: string | null;
|
||||||
|
activeTimerStartedAt: Date | null;
|
||||||
|
totalTrackedSeconds: number;
|
||||||
|
// ADD:
|
||||||
|
// projectOffers: ProjectOfferWithMicro[];
|
||||||
|
// availableMicros: OfferMicro[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- From src/app/admin/projects/[id]/page.tsx — Tabs structure to extend: -->
|
||||||
|
```tsx
|
||||||
|
// Existing tabs: phases | payments | documents | notes | comments | quote | timer
|
||||||
|
// ADD: <TabsTrigger value="offers">Offerte</TabsTrigger>
|
||||||
|
// ADD: <TabsContent value="offers"><OffersTab ... /></TabsContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- From src/db/schema.ts (after 05-01): -->
|
||||||
|
```typescript
|
||||||
|
export const project_offers = pgTable("project_offers", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
project_id: text("project_id").notNull(), // FK → projects
|
||||||
|
micro_id: text("micro_id").notNull(), // FK → offer_micros (RESTRICT on delete)
|
||||||
|
start_date: timestamp(...).notNull().defaultNow(),
|
||||||
|
accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }), // nullable
|
||||||
|
created_at: timestamp(...).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
export type ProjectOffer = typeof project_offers.$inferSelect;
|
||||||
|
export type OfferMicro = typeof offer_micros.$inferSelect;
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- From src/app/admin/catalog/actions.ts — pattern for project-actions.ts additions: -->
|
||||||
|
```typescript
|
||||||
|
"use server";
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) throw new Error("Non autorizzato");
|
||||||
|
}
|
||||||
|
// revalidatePath(`/admin/projects/${projectId}`);
|
||||||
|
// revalidatePath("/admin/forecast");
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Extend getProjectFullDetail + add project offer server actions</name>
|
||||||
|
<files>
|
||||||
|
src/lib/admin-queries.ts
|
||||||
|
src/app/admin/projects/project-actions.ts
|
||||||
|
</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- src/lib/admin-queries.ts — read the FULL file; understand ProjectFullDetail type and getProjectFullDetail function
|
||||||
|
- src/app/admin/projects/project-actions.ts — read the FULL file; understand existing action patterns
|
||||||
|
- src/db/schema.ts — confirm project_offers, offer_micros column names after 05-01
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
**Extend `src/lib/admin-queries.ts`:**
|
||||||
|
|
||||||
|
1. Add imports for new offer tables at the top of the import block:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
// existing imports...
|
||||||
|
offer_micros, project_offers,
|
||||||
|
} from "@/db/schema";
|
||||||
|
import type {
|
||||||
|
// existing types...
|
||||||
|
OfferMicro, ProjectOffer,
|
||||||
|
} from "@/db/schema";
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add a new type after the existing types:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type ProjectOfferWithMicro = {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
micro_id: string;
|
||||||
|
micro_internal_name: string;
|
||||||
|
micro_public_name: string;
|
||||||
|
micro_duration_months: number;
|
||||||
|
start_date: Date;
|
||||||
|
accepted_total: string | null;
|
||||||
|
created_at: Date;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Extend `ProjectFullDetail` type — add two fields at the end of the type definition:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
projectOffers: ProjectOfferWithMicro[];
|
||||||
|
availableMicros: Array<{ id: string; internal_name: string; public_name: string; duration_months: number }>;
|
||||||
|
```
|
||||||
|
|
||||||
|
4. In the `getProjectFullDetail` function, extend the parallel `Promise.all` array to include two new queries alongside the existing ones. Add them to the destructuring and return. The existing `Promise.all` is at line ~491; add two new queries to the array:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Add these two to the Promise.all:
|
||||||
|
|
||||||
|
// Query A: project offers for this project joined with micro info
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: project_offers.id,
|
||||||
|
project_id: project_offers.project_id,
|
||||||
|
micro_id: project_offers.micro_id,
|
||||||
|
micro_internal_name: offer_micros.internal_name,
|
||||||
|
micro_public_name: offer_micros.public_name,
|
||||||
|
micro_duration_months: offer_micros.duration_months,
|
||||||
|
start_date: project_offers.start_date,
|
||||||
|
accepted_total: project_offers.accepted_total,
|
||||||
|
created_at: project_offers.created_at,
|
||||||
|
})
|
||||||
|
.from(project_offers)
|
||||||
|
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
|
||||||
|
.where(eq(project_offers.project_id, id))
|
||||||
|
.orderBy(asc(project_offers.created_at)),
|
||||||
|
|
||||||
|
// Query B: all active micro-offers (for the assignment dropdown)
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: offer_micros.id,
|
||||||
|
internal_name: offer_micros.internal_name,
|
||||||
|
public_name: offer_micros.public_name,
|
||||||
|
duration_months: offer_micros.duration_months,
|
||||||
|
})
|
||||||
|
.from(offer_micros)
|
||||||
|
.orderBy(asc(offer_micros.internal_name)),
|
||||||
|
```
|
||||||
|
|
||||||
|
Destructure the two new results from `Promise.all` as `projectOffersRows` and `availableMicrosRows`. Add them to the return object:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
|
||||||
|
availableMicros: availableMicrosRows,
|
||||||
|
```
|
||||||
|
|
||||||
|
**Extend `src/app/admin/projects/project-actions.ts`:**
|
||||||
|
|
||||||
|
Add these three new server actions at the end of the file. Copy the `requireAdmin()` pattern exactly as it appears in the existing file:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ── Offer assignment actions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { project_offers } from "@/db/schema"; // add to existing imports at top
|
||||||
|
|
||||||
|
const assignOfferSchema = z.object({
|
||||||
|
project_id: z.string().min(1),
|
||||||
|
micro_id: z.string().min(1, "Seleziona una micro-offerta"),
|
||||||
|
accepted_total: z.coerce.number().min(0).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function assignOfferToProject(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const parsed = assignOfferSchema.safeParse({
|
||||||
|
project_id: formData.get("project_id"),
|
||||||
|
micro_id: formData.get("micro_id"),
|
||||||
|
accepted_total: formData.get("accepted_total") || undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||||
|
await db.insert(project_offers).values({
|
||||||
|
project_id: parsed.data.project_id,
|
||||||
|
micro_id: parsed.data.micro_id,
|
||||||
|
accepted_total: parsed.data.accepted_total !== undefined
|
||||||
|
? parsed.data.accepted_total.toFixed(2)
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
revalidatePath(`/admin/projects/${parsed.data.project_id}`);
|
||||||
|
revalidatePath("/admin/forecast");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeProjectOffer(projectOfferId: string, projectId: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
await db.delete(project_offers).where(eq(project_offers.id, projectOfferId));
|
||||||
|
revalidatePath(`/admin/projects/${projectId}`);
|
||||||
|
revalidatePath("/admin/forecast");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProjectOfferTotal(
|
||||||
|
projectOfferId: string,
|
||||||
|
projectId: string,
|
||||||
|
accepted_total: string
|
||||||
|
) {
|
||||||
|
await requireAdmin();
|
||||||
|
const amount = parseFloat(accepted_total);
|
||||||
|
if (isNaN(amount) || amount < 0) throw new Error("Importo non valido");
|
||||||
|
await db
|
||||||
|
.update(project_offers)
|
||||||
|
.set({ accepted_total: amount.toFixed(2) })
|
||||||
|
.where(eq(project_offers.id, projectOfferId));
|
||||||
|
revalidatePath(`/admin/projects/${projectId}`);
|
||||||
|
revalidatePath("/admin/forecast");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `project-actions.ts` already imports `z`, `revalidatePath`, `db`, `eq`, and `requireAdmin()` — check the existing imports and add only what is missing (`project_offers` table import).
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `npx tsc --noEmit` exits 0
|
||||||
|
- `grep "projectOffers\|availableMicros" src/lib/admin-queries.ts` returns at least 4 matches (type definition + return)
|
||||||
|
- `grep "assignOfferToProject\|removeProjectOffer\|updateProjectOfferTotal" src/app/admin/projects/project-actions.ts` returns 3 matches
|
||||||
|
- `grep "revalidatePath.*forecast" src/app/admin/projects/project-actions.ts` returns at least 3 matches (one per offer action)
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>admin-queries.ts extended with projectOffers field; project-actions.ts has three new offer actions; TypeScript compiles</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: OffersTab component + project workspace page update</name>
|
||||||
|
<files>
|
||||||
|
src/components/admin/tabs/OffersTab.tsx
|
||||||
|
src/app/admin/projects/[id]/page.tsx
|
||||||
|
</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- src/app/admin/projects/[id]/page.tsx — read FULL file before editing (understand current Tabs structure, destructuring, imports)
|
||||||
|
- src/components/admin/tabs/QuoteTab.tsx — read first 40 lines for component prop pattern (not logic)
|
||||||
|
- src/lib/admin-queries.ts — confirm ProjectOfferWithMicro and availableMicros types (just extended in Task 1)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
**Create `src/components/admin/tabs/OffersTab.tsx`:**
|
||||||
|
|
||||||
|
This is a `"use client"` component that handles assignment form submission and inline accepted_total editing.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition, useRef } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
assignOfferToProject,
|
||||||
|
removeProjectOffer,
|
||||||
|
updateProjectOfferTotal,
|
||||||
|
} from "@/app/admin/projects/project-actions";
|
||||||
|
import type { ProjectOfferWithMicro } from "@/lib/admin-queries";
|
||||||
|
|
||||||
|
type AvailableMicro = {
|
||||||
|
id: string;
|
||||||
|
internal_name: string;
|
||||||
|
public_name: string;
|
||||||
|
duration_months: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface OffersTabProps {
|
||||||
|
projectId: string;
|
||||||
|
projectOffers: ProjectOfferWithMicro[];
|
||||||
|
availableMicros: AvailableMicro[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OffersTab({ projectId, projectOffers, availableMicros }: OffersTabProps) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
function handleAssign(formData: FormData) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await assignOfferToProject(formData);
|
||||||
|
formRef.current?.reset();
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(offerId: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await removeProjectOffer(offerId, projectId);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTotalUpdate(offerId: string, value: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
await updateProjectOfferTotal(offerId, projectId, value);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-2xl">
|
||||||
|
{/* Active assignments */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Offerte Attive</h3>
|
||||||
|
{projectOffers.length === 0 ? (
|
||||||
|
<p className="text-sm text-[#71717a]">Nessuna offerta assegnata a questo progetto.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{projectOffers.map((offer) => (
|
||||||
|
<div
|
||||||
|
key={offer.id}
|
||||||
|
className="bg-white rounded-lg border border-[#e5e7eb] p-4 flex items-start justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-[#1a1a1a]">{offer.micro_internal_name}</p>
|
||||||
|
<p className="text-xs text-[#71717a]">
|
||||||
|
Pubblico: {offer.micro_public_name} · {offer.micro_duration_months}{" "}
|
||||||
|
{offer.micro_duration_months === 1 ? "mese" : "mesi"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[#71717a] mt-1">
|
||||||
|
Inizio: {new Date(offer.start_date).toLocaleDateString("it-IT")}
|
||||||
|
</p>
|
||||||
|
{/* Accepted total inline edit */}
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<label className="text-xs text-[#71717a]">Totale accettato €:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
defaultValue={offer.accepted_total ?? ""}
|
||||||
|
placeholder="0.00"
|
||||||
|
onBlur={(e) => {
|
||||||
|
const val = e.currentTarget.value.trim();
|
||||||
|
if (val !== (offer.accepted_total ?? "")) {
|
||||||
|
handleTotalUpdate(offer.id, val);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-24 border rounded px-2 py-0.5 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemove(offer.id)}
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-xs text-red-600 hover:underline shrink-0"
|
||||||
|
>
|
||||||
|
Rimuovi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assignment form */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Assegna Micro-Offerta</h3>
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
action={handleAssign}
|
||||||
|
className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="project_id" value={projectId} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[#71717a] block mb-1">Micro-offerta</label>
|
||||||
|
<select
|
||||||
|
name="micro_id"
|
||||||
|
required
|
||||||
|
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Seleziona micro-offerta...</option>
|
||||||
|
{availableMicros.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.internal_name} ({m.duration_months}{" "}
|
||||||
|
{m.duration_months === 1 ? "mese" : "mesi"})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[#71717a] block mb-1">Totale accettato € (opzionale)</label>
|
||||||
|
<input
|
||||||
|
name="accepted_total"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="0.00"
|
||||||
|
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending || availableMicros.length === 0}
|
||||||
|
className="bg-[#1A463C] text-white text-sm px-4 py-1.5 rounded hover:bg-[#163a31] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isPending ? "Salvataggio..." : "Assegna Offerta"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{availableMicros.length === 0 && (
|
||||||
|
<p className="text-xs text-[#71717a]">
|
||||||
|
Nessuna micro-offerta disponibile. Crea prima una micro-offerta in{" "}
|
||||||
|
<a href="/admin/offers" className="underline">Offerte</a>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update `src/app/admin/projects/[id]/page.tsx`:**
|
||||||
|
|
||||||
|
Read the full file. Make these three targeted changes:
|
||||||
|
|
||||||
|
1. Add import for `OffersTab` at the top of the imports:
|
||||||
|
```typescript
|
||||||
|
import { OffersTab } from "@/components/admin/tabs/OffersTab";
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Destructure the two new fields from `detail`:
|
||||||
|
```typescript
|
||||||
|
// Add to the existing destructuring:
|
||||||
|
const {
|
||||||
|
// ...existing fields...
|
||||||
|
projectOffers,
|
||||||
|
availableMicros,
|
||||||
|
} = detail;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add the Offerte tab trigger and content inside the `<Tabs>` component, after the `<TabsTrigger value="timer">Timer</TabsTrigger>` line:
|
||||||
|
```tsx
|
||||||
|
<TabsTrigger value="offers">Offerte</TabsTrigger>
|
||||||
|
```
|
||||||
|
And after the last `<TabsContent>`:
|
||||||
|
```tsx
|
||||||
|
<TabsContent value="offers">
|
||||||
|
<OffersTab
|
||||||
|
projectId={id}
|
||||||
|
projectOffers={projectOffers}
|
||||||
|
availableMicros={availableMicros}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `npx tsc --noEmit` exits 0
|
||||||
|
- `grep "OffersTab" src/app/admin/projects/[id]/page.tsx` returns 2 matches (import + usage)
|
||||||
|
- `grep "projectOffers\|availableMicros" src/app/admin/projects/[id]/page.tsx` returns at least 2 matches
|
||||||
|
- File `src/components/admin/tabs/OffersTab.tsx` exists
|
||||||
|
- `grep "assignOfferToProject\|removeProjectOffer\|updateProjectOfferTotal" src/components/admin/tabs/OffersTab.tsx` returns 3 matches
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>OffersTab created; project workspace page has Offerte tab; admin can assign micro-offers and set accepted_total; TypeScript compiles</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser → project-actions.ts | Admin assigns offers to projects via server actions |
|
||||||
|
| Admin session → project_offers mutations | Unauthenticated access must be rejected |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-05-06 | Elevation of Privilege | assignOfferToProject, removeProjectOffer, updateProjectOfferTotal | mitigate | `requireAdmin()` as first call in each action |
|
||||||
|
| T-05-07 | Tampering | removeProjectOffer — deletes project_offer row | mitigate | Action requires projectId param for revalidatePath only; deletion targets by projectOfferId (PK); no bulk delete possible |
|
||||||
|
| T-05-08 | Information Disclosure | OffersTab renders micro internal_name | accept | Tab is under /admin/projects/* — Auth.js session guard; internal_name exposure to admin is intentional and correct |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
After both tasks complete:
|
||||||
|
- `npx tsc --noEmit` exits 0
|
||||||
|
- Visit `/admin/projects/[id]` → Offerte tab is visible
|
||||||
|
- Offerte tab shows empty state when no offers assigned
|
||||||
|
- Assign a micro-offer → appears in the active list
|
||||||
|
- Set accepted_total by blurring the input → value persists on refresh
|
||||||
|
- Remove an assignment → disappears from list
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
1. Admin can assign a micro-offer to a project with optional accepted_total
|
||||||
|
2. Project workspace Offerte tab lists active assignments with micro name, duration, accepted_total
|
||||||
|
3. Admin can update the accepted_total inline (onBlur save)
|
||||||
|
4. Admin can remove a project offer assignment
|
||||||
|
5. All changes revalidate /admin/forecast path
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/05-offer-system/05-03-SUMMARY.md` using the summary template.
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
---
|
||||||
|
plan_id: 05-04
|
||||||
|
phase: 5
|
||||||
|
wave: 3
|
||||||
|
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>
|
||||||
Reference in New Issue
Block a user