diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md
index 6c169d1..9bff1e5 100644
--- a/.planning/REQUIREMENTS.md
+++ b/.planning/REQUIREMENTS.md
@@ -13,8 +13,11 @@ v2.0 (Phases 7–10) ha consegnato catalogo unificato, offerte con fasi, prevent
| OFFER-08 | L'utente assegna tag multi-select a servizi/offerte, creando nuovi tag al volo senza uscire dalla tabella | Complete |
| OFFER-09 | L'utente aggiunge un nuovo servizio/offerta tramite riga vuota in fondo alla tabella (nome + invio) | Complete |
| OFFER-10 | L'utente filtra/cerca servizi e offerte istantaneamente (ricerca client-side, nessun reload) | Complete |
-| OFFER-11 | L'utente compone un'offerta trascinando servizi dal catalogo (`@dnd-kit`), con totale live aggiornato durante il drag | Pending |
-| OFFER-12 | L'utente importa un CSV one-shot per popolare servizi/offerte esistenti (solo seed iniziale, non sync continuo) | Pending |
+| OFFER-11 | L'admin compone un'offerta a 3 tier (A/B/C) assegnando i servizi del catalogo unificato `services` tramite matrice di checkbox (servizio × tier); il totale servizi di ogni tier si aggiorna live | Pending |
+| OFFER-15 | Ogni offerta ha tag su 4 dimensioni (categoria, ticket, tipo, obiettivo) creabili al volo, riusando il sistema tag polimorfico di Phase 11 | Pending |
+| OFFER-16 | Per ogni tier l'admin imposta un "prezzo pubblico" manuale, distinto e indipendente dal totale dei servizi | Pending |
+| OFFER-17 | Ogni offerta ha una promessa di trasformazione strutturata (Cliente Ideale / Risultato / tempo / Pain / Metodo) | Pending |
+| OFFER-18 | La lista offerte è filtrabile per categoria e supporta l'archiviazione (mostra/nascondi archiviate) | Pending |
| OFFER-13 | I dati legacy di `service_catalog` e `offer_services` sono consolidati in `services` senza perdita (migration additiva) | Complete |
## Workspace Progetto — Servizi Attivi (v1)
@@ -57,6 +60,7 @@ v2.0 (Phases 7–10) ha consegnato catalogo unificato, offerte con fasi, prevent
### Catalogo Avanzato
+- **OFFER-12**: Import CSV/Notion one-shot per popolare servizi/offerte esistenti (solo seed iniziale, non sync continuo) — *rimandato il 2026-06-14 su decisione utente ("per ora saltiamo"); rientra nel filone "import dati da Notion" da valutare in una fase futura*
- **OFFER-14**: Sezioni analitiche stile Notion per le offerte (psicologia, rating, performance)
### Accesso Cliente
@@ -92,7 +96,11 @@ Popolata dal roadmapper durante la creazione della roadmap.
| OFFER-10 | Phase 11 | Complete |
| OFFER-13 | Phase 11 | Complete |
| OFFER-11 | Phase 12 | Pending |
-| OFFER-12 | Phase 12 | Pending |
+| OFFER-15 | Phase 12 | Pending |
+| OFFER-16 | Phase 12 | Pending |
+| OFFER-17 | Phase 12 | Pending |
+| OFFER-18 | Phase 12 | Pending |
+| OFFER-12 | Deferred (v2) | Rimandato 2026-06-14 |
| PROJ-06 | Phase 13 | Pending |
| PROJ-07 | Phase 13 | Pending |
| PROJ-08 | Phase 13 | Pending |
@@ -112,4 +120,4 @@ Popolata dal roadmapper durante la creazione della roadmap.
---
*Requirements defined: 2026-06-13*
-*Last updated: 2026-06-13 after milestone v2.1 kickoff*
+*Last updated: 2026-06-14 — Phase 12 re-scoped da mockup utente: editor offerte (OFFER-11 ridefinito + OFFER-15..18), import CSV OFFER-12 rimandato*
diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 1268785..79aa8cf 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -9,7 +9,7 @@
## Phases
- [x] **Phase 11: Catalog Database-View UX & Legacy Consolidation** - Catalogo `services` come tabella con inline edit, tag multi-select, quick-add, ricerca istantanea; consolidamento `service_catalog`/`offer_services` → `services`
-- [ ] **Phase 12: Offer Composition Drag&Drop & CSV Import** - Composizione offerte via drag&drop dal catalogo (`@dnd-kit`) con totale live; import CSV one-shot
+- [ ] **Phase 12: Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico** - Editor offerte completo: lista filtrabile + 3 tier (A/B/C) via matrice checkbox sui servizi `services`, totale live per tier, prezzo pubblico manuale, tag multi-dimensione, promessa di trasformazione (import CSV/Notion rimandato)
- [ ] **Phase 13: Workspace — Servizi Attivi** - Tab "Preventivo" rimossa (UI-only), "Offerte" → "Servizi attivi" (one-shot/ricorrenti + tracking incassi mensili), rimozione pagina Forecast
- [x] **Phase 14: CRM Attio-style & Fix** - Redesign `/admin/leads` stile Attio/Pipedrive (inline edit + tag), fix FollowUpWidget IT / LeadForm types / SendQuoteModal rami irraggiungibili (completed 2026-06-14)
- [ ] **Phase 15: Dashboard Revenue Stats** - Dashboard admin mostra statistiche revenue mensile (ex `/admin/forecast`) da `project_offers` — **BLOCCATA**, attesa mockup utente
@@ -38,18 +38,30 @@
**UI hint**: yes
**Status**: ✅ Complete (2026-06-13) — migration 0007 (`services.fase`) applicata in prod, commit pushato su `gitea/main`
-### Phase 12: Offer Composition Drag&Drop & CSV Import
-**Goal**: L'utente compone un'offerta trascinando servizi dal catalogo con un totale live durante il drag, e può importare un CSV one-shot per popolare servizi/offerte esistenti
-**Mode:** ui
-**Depends on**: Phase 11 (tabella `services` unificata)
-**Requirements**: OFFER-11, OFFER-12
+### Phase 12: Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico
+**Goal**: L'admin crea e modifica offerte complete da una lista filtrabile: editor con 3 tier (A/B/C) composti via matrice di checkbox dai servizi del catalogo unificato `services`, totale servizi live per tier, prezzo pubblico manuale per tier, tag multi-dimensione (categoria/ticket/tipo/obiettivo) e promessa di trasformazione strutturata. I servizi disponibili nella matrice sono filtrati per categoria offerta (entry/signature/retainer), designazione già impostata sul servizio a livello di catalogo.
+**Mode:** ui + schema
+**Depends on**: Phase 11 (tabella `services` unificata + sistema tag polimorfico)
+**Requirements**: OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18
**Success Criteria** (what must be TRUE):
- 1. L'utente compone un'offerta trascinando servizi dal catalogo (`@dnd-kit`)
- 2. Durante il drag, il totale dell'offerta si aggiorna live
- 3. L'utente importa un CSV one-shot per popolare servizi/offerte esistenti (solo seed iniziale, non sync continuo)
-**Plans**: TBD
-**UI hint**: yes
-**Status**: Pending planning
+ 1. L'admin compone un'offerta a 3 tier (A/B/C) assegnando i servizi del catalogo `services` tramite matrice di checkbox; il totale servizi di ogni tier si aggiorna live
+ 2. Ogni offerta ha tag su 4 dimensioni (categoria, ticket, tipo, obiettivo) creabili al volo, riusando il sistema tag di Phase 11
+ 3. Per ogni tier l'admin imposta un "prezzo pubblico" manuale, distinto e indipendente dal totale dei servizi
+ 4. Ogni offerta ha una promessa di trasformazione strutturata (Cliente Ideale / Risultato / tempo / Pain / Metodo)
+ 5. La lista offerte è filtrabile per categoria e supporta l'archiviazione (mostra/nascondi archiviate)
+**Note**: Import CSV/Notion (ex OFFER-12) spostato a fase futura su decisione utente (2026-06-14, "per ora saltiamo") — vedi REQUIREMENTS.md
+**Plans**: 5 plans
+**Plan list**:
+**Wave 1**
+ - [ ] 12-01-PLAN.md — Schema: offer_macros/offer_micros additive columns (category, ticket, is_archived, transformation promise, tier_letter, public_price) + offer_tier_services junction table, migration 0008 + idempotent push script
+**Wave 2** *(blocked on Wave 1 completion)*
+ - [ ] 12-02-PLAN.md — BLOCKING checkpoint: apply migration 0008 to production database
+ - [ ] 12-03-PLAN.md — Query layer (getOfferEditorData/getOfferListCards/getOfferFieldOptions) + server actions (saveOfferEditor/toggleOfferArchived/tag CRUD/createOfferMacro)
+**Wave 3** *(blocked on Wave 2 completion)*
+ - [ ] 12-04-PLAN.md — Offer list page redesign: category filter chips, archive toggle, card grid, "+ Nuova Offerta" CTA
+ - [ ] 12-05-PLAN.md — Offer editor detail page: tags (4 dimensions), services matrix (tier A/B/C checkboxes + live totals), prezzo pubblico, promessa di trasformazione, Salva/Annulla/Archivia
+**UI hint**: yes — riferimento mockup utente 2026-06-14 (lista card per categoria + editor con matrice servizi×tier)
+**Status**: Planned (2026-06-14) — ready for `/gsd-execute-phase 12`
### Phase 13: Workspace — Servizi Attivi
**Goal**: Il workspace progetto sostituisce le tab "Preventivo"/"Offerte"/"Forecast" con un'unica tab "Servizi attivi" che traccia servizi one-shot e ricorrenti con incassi mensili, senza perdere `accepted_total`/`quote_items`
@@ -129,7 +141,7 @@ Plans:
| Phase | Plans | Status | Completed |
|-------|-------|--------|-----------|
| 11. Catalog Database-View UX & Legacy Consolidation | 4/4 | ✅ Done | 2026-06-13 |
-| 12. Offer Composition Drag&Drop & CSV Import | — | Pending planning | — |
+| 12. Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico | 0/5 | Planned | — |
| 13. Workspace — Servizi Attivi | — | Pending planning | — |
| 14. CRM Attio-style & Fix | 3/3 | Complete | 2026-06-14 |
| 15. Dashboard Revenue Stats | — | Blocked (mockup utente) | — |
diff --git a/.planning/STATE.md b/.planning/STATE.md
index b4e5530..cf642f5 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -3,9 +3,9 @@ gsd_state_version: 1.0
milestone: v2.1
milestone_name: milestone
status: executing
-stopped_at: Phase 14 complete (5/5 success criteria verified); Phase 15 blocked on user mockup
-last_updated: "2026-06-14T13:20:53.997Z"
-last_activity: 2026-06-14 -- Phase 14 (crm-attio-style-fix) execution complete
+stopped_at: Phase 12 UI-SPEC approved (6/6 dimensions); planning in progress
+last_updated: "2026-06-14T13:54:45.922Z"
+last_activity: 2026-06-14 -- Phase 12 (offer-composition-drag-drop-csv-import) UI-SPEC approved, planning started
progress:
total_phases: 7
completed_phases: 2
@@ -22,14 +22,14 @@ See: .planning/PROJECT.md (updated 2026-06-13)
**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 15 — dashboard-revenue-stats (blocked on user mockup; Phase 14 complete)
+**Current focus:** Phase 12 — offer-composition-drag-drop-csv-import (UI-SPEC approved, planning in progress; Phase 15 remains blocked on user mockup)
## Current Position
-Phase: 15
-Plan: Not started
-Status: Blocked — Phase 15 awaiting user mockup (Phase 14 complete, see ROADMAP)
-Last activity: 2026-06-14 -- Phase 14 (crm-attio-style-fix) execution complete
+Phase: 12
+Plan: Not started (UI-SPEC approved, planning in progress)
+Status: Planning Phase 12 (UI-SPEC done, 6/6 dimensions passed)
+Last activity: 2026-06-14 -- Phase 12 (offer-composition-drag-drop-csv-import) UI-SPEC approved, planning started
Progress: [██████████] 100%
@@ -100,6 +100,6 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
-Last session: 2026-06-14T13:20:53.997Z
-Stopped at: Phase 14 complete (5/5 success criteria verified, code review 0 critical/6 warnings/3 info); Phase 15 blocked on user mockup
-Resume file: .planning/phases/14-crm-attio-style-fix/14-VERIFICATION.md
+Last session: 2026-06-14T13:54:45.922Z
+Stopped at: Phase 12 UI-SPEC approved (6/6 dimensions, 1 revision cycle for typography); planning in progress
+Resume file: .planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md
diff --git a/.planning/phases/12-offer-composition-drag-drop-csv-import/12-01-PLAN.md b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-01-PLAN.md
new file mode 100644
index 0000000..1df04d4
--- /dev/null
+++ b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-01-PLAN.md
@@ -0,0 +1,401 @@
+---
+phase: 12
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - src/db/schema.ts
+ - src/db/migrations/0008_offer_tier_schema.sql
+ - src/db/migrations/meta/_journal.json
+ - scripts/push-12-offer-tier-schema.ts
+autonomous: true
+requirements: [OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18]
+
+must_haves:
+ truths:
+ - "The Drizzle schema exposes tier_letter and public_price on offer_micros, and category, ticket, is_archived + 5 transformation-promise fields on offer_macros"
+ - "A new offer_tier_services junction table exists in the schema, referencing offer_micros and services (NOT the legacy offer_services)"
+ - "A hand-written, idempotent, additive-only SQL migration file exists that can be applied to prod via the existing push-script convention"
+ artifacts:
+ - path: "src/db/schema.ts"
+ provides: "offer_macros additive columns (description, category, ticket, is_archived, cliente_ideale, risultato, tempo, pain, metodo), offer_micros additive columns (tier_letter, public_price), new offer_tier_services table + relations + types"
+ contains: "offer_tier_services"
+ - path: "src/db/migrations/0008_offer_tier_schema.sql"
+ provides: "Additive ALTER TABLE + CREATE TABLE statements, IF NOT EXISTS / guarded DO block, no DROP/TRUNCATE"
+ contains: "CREATE TABLE IF NOT EXISTS offer_tier_services"
+ - path: "scripts/push-12-offer-tier-schema.ts"
+ provides: "Idempotent script to apply migration 0008 to prod via DATABASE_URL (SSH+docker exec target)"
+ exports: ["push"]
+ key_links:
+ - from: "src/db/schema.ts (offer_tier_services)"
+ to: "services.id / offer_micros.id"
+ via: "foreign key references with onDelete cascade"
+ pattern: "references\\(\\(\\) => (services|offer_micros)\\.id"
+---
+
+
+Extend the Drizzle schema additively for the Phase 12 Offer Editor: add tier designation
+(`tier_letter`) and manual public pricing (`public_price`) to `offer_micros`; add archive flag,
+single-select category/ticket dimensions, and structured transformation-promise fields to
+`offer_macros`; create a new junction table `offer_tier_services` (tier ↔ unified `services`
+catalog) WITHOUT touching the legacy `offer_micro_services`/`offer_services` tables.
+Hand-write the corresponding SQL migration (0008) following the 0003-0007 convention
+(drizzle-kit generate is broken in this repo), and create the idempotent push script that
+Plan 02 will run against production via SSH+docker exec.
+
+Purpose: Lay the additive data-model foundation (D-6, OFFER-11/15/16/17/18) so the Wave 2/3
+query layer, server actions, and editor UI have real columns/tables to read and write —
+including the `category`/`ticket` single-select dimensions needed for list filtering
+(OFFER-18) and matrix pre-filtering by offer category (D-4).
+Output: Updated `src/db/schema.ts` (types + relations), `src/db/migrations/0008_offer_tier_schema.sql`,
+updated `_journal.json`, and `scripts/push-12-offer-tier-schema.ts`.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/12-offer-composition-drag-drop-csv-import/12-CONTEXT.md
+@.planning/phases/12-offer-composition-drag-drop-csv-import/12-RESEARCH.md
+@src/db/schema.ts
+@src/db/migrations/0006_add_tags_table.sql
+@src/db/migrations/0007_add_services_fase.sql
+@src/db/migrations/meta/_journal.json
+@scripts/push-11b-fase-column.ts
+
+
+
+
+
+From src/db/schema.ts (offer_macros, current — lines 262-271):
+```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(),
+});
+```
+
+From src/db/schema.ts (offer_micros, current — lines 274-286):
+```typescript
+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),
+});
+```
+
+From src/db/schema.ts (services, Phase 11 unified catalog — lines 212-225):
+```typescript
+export const services = pgTable("services", {
+ id: text("id").primaryKey().$defaultFn(() => nanoid()),
+ name: text("name").notNull(),
+ description: text("description"),
+ unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
+ category: text("category"),
+ fase: text("fase"),
+ active: boolean("active").notNull().default(true),
+ migrated_from: text("migrated_from"),
+ migrated_id: text("migrated_id"),
+ created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+});
+```
+
+From src/db/schema.ts (tags, polymorphic — lines 119-140, NO schema change needed for tag
+dimensions — Phase 12 reuses this table with new entity_type string values for the two
+MULTI-select dimensions only: "offer_macros.tipo" | "offer_macros.obiettivo". The two
+SINGLE-select dimensions, Categoria and Ticket, become plain columns on `offer_macros`
+in this plan — see Task 1 step 1):
+```typescript
+export const tags = pgTable("tags", {
+ id: text("id").primaryKey().$defaultFn(() => nanoid()),
+ entity_type: text("entity_type").notNull(),
+ entity_id: text("entity_id").notNull(),
+ name: text("name").notNull(),
+ created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+}, (t) => ({
+ entityTagUnique: uniqueIndex("tags_entity_name_unique").on(t.entity_type, t.entity_id, t.name),
+ entityIdx: index("tags_entity_idx").on(t.entity_type, t.entity_id),
+}));
+```
+
+Existing offer_micro_services junction (legacy — DO NOT MODIFY, lines 300-313):
+```typescript
+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] }) }));
+```
+
+
+
+
+
+ Task 1: Extend offer_macros and offer_micros, add offer_tier_services junction in schema.ts
+ src/db/schema.ts
+
+ - src/db/schema.ts (full file — read once; note existing imports, offer_macros lines
+ 262-271, offer_micros lines 274-286, offer_micro_services lines 300-313 for the
+ "do not touch" pattern, services lines 212-225, relations section ~545-578, type
+ exports section ~633-646)
+ - .planning/phases/12-offer-composition-drag-drop-csv-import/12-CONTEXT.md (D-1..D-6 — locked decisions)
+ - .planning/phases/12-offer-composition-drag-drop-csv-import/12-RESEARCH.md (Pattern 1: Additive Schema, section "Example migrations")
+
+
+ Make the following ADDITIVE-ONLY changes to `src/db/schema.ts`. Do not remove, rename, or
+ alter any existing column on `offer_macros`, `offer_micros`, `offer_micro_services`,
+ `offer_services`, `clients`, `projects`, `payments`, or `phases` (Data Safety LOCKED).
+
+ 1. **Extend `offer_macros`** (after `transformation_promise`, before `sort_order` —
+ order within the object is cosmetic but keep additive fields grouped together for
+ readability) with:
+ - `description: text("description")` — short description shown on offer cards (D-1 UI)
+ - `category: text("category")` — single-select offer category (Entry Offer / Signature
+ Offer / Retainer Offer), OFFER-15 "Categoria" dimension + OFFER-18 list filter. Same
+ Notion-style single-select pool pattern as `services.category`/`services.fase`
+ (editable via `OptionSelect` + `renameServiceOption`-equivalent in Plan 03, scoped by
+ a distinct `entity_type` in the `tags` pool table — e.g. `"offer_macros.categoria"` —
+ so the option pool is independent of the catalog's `services.category` pool, even
+ though admins are expected to use matching label text per D-4).
+ - `ticket: text("ticket")` — single-select "Ticket" dimension (Low/Mid/High Ticket),
+ OFFER-15. Same pattern as `category` above, pool entity_type `"offer_macros.ticket"`.
+ - `is_archived: boolean("is_archived").notNull().default(false)` — OFFER-18 archive flag
+ - Five structured transformation-promise fields (OFFER-17), each nullable text,
+ additive alongside the existing legacy `transformation_promise` column (leave
+ `transformation_promise` untouched — it remains on `offer_micros` only as legacy):
+ - `cliente_ideale: text("cliente_ideale")` — "Aiuto: [Cliente Ideale]"
+ - `risultato: text("risultato")` — "A ottenere: [Risultato]"
+ - `tempo: text("tempo")` — "In: [tempo]"
+ - `pain: text("pain")` — "Senza: [Pain]"
+ - `metodo: text("metodo")` — "Grazie a: [Metodo]"
+
+ Add a comment block directly above `offer_macros` documenting that these are Phase 12
+ additive columns (mirror the existing comment style used for `services.migrated_from`).
+
+ 2. **Extend `offer_micros`** with:
+ - `tier_letter: text("tier_letter")` — nullable text, values constrained to 'A'|'B'|'C'
+ via a CHECK constraint at the SQL level (Task 2 migration), NOT enforced in Drizzle
+ (Drizzle pg-core has no native CHECK helper in this project's version — validate in
+ Zod at the server-action layer in Plan 03). Add a TS comment noting the CHECK
+ constraint lives in the migration.
+ - `public_price: numeric("public_price", { precision: 10, scale: 2 })` — nullable,
+ manual public price per tier (D-5/OFFER-16); independent of the computed services
+ total (computed at query time in Plan 03, never stored).
+
+ 3. **Add new table `offer_tier_services`** (place it directly after
+ `offer_micro_services` for proximity, with a comment block explaining it is the
+ Phase 12 additive replacement junction — NOT a modification of
+ `offer_micro_services`, which stays untouched and points to the legacy
+ `offer_services`):
+ ```typescript
+ // ============ OFFER TIER SERVICES (Phase 12 — junction: offer_micros <-> services) ============
+ // Additive replacement for the legacy offer_micro_services (which points to the
+ // deprecated offer_services table). This junction connects a tier (offer_micros row
+ // with tier_letter set) to the unified services catalog (Phase 11). Do NOT modify
+ // offer_micro_services — it remains untouched for legacy data.
+ export const offer_tier_services = pgTable(
+ "offer_tier_services",
+ {
+ tier_id: text("tier_id")
+ .notNull()
+ .references(() => offer_micros.id, { onDelete: "cascade" }),
+ service_id: text("service_id")
+ .notNull()
+ .references(() => services.id, { onDelete: "cascade" }),
+ created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (t) => ({
+ pk: primaryKey({ columns: [t.tier_id, t.service_id] }),
+ tierIdx: index("offer_tier_services_tier_idx").on(t.tier_id),
+ })
+ );
+ ```
+
+ 4. **Add relations** for `offer_tier_services` (near `offerMicroServicesRelations`,
+ ~line 559-562):
+ ```typescript
+ export const offerTierServicesRelations = relations(offer_tier_services, ({ one }) => ({
+ tier: one(offer_micros, { fields: [offer_tier_services.tier_id], references: [offer_micros.id] }),
+ service: one(services, { fields: [offer_tier_services.service_id], references: [services.id] }),
+ }));
+ ```
+ Also extend `offerMicrosRelations` (~line 549-553) to add a `tierServices: many(offer_tier_services)`
+ entry alongside the existing `services: many(offer_micro_services)` (keep both — do not
+ remove the legacy relation).
+
+ 5. **Add TypeScript types** in the type-exports section (~line 633-646), following the
+ exact `$inferSelect`/`$inferInsert` pattern used for every other table:
+ ```typescript
+ export type OfferTierService = typeof offer_tier_services.$inferSelect;
+ export type NewOfferTierService = typeof offer_tier_services.$inferInsert;
+ ```
+ `OfferMacro`/`NewOfferMacro` and `OfferMicro`/`NewOfferMicro` types already exist and
+ automatically pick up the new columns via `$inferSelect` — no changes needed to those
+ two type aliases themselves.
+
+
+ cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30
+
+
+ - `npx tsc --noEmit` passes with zero errors (full project)
+ - `grep -c "offer_tier_services" src/db/schema.ts` >= 4 (table def, relations, both type exports)
+ - `grep -c "tier_letter\|public_price" src/db/schema.ts` == 2 (both new offer_micros columns present exactly once)
+ - `grep -c "is_archived\|cliente_ideale\|risultato\|tempo\|pain\|metodo" src/db/schema.ts` == 6 (all 6 new offer_macros transformation/archive columns present)
+ - `grep -cE '"(category|ticket)"' src/db/schema.ts` >= 2 (new offer_macros category/ticket columns present — note `services.category` also matches "category", so this is a >= check, not ==)
+ - `git diff HEAD -- src/db/schema.ts | grep -E '^-' | grep -v '^---'` returns empty (no existing lines removed — additive diff only)
+
+ schema.ts compiles, exposes all new additive columns/table/types, and the legacy offer_micro_services/offer_services definitions are byte-identical to their pre-task state.
+
+
+
+ Task 2: Hand-write migration 0008 SQL + journal entry + idempotent push script
+ src/db/migrations/0008_offer_tier_schema.sql, src/db/migrations/meta/_journal.json, scripts/push-12-offer-tier-schema.ts
+
+ - src/db/migrations/0007_add_services_fase.sql (simplest additive precedent: `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`)
+ - src/db/migrations/0006_add_tags_table.sql (CREATE TABLE + indexes precedent, Drizzle output format with `--> statement-breakpoint`)
+ - src/db/migrations/meta/_journal.json (current entries — note it already has gaps for 0003-0005/0007; append 0008 entry following the 0006 entry's shape)
+ - scripts/push-11-tags-migration.ts (idempotent CREATE TABLE IF NOT EXISTS + indexes pattern, with the PRODUCTION DEPLOY NOTE comment header)
+ - scripts/push-11b-fase-column.ts (idempotent ADD COLUMN IF NOT EXISTS pattern, shorter form)
+
+
+ 1. Create `src/db/migrations/0008_offer_tier_schema.sql` — hand-written, additive-only,
+ idempotent (every statement uses `IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`), no
+ DROP/TRUNCATE/RENAME. Content:
+
+ ```sql
+ -- Phase 12: Offer Editor — additive schema for tier designations, public pricing,
+ -- archive flag, category/ticket dimensions, structured transformation promise, and
+ -- tier<->services junction. All statements additive/idempotent. No drops/truncates
+ -- (Data Safety LOCKED).
+
+ -- offer_macros: archive flag + short description + category/ticket dimensions +
+ -- structured transformation promise
+ ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS description text;
+ ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS category text;
+ ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS ticket text;
+ ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS is_archived boolean NOT NULL DEFAULT false;
+ ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS cliente_ideale text;
+ ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS risultato text;
+ ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS tempo text;
+ ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS pain text;
+ ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS metodo text;
+
+ -- offer_micros: tier designation (A/B/C) + manual public price
+ ALTER TABLE offer_micros ADD COLUMN IF NOT EXISTS tier_letter text;
+ ALTER TABLE offer_micros ADD COLUMN IF NOT EXISTS public_price numeric(10, 2);
+
+ -- CHECK constraint for tier_letter, added separately so the ADD COLUMN above stays
+ -- idempotent even if the constraint already exists (guarded via DO block).
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint WHERE conname = 'offer_micros_tier_letter_check'
+ ) THEN
+ ALTER TABLE offer_micros
+ ADD CONSTRAINT offer_micros_tier_letter_check
+ CHECK (tier_letter IS NULL OR tier_letter IN ('A', 'B', 'C'));
+ END IF;
+ END $$;
+
+ -- New junction table: tier (offer_micros) <-> unified services catalog.
+ -- Additive replacement for legacy offer_micro_services (untouched, points to offer_services).
+ CREATE TABLE IF NOT EXISTS offer_tier_services (
+ tier_id text NOT NULL REFERENCES offer_micros(id) ON DELETE CASCADE,
+ service_id text NOT NULL REFERENCES services(id) ON DELETE CASCADE,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ PRIMARY KEY (tier_id, service_id)
+ );
+
+ CREATE INDEX IF NOT EXISTS offer_tier_services_tier_idx ON offer_tier_services USING btree (tier_id);
+ ```
+
+ 2. Append a journal entry for `0008_offer_tier_schema` to `src/db/migrations/meta/_journal.json`,
+ following the exact shape of the existing `0006_add_tags_table` entry (idx: 8, version: "7",
+ a `when` timestamp larger than the 0006 entry's, tag: "0008_offer_tier_schema", breakpoints: true).
+ This file already has gaps (no entries for 0003-0005/0007) — do not attempt to backfill
+ those; only append the new 0008 entry to the `entries` array.
+
+ 3. Create `scripts/push-12-offer-tier-schema.ts` — idempotent push script following the
+ `push-11-tags-migration.ts` / `push-11b-fase-column.ts` pattern: reads `DATABASE_URL`
+ from env, uses the `postgres` package, runs each statement from migration 0008 (the 11
+ `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` statements, the DO-block CHECK constraint
+ guard, `CREATE TABLE IF NOT EXISTS offer_tier_services`, and
+ `CREATE INDEX IF NOT EXISTS offer_tier_services_tier_idx`), logs success per statement
+ group, and exits 0/1. Include the same "PRODUCTION DEPLOY NOTE" comment header as
+ `push-11-tags-migration.ts`, referencing this script and migration 0008, and stating
+ that Plan 02 (BLOCKING, SSH+docker exec) must run this before Wave 3 code (query layer
+ reading `offer_tier_services`/`tier_letter`/`public_price`/`category`/`ticket`) is
+ exercised against production data.
+
+ Catch and tolerate "already exists" / duplicate-column / duplicate-constraint errors
+ per-statement (same try/catch-per-block resilience as the precedent scripts) so the
+ script is safely re-runnable. Export an async `push()` function as the module's main
+ entry point (called at the bottom of the file, matching precedent scripts).
+
+
+ cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -20; test -f src/db/migrations/0008_offer_tier_schema.sql && echo "MIGRATION_EXISTS"; node -e "const j=require('./src/db/migrations/meta/_journal.json'); console.log(j.entries.find(e=>e.tag==='0008_offer_tier_schema') ? 'JOURNAL_OK' : 'JOURNAL_MISSING')"
+
+
+ - `test -f src/db/migrations/0008_offer_tier_schema.sql` succeeds
+ - The migration file contains zero occurrences of `DROP`, `TRUNCATE`, or `RENAME` (case-insensitive grep returns no matches)
+ - Every `ALTER TABLE ... ADD COLUMN` statement includes `IF NOT EXISTS`: `grep -c "ADD COLUMN IF NOT EXISTS" src/db/migrations/0008_offer_tier_schema.sql` == 11
+ - `grep -c "CREATE TABLE IF NOT EXISTS offer_tier_services" src/db/migrations/0008_offer_tier_schema.sql` == 1
+ - `_journal.json` parses as valid JSON and contains an entry with `"tag": "0008_offer_tier_schema"`
+ - `npx tsc --noEmit` (full project, including the new script) passes with zero errors
+ - `scripts/push-12-offer-tier-schema.ts` contains a "PRODUCTION DEPLOY NOTE" comment referencing migration 0008 and the BLOCKING SSH+docker exec apply step
+
+ Migration 0008 SQL file exists, is additive/idempotent (IF NOT EXISTS guards throughout, no destructive statements), journal has a corresponding entry, and the push script typechecks and is ready for Plan 02 to execute against production.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Migration script -> Production DB | `scripts/push-12-offer-tier-schema.ts` executes raw SQL via `DATABASE_URL`; the only "input" is the hardcoded migration SQL (no user input at this layer) |
+| schema.ts -> downstream query/action code (Plan 03+) | New columns/table become part of the type-safe Drizzle surface consumed by admin-only server actions |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-12-01 | Tampering | `0008_offer_tier_schema.sql` accidentally includes a destructive statement | mitigate | Acceptance criteria grep-gate explicitly rejects DROP/TRUNCATE/RENAME before this plan is considered done; CLAUDE.md Data Safety LOCKED reviewed before writing the migration |
+| T-12-02 | Tampering | `offer_micros.tier_letter` accepts arbitrary strings at the DB layer | mitigate | SQL-level CHECK constraint (`tier_letter IN ('A','B','C')` or NULL) added via guarded DO block; Zod enum validation added at the server-action layer in Plan 03 as defense-in-depth |
+| T-12-03 | Repudiation | Push script silently no-ops on errors, masking a failed migration | accept | Script logs per-statement success/failure and exits non-zero on unexpected errors (only "already exists"/duplicate errors are tolerated); operator (Plan 02) reviews script output before proceeding |
+| T-12-04 | Information Disclosure | `offer_tier_services`/new columns become queryable but must remain admin-only (`/admin/offers`) | accept | No client-facing route reads these tables in this plan; enforcement of admin-only access happens in Plan 03 (`requireAdmin`) — tracked, not this plan's scope |
+
+
+
+1. `npx tsc --noEmit` passes for the full project (schema.ts changes compile cleanly with all existing consumers).
+2. `src/db/migrations/0008_offer_tier_schema.sql` exists, is additive-only, and every ALTER/CREATE statement is idempotent (`IF NOT EXISTS` / guarded DO block).
+3. `scripts/push-12-offer-tier-schema.ts` typechecks and documents the BLOCKING manual prod-apply step for Plan 02.
+4. `git diff HEAD -- src/db/schema.ts` shows ONLY additions (new columns, new table, new relations, new types) — no removed or renamed lines for `offer_macros`, `offer_micros`, `offer_micro_services`, `offer_services`, `clients`, `projects`, `payments`, `phases`.
+
+
+
+- `offer_macros` and `offer_micros` expose all Phase 12 additive columns in the Drizzle schema and TypeScript types.
+- `offer_tier_services` junction table is defined, related, and typed — pointing at `services` (not `offer_services`).
+- Migration 0008 + push script are ready for Plan 02's BLOCKING production-apply step.
+- No existing table/column was dropped, renamed, or altered destructively.
+
+
+
diff --git a/.planning/phases/12-offer-composition-drag-drop-csv-import/12-02-PLAN.md b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-02-PLAN.md
new file mode 100644
index 0000000..78c378b
--- /dev/null
+++ b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-02-PLAN.md
@@ -0,0 +1,142 @@
+---
+phase: 12
+plan: 02
+type: execute
+wave: 2
+depends_on: [1]
+files_modified: []
+autonomous: false
+requirements: [OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18]
+
+must_haves:
+ truths:
+ - "Migration 0008 (offer_macros/offer_micros additive columns + offer_tier_services table) has been applied to the production database"
+ - "Plan 03's query layer and Plan 04/05's UI can read/write tier_letter, public_price, offer_macros category/ticket/transformation-promise fields, and offer_tier_services against real production data"
+ artifacts: []
+ key_links:
+ - from: "scripts/push-12-offer-tier-schema.ts"
+ to: "production Postgres (178.104.27.55:54321, via SSH+docker exec)"
+ via: "manual operator run of the idempotent push script against prod DATABASE_URL"
+ pattern: "tier_letter|public_price|offer_tier_services"
+---
+
+
+BLOCKING checkpoint: apply migration 0008 (`src/db/migrations/0008_offer_tier_schema.sql`,
+created in Plan 01) to the production database. Per CLAUDE.md Data Safety and project memory
+(`project_clienthub_deploy_db.md`: Gitea→Coolify, prod Postgres reachable only via SSH+docker
+exec, port 54321 firewalled from outside), this migration MUST be applied to prod BEFORE
+Plans 03-05 (query layer, server actions, and UI that read/write `tier_letter`, `public_price`,
+the new `offer_macros` columns, and `offer_tier_services`) are exercised against production
+data.
+
+Purpose: Unblock Wave 2/3 schema-dependent code per the project's locked migration convention
+(migrations applied to prod before schema-dependent code ships).
+Output: Migration 0008 applied to production; confirmation that `offer_tier_services` exists
+and the new columns are queryable on the live DB.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/STATE.md
+@scripts/push-12-offer-tier-schema.ts
+@src/db/migrations/0008_offer_tier_schema.sql
+
+
+
+
+
+ Task 1: Apply migration 0008 to production database
+
+ Plan 01 created `src/db/migrations/0008_offer_tier_schema.sql` (additive-only: 11
+ `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` statements on `offer_macros`/`offer_micros`, a
+ guarded `CHECK` constraint on `offer_micros.tier_letter`, and
+ `CREATE TABLE IF NOT EXISTS offer_tier_services` + index) and the idempotent push script
+ `scripts/push-12-offer-tier-schema.ts` that applies it.
+
+ This script CANNOT be run from this environment: the production DATABASE_URL
+ (`178.104.27.55:54321`) is firewalled and reachable only via SSH to the production host
+ (per `project_clienthub_deploy_db.md`). Opening a root SSH session to the shared
+ production host is outside this agent's authorization.
+
+
+ On your machine (or via your existing SSH access to the production host), run the
+ migration push script against the production database, then confirm the new schema
+ objects exist:
+
+ 1. SSH to the production host and exec into the Postgres container (same pattern used
+ for the Phase 11 migration apply):
+ ```bash
+ ssh root@178.104.27.55
+ docker exec -it psql -U clienthub -d clienthub
+ ```
+ OR, if you prefer running the idempotent TypeScript push script with
+ `DATABASE_URL` pointed at prod (via SSH port-forward / tunnel, same as Phase 11):
+ ```bash
+ npx tsx scripts/push-12-offer-tier-schema.ts
+ ```
+
+ 2. If using `psql` directly, run the contents of
+ `src/db/migrations/0008_offer_tier_schema.sql` (copy/paste — it is additive-only and
+ idempotent, safe to run even if partially applied already).
+
+ 3. Confirm the new columns and table exist:
+ ```sql
+ \d offer_macros
+ \d offer_micros
+ \d offer_tier_services
+ ```
+ Expect: `offer_macros` has `description`, `category`, `ticket`, `is_archived`,
+ `cliente_ideale`, `risultato`, `tempo`, `pain`, `metodo`; `offer_micros` has
+ `tier_letter` (with a CHECK constraint `offer_micros_tier_letter_check`) and
+ `public_price`; `offer_tier_services` table exists with columns `tier_id`,
+ `service_id`, `created_at` and a composite primary key on (`tier_id`, `service_id`).
+
+ 4. Confirm no existing data was affected:
+ ```sql
+ SELECT count(*) FROM offer_macros;
+ SELECT count(*) FROM offer_micros;
+ SELECT count(*) FROM clients;
+ SELECT count(*) FROM projects;
+ ```
+ Row counts should match pre-migration counts (migration is additive — no rows
+ deleted).
+
+ Type "applied" once migration 0008 has been run against production and you've confirmed the new columns/table exist (or describe any errors encountered).
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Operator (human) -> Production Postgres | Manual SSH+docker exec session per project convention; only additive, idempotent SQL from migration 0008 is executed |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-12-05 | Denial of Service / Tampering | Manual prod migration apply | accept | Operator runs only the additive, idempotent migration 0008 (already grep-gated in Plan 01 against DROP/TRUNCATE/RENAME); per project convention this is a manual, out-of-band step not reachable via any HTTP endpoint |
+
+
+
+1. Operator confirms (via `\d offer_macros`, `\d offer_micros`, `\d offer_tier_services` on prod) that all Plan 01 additive columns and the new junction table exist.
+2. Operator confirms row counts for `offer_macros`, `offer_micros`, `clients`, `projects` are unchanged (additive migration, no data loss).
+3. Resume-signal "applied" received before Wave 3 (Plans 04/05) begins relying on these columns/table against production data.
+
+
+
+- Migration 0008 is live on production.
+- `offer_tier_services` table and all new `offer_macros`/`offer_micros` columns are queryable on prod.
+- No row in `clients`, `projects`, `payments`, `phases`, `offer_macros`, or `offer_micros` was deleted or modified destructively.
+
+
+
diff --git a/.planning/phases/12-offer-composition-drag-drop-csv-import/12-03-PLAN.md b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-03-PLAN.md
new file mode 100644
index 0000000..9e4ad16
--- /dev/null
+++ b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-03-PLAN.md
@@ -0,0 +1,477 @@
+---
+phase: 12
+plan: 03
+type: execute
+wave: 2
+depends_on: [1]
+files_modified:
+ - src/lib/offer-queries.ts
+ - src/app/admin/offers/actions.ts
+autonomous: true
+requirements: [OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18]
+
+must_haves:
+ truths:
+ - "Admin can fetch the full offer-editor data shape for a single offer: macro fields (incl. category, ticket, archive flag, transformation-promise fields), its A/B/C tiers, each tier's assigned service IDs + computed services total, and the offer's Tipo/Obiettivo tag values"
+ - "Admin can fetch the offer list (card grid) with category + archive status, filterable client-side"
+ - "Admin can save the full editor state in one server action: macro scalar fields, category/ticket, transformation-promise fields, per-tier public_price, per-tier service assignments (offer_tier_services), and Tipo/Obiettivo tags — all validated server-side (requireAdmin + Zod, tier_letter in A/B/C)"
+ - "Admin can toggle is_archived on an offer"
+ artifacts:
+ - path: "src/lib/offer-queries.ts"
+ provides: "getOfferEditorData(macroId), getOfferListCards(), getOfferFieldOptions() — query layer for Plans 04/05"
+ exports: ["getOfferEditorData", "getOfferListCards", "getOfferFieldOptions"]
+ - path: "src/app/admin/offers/actions.ts"
+ provides: "saveOfferEditor, toggleOfferArchived, addOfferTag, removeOfferTag, renameOfferOption — server actions for Plans 04/05"
+ exports: ["saveOfferEditor", "toggleOfferArchived", "addOfferTag", "removeOfferTag", "renameOfferOption", "createOfferMacro"]
+ key_links:
+ - from: "src/app/admin/offers/actions.ts (saveOfferEditor)"
+ to: "offer_tier_services / offer_micros / offer_macros"
+ via: "delete-then-reinsert per tier (mirrors updateMicroOfferServices upsert-replace pattern) + update on offer_macros/offer_micros"
+ pattern: "offer_tier_services"
+ - from: "src/lib/offer-queries.ts (getOfferEditorData)"
+ to: "services (Phase 11 unified catalog)"
+ via: "filter by services.category matching offer_macros.category (D-4 pre-filter)"
+ pattern: "eq\\(services\\.category"
+---
+
+
+Build the query layer and server actions that power the Phase 12 Offer Editor: a single
+`getOfferEditorData(macroId)` query returning everything Plan 05's editor page needs (macro
+fields including `category`/`ticket`/`is_archived`/transformation-promise, its tiers with
+`tier_letter`/`public_price`/assigned service IDs/computed totals, the category-filtered
+service catalog for the matrix, and Tipo/Obiettivo tag values); `getOfferListCards()` for
+Plan 04's list page; and a `saveOfferEditor` server action that persists the entire editor
+state (macro scalars, tier public prices, tier↔service assignments via
+`offer_tier_services`, and Tipo/Obiettivo tags) in one call, plus smaller actions for archive
+toggling and tag CRUD.
+
+Purpose: Give Plans 04/05 (Wave 3, UI) a complete, type-safe, admin-only data contract so they
+can be built without touching the DB layer directly — covers OFFER-11 (matrix composition +
+live totals via computed `services_total`), OFFER-15 (4-dimension tags), OFFER-16 (public
+price), OFFER-17 (transformation promise), OFFER-18 (list filter + archive).
+Output: `src/lib/offer-queries.ts` additions, `src/app/admin/offers/actions.ts` additions.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/12-offer-composition-drag-drop-csv-import/12-CONTEXT.md
+@.planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md
+@src/lib/offer-queries.ts
+@src/app/admin/offers/actions.ts
+@src/app/admin/catalog/actions.ts
+
+
+
+
+
+From src/db/schema.ts (after Plan 01 — offer_macros additive columns):
+```typescript
+// offer_macros now also has (all nullable except is_archived):
+// description: text
+// category: text — single-select "Entry Offer" | "Signature Offer" | "Retainer Offer" (free pool, OFFER-15/18)
+// ticket: text — single-select "Low Ticket" | "Mid Ticket" | "High Ticket" (free pool, OFFER-15)
+// is_archived: boolean — not null, default false (OFFER-18)
+// cliente_ideale, risultato, tempo, pain, metodo: text (OFFER-17)
+```
+
+From src/db/schema.ts (after Plan 01 — offer_micros additive columns):
+```typescript
+// offer_micros now also has:
+// tier_letter: text — 'A' | 'B' | 'C' | null, CHECK constraint at DB level
+// public_price: numeric(10,2) | null (OFFER-16, manual, independent of services total)
+```
+
+From src/db/schema.ts (after Plan 01 — new junction table + types):
+```typescript
+export const offer_tier_services = pgTable("offer_tier_services", {
+ tier_id: text("tier_id").notNull().references(() => offer_micros.id, { onDelete: "cascade" }),
+ service_id: text("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
+ created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+}, (t) => ({ pk: primaryKey({ columns: [t.tier_id, t.service_id] }), tierIdx: index(...) }));
+
+export type OfferTierService = typeof offer_tier_services.$inferSelect;
+export type NewOfferTierService = typeof offer_tier_services.$inferInsert;
+```
+
+From src/db/schema.ts (services, Phase 11 unified catalog — unchanged):
+```typescript
+export const services = pgTable("services", {
+ id: text("id").primaryKey().$defaultFn(() => nanoid()),
+ name: text("name").notNull(),
+ description: text("description"),
+ unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
+ category: text("category"),
+ fase: text("fase"),
+ active: boolean("active").notNull().default(true),
+ // ...
+});
+```
+
+From src/db/schema.ts (tags, polymorphic — unchanged, reused for Tipo/Obiettivo only):
+```typescript
+export const tags = pgTable("tags", {
+ id: text("id").primaryKey().$defaultFn(() => nanoid()),
+ entity_type: text("entity_type").notNull(),
+ entity_id: text("entity_id").notNull(),
+ name: text("name").notNull(),
+ created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+}, (t) => ({
+ entityTagUnique: uniqueIndex("tags_entity_name_unique").on(t.entity_type, t.entity_id, t.name),
+ entityIdx: index("tags_entity_idx").on(t.entity_type, t.entity_id),
+}));
+```
+
+From src/lib/offer-queries.ts (current — DO NOT MODIFY these existing exports, they serve the
+legacy `/admin/offers` page and `offer_services`; this plan ADDS new exports alongside):
+```typescript
+export type MicroWithServices = OfferMicro & { services: Array<{...}>; cumulative_price: string };
+export type MacroWithMicros = OfferMacro & { micros: MicroWithServices[] };
+export async function getCatalogWithMicros(): Promise;
+export async function getAllOfferServices(): Promise;
+export async function getMicroAssignedServiceIds(microId: string): Promise;
+```
+
+From src/app/admin/catalog/actions.ts (pattern reference — Notion-style single/multi-select
+option pools via the polymorphic `tags` table; reuse this EXACT pattern for offer tags):
+```typescript
+// addServiceOption(field, serviceId, value) -> tags.insert().onConflictDoNothing()
+// removeServiceOption(field, serviceId, value) -> tags.delete().where(entity_type, entity_id, name)
+// renameServiceOption(field, oldValue, newValue) -> tags.update({name}).where(entity_type, name=old)
+// OR services.update({category|fase}).where(category|fase = old) for single-select columns
+```
+
+From src/app/admin/offers/actions.ts (current — DO NOT MODIFY these existing exports, they
+serve the legacy macro/micro CRUD; this plan ADDS new exports alongside):
+```typescript
+// requireAdmin() — async, throws "Non autorizzato" if no session. Reuse identically.
+export async function createMacro(formData: FormData): Promise;
+export async function deleteMacro(macroId: string): Promise;
+export async function createMicro(formData: FormData): Promise;
+export async function deleteMicro(microId: string): Promise;
+export async function updateMicroOfferServices(microId: string, serviceIds: string[]): Promise;
+// ^ delete-then-reinsert pattern — saveOfferEditor's per-tier service assignment follows this.
+```
+
+
+
+
+
+ Task 1: Query layer — getOfferEditorData, getOfferListCards, getOfferFieldOptions
+ src/lib/offer-queries.ts
+
+ Add to `src/lib/offer-queries.ts` (alongside existing exports, do not remove them):
+
+ - Test 1: `getOfferListCards()` returns one card per `offer_macros` row with
+ `{ id, internal_name, description, category, is_archived }`, ordered by `sort_order`.
+ An offer with `is_archived = true` is still returned (filtering happens client-side
+ per UI-SPEC "Mostra offerte archiviate" toggle) — input: 2 macros (1 archived, 1 not)
+ -> output: array of length 2, both present, `is_archived` flags correct.
+ - Test 2: `getOfferEditorData(macroId)` for a macro with 0 `offer_micros` rows returns
+ `{ macro: {...all fields incl. category/ticket/transformation promise...}, tiers: [],
+ availableServices: [...services filtered by services.category === macro.category...],
+ tipoTags: [], obiettivoTags: [] }` — input: macro with no tiers, `category =
+ "Signature Offer"`, 3 services exist with `services.category = "Signature Offer"` and
+ 2 with a different category -> output: `tiers.length === 0`,
+ `availableServices.length === 3` (only matching-category services).
+ - Test 3: `getOfferEditorData(macroId)` for a macro with 3 `offer_micros` rows
+ (`tier_letter` = 'A'/'B'/'C') and 2 `offer_tier_services` rows assigned to tier A
+ returns `tiers` sorted A→B→C, each tier has `{ id, tier_letter, public_price,
+ assignedServiceIds: string[], servicesTotal: string }`; tier A's `servicesTotal`
+ equals the sum of `unit_price` for its 2 assigned services (computed via SQL
+ `sum(...)`, matching the `cumulative_price` pattern in `getCatalogWithMicros`); tiers
+ B/C have `assignedServiceIds: []` and `servicesTotal: "0"`.
+ - Test 4: `getOfferEditorData(macroId)` returns `tipoTags`/`obiettivoTags` as `string[]`
+ sourced from the polymorphic `tags` table with `entity_type = "offer_macros.tipo"` /
+ `"offer_macros.obiettivo"` and `entity_id = macroId` — input: 2 "tipo" tags + 1
+ "obiettivo" tag exist for the macro -> output: `tipoTags.length === 2`,
+ `obiettivoTags.length === 1`.
+ - Test 5: `getOfferFieldOptions()` returns
+ `{ categoria: string[], ticket: string[], tipo: string[], obiettivo: string[] }` —
+ distinct `offer_macros.category` / `offer_macros.ticket` values (non-null, like
+ `getCatalogFieldOptions`'s `categoria`/`fase` pattern) plus distinct `tags.name` where
+ `entity_type IN ("offer_macros.tipo", "offer_macros.obiettivo")`, split by
+ `entity_type`. Input: 2 macros with categories "Entry Offer"/"Signature Offer", 1
+ "tipo" tag "Audit", 1 "obiettivo" tag "Lead Generation" -> output: `categoria` contains
+ both values, `tipo === ["Audit"]`, `obiettivo === ["Lead Generation"]`.
+
+ Write these as a Vitest/Jest test file if a test runner is configured (`npm test` —
+ check `package.json` `scripts.test` first); if no test runner exists in this repo,
+ SKIP the dedicated test file (do not introduce a new test framework — out of scope) and
+ instead write a one-off `scripts/verify-12-03-queries.ts` that calls each function
+ against a temporary in-memory assertion of the SQL shape (or, if `DATABASE_URL` is
+ unreachable from this environment per project memory, write the script so it TYPECHECKS
+ and documents expected behavior in comments matching the 5 cases above — to be run
+ manually by the operator against a dev DB later). Either path satisfies the `tdd="true"`
+ intent: behavior is specified before/alongside implementation.
+
+
+ Implement in `src/lib/offer-queries.ts`:
+
+ 1. **`getOfferListCards()`**: select `id, internal_name, description, category,
+ is_archived, sort_order` from `offer_macros`, `orderBy(asc(sort_order),
+ asc(created_at))`. Return type `OfferListCard[]` where
+ `OfferListCard = Pick`.
+
+ 2. **`getOfferEditorData(macroId: string)`**:
+ - Fetch the `offer_macros` row by id (return `null` if not found — Plan 05 handles
+ 404).
+ - Fetch its `offer_micros` rows ordered by `tier_letter` (A, B, C — use
+ `sql\`CASE tier_letter WHEN 'A' THEN 1 WHEN 'B' THEN 2 WHEN 'C' THEN 3 ELSE 4 END\``
+ for ordering, since plain `asc(tier_letter)` would sort nulls/letters
+ alphabetically which happens to match A `services`.
+ - Fetch `availableServices`: all `services` where `active = true` AND (if
+ `macro.category` is set) `services.category = macro.category`; if
+ `macro.category` is null, return all active services (no pre-filter — D-4 filter
+ is best-effort, not a hard constraint when category is unset). Shape:
+ `{ id, name, unit_price, category }[]`.
+ - Fetch `tipoTags`/`obiettivoTags`: `select distinct name from tags where entity_type
+ in ("offer_macros.tipo","offer_macros.obiettivo") and entity_id = macroId`, split
+ by `entity_type` into two `string[]`.
+ - Return shape:
+ ```typescript
+ export type OfferTierData = {
+ id: string;
+ tier_letter: string | null;
+ internal_name: string;
+ public_name: string;
+ duration_months: number;
+ public_price: string | null;
+ assignedServiceIds: string[];
+ servicesTotal: string;
+ };
+ export type OfferEditorData = {
+ macro: OfferMacro; // includes category, ticket, is_archived, cliente_ideale, risultato, tempo, pain, metodo, description
+ tiers: OfferTierData[];
+ availableServices: Array<{ id: string; name: string; unit_price: string; category: string | null }>;
+ tipoTags: string[];
+ obiettivoTags: string[];
+ };
+ export async function getOfferEditorData(macroId: string): Promise;
+ ```
+
+ 3. **`getOfferFieldOptions()`**:
+ ```typescript
+ export type OfferFieldOptions = {
+ categoria: string[];
+ ticket: string[];
+ tipo: string[];
+ obiettivo: string[];
+ };
+ export async function getOfferFieldOptions(): Promise;
+ ```
+ `categoria`/`ticket`: `selectDistinct` on `offer_macros.category` /
+ `offer_macros.ticket`, filter out nulls, sort alphabetically (mirror
+ `getCatalogFieldOptions`'s null-filter pattern for `categoria`/`fase`).
+ `tipo`/`obiettivo`: `selectDistinct({ name: tags.name, type: tags.entity_type })` where
+ `entity_type in ("offer_macros.tipo", "offer_macros.obiettivo")`, split by type.
+
+ Use existing imports (`db`, `eq`, `asc`, `inArray`, `sql`, `and`) — add `desc` or others
+ only if genuinely needed. Import `offer_tier_services`, `tags`, `services` from
+ `@/db/schema` (services/tags likely not yet imported in this file — check current
+ imports first).
+
+
+ cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30
+
+
+ - `npx tsc --noEmit` passes with zero errors
+ - `grep -c "export async function getOfferEditorData\|export async function getOfferListCards\|export async function getOfferFieldOptions" src/lib/offer-queries.ts` == 3
+ - `grep -c "export async function getCatalogWithMicros\|export async function getAllOfferServices\|export async function getMicroAssignedServiceIds" src/lib/offer-queries.ts` == 3 (existing exports untouched)
+ - `grep -c "offer_tier_services" src/lib/offer-queries.ts` >= 1
+
+ offer-queries.ts exports getOfferEditorData, getOfferListCards, getOfferFieldOptions with the documented shapes; existing exports unchanged; file typechecks.
+
+
+
+ Task 2: Server actions — saveOfferEditor, toggleOfferArchived, offer tag CRUD, createOfferMacro
+ src/app/admin/offers/actions.ts
+
+ Add to `src/app/admin/offers/actions.ts` (alongside existing exports, do not remove
+ them):
+
+ - Test 1: `saveOfferEditor(macroId, payload)` rejects (throws) when `payload.tiers`
+ contains a `tier_letter` not in `["A","B","C"]` — Zod enum validation, defense-in-depth
+ alongside the DB CHECK constraint from Plan 01.
+ - Test 2: `saveOfferEditor(macroId, payload)` updates `offer_macros` scalar fields
+ (`internal_name`, `description`, `category`, `ticket`, `cliente_ideale`, `risultato`,
+ `tempo`, `pain`, `metodo`) in one `db.update(offer_macros)...where(eq(id, macroId))`.
+ - Test 3: `saveOfferEditor` upserts each tier in `payload.tiers`: if a tier `id` is
+ provided, `db.update(offer_micros).set({ tier_letter, public_price, internal_name,
+ public_name, duration_months })`; if no `id`, `db.insert(offer_micros).values({...,
+ macro_id: macroId})` (supports creating tiers for a macro that has fewer than 3).
+ - Test 4: `saveOfferEditor` replaces each tier's `offer_tier_services` rows via
+ delete-then-reinsert (same pattern as `updateMicroOfferServices`): input tier with
+ `assignedServiceIds: ["svc1","svc2"]` -> after call, `offer_tier_services` has exactly
+ 2 rows for that `tier_id`, both pointing at svc1/svc2.
+ - Test 5: `saveOfferEditor` replaces Tipo/Obiettivo tags via delete-then-reinsert against
+ `tags` where `entity_type in ("offer_macros.tipo","offer_macros.obiettivo")` and
+ `entity_id = macroId`.
+ - Test 6: `toggleOfferArchived(macroId, archived: boolean)` sets
+ `offer_macros.is_archived = archived`.
+ - Test 7: `addOfferTag(dimension, macroId, value)` / `removeOfferTag(dimension, macroId,
+ value)` work for `dimension in ("tipo","obiettivo")`, mirroring
+ `addServiceOption`/`removeServiceOption` against `entity_type =
+ "offer_macros." + dimension`. Reject any other `dimension` value.
+ - Test 8: `renameOfferOption(field, oldValue, newValue)` for `field in
+ ("categoria","ticket")` updates `offer_macros.category`/`offer_macros.ticket` for all
+ rows matching `oldValue` (mirrors `renameServiceOption`'s single-select branch); for
+ `field in ("tipo","obiettivo")` updates `tags.name` (mirrors the multi-select branch).
+ - Test 9: `createOfferMacro(formData)` creates a new `offer_macros` row from
+ `internal_name` (required) + optional `public_name`/`description`/`category` —
+ needed so Plan 04's "+ Nuova Offerta" button has a target action (UI-SPEC section 1A).
+ If `public_name` is omitted, default it to `internal_name` (existing `offer_macros`
+ schema requires `public_name` NOT NULL).
+
+ Same test-runner decision as Plan 03 Task 1: if no test runner configured, write
+ `scripts/verify-12-03-actions.ts` (typechecks, documents the 9 cases) instead of a
+ dedicated test file — do not introduce a new framework.
+
+
+ Implement in `src/app/admin/offers/actions.ts`, reusing `requireAdmin()`,
+ `revalidatePath("/admin/offers")`, and the `z` (Zod) import already present:
+
+ 1. **`saveOfferEditor(macroId: string, payload: SaveOfferEditorPayload)`** where:
+ ```typescript
+ const tierSchema = z.object({
+ id: z.string().optional(),
+ tier_letter: z.enum(["A", "B", "C"]),
+ internal_name: z.string().min(1),
+ public_name: z.string().min(1),
+ duration_months: z.coerce.number().int().min(1),
+ public_price: z.coerce.number().min(0).optional().nullable(),
+ assignedServiceIds: z.array(z.string()),
+ });
+ const saveOfferEditorSchema = z.object({
+ internal_name: z.string().min(1, "Nome interno richiesto"),
+ public_name: z.string().min(1, "Nome pubblico richiesto"),
+ description: z.string().optional(),
+ category: z.string().optional(),
+ ticket: z.string().optional(),
+ cliente_ideale: z.string().optional(),
+ risultato: z.string().optional(),
+ tempo: z.string().optional(),
+ pain: z.string().optional(),
+ metodo: z.string().optional(),
+ tiers: z.array(tierSchema).max(3),
+ tipoTags: z.array(z.string()),
+ obiettivoTags: z.array(z.string()),
+ });
+ export type SaveOfferEditorPayload = z.infer;
+ ```
+ Implementation order (sequential `await`s, no transaction wrapper needed — Drizzle
+ + `postgres` driver here doesn't use an interactive tx helper elsewhere in this
+ codebase, follow existing per-statement pattern):
+ - `safeParse` the payload; throw `parsed.error.issues[0].message` on failure.
+ - `db.update(offer_macros).set({...scalars, public_price fields N/A here...}).where(eq(offer_macros.id, macroId))`
+ — set `internal_name, public_name, description, category, ticket, cliente_ideale,
+ risultato, tempo, pain, metodo` (empty-string -> `null` for optional text fields,
+ same `|| null` convention as `createMacro`).
+ - For each tier in `parsed.data.tiers`:
+ - If `tier.id` exists: `db.update(offer_micros).set({ tier_letter, internal_name,
+ public_name, duration_months, public_price: tier.public_price != null ?
+ String(tier.public_price) : null }).where(eq(offer_micros.id, tier.id))`.
+ - Else: `db.insert(offer_micros).values({ macro_id: macroId, tier_letter,
+ internal_name, public_name, duration_months, public_price: ... })` and capture
+ the new id (insert `.returning({ id: offer_micros.id })`).
+ - Delete-then-reinsert `offer_tier_services` for that tier id (mirror
+ `updateMicroOfferServices`): `db.delete(offer_tier_services).where(eq(tier_id,
+ ...))`, then if `assignedServiceIds.length > 0`,
+ `db.insert(offer_tier_services).values(assignedServiceIds.map(service_id => ({
+ tier_id, service_id })))`.
+ - Delete-then-reinsert Tipo/Obiettivo tags: `db.delete(tags).where(and(eq(entity_id,
+ macroId), inArray(entity_type, ["offer_macros.tipo","offer_macros.obiettivo"])))`,
+ then insert rows for each `tipoTags`/`obiettivoTags` entry with the corresponding
+ `entity_type`, using `onConflictDoNothing()` per row (mirror `addServiceOption`).
+ - `revalidatePath("/admin/offers")` and `revalidatePath(\`/admin/offers/${macroId}/edit\`)`.
+
+ 2. **`toggleOfferArchived(macroId: string, archived: boolean)`**: `requireAdmin()`,
+ `db.update(offer_macros).set({ is_archived: archived }).where(eq(id, macroId))`,
+ `revalidatePath("/admin/offers")`.
+
+ 3. **`addOfferTag(dimension: "tipo" | "obiettivo", macroId: string, value: string)`** /
+ **`removeOfferTag(dimension, macroId, value)`**: mirror
+ `addServiceOption`/`removeServiceOption` exactly, using `entity_type =
+ \`offer_macros.${dimension}\`` and `entity_id = macroId`. Validate `dimension` is one
+ of the two allowed values (throw otherwise).
+
+ 4. **`renameOfferOption(field: "categoria" | "ticket" | "tipo" | "obiettivo", oldValue:
+ string, newValue: string)`**: mirror `renameServiceOption`. For `"categoria"`:
+ `db.update(offer_macros).set({ category: next }).where(eq(category, oldValue))`. For
+ `"ticket"`: same on `offer_macros.ticket`. For `"tipo"`/`"obiettivo"`:
+ `db.update(tags).set({ name: next }).where(and(eq(entity_type,
+ \`offer_macros.${field}\`), eq(name, oldValue)))`.
+
+ 5. **`createOfferMacro(formData: FormData)`**: Zod-validate `internal_name` (required),
+ optional `public_name` (default = `internal_name` if empty), `description`,
+ `category`. `db.insert(offer_macros).values({...})`, `revalidatePath("/admin/offers")`.
+ This is additive alongside the existing `createMacro` (legacy form-based create on
+ the old page) — `createOfferMacro` is the Plan 04 "+ Nuova Offerta" target with the
+ new fields.
+
+ Import `offer_tier_services`, `tags` from `@/db/schema` (add to existing import list);
+ import `inArray` from `drizzle-orm` if not already imported.
+
+
+ cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30
+
+
+ - `npx tsc --noEmit` passes with zero errors
+ - `grep -c "export async function saveOfferEditor\|export async function toggleOfferArchived\|export async function addOfferTag\|export async function removeOfferTag\|export async function renameOfferOption\|export async function createOfferMacro" src/app/admin/offers/actions.ts` == 6
+ - `grep -c "export async function createMacro\|export async function deleteMacro\|export async function createMicro\|export async function deleteMicro\|export async function updateMicroOfferServices" src/app/admin/offers/actions.ts` == 5 (existing exports untouched)
+ - `grep -c "z.enum(\[\"A\", \"B\", \"C\"\]\|z.enum([\"A\",\"B\",\"C\"]" src/app/admin/offers/actions.ts` >= 1 (tier_letter Zod validation present)
+ - `grep -c "requireAdmin()" src/app/admin/offers/actions.ts` >= 11 (every action, old + new, calls requireAdmin)
+
+ actions.ts exports saveOfferEditor, toggleOfferArchived, addOfferTag, removeOfferTag, renameOfferOption, createOfferMacro — all requireAdmin-guarded, Zod-validated, revalidating /admin/offers; existing exports unchanged; file typechecks.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Admin browser -> saveOfferEditor server action | Admin-authenticated input (full editor payload: scalars, tier compositions, tags) crosses into DB writes |
+| Admin browser -> offer tag CRUD actions | Free-text tag names (Tipo/Obiettivo/Categoria/Ticket) crosses into `tags`/`offer_macros` |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-12-06 | Tampering | `saveOfferEditor` tier_letter / numeric fields | mitigate | Zod schema: `tier_letter` restricted to enum `["A","B","C"]`, `public_price`/`duration_months` coerced + min-bounded; DB CHECK constraint (Plan 01) as second layer |
+| T-12-07 | Elevation of Privilege | All new server actions (`saveOfferEditor`, `toggleOfferArchived`, tag CRUD, `createOfferMacro`) | mitigate | Every action calls `requireAdmin()` first (session check via Auth.js), identical to all existing `/admin/offers` and `/admin/catalog` actions |
+| T-12-08 | Tampering | Free-text tag/category/ticket values stored via `onConflictDoNothing`/update | accept | Same trust level as Phase 11 catalog tags (admin-only input, no client-facing exposure); length/content not constrained beyond non-empty, consistent with existing `addServiceOption`/`renameServiceOption` |
+| T-12-09 | Information Disclosure | `getOfferEditorData`/`getOfferListCards`/`getOfferFieldOptions` expose `offer_tier_services`/pricing data | accept | Functions are imported only by `/admin/offers/*` pages (Plans 04/05), which sit behind Auth.js session middleware; no route in this plan is client-facing |
+
+
+
+1. `npx tsc --noEmit` passes for the full project.
+2. `getOfferEditorData`, `getOfferListCards`, `getOfferFieldOptions` exported from `src/lib/offer-queries.ts`, existing exports (`getCatalogWithMicros`, `getAllOfferServices`, `getMicroAssignedServiceIds`) untouched.
+3. `saveOfferEditor`, `toggleOfferArchived`, `addOfferTag`, `removeOfferTag`, `renameOfferOption`, `createOfferMacro` exported from `src/app/admin/offers/actions.ts`, existing exports (`createMacro`, `deleteMacro`, `createMicro`, `deleteMicro`, `updateMicroOfferServices`) untouched.
+4. Every new server action calls `requireAdmin()` before any DB write.
+5. `saveOfferEditor` validates `tier_letter` against `["A","B","C"]` via Zod before any write.
+
+
+
+- Plan 05's editor page can fetch a complete `OfferEditorData` shape and persist all of it via `saveOfferEditor` in one call.
+- Plan 04's list page can fetch `OfferListCard[]` with category + archive status.
+- All four tag dimensions (Categoria, Ticket, Tipo, Obiettivo) are readable/writable via `getOfferFieldOptions`/`addOfferTag`/`removeOfferTag`/`renameOfferOption`.
+- No legacy `offer_micro_services`/`offer_services`/existing-export code paths modified.
+
+
+
diff --git a/.planning/phases/12-offer-composition-drag-drop-csv-import/12-04-PLAN.md b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-04-PLAN.md
new file mode 100644
index 0000000..870ed35
--- /dev/null
+++ b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-04-PLAN.md
@@ -0,0 +1,284 @@
+---
+phase: 12
+plan: 04
+type: execute
+wave: 3
+depends_on: [1, 3]
+files_modified:
+ - src/app/admin/offers/page.tsx
+ - src/components/admin/offers/OfferListClient.tsx
+autonomous: true
+requirements: [OFFER-18]
+
+must_haves:
+ truths:
+ - "Admin sees a card grid of all offers, each showing internal name, short description, and category chip"
+ - "Admin can filter the card grid by category (Tutti / Entry Offer / Signature Offer / Retainer Offer, derived dynamically from existing category values)"
+ - "Admin can toggle 'Mostra offerte archiviate' to show/hide archived offers (hidden by default)"
+ - "Admin can create a new offer via '+ Nuova Offerta', which creates an offer_macros row and the new card appears in the grid"
+ - "Each card links to the Plan 05 editor route /admin/offers/[id]/edit"
+ artifacts:
+ - path: "src/app/admin/offers/page.tsx"
+ provides: "Server component: fetches getOfferListCards() + getOfferFieldOptions(), renders OfferListClient"
+ - path: "src/components/admin/offers/OfferListClient.tsx"
+ provides: "Client component: category filter chips, archive toggle, card grid, '+ Nuova Offerta' button calling createOfferMacro"
+ exports: ["OfferListClient"]
+ key_links:
+ - from: "src/components/admin/offers/OfferListClient.tsx"
+ to: "/admin/offers/[id]/edit"
+ via: "next/link Link per card"
+ pattern: "admin/offers/\\$\\{.*\\}/edit"
+ - from: "src/components/admin/offers/OfferListClient.tsx (+ Nuova Offerta)"
+ to: "createOfferMacro server action (Plan 03)"
+ via: "useTransition + router.refresh on success, new card appears in grid"
+ pattern: "createOfferMacro"
+---
+
+
+Redesign `/admin/offers` (currently a flat macro/micro CRUD list — the legacy page) into the
+Phase 12 UI-SPEC card-grid list: filterable by category chips, archive toggle, "+ Nuova
+Offerta" CTA, each card linking to the new per-offer editor route (`/admin/offers/[id]/edit`,
+built in Plan 05).
+
+Purpose: Deliver OFFER-18 (category-filterable, archive-aware offer list) per the locked
+UI-SPEC (`12-UI-SPEC.md` section 1) and D-1 (full editor list+detail, CSV import deferred).
+Output: `src/app/admin/offers/page.tsx` rewritten as the new list page;
+`src/components/admin/offers/OfferListClient.tsx` (new) implementing filter/grid/CTA logic.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/STATE.md
+@.planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md
+@src/app/admin/catalog/CatalogSearch.tsx
+@src/components/admin/AdminSidebar.tsx
+
+
+
+
+
+```typescript
+export type OfferListCard = Pick;
+export async function getOfferListCards(): Promise;
+
+export type OfferFieldOptions = { categoria: string[]; ticket: string[]; tipo: string[]; obiettivo: string[] };
+export async function getOfferFieldOptions(): Promise;
+```
+
+
+
+```typescript
+// Creates a new offer_macros row from FormData { internal_name, public_name?, description?, category? }
+// public_name defaults to internal_name if omitted. Returns void (revalidates /admin/offers).
+// NOTE: createOfferMacro does NOT return the new row's id (FormData server actions can't
+// easily return values to client callers in the
+
+
+
+
+ Task 1: Rewrite /admin/offers page.tsx as the new list page entry point
+ src/app/admin/offers/page.tsx
+
+ - src/app/admin/offers/page.tsx (current — full file, ~239 lines, the legacy macro/micro
+ CRUD page being replaced)
+ - src/app/admin/catalog/page.tsx (pattern: thin server component, `Promise.all` fetch,
+ delegates to a client component for interactivity)
+ - .planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md (section 1:
+ Offer List Page layout, header, filter row, card grid, empty state, copywriting
+ contract table)
+
+
+ Replace the entire contents of `src/app/admin/offers/page.tsx` with a thin server
+ component matching the `catalog/page.tsx` pattern:
+
+ ```typescript
+ import { getOfferListCards, getOfferFieldOptions } from "@/lib/offer-queries";
+ import { OfferListClient } from "@/components/admin/offers/OfferListClient";
+
+ export const revalidate = 0;
+
+ export default async function OffersPage() {
+ const [cards, options] = await Promise.all([
+ getOfferListCards(),
+ getOfferFieldOptions(),
+ ]);
+
+ return (
+
+
+
+ );
+ }
+ ```
+
+ The legacy macro/micro/offer_services CRUD UI previously on this page (forms for
+ `createMacro`/`createMicro`/`createOfferService`, `ServiceCheckboxList`, etc.) is
+ REMOVED from this route per D-1 (full editor redesign) and the UI-SPEC (section 1 — no
+ legacy CRUD forms in the new list page). The underlying `offer_macros`/`offer_micros`
+ legacy actions (`createMacro`, `deleteMacro`, `createMicro`, `deleteMicro`,
+ `updateMicroOfferServices`) remain exported from `actions.ts` (Plan 03 did not remove
+ them) but are no longer referenced from any page — this is intentional dead-export
+ cleanup deferred to a future phase if needed (do NOT delete the exports now, only
+ routes/usages, to avoid breaking `npx tsc --noEmit` if anything else imports them; grep
+ confirms nothing else does, but leaving the exports is zero-risk and out of this plan's
+ scope to remove).
+
+
+ cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -20
+
+
+ - `src/app/admin/offers/page.tsx` is a server component importing `getOfferListCards`, `getOfferFieldOptions`, and `OfferListClient`
+ - `grep -c "ServiceCheckboxList\|createMacro\|createMicro\|createOfferService" src/app/admin/offers/page.tsx` == 0 (legacy forms removed from this page)
+ - `npx tsc --noEmit` passes (will fail until Task 2 creates `OfferListClient` — run combined verification after both tasks)
+
+ page.tsx is a thin server component delegating to OfferListClient with the new data shape.
+
+
+
+ Task 2: Build OfferListClient — category filter, archive toggle, card grid, create CTA
+ src/components/admin/offers/OfferListClient.tsx
+
+ - src/app/admin/catalog/CatalogSearch.tsx (client component pattern: "use client",
+ useState filter state, useMemo derived list, search input)
+ - src/components/ui/button.tsx, src/components/ui/badge.tsx, src/components/ui/input.tsx
+ (exported component signatures — use these for CTA button, category chips/badges,
+ "+ Nuova Offerta" form input)
+ - .planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md (section 1:
+ exact layout ASCII diagram, color tokens #1A463C/#DEF168/#dc2626/#71717a/#e5e7eb,
+ card structure, empty state copy, filter chip active/inactive styling, copywriting
+ contract table for all literal strings)
+
+
+ Create `src/components/admin/offers/OfferListClient.tsx`:
+
+ ```typescript
+ "use client";
+ import { useState, useMemo, useTransition } from "react";
+ import { useRouter } from "next/navigation";
+ import Link from "next/link";
+ import { createOfferMacro } from "@/app/admin/offers/actions";
+ import type { OfferListCard } from "@/lib/offer-queries";
+
+ export function OfferListClient({
+ cards,
+ categoryOptions,
+ }: {
+ cards: OfferListCard[];
+ categoryOptions: string[];
+ }) {
+ // ... implementation below
+ }
+ ```
+
+ Implementation requirements (per UI-SPEC section 1 + copywriting contract table):
+
+ 1. **State**: `activeCategory: string | null` (null = "Tutti"), `showArchived: boolean`
+ (default `false`), `showCreateForm: boolean` + a controlled text input for the new
+ offer's `internal_name`.
+
+ 2. **Filter row**:
+ - Chip row: "Tutti" + one chip per `categoryOptions` value. Active chip: border
+ `#1A463C`, text `#1A463C`. Inactive: border `#e5e7eb`, text `#71717a`. 8px gap
+ (`gap-2`).
+ - "Mostra offerte archiviate" checkbox + label (`#71717a` text), toggles
+ `showArchived`.
+
+ 3. **Filtered list** (`useMemo`): `cards.filter(c => (activeCategory === null ||
+ c.category === activeCategory) && (showArchived || !c.is_archived))`.
+
+ 4. **Card grid**: `grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4`. Each card:
+ - `Link href={`/admin/offers/${card.id}/edit`}` wrapping a `div` with white bg,
+ `border border-[#e5e7eb] rounded-lg p-4 hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)]
+ cursor-pointer transition-shadow`.
+ - `internal_name` as h3 (16px/600).
+ - `description` (if present) as body text, `line-clamp-2`, `#71717a`.
+ - Category badge: small chip showing `category` (if present) — reuse
+ `src/components/ui/badge.tsx` `` or a styled ``.
+ - If `is_archived`: red "Archiviata" badge (text `#dc2626`).
+
+ 5. **Empty state**: if `filteredCards.length === 0`, show centered block: heading
+ "Nessuna offerta" (h3), body "Inizia creando la tua prima offerta" (`#71717a`), and
+ the "+ Nuova Offerta" CTA (same as header).
+
+ 6. **"+ Nuova Offerta" CTA** (top-right of page header, "Offerte" h2 title to its left):
+ - Clicking "+ Nuova Offerta" toggles `showCreateForm`, revealing a small inline
+ `
+
+ cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30 && npx eslint src/app/admin/offers/page.tsx src/components/admin/offers/OfferListClient.tsx 2>&1 | tail -30
+
+
+ - `npx tsc --noEmit` passes with zero errors (page.tsx + OfferListClient.tsx + rest of project)
+ - `npx eslint src/app/admin/offers/page.tsx src/components/admin/offers/OfferListClient.tsx` reports zero errors
+ - `grep -c "OfferListCard" src/components/admin/offers/OfferListClient.tsx` >= 1
+ - `grep -c "createOfferMacro" src/components/admin/offers/OfferListClient.tsx` >= 1
+ - `grep -c "admin/offers/\${" src/components/admin/offers/OfferListClient.tsx` >= 1 (card -> editor link)
+ - `grep -c "Mostra offerte archiviate\|Nessuna offerta\|Nuova Offerta" src/components/admin/offers/OfferListClient.tsx` >= 3 (UI-SPEC copy present)
+
+ OfferListClient renders the category-filterable, archive-aware card grid with working "+ Nuova Offerta" creation; page.tsx + OfferListClient.tsx typecheck and lint clean.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Admin browser -> createOfferMacro (via "+ Nuova Offerta") | Free-text `internal_name` input crosses into a new `offer_macros` row |
+| Admin browser -> /admin/offers/[id]/edit links | Card `id` values are server-fetched (not user input) — links are safe by construction |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-12-10 | Tampering | "+ Nuova Offerta" inline form | accept | `createOfferMacro` (Plan 03) already calls `requireAdmin()` + Zod-validates `internal_name` non-empty; this plan only adds the UI trigger, no new validation surface |
+| T-12-11 | Information Disclosure | Offer list exposes `description`/`category`/`is_archived` for all offers | accept | Route is under `/admin/*`, Auth.js session-gated middleware (existing project-wide constraint); no change to access control in this plan |
+
+
+
+1. `npx tsc --noEmit` passes for the full project.
+2. `npx eslint` clean for both modified/created files.
+3. `/admin/offers` renders a card grid sourced from `getOfferListCards()`, with category filter chips from `getOfferFieldOptions().categoria` and an archive toggle (default off).
+4. "+ Nuova Offerta" creates a new `offer_macros` row via `createOfferMacro` and the new card appears after `router.refresh()`.
+5. Each card links to `/admin/offers/${id}/edit` (Plan 05's route — may 404 until Plan 05 lands, but the link target is correct).
+
+
+
+- `/admin/offers` matches the UI-SPEC section 1 layout: header + CTA, category filter chips, archive toggle, card grid, empty state.
+- OFFER-18 satisfied: list filterable by category, archive toggle hides archived offers by default.
+- No legacy macro/micro CRUD forms remain on `/admin/offers`.
+
+
+
diff --git a/.planning/phases/12-offer-composition-drag-drop-csv-import/12-05-PLAN.md b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-05-PLAN.md
new file mode 100644
index 0000000..7e7c202
--- /dev/null
+++ b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-05-PLAN.md
@@ -0,0 +1,463 @@
+---
+phase: 12
+plan: 05
+type: execute
+wave: 3
+depends_on: [1, 3]
+files_modified:
+ - src/app/admin/offers/[id]/edit/page.tsx
+ - src/components/admin/offers/OfferEditorClient.tsx
+autonomous: true
+requirements: [OFFER-11, OFFER-15, OFFER-16, OFFER-17]
+
+must_haves:
+ truths:
+ - "Admin can open /admin/offers/[id]/edit and see the offer's name, category/ticket chips, 4 tag dimensions, services matrix, transformation promise, and Salva/Annulla/Archivia actions"
+ - "Admin can select Categoria and Ticket (single-select), and add/remove Tipo and Obiettivo tags (multi-select, creatable on the fly) — OFFER-15"
+ - "Admin sees a service x tier (A/B/C) checkbox matrix pre-filtered to services matching the offer's category, with live-updating 'Totale Servizi' per tier as checkboxes toggle — OFFER-11"
+ - "Admin can enter an independent manual 'Prezzo Pubblico' per tier, separate from 'Totale Servizi' — OFFER-16"
+ - "Admin can edit 5 transformation-promise fields (Aiuto/A ottenere/In/Senza/Grazie a) — OFFER-17"
+ - "Clicking 'Salva Offerta' persists all changes (macro fields, tier data, tier-service assignments, tags) via saveOfferEditor and redirects to /admin/offers"
+ - "Clicking 'Archivia' toggles is_archived (with confirmation) via toggleOfferArchived"
+ artifacts:
+ - path: "src/app/admin/offers/[id]/edit/page.tsx"
+ provides: "Server component: fetches getOfferEditorData(id) + getOfferFieldOptions(), renders OfferEditorClient or notFound()"
+ - path: "src/components/admin/offers/OfferEditorClient.tsx"
+ provides: "Client component: editable name/category/ticket, 4-dimension tags, services matrix with live totals, transformation promise fields, Salva/Annulla/Archivia actions"
+ exports: ["OfferEditorClient"]
+ key_links:
+ - from: "src/components/admin/offers/OfferEditorClient.tsx (Salva Offerta)"
+ to: "saveOfferEditor server action (Plan 03)"
+ via: "useTransition + router.push('/admin/offers') on success"
+ pattern: "saveOfferEditor"
+ - from: "src/components/admin/offers/OfferEditorClient.tsx (services matrix)"
+ to: "OfferEditorData.tiers[].assignedServiceIds + availableServices"
+ via: "client-side state, checkbox toggles update local tier.assignedServiceIds, Totale Servizi recomputed via useMemo from unit_price sums"
+ pattern: "Totale Servizi|assignedServiceIds"
+ - from: "src/components/admin/offers/OfferEditorClient.tsx (Archivia)"
+ to: "toggleOfferArchived server action (Plan 03)"
+ via: "confirm dialog + useTransition + router.push('/admin/offers')"
+ pattern: "toggleOfferArchived"
+---
+
+
+Build the Offer Editor detail page at `/admin/offers/[id]/edit`: a full-page editor covering
+the offer's name/category/ticket, 4-dimension tags (Categoria/Ticket single-select,
+Tipo/Obiettivo multi-select creatable on the fly), the service x tier (A/B/C) checkbox
+matrix pre-filtered by category with live "Totale Servizi" totals, independent manual
+"Prezzo Pubblico" per tier, the 5-field transformation promise block, and
+Salva/Annulla/Archivia actions.
+
+Purpose: Deliver OFFER-11 (3-tier checkbox matrix with live totals), OFFER-15 (4-dimension
+tags), OFFER-16 (manual public price per tier), and OFFER-17 (structured transformation
+promise) per the locked UI-SPEC section 2 and CONTEXT.md decisions D-2 through D-5.
+Output: `src/app/admin/offers/[id]/edit/page.tsx` (new server component) and
+`src/components/admin/offers/OfferEditorClient.tsx` (new client component) implementing the
+full editor.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/STATE.md
+@.planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md
+@src/components/admin/catalog/ServiceTable.tsx
+@src/components/ui/option-select.tsx
+@src/components/ui/option-multi-select.tsx
+@src/components/ui/editable-cell.tsx
+
+
+
+
+
+```typescript
+export type OfferTierData = {
+ id: string;
+ tier_letter: string | null; // "A" | "B" | "C" | null
+ internal_name: string;
+ public_name: string;
+ duration_months: number;
+ public_price: string | null; // numeric as string, e.g. "499.00"
+ assignedServiceIds: string[];
+ servicesTotal: string; // pre-computed sum, numeric as string e.g. "350.00"
+};
+
+export type OfferEditorData = {
+ macro: OfferMacro; // includes id, internal_name, public_name, description,
+ // category, ticket, is_archived, cliente_ideale,
+ // risultato, tempo, pain, metodo (all from Plan 01 schema)
+ tiers: OfferTierData[]; // ordered A, B, C (0-3 entries)
+ availableServices: Array<{ id: string; name: string; unit_price: string; category: string | null }>;
+ tipoTags: string[]; // current Tipo tag values for this offer
+ obiettivoTags: string[]; // current Obiettivo tag values for this offer
+};
+
+export async function getOfferEditorData(macroId: string): Promise;
+
+export type OfferFieldOptions = { categoria: string[]; ticket: string[]; tipo: string[]; obiettivo: string[] };
+export async function getOfferFieldOptions(): Promise;
+```
+
+
+
+```typescript
+// Zod-validated; tiers array max 3, each tier has tier_letter "A"|"B"|"C",
+// internal_name, public_name, duration_months (int >= 1), public_price (number >= 0 | null),
+// assignedServiceIds (string[]). id optional per tier (omit for new tier, include to update).
+export type SaveOfferEditorPayload = {
+ internal_name: string;
+ public_name: string;
+ description?: string;
+ category?: string;
+ ticket?: string;
+ cliente_ideale?: string;
+ risultato?: string;
+ tempo?: string;
+ pain?: string;
+ metodo?: string;
+ tiers: Array<{
+ id?: string;
+ tier_letter: "A" | "B" | "C";
+ internal_name: string;
+ public_name: string;
+ duration_months: number;
+ public_price?: number | null;
+ assignedServiceIds: string[];
+ }>;
+ tipoTags: string[];
+ obiettivoTags: string[];
+};
+
+export async function saveOfferEditor(macroId: string, payload: SaveOfferEditorPayload): Promise;
+export async function toggleOfferArchived(macroId: string, archived: boolean): Promise;
+export async function addOfferTag(dimension: "tipo" | "obiettivo", macroId: string, value: string): Promise;
+export async function removeOfferTag(dimension: "tipo" | "obiettivo", macroId: string, value: string): Promise;
+export async function renameOfferOption(field: "categoria" | "ticket" | "tipo" | "obiettivo", oldValue: string, newValue: string): Promise;
+```
+
+
+
+```typescript
+export interface OptionSelectProps {
+ value: string | null;
+ options: string[];
+ onChange: (value: string | null) => void;
+ onRename?: (oldValue: string, newValue: string) => void;
+ placeholder?: string;
+ disabled?: boolean;
+}
+export function OptionSelect(props: OptionSelectProps): JSX.Element;
+
+export interface OptionMultiSelectProps {
+ values: string[];
+ options: string[];
+ onAdd: (value: string) => void;
+ onRemove: (value: string) => void;
+ onRename?: (oldValue: string, newValue: string) => void;
+ placeholder?: string;
+ disabled?: boolean;
+}
+export function OptionMultiSelect(props: OptionMultiSelectProps): JSX.Element;
+```
+
+
+
+```typescript
+export interface EditableCellProps {
+ value: string;
+ type?: "text" | "number";
+ onSave: (value: string) => void | Promise;
+ required?: boolean;
+ placeholder?: string;
+ disabled?: boolean;
+ formatDisplay?: (value: string) => string;
+}
+export function EditableCell(props: EditableCellProps): JSX.Element;
+```
+
+
+
+
+
+ Task 1: Create editor route + page.tsx server component
+ src/app/admin/offers/[id]/edit/page.tsx
+
+ - src/app/admin/offers/page.tsx (current, post-Plan-04 — server component fetch pattern)
+ - src/lib/offer-queries.ts (Plan 03 — getOfferEditorData, getOfferFieldOptions signatures, confirm null-handling)
+
+
+ Create `src/app/admin/offers/[id]/edit/page.tsx` as a server component (Next.js 16 App
+ Router, async `params`):
+
+ ```typescript
+ import { notFound } from "next/navigation";
+ import { getOfferEditorData, getOfferFieldOptions } from "@/lib/offer-queries";
+ import { OfferEditorClient } from "@/components/admin/offers/OfferEditorClient";
+
+ export const revalidate = 0;
+
+ export default async function OfferEditPage({
+ params,
+ }: {
+ params: Promise<{ id: string }>;
+ }) {
+ const { id } = await params;
+ const [data, options] = await Promise.all([
+ getOfferEditorData(id),
+ getOfferFieldOptions(),
+ ]);
+
+ if (!data) {
+ notFound();
+ }
+
+ return ;
+ }
+ ```
+
+ Use the Next.js 16 async `params` pattern (consistent with other dynamic routes in this
+ project — confirm by checking an existing `[id]` route if one exists, e.g. under
+ `/admin/leads/[id]` from Phase 14, otherwise this signature is correct for Next 16 App
+ Router).
+
+
+ cd /Users/simonecavalli/Vault/IAMCAVALLI && grep -c "getOfferEditorData\|getOfferFieldOptions\|OfferEditorClient\|notFound" src/app/admin/offers/\[id\]/edit/page.tsx
+
+
+ - File exists at `src/app/admin/offers/[id]/edit/page.tsx`
+ - Server component (no `"use client"` directive)
+ - Calls `getOfferEditorData(id)` and `getOfferFieldOptions()` via `Promise.all`
+ - Calls `notFound()` when `data === null`
+ - Renders ``
+
+ page.tsx exists, fetches editor data + field options, handles not-found, delegates to OfferEditorClient.
+
+
+
+ Task 2: Build OfferEditorClient — tags, services matrix with live totals, promise block, actions
+ src/components/admin/offers/OfferEditorClient.tsx
+
+ - src/components/admin/catalog/ServiceTable.tsx (EditableCell/OptionSelect/OptionMultiSelect
+ usage patterns, table layout conventions)
+ - .planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md section 2 (full
+ editor layout, color tokens, copywriting contract table, visual states)
+
+
+ Create `src/components/admin/offers/OfferEditorClient.tsx`:
+
+ ```typescript
+ "use client";
+ import { useState, useMemo, useTransition } from "react";
+ import { useRouter } from "next/navigation";
+ import Link from "next/link";
+ import {
+ saveOfferEditor,
+ toggleOfferArchived,
+ addOfferTag,
+ removeOfferTag,
+ renameOfferOption,
+ type SaveOfferEditorPayload,
+ } from "@/app/admin/offers/actions";
+ import { OptionSelect } from "@/components/ui/option-select";
+ import { OptionMultiSelect } from "@/components/ui/option-multi-select";
+ import { EditableCell } from "@/components/ui/editable-cell";
+ import type { OfferEditorData, OfferFieldOptions } from "@/lib/offer-queries";
+
+ export function OfferEditorClient({
+ data,
+ fieldOptions,
+ }: {
+ data: OfferEditorData;
+ fieldOptions: OfferFieldOptions;
+ }) {
+ // ... implementation below
+ }
+ ```
+
+ Implementation requirements (per UI-SPEC section 2 + copywriting contract table — all
+ literal strings verbatim Italian):
+
+ 1. **Local editable state** (all derived from `data` on mount, no re-fetch on change):
+ - `macro` fields: `internal_name`, `public_name`, `description`, `category`,
+ `ticket`, `cliente_ideale`, `risultato`, `tempo`, `pain`, `metodo` — each as
+ individual `useState` or one combined `useState`
+ object updated via spread.
+ - `tiers: OfferTierData[]` state, seeded from `data.tiers`. If `data.tiers.length <
+ 3`, pad to exactly 3 tiers (A, B, C) with empty placeholders: `{ id: "", tier_letter:
+ "A"|"B"|"C", internal_name: "", public_name: "", duration_months: 1, public_price:
+ null, assignedServiceIds: [], servicesTotal: "0" }` for any missing letter, so the
+ matrix always renders 3 columns.
+ - `tipoTags: string[]`, `obiettivoTags: string[]` state, seeded from
+ `data.tipoTags`/`data.obiettivoTags`.
+ - `categoriaOptions`/`ticketOptions`/`tipoOptions`/`obiettivoOptions` from
+ `fieldOptions`, kept in local state so `renameOfferOption` can update them
+ optimistically.
+
+ 2. **Header & navigation**:
+ - "← Indietro" link (text, `#71717a`) -> `Link href="/admin/offers"`.
+ - "Modifica Offerta" as h2 (20px/600).
+ - Offer name as `EditableCell` (text, required) bound to `macro.internal_name`, styled
+ as h3 (16px/600).
+
+ 3. **Tags block** ("Tag" h3 section label, 16px between rows):
+ - **Categoria** row: label "Categoria" (12px/400, `#71717a`) + ` { renameOfferOption("categoria", old, next); /* update local options +
+ macro.category if it matched old */ }} placeholder="Seleziona categoria..." />`.
+ When `macro.category` changes, the services matrix (step 5) must re-filter
+ `availableServices` by the new category (D-4) — implement via `useMemo` keyed on
+ `macro.category`.
+ - **Ticket** row: same pattern with `macro.ticket` / `ticketOptions` /
+ `renameOfferOption("ticket", ...)`, placeholder "Seleziona ticket...".
+ - **Tipo** row: label "Tipo" + ` { setTipoTags([...tipoTags, v]); if
+ (!tipoOptions.includes(v)) setTipoOptions([...tipoOptions, v]); }}
+ onRemove={(v) => setTipoTags(tipoTags.filter(t => t !== v))}
+ onRename={(old, next) => renameOfferOption("tipo", old, next)} placeholder="+ Crea
+ Tipo" />`. Tag additions/removals are tracked in local state only and persisted on
+ "Salva Offerta" via `tipoTags` in the payload — do NOT call `addOfferTag`/
+ `removeOfferTag` on every click (avoids partial-save inconsistency); these two
+ actions exist for potential future inline-only flows but this editor batches via
+ `saveOfferEditor`.
+ - **Obiettivo** row: same pattern as Tipo, using `obiettivoTags`/`obiettivoOptions`,
+ placeholder "+ Crea Obiettivo".
+
+ 4. **Services matrix** ("Servizi Inclusi" h3 section label):
+ - `filteredServices = useMemo(() => macro.category ? data.availableServices.filter(s
+ => s.category === macro.category) : data.availableServices, [macro.category,
+ data.availableServices])`.
+ - Table: header row "Servizio" (left) | "Prezzo" (right-aligned) | "A" | "B" | "C"
+ (centered), header bg `#f9f9f9`, border-bottom `#e5e7eb`.
+ - One row per `filteredServices` item: service `name` (left), `unit_price` formatted
+ as "€X,XX" (right-aligned, tabular-nums), then one `` per
+ tier (A/B/C), checked if `tiers[tierIdx].assignedServiceIds.includes(service.id)`.
+ On change: toggle the service id in/out of that tier's `assignedServiceIds` array
+ (immutable update via `setTiers`). Checkbox accent color `#1A463C` (use
+ `accent-[#1A463C]` Tailwind class or inline style `accentColor: "#1A463C"`). Row
+ height ~40px, hover bg `#f9f9f9`, border-bottom `#e5e7eb`.
+ - If `filteredServices.length === 0`: render a single placeholder row spanning all
+ columns with text "Nessun servizio disponibile per questa categoria" (`#71717a`),
+ table headers still visible.
+ - **Totale Servizi row** (sticky/bold, bottom of table, computed via `useMemo`):
+ label "Totale Servizi" (bold 14px/600, `#1a1a1a`), then per tier:
+ `tiers[i].assignedServiceIds.reduce((sum, id) => sum +
+ Number(filteredServices.find(s => s.id === id)?.unit_price ?? 0), 0)` formatted as
+ "€X.XXX,XX" (Italian locale, e.g. `toLocaleString("it-IT", { minimumFractionDigits:
+ 2, maximumFractionDigits: 2 })`). Recomputes instantly on every checkbox toggle (no
+ network call) — this is the OFFER-11 live-total requirement.
+ - **Prezzo Pubblico row** (bold label, bottom of table, below Totale Servizi): label
+ "Prezzo Pubblico" (bold 14px/600), then per tier an `` bound to `tiers[i].public_price` (string | null -> controlled value
+ `tiers[i].public_price ?? ""`), placeholder "€0,00". On change, update
+ `tiers[i].public_price` in state (store as string; convert to number on save).
+ Independent of Totale Servizi (D-5) — no auto-sync between the two rows.
+
+ 5. **Transformation Promise block** ("Promessa di Trasformazione" h3 section label, 16px
+ gap between fields): 5 rows, each `label (12px/400, #71717a) + EditableCell(text,
+ optional)`:
+ - "Aiuto" -> `macro.cliente_ideale`, placeholder "Aggiungi cliente ideale"
+ - "A ottenere" -> `macro.risultato`, placeholder "Aggiungi risultato"
+ - "In" -> `macro.tempo`, placeholder "Aggiungi durata (es. 3 mesi)"
+ - "Senza" -> `macro.pain`, placeholder "Aggiungi pain point"
+ - "Grazie a" -> `macro.metodo`, placeholder "Aggiungi metodo"
+ All optional (no `required` prop), `onSave` updates the corresponding `macro.*` state
+ field.
+
+ 6. **Action buttons** (bottom, 8px gap):
+ - **"Salva Offerta"** (primary, bg `#1A463C`, text white, 14px/600, padding `16px
+ 24px`): disabled if NO tier has `assignedServiceIds.length > 0` (tooltip "Seleziona
+ almeno un servizio" via `title` attribute when disabled). On click:
+ `startTransition(async () => { const payload: SaveOfferEditorPayload = { ...macro
+ fields, tiers: tiers.map(t => ({ id: t.id || undefined, tier_letter: t.tier_letter,
+ internal_name: t.internal_name || t.tier_letter (e.g. "Tier A"), public_name:
+ t.public_name || t.tier_letter, duration_months: t.duration_months || 1,
+ public_price: t.public_price ? Number(t.public_price) : null, assignedServiceIds:
+ t.assignedServiceIds })), tipoTags, obiettivoTags }; try { await
+ saveOfferEditor(data.macro.id, payload); router.push("/admin/offers"); } catch (e) {
+ /* show inline error text "Errore nel salvataggio. Verifica i campi." */ } })`.
+ - **"Annulla"** (secondary, border `#e5e7eb`, text `#1a1a1a`): `Link
+ href="/admin/offers"` — no dirty-check confirmation in this iteration (acceptable
+ simplification per UI-SPEC note "Auto-save optional (planner decides)" — explicit
+ Salva is the persistence model, Annulla is a plain navigation link).
+ - **"Archivia"** (destructive, text `#dc2626`, no fill), visible only if
+ `!macro_initial.is_archived` (track initial archived state separately from any
+ local toggle): on click, `window.confirm("Sei sicuro? L'offerta non sarà visibile
+ nella lista.")`, if confirmed `startTransition(async () => { await
+ toggleOfferArchived(data.macro.id, true); router.push("/admin/offers"); })`.
+
+ All literal copy strings (Italian) per the UI-SPEC copywriting contract table: "←
+ Indietro", "Modifica Offerta", "Tag", "Categoria", "Ticket", "Tipo", "Obiettivo", "+ Crea
+ Tipo", "+ Crea Obiettivo", "Servizi Inclusi", "Servizio", "Prezzo", "A", "B", "C",
+ "Totale Servizi", "Prezzo Pubblico", "Promessa di Trasformazione", "Aiuto", "A
+ ottenere", "In", "Senza", "Grazie a", "Salva Offerta", "Annulla", "Archivia", "Sei
+ sicuro? L'offerta non sarà visibile nella lista.", "Nessun servizio disponibile per
+ questa categoria", "Errore nel salvataggio. Verifica i campi.", "Seleziona almeno un
+ servizio".
+
+
+ cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30 && npx eslint src/app/admin/offers/\[id\]/edit/page.tsx src/components/admin/offers/OfferEditorClient.tsx 2>&1 | tail -30
+
+
+ - `npx tsc --noEmit` passes with zero errors for the full project
+ - `npx eslint src/app/admin/offers/[id]/edit/page.tsx src/components/admin/offers/OfferEditorClient.tsx` reports zero errors
+ - `grep -c "saveOfferEditor\|toggleOfferArchived" src/components/admin/offers/OfferEditorClient.tsx` >= 2
+ - `grep -c "OptionSelect\|OptionMultiSelect" src/components/admin/offers/OfferEditorClient.tsx` >= 2 (Categoria/Ticket via OptionSelect, Tipo/Obiettivo via OptionMultiSelect)
+ - `grep -c "Totale Servizi\|Prezzo Pubblico" src/components/admin/offers/OfferEditorClient.tsx` >= 2
+ - `grep -c "Promessa di Trasformazione\|cliente_ideale\|risultato\|tempo\|pain\|metodo" src/components/admin/offers/OfferEditorClient.tsx` >= 6
+ - `grep -c "type=\"checkbox\"" src/components/admin/offers/OfferEditorClient.tsx` >= 1
+ - `grep -c "Nessun servizio disponibile per questa categoria" src/components/admin/offers/OfferEditorClient.tsx` == 1
+
+ OfferEditorClient renders the full editor per UI-SPEC section 2: tags (4 dimensions), category-filtered services matrix with live Totale Servizi + independent Prezzo Pubblico per tier, transformation promise block, and working Salva/Annulla/Archivia actions; project typechecks and lints clean.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Admin browser -> saveOfferEditor | Free-text macro fields, tier data, service-id arrays, and tag arrays cross into `offer_macros`/`offer_micros`/`offer_tier_services`/`tags` rows |
+| Admin browser -> toggleOfferArchived | Boolean flag crosses into `offer_macros.is_archived` |
+| URL param `[id]` -> getOfferEditorData | Route param crosses into a DB lookup; returns `null` (404) for unknown/foreign ids |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-12-12 | Tampering | "Salva Offerta" payload (tier_letter, public_price, assignedServiceIds, tag arrays) | mitigate | `saveOfferEditor` (Plan 03) Zod-validates `tier_letter` against `z.enum(["A","B","C"])`, `public_price` against `z.coerce.number().min(0)`, and the CHECK constraint on `offer_micros.tier_letter` (Plan 01) provides DB-level defense in depth |
+| T-12-13 | Tampering | `assignedServiceIds` referencing services outside the offer's category or inactive services | accept | `availableServices` is server-fetched and category-filtered (D-4); a malicious client could submit arbitrary service ids in the payload, but `offer_tier_services` has FK `ON DELETE CASCADE` to `services.id` (Plan 01) — invalid ids fail the FK constraint, no data corruption possible; low-value target (admin-only route) |
+| T-12-14 | Elevation of Privilege | All editor mutations (`saveOfferEditor`, `toggleOfferArchived`, `renameOfferOption`) | mitigate | Plan 03 wraps every action in `requireAdmin()`; this plan adds no new server-side surface, only UI calling the already-gated actions |
+| T-12-15 | Information Disclosure | `/admin/offers/[id]/edit` exposes full offer internals (internal_name, all tier data, transformation promise) | accept | Route is under `/admin/*`, Auth.js session-gated middleware (existing project-wide constraint); consistent with Plan 04's list page disposition (T-12-11) |
+| T-12-16 | Denial of Service | Unbounded `[id]` route param triggers a DB query for every request | accept | `getOfferEditorData` returns `null` -> `notFound()` for any non-matching id; standard Next.js 404 handling, no amplification risk on an admin-only route |
+
+
+
+1. `npx tsc --noEmit` passes for the full project.
+2. `npx eslint` clean for both modified/created files.
+3. `/admin/offers/[id]/edit` renders the offer name, Categoria/Ticket single-select chips, Tipo/Obiettivo multi-select chips (with "+ Crea Tipo"/"+ Crea Obiettivo" inline creation), and the services matrix pre-filtered by `macro.category`.
+4. Toggling a service checkbox in any tier column instantly updates that tier's "Totale Servizi" value (client-side, no network call).
+5. Entering a value in "Prezzo Pubblico" for a tier does not affect "Totale Servizi" for that tier (independent fields, D-5).
+6. All 5 transformation-promise fields (Aiuto/A ottenere/In/Senza/Grazie a) are editable via EditableCell and map to `cliente_ideale`/`risultato`/`tempo`/`pain`/`metodo`.
+7. "Salva Offerta" is disabled (with tooltip) when no tier has any assigned service; when enabled, clicking it calls `saveOfferEditor` and navigates back to `/admin/offers`.
+8. "Archivia" (visible only for non-archived offers) prompts for confirmation, then calls `toggleOfferArchived(id, true)` and navigates back to `/admin/offers`.
+9. "Annulla" navigates back to `/admin/offers` without persisting changes.
+
+
+
+- `/admin/offers/[id]/edit` matches the UI-SPEC section 2 layout: header/back-link, name + tag dimensions, category-filtered services matrix with live totals and independent public price, transformation promise block, and Salva/Annulla/Archivia actions.
+- OFFER-11 satisfied: 3-tier (A/B/C) checkbox matrix with live "Totale Servizi" recalculation.
+- OFFER-15 satisfied: Categoria/Ticket single-select, Tipo/Obiettivo multi-select with on-the-fly creation, all backed by `getOfferFieldOptions`/`renameOfferOption`/`saveOfferEditor`.
+- OFFER-16 satisfied: "Prezzo Pubblico" per tier is a manual numeric input independent of "Totale Servizi".
+- OFFER-17 satisfied: all 5 transformation-promise fields editable and persisted via `saveOfferEditor`.
+
+
+
diff --git a/.planning/phases/12-offer-composition-drag-drop-csv-import/12-CONTEXT.md b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-CONTEXT.md
new file mode 100644
index 0000000..9c0004d
--- /dev/null
+++ b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-CONTEXT.md
@@ -0,0 +1,91 @@
+---
+phase: 12
+phase_name: offer-editor-tier-tag-prezzo-pubblico
+source: user mockups + decisions (2026-06-14)
+status: decisions-locked
+---
+
+# Phase 12 — Context (User Decisions)
+
+> Catturato da 2 mockup utente + risposte dirette il 2026-06-14. Questo documento è
+> **autorevole** per il planner: dove confligge con la roadmap originale ("drag&drop + CSV"),
+> vince questo. La Phase 12 è stata **ri-scoped** da "Offer Composition Drag&Drop & CSV Import"
+> a **"Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico"**.
+
+## Decisioni bloccate
+
+| # | Decisione | Valore |
+|---|-----------|--------|
+| D-1 | Scope | Editor offerte completo (lista + dettaglio). Import CSV/Notion (ex OFFER-12) **rimandato** a fase futura. |
+| D-2 | UX composizione tier | **Matrice di checkbox** servizio × tier (A/B/C), NON drag&drop puro. Totale servizi live per colonna/tier. `@dnd-kit` usato solo (eventualmente) per riordinare le righe. |
+| D-3 | Fonte servizi | Catalogo unificato `services` (Phase 11), **non** il legacy `offer_services`. |
+| D-4 | Filtro servizi per categoria | Il servizio porta già, a livello di catalogo, la designazione di categoria offerta (entry / signature / retainer). L'editor mostra/pre-filtra nella matrice i servizi pertinenti alla categoria dell'offerta in modifica. |
+| D-5 | Prezzo | Per ogni tier: **Totale servizi** (auto-somma dei servizi spuntati) + **Prezzo Pubblico** (manuale, indipendente). Coerente con la decisione PROJECT.md "prezzi pacchetti per-preventivo, non da catalogo". |
+| D-6 | Schema | **Solo additivo** (Data Safety LOCKED). Riusare le tabelle offerte esistenti, non ricostruire. |
+
+## Modello dati (intento — il planner produce la migration esatta)
+
+Mappatura mockup → schema esistente (`src/db/schema.ts`):
+
+- **Offerta** → `offer_macros` (esiste: `internal_name`, `public_name`, `transformation_promise`, `sort_order`).
+ Aggiunte additive previste: descrizione breve, flag `is_archived`, campi strutturati della
+ promessa di trasformazione (Cliente Ideale / Risultato / tempo / Pain / Metodo).
+- **Tier A/B/C** → `offer_micros` (esiste: `macro_id`, `cumulative_price`, `duration_months`, ...).
+ Aggiunte additive previste: lettera tier (`A`|`B`|`C`) e `public_price` (prezzo pubblico manuale).
+ Un'offerta = una macro con (fino a) 3 micro-tier.
+- **Matrice servizi × tier** → junction tier ↔ `services`. La junction legacy `offer_micro_services`
+ punta a `offer_services`; **non modificarla**. Approccio additivo consigliato: **nuova tabella di
+ junction** (es. `offer_tier_services`: tier_id → `offer_micros.id`, service_id → `services.id`) così
+ da non toccare dati legacy e agganciare il catalogo unificato. Il planner decide il nome/struttura
+ esatti; deve restare additivo.
+- **Tag multi-dimensione** (categoria / ticket / tipo / obiettivo) → riusare la tabella polimorfica
+ `tags` (`entity_type`, `entity_id`, `name`) di Phase 11 con `entity_type = "offer_macros"` (o simile).
+ Le 4 dimensioni vanno distinte: il planner valuta se serve un campo `dimension` additivo sulla
+ tabella `tags`, oppure colonne single-select dedicate su `offer_macros` per categoria/ticket (singole)
+ + tag multipli per tipo/obiettivo. Da chiarire in research; resta additivo.
+- **Designazione categoria del servizio** (entry/signature/retainer, D-4): verificare se va su
+ `services.category` (single-select già esistente), su un tag, o su una nuova colonna additiva
+ dedicata. NON sovrascrivere la semantica attuale di `services.category`/`services.fase` se già usata
+ diversamente in `/admin/catalog` — il planner verifica l'uso corrente prima di scegliere.
+
+## UI (riferimento mockup)
+
+**Lista offerte** (`/admin/offers` ridisegnata): card filtrabili per categoria (Entry / Signature /
+Retainer Offer come chip colorati), ogni card = nome custom + breve descrizione + chip categoria;
+toggle "mostra offerte archiviate"; bottone "+ Nuova Offerta".
+
+**Editor offerta**: nome custom editabile in alto; chip Categoria + Ticket; chip Tipo (Audit /
+Done For You) e Obiettivo (Primo Acquisto / Up Scala Valore / Lead Generation); tabella "servizi
+inclusi" con colonne A / B / C e checkbox per cella, prezzo del servizio a sinistra; riga "Totale dei
+servizi" (auto) e "Prezzo Pubblico" (manuale) per ogni tier; blocco "Promessa di Trasformazione"
+strutturato (Aiuto Cliente Ideale / A ottenere Risultato / in tempo / Senza Pain / Grazie a Metodo);
+bottone "Salva Offerta".
+
+Stile: continuità con Phase 11 (vista database Notion-like, palette brand #1A463C / #DEF168, EditableCell,
+OptionMultiSelect/tag, server actions `requireAdmin`-guarded). Vedi `12-UI-SPEC.md` (da rigenerare su
+questo scope).
+
+## Vincoli (da CLAUDE.md / PROJECT.md)
+
+- **Data Safety LOCKED**: migration solo additive su `clients`/`projects`/`payments`/`phases`; nessun
+ drop/truncate. Anche le tabelle offerte vanno estese, non ricostruite.
+- **Migration manuali**: `drizzle-kit generate` è non funzionante (snapshot fuori sync da migration 0001) —
+ scrivere SQL a mano seguendo la convenzione 0003-0007. Applicare a **prod** via SSH+docker exec
+ **PRIMA** di pushare codice che dipende dal nuovo schema (DB prod firewalled, raggiungibile solo via SSH).
+- `quote_items` mai esposti via client API. L'editor offerte è admin-only (`/admin/*`, sessione Auth.js).
+
+## Fuori scope (questa fase)
+
+- Import CSV / import da Notion (OFFER-12, rimandato).
+- Sezioni analitiche Notion (psicologia/rating/performance — OFFER-14, v2).
+- Collegamento offerta→preventivo/pagamento (Proposal AI, Phase 16/17).
+- Pagina pubblica dell'offerta (Phase 17).
+
+## Domande risolte (erano aperte nel primo RESEARCH)
+
+1. Placement composer → **`/admin/offers` ridisegnata** (lista + editor). ✓
+2. Persistenza offerta → **macro = offerta, micro = tier A/B/C** (riuso additivo). ✓
+3. Tag audit CSV → **N/A** (import rimandato). ✓
+4. Duplicati / parsing CSV → **N/A** (import rimandato). ✓
+5. Prezzi servizi editabili → **no inline qui**: il tier ha totale auto + prezzo pubblico manuale; i
+ prezzi unitari restano gestiti nel catalogo `services` (Phase 11). ✓
diff --git a/.planning/phases/12-offer-composition-drag-drop-csv-import/12-RESEARCH.md b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-RESEARCH.md
new file mode 100644
index 0000000..b4e4ea3
--- /dev/null
+++ b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-RESEARCH.md
@@ -0,0 +1,819 @@
+# Phase 12: Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico - Research
+
+**Researched:** 2026-06-14
+**Domain:** Offer composition UI + additive schema (tier designations, tag dimensions, public pricing)
+**Confidence:** HIGH
+
+## Summary
+
+Phase 12 is a **complete UI + schema redesign** of offer composition, re-scoped from "drag-drop composer + CSV import" to a **tier-matrix offer editor** (A/B/C tiers with checkbox matrix, live totals, tag dimensions, structured transformation promise, public pricing per tier). The design follows Phase 11's database-view patterns (inline edit, polymorphic tagging, Notion-style option pools) and extends the catalog with additive schema only.
+
+**Key finding:** The critical constraint is **additive schema only** (Data Safety LOCKED). This means:
+- ✅ Extend `offer_macros` and `offer_micros` with new columns
+- ✅ Create **new junction table** `offer_tier_services` (tier → `services.id`, not `offer_services.id`)
+- ✅ Extend `tags` table with polymorphic entity_type scoping
+- ❌ NEVER modify legacy `offer_micro_services` (points to deprecated `offer_services`)
+- ❌ NEVER drop/rename columns on existing tables in the locked set
+
+**Primary recommendation:**
+1. Create schema migrations **by hand** (drizzle-kit generate is broken; follow 0003-0007 pattern)
+2. Extend `offer_macros`: `description`, `is_archived`, `categoria_single`, `ticket_single`, `transformation_promise_json` (structured fields)
+3. Extend `offer_micros`: `tier_letter` (A|B|C), `public_price`
+4. Add **new junction table** `offer_tier_services`: `(tier_id, service_id)` as PK
+5. Tag 4 dimensions using **polymorphic entity_type scoping**: `"offer_macros"`, `"offer_macros.ticket"`, `"offer_macros.tipo"`, `"offer_macros.obiettivo"`
+6. Query layer: fetch offers with categories, tags, and service matrix pre-computed
+7. Editor UI: use Phase 11 patterns (`EditableCell`, `OptionMultiSelect`, `requireAdmin` server actions, inline validation via Zod)
+
+---
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+- **D-1 (Scope):** Editor offerte completo (lista + dettaglio). Import CSV/Notion (ex OFFER-12) **rimandato** a fase futura.
+- **D-2 (UX composizione tier):** **Matrice di checkbox** servizio × tier (A/B/C), NON drag&drop puro. Totale servizi live per colonna/tier. `@dnd-kit` usato solo (eventualmente) per riordinare le righe.
+- **D-3 (Fonte servizi):** Catalogo unificato `services` (Phase 11), **non** il legacy `offer_services`.
+- **D-4 (Filtro servizi per categoria):** Il servizio porta già, a livello di catalogo, la designazione di categoria offerta (entry / signature / retainer). L'editor mostra/pre-filtra nella matrice i servizi pertinenti alla categoria dell'offerta in modifica.
+- **D-5 (Prezzo):** Per ogni tier: **Totale servizi** (auto-somma dei servizi spuntati) + **Prezzo Pubblico** (manuale, indipendente). Coerente con la decisione PROJECT.md "prezzi pacchetti per-preventivo, non da catalogo".
+- **D-6 (Schema):** **Solo additivo** (Data Safety LOCKED). Riusare le tabelle offerte esistenti, non ricostruire.
+
+### Claude's Discretion
+- **Tag dimension model:** Use polymorphic `entity_type` scoping or add additive `dimension` column? Recommend: polymorphic entity_type (`"offer_macros.categoria"`, `"offer_macros.ticket"`, etc.) to avoid schema change and reuse Phase 11 pattern.
+- **Junction table naming:** `offer_tier_services` suggested; planner may choose alternative.
+- **Tier letter persistence:** Store in `offer_micros.tier_letter` or compute from row position? Recommend: store explicitly for clarity.
+- **Public price per tier:** Manual input or formula? Locked as manual (D-5).
+
+### Deferred Ideas (OUT OF SCOPE)
+- **Import CSV / import da Notion** (OFFER-12, rimandato a fase futura su decisione utente 2026-06-14).
+- **Sezioni analitiche Notion** (psicologia/rating/performance — OFFER-14, v2).
+- **Drag&drop composer** (originally in Phase 12 scope per UI-SPEC 12-UI-SPEC.md, now replaced by checkbox matrix).
+- **Collegamento offerta→preventivo/pagamento** (Proposal AI, Phase 16/17).
+
+---
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| OFFER-11 | L'admin compone un'offerta a 3 tier (A/B/C) assegnando i servizi del catalogo unificato `services` tramite matrice di checkbox; il totale servizi di ogni tier si aggiorna live | Schema: `offer_micros.tier_letter`, new junction `offer_tier_services`; UI: checkbox matrix with live total calculation (client-side sum per tier) |
+| OFFER-15 | Ogni offerta ha tag su 4 dimensioni (categoria, ticket, tipo, obiettivo) creabili al volo, riusando il sistema tag di Phase 11 | Tag pattern: polymorphic `tags` table with scoped `entity_type` (e.g. `"offer_macros.categoria"`, `"offer_macros.ticket"`) — reuse Phase 11 pattern; addServiceOption/removeServiceOption actions |
+| OFFER-16 | Per ogni tier l'admin imposta un "prezzo pubblico" manuale, distinto e indipendente dal totale dei servizi | Schema: `offer_micros.public_price` (numeric); UI: EditableCell input per tier row |
+| OFFER-17 | Ogni offerta ha una promessa di trasformazione strutturata (Cliente Ideale / Risultato / tempo / Pain / Metodo) | Schema: `offer_macros.transformation_promise_json` (JSONB); structured form in editor with 5 text inputs |
+| OFFER-18 | La lista offerte è filtrabile per categoria e supporta l'archiviazione (mostra/nascondi archiviate) | Schema: `offer_macros.is_archived` (boolean); UI: category chip filter + "Mostra archiviate" toggle |
+
+---
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Offer list rendering (cards, filters) | Frontend Server (SSR) | — | Server-side query fetch (offer list + tags + counts), hydrate React component for filter UI |
+| Offer editor (form + matrix) | Browser / Frontend | Frontend Server | Browser state (tier matrix checkboxes, live total); form submission to server action |
+| Live tier total calculation | Browser / Frontend | — | Deterministic sum of selected service prices per tier; no server round-trip per toggle |
+| Tag management (add/remove/rename) | API / Backend | Frontend | Server actions (`addOfferTag`, `removeOfferTag`, `renameOfferTag` — requireAdmin-guarded) |
+| Service filtering by offer category | Frontend Server (SSR) | — | Query layer: fetch services filtered by category, return in hydrated props |
+| Transformation promise validation | API / Backend | — | Zod schema validation on offer save |
+| Schema migrations | Database / Infrastructure | — | Hand-written SQL (drizzle-kit broken); applies to prod via SSH+docker exec before code push |
+
+---
+
+## Standard Stack
+
+### Core (Locked from CLAUDE.md)
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| Next.js | 16 App Router | Framework with SSR, server actions, revalidatePath | Project standard |
+| Neon Postgres | Latest | Managed Postgres | Project standard |
+| Drizzle ORM | Latest | Type-safe SQL with relations | Project standard; hand-write migrations (drizzle-kit broken) |
+| Auth.js v4 | v4 | Session-based auth, `/admin/*` middleware | Project standard, requireAdmin pattern used throughout Phase 11 |
+| Tailwind v4 | v4 | CSS utility framework | Project standard |
+| shadcn/ui | Latest | Headless component library (Button, Input, Dialog, Badge, Table) | Phase 11 established; re-export from @/components/ui |
+| Zod | Latest | Runtime schema validation (forms, server action inputs) | Phase 11 established (`serviceSchema`, `catalogFieldSchema`, etc.) |
+
+### Supporting (Reuse Phase 11 patterns)
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| react-hook-form | Latest | Uncontrolled form state (optional; Phase 11 uses mostly FormData) | Only if structured form (5-field transformation promise) requires form state |
+| Lucide React | Latest | Icon library | Drag handle, trash icon, filter chips (Phase 11 pattern) |
+
+### NOT Used (Deferred)
+| Instead of | Could Use | Why Not Used |
+|-----------|-----------|--------------|
+| `@dnd-kit/core` + `@dnd-kit/sortable` | Native drag-and-drop API | Originally in scope (D-2 mentions "@dnd-kit usato solo eventualmente per riordinare le righe"); checkbox matrix is simpler and preferred UX (D-2 locked). Row reorder optional; defer to Phase 12 plan. |
+| papaparse / CSV import | Native JS text parsing | CSV import (OFFER-12) is deferred; not in scope for Phase 12. |
+
+---
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ /admin/offers │
+│ (OffersList + OfferEditor) │
+└────────────┬──────────────────────────────────┬───────────────────┘
+ │ │
+ ▼ ▼
+ ┌────────────────────┐ ┌──────────────────────┐
+ │ OfferListQuery │ │ OfferEditorQuery │
+ │ (SSR page.tsx) │ │ (SSR page.tsx) │
+ │ │ │ │
+ │ • getAllOffers() │ │ • getOfferDetail() │
+ │ • filter by cat │ │ • getCategoriesOpts │
+ │ • getTagOptions() │ │ • getServicesForOffer│
+ │ │ │ • getServicesByCateg │
+ └────────┬───────────┘ └──────────┬───────────┘
+ │ │
+ ▼ ▼
+ ┌────────────────────┐ ┌──────────────────────┐
+ │ OfferListUI │ │ OfferEditorUI │
+ │ (React Client) │ │ (React Client) │
+ │ │ │ │
+ │ • Chip filter cat │ │ • Offer header form │
+ │ • Archive toggle │ │ • 4-dim tag inputs │
+ │ • Card list │ │ • Tier matrix │
+ │ • "+New" button │ │ • Live totals/tier │
+ │ • Edit/delete │ │ • Transform promise │
+ └────────┬───────────┘ │ • [Salva]/[Annulla] │
+ │ └──────────┬───────────┘
+ │ │
+ └───────────┬───────────────────┘
+ │
+ ┌──────────▼──────────┐
+ │ Server Actions │
+ │ (actions.ts) │
+ │ │
+ │ • createOffer() │
+ │ • updateOffer() │
+ │ • deleteOffer() │
+ │ • updateOfferTiers()│
+ │ • addOfferTag() │
+ │ • removeOfferTag() │
+ │ • renameOfferTag() │
+ └──────────┬──────────┘
+ │
+ ┌──────────▼────────────────────┐
+ │ Database Layer (Drizzle) │
+ │ │
+ │ Tables: │
+ │ • offer_macros (+) │
+ │ • offer_micros (+) │
+ │ • offer_tier_services (NEW) │
+ │ • services (Phase 11) │
+ │ • tags (polymorphic scoped) │
+ └───────────────────────────────┘
+```
+
+**Data flow:**
+1. **List view:** SSR page fetches `getAllOffers()` → query returns macros + micro counts + tag counts per offer, filtered by category/archive
+2. **Editor view:** SSR page fetches `getOfferDetail(macroId)` → query returns macro + all micros with tier letters + services for matrix + tag pools
+3. **Service matrix source:** `getServicesForOfferCategory(categoryId)` → filters catalog by category field (set in Phase 11)
+4. **Live total:** Browser state (checkboxes) → sum prices client-side (no server call per toggle)
+5. **Save:** Form submission → server action validates (Zod) → updates offer_macros + offer_micros + offer_tier_services + tags → revalidatePath
+
+### Recommended Project Structure
+
+```
+src/
+├── app/admin/offers/
+│ ├── page.tsx # OffersList (SSR, list + filters)
+│ ├── [macroId]/
+│ │ └── page.tsx # OfferEditor (SSR, detail + matrix editor)
+│ ├── actions.ts # Server actions (CRUD, tags)
+│ └── layout.tsx # Shared layout
+├── lib/
+│ └── offer-queries.ts # Query layer (new/extended)
+├── components/admin/offers/
+│ ├── OffersList.tsx # List component (filters, cards)
+│ ├── OfferEditor.tsx # Editor component (form + matrix)
+│ ├── OfferMatrix.tsx # Tier×Service checkbox matrix
+│ ├── TransformationPromiseForm.tsx # 5-field structured form
+│ ├── OfferTagInput.tsx # Multi-select tag input
+│ └── OfferCategoryFilter.tsx # Category chip filter
+└── db/
+ └── schema.ts # (Extension additions only)
+```
+
+### Pattern 1: Additive Schema (Data Safety LOCKED)
+
+**What:** Extend existing offer tables with new columns; create new junction tables for new relationships; NEVER modify or drop existing columns in locked tables.
+
+**When to use:** Every schema change in Phase 12. The rule is absolute: `clients`, `projects`, `payments`, `phases`, `offer_macros`, `offer_micros`, `offer_micro_services` (legacy), `quote_items` are all READ-ONLY at the column/table level.
+
+**Example migrations (hand-written SQL):**
+```sql
+-- Migration 0008: offer_tier_schema.sql (following 0003-0007 convention)
+
+-- Extend offer_macros with new columns
+ALTER TABLE offer_macros ADD COLUMN description TEXT;
+ALTER TABLE offer_macros ADD COLUMN is_archived BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE offer_macros ADD COLUMN transformation_promise_json JSONB;
+
+-- Extend offer_micros with tier designations and public pricing
+ALTER TABLE offer_micros ADD COLUMN tier_letter TEXT CHECK (tier_letter IN ('A', 'B', 'C'));
+ALTER TABLE offer_micros ADD COLUMN public_price NUMERIC(10, 2);
+
+-- NEW junction table: tier ↔ services (replaces legacy offer_micro_services reference)
+CREATE TABLE offer_tier_services (
+ tier_id TEXT NOT NULL REFERENCES offer_micros(id) ON DELETE CASCADE,
+ service_id TEXT NOT NULL REFERENCES services(id) ON DELETE CASCADE,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (tier_id, service_id),
+ INDEX offer_tier_services_tier_idx (tier_id)
+);
+
+-- Tags for offers are scoped via entity_type (no schema change needed)
+-- entity_type values: "offer_macros", "offer_macros.ticket", "offer_macros.tipo", "offer_macros.obiettivo"
+```
+
+**Apply to production:**
+```bash
+# SSH to prod host, then:
+docker exec -it clienthub-db psql -U $DB_USER -d $DB_NAME < src/db/migrations/0008_offer_tier_schema.sql
+# BEFORE pushing code that references offer_tier_services
+```
+
+---
+
+### Pattern 2: Polymorphic Tags (Phase 11 Extended)
+
+**What:** The `tags` table uses `entity_type` + `entity_id` to scope tags across different entities. Phase 11 scoped tags as `"services"` and `"services.pacchetto"`. Phase 12 extends this to offers with 4 dimensions.
+
+**Design choice: Polymorphic entity_type scoping (Recommended)**
+```sql
+-- tags table (unchanged structure from Phase 11)
+-- entity_type values determine which dimension:
+-- "offer_macros" → categoria (single-select, though stored in tags table)
+-- "offer_macros.ticket" → ticket (single-select, though stored in tags table)
+-- "offer_macros.tipo" → tipo (multi-select)
+-- "offer_macros.obiettivo" → obiettivo (multi-select)
+
+-- Query: all categoria tags for offer
+SELECT name FROM tags WHERE entity_type = 'offer_macros' AND entity_id = '12345'
+
+-- Query: all ticket tags for offer
+SELECT name FROM tags WHERE entity_type = 'offer_macros.ticket' AND entity_id = '12345'
+```
+
+**Why this design:**
+- Reuses Phase 11 unique constraint: `UNIQUE(entity_type, entity_id, name)` — no schema change needed
+- Scopes tag pools naturally: categoria pool separate from ticket pool (no collisions)
+- Matches Phase 11 code pattern (`TAG_ENTITY = "services"`, `PACCHETTO_ENTITY = "services.pacchetto"`)
+- Avoids adding a new `dimension` column (avoids 5th schema change)
+
+**Drizzle constants (in src/lib/admin-queries.ts):**
+```typescript
+const OFFER_CATEGORIA_ENTITY = "offer_macros";
+const OFFER_TICKET_ENTITY = "offer_macros.ticket";
+const OFFER_TIPO_ENTITY = "offer_macros.tipo";
+const OFFER_OBIETTIVO_ENTITY = "offer_macros.obiettivo";
+```
+
+**Server action pattern (reuse from Phase 11):**
+```typescript
+export async function addOfferTag(
+ macroId: string,
+ dimension: "categoria" | "ticket" | "tipo" | "obiettivo",
+ tagName: string
+) {
+ await requireAdmin();
+ const entity_type = {
+ categoria: OFFER_CATEGORIA_ENTITY,
+ ticket: OFFER_TICKET_ENTITY,
+ tipo: OFFER_TIPO_ENTITY,
+ obiettivo: OFFER_OBIETTIVO_ENTITY,
+ }[dimension];
+
+ await db.insert(tags).values({
+ entity_type,
+ entity_id: macroId,
+ name: tagName.trim(),
+ }).onConflictDoNothing();
+
+ revalidatePath("/admin/offers");
+}
+```
+
+---
+
+### Pattern 3: Tier Matrix Composition (Checkbox Pattern)
+
+**What:** Instead of drag-and-drop, use a simple checkbox matrix: rows are services, columns are tiers (A/B/C), cells are checkboxes.
+
+**When to use:** Building the OfferMatrix UI component.
+
+**Example UX:**
+```
+┌──────────────────────────────────────────────────────────────┐
+│ Servizi inclusi nella tua offerta │
+├─────────────────────────────┬──────┬──────┬──────┬──────────┤
+│ Servizio │ Tier │ Tier │ Tier │ Prezzo │
+│ │ A │ B │ C │ unitario │
+├─────────────────────────────┼──────┼──────┼──────┼──────────┤
+│ Personal Branding Audit │ [x] │ [ ] │ [ ] │ €500 │
+│ Brand Strategy Workshop │ [x] │ [x] │ [ ] │ €300 │
+│ Logo Design │ [ ] │ [x] │ [x] │ €800 │
+├─────────────────────────────┼──────┼──────┼──────┼──────────┤
+│ Totale servizi (auto) │ €800 │ €1.1K│ €800 │ │
+│ Prezzo pubblico (manuale) │ [ €900 ] │ [ €1.2K ] │ [ €900 ] │
+└─────────────────────────────┴──────┴──────┴──────┴──────────┘
+```
+
+**React component pattern:**
+```typescript
+// OfferMatrix.tsx
+type OfferMatrixProps = {
+ services: Service[]; // filtered by offer category
+ tiers: Array;
+ initialAssignments: Map; // Map
+ onChange: (assignments: Map) => void;
+};
+
+export function OfferMatrix({ services, tiers, initialAssignments, onChange }: OfferMatrixProps) {
+ const [assignments, setAssignments] = useState