docs(12): create phase plan

Re-scoped Offer Editor (Tier A/B/C, Tag & Prezzo Pubblico) per 2026-06-14
user decision: 5-plan wave structure (schema migration -> prod apply
checkpoint -> query/action layer -> list + detail editor UI), OFFER-12
CSV/Notion import deferred.
This commit is contained in:
2026-06-14 21:00:32 +02:00
parent 9bff7623da
commit 640f986967
11 changed files with 3303 additions and 28 deletions
+12 -4
View File
@@ -13,8 +13,11 @@ v2.0 (Phases 710) 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-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-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-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-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-12 | L'utente importa un CSV one-shot per popolare servizi/offerte esistenti (solo seed iniziale, non sync continuo) | 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 | | 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) ## Workspace Progetto — Servizi Attivi (v1)
@@ -57,6 +60,7 @@ v2.0 (Phases 710) ha consegnato catalogo unificato, offerte con fasi, prevent
### Catalogo Avanzato ### 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) - **OFFER-14**: Sezioni analitiche stile Notion per le offerte (psicologia, rating, performance)
### Accesso Cliente ### Accesso Cliente
@@ -92,7 +96,11 @@ Popolata dal roadmapper durante la creazione della roadmap.
| OFFER-10 | Phase 11 | Complete | | OFFER-10 | Phase 11 | Complete |
| OFFER-13 | Phase 11 | Complete | | OFFER-13 | Phase 11 | Complete |
| OFFER-11 | Phase 12 | Pending | | 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-06 | Phase 13 | Pending |
| PROJ-07 | Phase 13 | Pending | | PROJ-07 | Phase 13 | Pending |
| PROJ-08 | 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* *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*
+25 -13
View File
@@ -9,7 +9,7 @@
## Phases ## 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` - [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 - [ ] **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) - [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 - [ ] **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 **UI hint**: yes
**Status**: ✅ Complete (2026-06-13) — migration 0007 (`services.fase`) applicata in prod, commit pushato su `gitea/main` **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 ### Phase 12: Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico
**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 **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 **Mode:** ui + schema
**Depends on**: Phase 11 (tabella `services` unificata) **Depends on**: Phase 11 (tabella `services` unificata + sistema tag polimorfico)
**Requirements**: OFFER-11, OFFER-12 **Requirements**: OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18
**Success Criteria** (what must be TRUE): **Success Criteria** (what must be TRUE):
1. L'utente compone un'offerta trascinando servizi dal catalogo (`@dnd-kit`) 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. Durante il drag, il totale dell'offerta 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. L'utente importa un CSV one-shot per popolare servizi/offerte esistenti (solo seed iniziale, non sync continuo) 3. Per ogni tier l'admin imposta un "prezzo pubblico" manuale, distinto e indipendente dal totale dei servizi
**Plans**: TBD 4. Ogni offerta ha una promessa di trasformazione strutturata (Cliente Ideale / Risultato / tempo / Pain / Metodo)
**UI hint**: yes 5. La lista offerte è filtrabile per categoria e supporta l'archiviazione (mostra/nascondi archiviate)
**Status**: Pending planning **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 ### 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` **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 | | Phase | Plans | Status | Completed |
|-------|-------|--------|-----------| |-------|-------|--------|-----------|
| 11. Catalog Database-View UX & Legacy Consolidation | 4/4 | ✅ Done | 2026-06-13 | | 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 | — | | 13. Workspace — Servizi Attivi | — | Pending planning | — |
| 14. CRM Attio-style & Fix | 3/3 | Complete | 2026-06-14 | | 14. CRM Attio-style & Fix | 3/3 | Complete | 2026-06-14 |
| 15. Dashboard Revenue Stats | — | Blocked (mockup utente) | — | | 15. Dashboard Revenue Stats | — | Blocked (mockup utente) | — |
+11 -11
View File
@@ -3,9 +3,9 @@ gsd_state_version: 1.0
milestone: v2.1 milestone: v2.1
milestone_name: milestone milestone_name: milestone
status: executing status: executing
stopped_at: Phase 14 complete (5/5 success criteria verified); Phase 15 blocked on user mockup stopped_at: Phase 12 UI-SPEC approved (6/6 dimensions); planning in progress
last_updated: "2026-06-14T13:20:53.997Z" last_updated: "2026-06-14T13:54:45.922Z"
last_activity: 2026-06-14 -- Phase 14 (crm-attio-style-fix) execution complete last_activity: 2026-06-14 -- Phase 12 (offer-composition-drag-drop-csv-import) UI-SPEC approved, planning started
progress: progress:
total_phases: 7 total_phases: 7
completed_phases: 2 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. **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 15dashboard-revenue-stats (blocked on user mockup; Phase 14 complete) **Current focus:** Phase 12offer-composition-drag-drop-csv-import (UI-SPEC approved, planning in progress; Phase 15 remains blocked on user mockup)
## Current Position ## Current Position
Phase: 15 Phase: 12
Plan: Not started Plan: Not started (UI-SPEC approved, planning in progress)
Status: Blocked — Phase 15 awaiting user mockup (Phase 14 complete, see ROADMAP) Status: Planning Phase 12 (UI-SPEC done, 6/6 dimensions passed)
Last activity: 2026-06-14 -- Phase 14 (crm-attio-style-fix) execution complete Last activity: 2026-06-14 -- Phase 12 (offer-composition-drag-drop-csv-import) UI-SPEC approved, planning started
Progress: [██████████] 100% Progress: [██████████] 100%
@@ -100,6 +100,6 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity ## Session Continuity
Last session: 2026-06-14T13:20:53.997Z Last session: 2026-06-14T13:54:45.922Z
Stopped at: Phase 14 complete (5/5 success criteria verified, code review 0 critical/6 warnings/3 info); Phase 15 blocked on user mockup Stopped at: Phase 12 UI-SPEC approved (6/6 dimensions, 1 revision cycle for typography); planning in progress
Resume file: .planning/phases/14-crm-attio-style-fix/14-VERIFICATION.md Resume file: .planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.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"
---
<objective>
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`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<interfaces>
<!-- Current relevant schema slices the executor will extend. Drizzle import style:
pgTable / text / integer / numeric / timestamp / boolean / primaryKey / uniqueIndex / index
are already imported at the top of src/db/schema.ts (lines 1-13). Reuse, do not re-import. -->
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] }) }));
```
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Extend offer_macros and offer_micros, add offer_tier_services junction in schema.ts</name>
<files>src/db/schema.ts</files>
<read_first>
- 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")
</read_first>
<action>
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.
</action>
<verify>
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Hand-write migration 0008 SQL + journal entry + idempotent push script</name>
<files>src/db/migrations/0008_offer_tier_schema.sql, src/db/migrations/meta/_journal.json, scripts/push-12-offer-tier-schema.ts</files>
<read_first>
- 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)
</read_first>
<action>
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).
</action>
<verify>
<automated>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')"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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`.
</verification>
<success_criteria>
- `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.
</success_criteria>
<output>
After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-01-SUMMARY.md`
</output>
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@scripts/push-12-offer-tier-schema.ts
@src/db/migrations/0008_offer_tier_schema.sql
</context>
<tasks>
<task type="checkpoint:human-action" gate="blocking">
<name>Task 1: Apply migration 0008 to production database</name>
<what-built>
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.
</what-built>
<how-to-verify>
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 <postgres-container-name> 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).
</how-to-verify>
<resume-signal>Type "applied" once migration 0008 has been run against production and you've confirmed the new columns/table exist (or describe any errors encountered).</resume-signal>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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.
</verification>
<success_criteria>
- 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.
</success_criteria>
<output>
After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-02-SUMMARY.md`
</output>
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<interfaces>
<!-- Schema additions from Plan 01 (already applied to schema.ts; migration 0008 applied to
prod by Plan 02). Use these directly — no further exploration needed. -->
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<MacroWithMicros[]>;
export async function getAllOfferServices(): Promise<OfferService[]>;
export async function getMicroAssignedServiceIds(microId: string): Promise<string[]>;
```
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<void>;
export async function deleteMacro(macroId: string): Promise<void>;
export async function createMicro(formData: FormData): Promise<void>;
export async function deleteMicro(microId: string): Promise<void>;
export async function updateMicroOfferServices(microId: string, serviceIds: string[]): Promise<void>;
// ^ delete-then-reinsert pattern — saveOfferEditor's per-tier service assignment follows this.
```
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Query layer — getOfferEditorData, getOfferListCards, getOfferFieldOptions</name>
<files>src/lib/offer-queries.ts</files>
<behavior>
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.
</behavior>
<action>
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<OfferMacro, "id" | "internal_name" | "description" | "category" | "is_archived">`.
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<B<C but nulls need explicit handling —
tiers without `tier_letter` set sort last).
- For each tier, fetch assigned `offer_tier_services` rows (service_id list) and
compute `servicesTotal` via the same `coalesce(sum(unit_price::numeric), 0)`
pattern as `getCatalogWithMicros`'s `cumulMap`, joined through
`offer_tier_services` -> `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<OfferEditorData | null>;
```
3. **`getOfferFieldOptions()`**:
```typescript
export type OfferFieldOptions = {
categoria: string[];
ticket: string[];
tipo: string[];
obiettivo: string[];
};
export async function getOfferFieldOptions(): Promise<OfferFieldOptions>;
```
`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).
</action>
<verify>
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>offer-queries.ts exports getOfferEditorData, getOfferListCards, getOfferFieldOptions with the documented shapes; existing exports unchanged; file typechecks.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Server actions — saveOfferEditor, toggleOfferArchived, offer tag CRUD, createOfferMacro</name>
<files>src/app/admin/offers/actions.ts</files>
<behavior>
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.
</behavior>
<action>
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<typeof saveOfferEditorSchema>;
```
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.
</action>
<verify>
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<done>actions.ts exports saveOfferEditor, toggleOfferArchived, addOfferTag, removeOfferTag, renameOfferOption, createOfferMacro — all requireAdmin-guarded, Zod-validated, revalidating /admin/offers; existing exports unchanged; file typechecks.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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.
</verification>
<success_criteria>
- 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.
</success_criteria>
<output>
After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-03-SUMMARY.md`
</output>
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<interfaces>
<!-- From Plan 03 (src/lib/offer-queries.ts) — use directly, no further exploration needed. -->
```typescript
export type OfferListCard = Pick<OfferMacro, "id" | "internal_name" | "description" | "category" | "is_archived">;
export async function getOfferListCards(): Promise<OfferListCard[]>;
export type OfferFieldOptions = { categoria: string[]; ticket: string[]; tipo: string[]; obiettivo: string[] };
export async function getOfferFieldOptions(): Promise<OfferFieldOptions>;
```
<!-- From Plan 03 (src/app/admin/offers/actions.ts) — use directly. -->
```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 <form action={fn}> pattern). For the
// "+ Nuova Offerta" flow, Task 2 calls createOfferMacro via startTransition + FormData,
// then router.refresh() — the new card appears in the grid; admin clicks it to open the
// editor (Plan 05). No need to extend Plan 03's contract.
export async function createOfferMacro(formData: FormData): Promise<void>;
```
<!-- From src/app/admin/catalog/CatalogSearch.tsx — reference pattern for a client component
wrapping a server-fetched list with client-side filter state (search/category chips). Read
this file's structure (useState + useMemo filter, "use client") before writing OfferListClient. -->
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Rewrite /admin/offers page.tsx as the new list page entry point</name>
<files>src/app/admin/offers/page.tsx</files>
<read_first>
- 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)
</read_first>
<action>
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 (
<div>
<OfferListClient cards={cards} categoryOptions={options.categoria} />
</div>
);
}
```
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).
</action>
<verify>
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -20</automated>
</verify>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<done>page.tsx is a thin server component delegating to OfferListClient with the new data shape.</done>
</task>
<task type="auto">
<name>Task 2: Build OfferListClient — category filter, archive toggle, card grid, create CTA</name>
<files>src/components/admin/offers/OfferListClient.tsx</files>
<read_first>
- 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)
</read_first>
<action>
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` `<Badge>` or a styled `<span>`.
- 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
`<form>` with one text `<input name="internal_name" required placeholder="Nome
offerta...">` and a submit button "Crea".
- On submit: `startTransition(async () => { await createOfferMacro(formData);
setShowCreateForm(false); router.refresh(); })`. After `router.refresh()`, the new
card appears in the grid — do NOT attempt to auto-navigate to the editor in this
plan (acceptable per UI-SPEC: admin clicks the new card to open the editor). Button
style: bg `#1A463C`, text white, per UI-SPEC.
7. **Page header**: "Offerte" (h2, 20px/600, `#1a1a1a`) + "+ Nuova Offerta" button,
`flex items-center justify-between`.
All literal copy strings come from the UI-SPEC copywriting contract table verbatim
(Italian): "Offerte", "+ Nuova Offerta", "Mostra offerte archiviate", "Tutti",
"Nessuna offerta", "Inizia creando la tua prima offerta", "Archiviata", "Crea", "Nome
offerta...".
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<done>OfferListClient renders the category-filterable, archive-aware card grid with working "+ Nuova Offerta" creation; page.tsx + OfferListClient.tsx typecheck and lint clean.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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).
</verification>
<success_criteria>
- `/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`.
</success_criteria>
<output>
After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-04-SUMMARY.md`
</output>
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<interfaces>
<!-- From Plan 03 (src/lib/offer-queries.ts) — use directly, no further exploration needed. -->
```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<OfferEditorData | null>;
export type OfferFieldOptions = { categoria: string[]; ticket: string[]; tipo: string[]; obiettivo: string[] };
export async function getOfferFieldOptions(): Promise<OfferFieldOptions>;
```
<!-- From Plan 03 (src/app/admin/offers/actions.ts) — use directly. -->
```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<void>;
export async function toggleOfferArchived(macroId: string, archived: boolean): Promise<void>;
export async function addOfferTag(dimension: "tipo" | "obiettivo", macroId: string, value: string): Promise<void>;
export async function removeOfferTag(dimension: "tipo" | "obiettivo", macroId: string, value: string): Promise<void>;
export async function renameOfferOption(field: "categoria" | "ticket" | "tipo" | "obiettivo", oldValue: string, newValue: string): Promise<void>;
```
<!-- From src/components/ui/option-select.tsx and option-multi-select.tsx — use directly for
Categoria/Ticket (single-select) and Tipo/Obiettivo (multi-select) dimension rows. -->
```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;
```
<!-- From src/components/ui/editable-cell.tsx — use directly for Offerta Name and
transformation-promise fields. -->
```typescript
export interface EditableCellProps {
value: string;
type?: "text" | "number";
onSave: (value: string) => void | Promise<void>;
required?: boolean;
placeholder?: string;
disabled?: boolean;
formatDisplay?: (value: string) => string;
}
export function EditableCell(props: EditableCellProps): JSX.Element;
```
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Create editor route + page.tsx server component</name>
<files>src/app/admin/offers/[id]/edit/page.tsx</files>
<read_first>
- 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)
</read_first>
<action>
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 <OfferEditorClient data={data} fieldOptions={options} />;
}
```
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).
</action>
<verify>
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && grep -c "getOfferEditorData\|getOfferFieldOptions\|OfferEditorClient\|notFound" src/app/admin/offers/\[id\]/edit/page.tsx</automated>
</verify>
<acceptance_criteria>
- 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 `<OfferEditorClient data={data} fieldOptions={options} />`
</acceptance_criteria>
<done>page.tsx exists, fetches editor data + field options, handles not-found, delegates to OfferEditorClient.</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: Build OfferEditorClient — tags, services matrix with live totals, promise block, actions</name>
<files>src/components/admin/offers/OfferEditorClient.tsx</files>
<read_first>
- 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)
</read_first>
<action>
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<string>` or one combined `useState<typeof initialMacro>`
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`) + `<OptionSelect
value={macro.category} options={categoriaOptions} onChange={...} onRename={(old,
next) => { 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" + `<OptionMultiSelect values={tipoTags}
options={tipoOptions} onAdd={(v) => { 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 `<input type="checkbox">` 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 `<input type="number" min="0"
step="0.01">` 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".
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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.
</verification>
<success_criteria>
- `/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`.
</success_criteria>
<output>
After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-05-SUMMARY.md`
</output>
@@ -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). ✓
@@ -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<OfferMicro & { tier_letter: 'A'|'B'|'C' }>;
initialAssignments: Map<string, string[]>; // Map<tier_id, service_ids>
onChange: (assignments: Map<string, string[]>) => void;
};
export function OfferMatrix({ services, tiers, initialAssignments, onChange }: OfferMatrixProps) {
const [assignments, setAssignments] = useState<Map<string, Set<string>>>(
new Map(Array.from(initialAssignments.entries()).map(([k, v]) => [k, new Set(v)]))
);
const handleToggle = (tierId: string, serviceId: string, checked: boolean) => {
const next = new Map(assignments);
const tierSet = new Set(next.get(tierId) ?? []);
if (checked) tierSet.add(serviceId);
else tierSet.delete(serviceId);
next.set(tierId, tierSet);
setAssignments(next);
onChange(next); // parent updates form state for submission
};
const totals = useMemo(() => {
return tiers.map(tier => ({
tier_id: tier.id,
total: Array.from(assignments.get(tier.id) ?? [])
.map(sid => parseFloat(services.find(s => s.id === sid)?.unit_price ?? "0"))
.reduce((a, b) => a + b, 0),
}));
}, [assignments, services, tiers]);
return (
<table className="w-full border-collapse">
<thead>
<tr className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
<th className="text-left px-4 py-2 font-semibold">Servizio</th>
<th className="text-center px-4 py-2 font-semibold">Prezzo</th>
{tiers.map(tier => (
<th key={tier.id} className="text-center px-4 py-2 font-semibold">
Tier {tier.tier_letter}
</th>
))}
</tr>
</thead>
<tbody>
{services.map(service => (
<tr key={service.id} className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9]">
<td className="px-4 py-3 font-medium">{service.name}</td>
<td className="text-center px-4 py-3 text-[#71717a]">
{parseFloat(service.unit_price).toFixed(2)}
</td>
{tiers.map(tier => (
<td key={tier.id} className="text-center px-4 py-3">
<input
type="checkbox"
checked={(assignments.get(tier.id) ?? new Set()).has(service.id)}
onChange={e => handleToggle(tier.id, service.id, e.target.checked)}
className="cursor-pointer"
/>
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
```
**Data flow on save:**
1. User toggles checkbox → state updates → live total recalculates (client-side)
2. User clicks "Salva" → form submission with Map<tier_id, Set<service_id>>
3. Server action converts to array, validates via Zod, then:
- Delete all rows in `offer_tier_services` where tier_id IN (this offer's tiers)
- Insert new rows: `INSERT INTO offer_tier_services (tier_id, service_id) VALUES ...`
4. Revalidate `/admin/offers` → user sees updated matrix
---
### Pattern 4: Live Total Calculation (Client-Side)
**What:** Sum of selected service prices per tier, updated immediately on checkbox toggle.
**When to use:** OfferMatrix component and any form showing price aggregates.
**Implementation:**
```typescript
const computeTierTotal = (selectedServiceIds: string[], services: Service[]): number => {
return services
.filter(s => selectedServiceIds.includes(s.id))
.reduce((sum, s) => sum + parseFloat(s.unit_price), 0);
};
// On checkbox toggle:
const tierTotal = computeTierTotal(Array.from(checkedIds), services);
setTotals(prev => ({ ...prev, [tierId]: tierTotal }));
```
**Why client-side:**
- Deterministic (sum is pure function of selected items + prices)
- No server round-trip per toggle (better UX)
- Prices sourced from Phase 11 catalog (immutable, already in props)
- Validation on save (server-side Zod schema ensures data integrity)
---
### Pattern 5: Server Actions with requireAdmin (Phase 11 Reuse)
**What:** Use `getServerSession(authOptions)` to guard server actions; throw on non-admin.
**When to use:** All mutation operations.
**Example:**
```typescript
// src/app/admin/offers/actions.ts
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
const updateOfferTiersSchema = z.object({
macro_id: z.string().min(1),
tiers: z.array(
z.object({
micro_id: z.string().min(1),
tier_letter: z.enum(["A", "B", "C"]),
public_price: z.coerce.number().min(0),
service_ids: z.array(z.string()),
})
),
});
export async function updateOfferTiers(formData: FormData) {
await requireAdmin();
const parsed = updateOfferTiersSchema.safeParse({
macro_id: formData.get("macro_id"),
tiers: JSON.parse(formData.get("tiers_json") as string),
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
const { macro_id, tiers } = parsed.data;
// Update each tier
for (const tier of tiers) {
await db.update(offer_micros)
.set({
tier_letter: tier.tier_letter,
public_price: tier.public_price.toFixed(2),
})
.where(eq(offer_micros.id, tier.micro_id));
// Delete + re-insert services for this tier
await db.delete(offer_tier_services)
.where(eq(offer_tier_services.tier_id, tier.micro_id));
if (tier.service_ids.length > 0) {
await db.insert(offer_tier_services).values(
tier.service_ids.map(sid => ({ tier_id: tier.micro_id, service_id: sid }))
);
}
}
revalidatePath("/admin/offers");
}
```
---
### Anti-Patterns to Avoid
- **❌ Exposing offer details via client API:** Only `/admin/offers` (admin-only routes with session check). Never `/api/offers` (public endpoint). Offer composition is locked behind Auth.js.
- **❌ Computing cumulative_price at schema time:** It's a derived value (sum of selected services). Compute at **query time** (SQL SUM), not stored.
- **❌ Storing tier letter as computed rank:** Store as explicit column `tier_letter` so it's immutable and queryable.
- **❌ Re-using `offer_micro_services` for the new matrix:** It's legacy and points to deprecated `offer_services`. Always use the **new `offer_tier_services`** junction to `services.id`.
- **❌ Bulk-updating tier prices inline:** Each tier edit is a separate save. Avoid multi-tier batch updates unless required by spec.
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|------------|-------------|-----|
| Multi-select tag input | Custom autocomplete with tag picker | `shadcn/ui` Badge + Input with onKeyDown, pattern from Phase 11 `OptionMultiSelect` | Phase 11 already solved this; reuse component + logic |
| Tier matrix checkbox logic | Custom matrix renderer | React state (useState) + table component | Simple enough; native HTML + React state is cleaner than a library |
| Live total calculation | Custom sum function | `reduce()` over selected services | Standard JS; no library needed for deterministic arithmetic |
| Tag management (add/remove/rename) | Custom CRUD logic | Mirror Phase 11 server actions: `addOfferTag`, `removeOfferTag`, `renameOfferTag` | Tag lifecycle is identical across entities; DRY principle |
| Offer schema validation | Regex or ad-hoc checks | Zod schema (`offerMacroSchema`, `offerMicroSchema`) | Type-safe, composable, reuses project standard |
| JSON transformation promise | String concatenation | JSONB column with typed read/write (Zod parse on read) | Structured data → queryable later; no parsing fragility |
**Key insight:** Phase 11 established production-grade patterns for inline edit, tagging, quick-add. Phase 12 must inherit these patterns exactly; the only new logic is tier matrix composition (fundamentally simpler than drag-drop).
---
## Code Examples
### Example 1: Query — Offer Detail with Tags & Services
```typescript
// src/lib/offer-queries.ts (new function)
export type OfferDetailWithServices = {
macro: OfferMacro & {
tags: {
categoria: string[];
ticket: string[];
tipo: string[];
obiettivo: string[];
}
};
micros: Array<OfferMicro & {
services: Array<{ id: string; name: string; unit_price: string }>;
cumulative_price: string;
}>;
};
export async function getOfferDetail(macroId: string): Promise<OfferDetailWithServices | null> {
const macro = await db.query.offer_macros.findFirst({ where: eq(offer_macros.id, macroId) });
if (!macro) return null;
// Fetch tags scoped to this offer (4 dimensions)
const tagRows = await db
.select({ entity_type: tags.entity_type, name: tags.name })
.from(tags)
.where(
and(
eq(tags.entity_id, macroId),
inArray(tags.entity_type, [
"offer_macros",
"offer_macros.ticket",
"offer_macros.tipo",
"offer_macros.obiettivo",
])
)
);
const tagsMap = {
categoria: tagRows.filter(t => t.entity_type === "offer_macros").map(t => t.name),
ticket: tagRows.filter(t => t.entity_type === "offer_macros.ticket").map(t => t.name),
tipo: tagRows.filter(t => t.entity_type === "offer_macros.tipo").map(t => t.name),
obiettivo: tagRows.filter(t => t.entity_type === "offer_macros.obiettivo").map(t => t.name),
};
// Fetch micros + services via NEW junction
const micros = await db.select().from(offer_micros).where(eq(offer_micros.macro_id, macroId));
const microIds = micros.map(m => m.id);
const servicesForMicros = await db
.select({
micro_id: offer_tier_services.tier_id,
service_id: offer_tier_services.service_id,
name: services.name,
unit_price: services.unit_price,
})
.from(offer_tier_services)
.innerJoin(services, eq(offer_tier_services.service_id, services.id))
.where(inArray(offer_tier_services.tier_id, microIds));
// Compute cumulative totals at query time
const cumulPrices = await db
.select({
micro_id: offer_tier_services.tier_id,
cumulative: sql<string>`COALESCE(SUM(${services.unit_price}::numeric), 0)`,
})
.from(offer_tier_services)
.innerJoin(services, eq(offer_tier_services.service_id, services.id))
.where(inArray(offer_tier_services.tier_id, microIds))
.groupBy(offer_tier_services.tier_id);
const cumulMap = new Map(cumulPrices.map(r => [r.micro_id, r.cumulative]));
const servicesMap = new Map<string, typeof servicesForMicros>();
for (const s of servicesForMicros) {
if (!servicesMap.has(s.micro_id)) servicesMap.set(s.micro_id, []);
servicesMap.get(s.micro_id)!.push(s);
}
return {
macro: { ...macro, tags: tagsMap },
micros: micros.map(m => ({
...m,
services: (servicesMap.get(m.id) ?? []).map(s => ({
id: s.service_id,
name: s.name,
unit_price: s.unit_price,
})),
cumulative_price: cumulMap.get(m.id) ?? "0",
})),
};
}
```
**Source:** [VERIFIED: Pattern from Phase 11 getAllServices + getCatalogWithMicros in offer-queries.ts lines 2079]
---
### Example 2: Tier Matrix Component
```typescript
// src/components/admin/offers/OfferMatrix.tsx
"use client";
import { useState, useMemo } from "react";
import type { Service, OfferMicro } from "@/db/schema";
type Props = {
services: Service[];
tiers: Array<OfferMicro & { tier_letter: 'A'|'B'|'C' }>;
initialAssignments: Map<string, string[]>;
onChange: (assignments: Map<string, string[]>) => void;
};
export function OfferMatrix({ services, tiers, initialAssignments, onChange }: Props) {
const [assignments, setAssignments] = useState<Map<string, Set<string>>>(
new Map(Array.from(initialAssignments.entries()).map(([k, v]) => [k, new Set(v)]))
);
const handleToggle = (tierId: string, serviceId: string, checked: boolean) => {
const next = new Map(assignments);
const tierSet = new Set(next.get(tierId) ?? []);
if (checked) tierSet.add(serviceId);
else tierSet.delete(serviceId);
next.set(tierId, tierSet);
setAssignments(next);
onChange(next);
};
const totals = useMemo(() => {
return tiers.map(tier => ({
tier_id: tier.id,
total: Array.from(assignments.get(tier.id) ?? [])
.map(sid => parseFloat(services.find(s => s.id === sid)?.unit_price ?? "0"))
.reduce((a, b) => a + b, 0),
}));
}, [assignments, services, tiers]);
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
<th className="text-left px-4 py-2 font-semibold">Servizio</th>
<th className="text-center px-4 py-2 font-semibold">Prezzo</th>
{tiers.map(tier => (
<th key={tier.id} className="text-center px-4 py-2 font-semibold">
Tier {tier.tier_letter}
</th>
))}
</tr>
</thead>
<tbody>
{services.map(service => (
<tr key={service.id} className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9]">
<td className="px-4 py-3 font-medium">{service.name}</td>
<td className="text-center px-4 py-3 text-[#71717a]">
{parseFloat(service.unit_price).toFixed(2)}
</td>
{tiers.map(tier => (
<td key={tier.id} className="text-center px-4 py-3">
<input
type="checkbox"
checked={(assignments.get(tier.id) ?? new Set()).has(service.id)}
onChange={e => handleToggle(tier.id, service.id, e.target.checked)}
className="cursor-pointer"
/>
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="mt-4 flex gap-8 font-semibold">
{totals.map(({ tier_id, total }) => (
<div key={tier_id}>
<span className="text-[#71717a]">Tier {tiers.find(t => t.id === tier_id)?.tier_letter}:</span>
<span className="ml-2">{total.toFixed(2)}</span>
</div>
))}
</div>
</div>
);
}
```
**Source:** [VERIFIED: React hooks pattern + browser native checkbox]
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `offer_micro_services``offer_services` | `offer_tier_services``services` (unified) | Phase 11 / Phase 12 | Single service catalog reduces duplication |
| Drag-and-drop composer (UI-SPEC.md, original scope) | Checkbox matrix (CONTEXT.md D-2, re-scoped 2026-06-14) | 2026-06-14 user decision | Simpler UX, faster implementation, better accessibility |
| Offer tiers as implicit rows | Explicit tier letters A/B/C in schema | Phase 12 (D-2) | Tier designations immutable, queryable, unambiguous |
| Manual text transformation promise | Structured JSON fields (5-field JSONB) | Phase 12 (OFFER-17) | Enables future analytics, templating, per-field editing |
| Shared `tags` table with single pool | Polymorphic entity_type scoping | Phase 11 / Phase 12 | Multiple tag dimensions stay separate; no collision risk |
---
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Services already filtered by category field in Phase 11; offer editor can pre-filter matrix by `services.category` | D-4 | If category is not set or semantics differ, matrix shows all services instead of relevant ones |
| A2 | `cumulative_price` should be **computed at query time** (SQL SUM), not stored | Schema Design | If developer stores cumulative_price and forgets to update it, totals will be stale |
| A3 | Public pricing (public_price) is **always manual input**, never auto-calculated | D-5 | If business logic changes to auto-calculate public price, schema will need redesign |
| A4 | Tier letters are **always exactly 3 tiers** (A/B/C) per offer | D-2 | If future phases add variable tier counts, CHECK constraint will fail |
| A5 | **New `offer_tier_services` junction is not used elsewhere**; legacy `offer_micro_services` untouched | Schema Design | If downstream code accidentally reads offer_tier_services, queries will break |
| A6 | Phase 11's `services.category` and `services.fase` are not overloaded with other semantics | D-4 | If category/fase used differently elsewhere, offer editor filtering will be wrong |
---
## Open Questions
1. **Services all have category set? (Assumption A1)**
- **Recommendation:** In Phase 12 plan: verify all services have category; add default ("General") for nulls if needed
2. **Transformation promise: JSON or 5 columns? (OFFER-17)**
- **Recommendation:** Use **single JSONB column** for type safety and future queryability
3. **Tag dimensions: polymorphic entity_type or dimension column? (OFFER-15)**
- **Recommendation:** Use polymorphic entity_type (Design A) to avoid schema change, reuse Phase 11 pattern
4. **Row reordering via @dnd-kit? (D-2 optional mention)**
- **Recommendation:** Not in initial scope. If required, add `@dnd-kit/sortable` in Phase 12 plan
---
## Environment Availability
Not applicable — Phase 12 is code/schema only, no external service dependencies beyond existing Postgres/Neon.
---
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | Yes | Auth.js v4 session, requireAdmin guard on all server actions |
| V3 Session Management | Yes | Auth.js v4, immutable session, HTTPS-only cookies |
| V4 Access Control | Yes | `/admin/offers/*` routes check session; public routes don't expose offer data |
| V5 Input Validation | Yes | Zod schema on server action inputs; HTML5 numeric validation on prices |
| V6 Cryptography | No | Session token encrypted by Auth.js framework |
### Known Threat Patterns
| Pattern | STRIDE | Mitigation |
|---------|--------|-----------|
| CSRF on form submission | Tampering | Next.js server actions auto-include SameSite=Strict cookie |
| SQL injection via ORM | Tampering | Drizzle ORM uses parameterized queries; no string interpolation |
| Unvalidated price input | Tampering | Zod validates numeric range; coerced to number, min 0 |
| Privilege escalation | Elevation | requireAdmin() checks session; throws if missing |
| Data exposure via API | Information Disclosure | No public API; only `/admin/offers*` (private) access offer data |
| Race condition on service assignment | Tampering | PK constraint on junction prevents duplicates; last-write-wins acceptable for admin-only |
---
## Sources
### Primary (HIGH confidence)
- **12-CONTEXT.md** — User decisions locked (D-1 through D-6)
- **ROADMAP.md Phase 12** — Requirements OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18; re-scoped 2026-06-14
- **REQUIREMENTS.md** — All phase requirements explicitly mapped; OFFER-12 deferred
- **src/db/schema.ts** — Current `offer_macros` (lines 262271), `offer_micros` (lines 274286), `tags` (lines 119140)
- **src/app/admin/catalog/actions.ts** — Phase 11 patterns for tag management (lines 74199)
- **src/lib/admin-queries.ts** — Query patterns (lines 366449); polymorphic tagging (lines 889927)
### Secondary (MEDIUM confidence)
- **CLAUDE.md** — Architecture Constraints (LOCKED): additive schema only, hand-write migrations, schema push via SSH+docker exec
- **12-UI-SPEC.md** — Original drag-drop scope; superseded by CONTEXT.md; kept for UI pattern reference
---
## Metadata
**Confidence breakdown:**
- **Standard stack:** HIGH — All locked in CLAUDE.md; versions current
- **Architecture:** HIGH — Schema additions minimal, scoped; query patterns mirror Phase 11
- **Additive schema:** HIGH — Data Safety locked; requires hand-written SQL (drizzle-kit broken)
- **Polymorphic tag model:** HIGH — Phase 11 establishes pattern; straightforward extension
- **Tier matrix UX:** MEDIUM — Checkbox pattern simpler than drag-drop; spec from CONTEXT.md user decision
- **Service category filtering:** MEDIUM — Assumes Phase 11 populated all services with category (Assumption A1)
- **Public pricing:** HIGH — Locked as manual input (D-5)
**Research date:** 2026-06-14
**Valid until:** 2026-06-21 (7 days — offer editor straightforward; schema stable; low tech drift risk)
---
*Research completed. Ready for Phase 12 planning.*
@@ -0,0 +1,578 @@
---
phase: 12
slug: offer-editor-tier-tag-prezzo-pubblico
status: draft
shadcn_initialized: true
preset: default
created: 2026-06-14
---
# Phase 12 — UI Design Contract
## Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico
> Visual and interaction contract for the Offer Editor: list page + detail editor with 3-tier checkbox matrix, multi-dimensional tags, and structured transformation promise. Generated by gsd-ui-researcher, verified by gsd-ui-checker.
---
## Design System
| Property | Value |
|----------|-------|
| Tool | shadcn/ui |
| Preset | default (existing, Phase 11 forward) |
| Component library | radix |
| Icon library | Lucide React |
| Font | Geist Sans |
| Design direction | ClickUp/Pipedrive minimal flat style (per DESIGN-SYSTEM.md) |
**Notes:** Phase 12 builds on Phase 11's established database-view UX (inline edit, tag multi-select, EditableCell). All colors, spacing, typography inherited from Phase 11 and project globals. No new design system tokens introduced.
---
## Spacing Scale
Multiples of 4px per project standard:
| Token | Value | Usage |
|-------|-------|-------|
| xs | 4px | Icon gaps, inline padding |
| sm | 8px | Compact element spacing, tag chip gaps |
| md | 16px | Default cell/control spacing, table row padding |
| lg | 24px | Section padding, card margin, tag dimension spacing |
| xl | 32px | Layout gaps between sections |
| 2xl | 48px | Major section breaks |
| 3xl | 64px | Page-level spacing |
Exceptions: None
---
## Typography
Phase 12 reuses Phase 11 typography (no new sizes introduced). Two weights only (400 regular, 600 bold):
| Role | Size | Weight | Line Height |
|------|------|--------|-------------|
| Body | 14px | 400 | 1.5 |
| Label | 12px | 400 | 1.5 |
| Heading (h3) | 16px | 600 | 1.2 |
| Heading (h2) | 20px | 600 | 1.2 |
---
## Color
Phase 12 uses Phase 11 color contract (invariant brand tokens):
| Role | Value | Usage |
|------|-------|-------|
| Dominant (60%) | #ffffff | Page/card backgrounds, editor surfaces |
| Secondary (30%) | #f9f9f9 | Muted backgrounds, table headers, category filter chips (inactive) |
| Accent (10%) | #1A463C | Primary CTAs ("Salva Offerta"), focus ring, active/selected state, active filter chips |
| Accent secondary | #DEF168 | Status indicators (archived badge), active offer state |
| Destructive | #dc2626 | Archive/delete actions only |
| Muted text | #71717a | Placeholders, metadata, disabled states, inactive labels |
| Border | #e5e7eb | Cell/block dividers, table borders |
**Accent reserved for:**
- "Salva Offerta" primary CTA button
- Focus ring on EditableCell, TagMultiSelect, and input fields
- Active state indicator (category filter chip, active tags)
- Selection highlight in checkbox matrix
---
## Components & Interaction Patterns
All patterns inherited from Phase 11 (EditableCell, TagMultiSelect, database-view table) and extended for offer composition and tier management.
### 1. Offer List Page (`/admin/offers`)
**Purpose:** Display all offers (active + optionally archived) in a filterable card grid. Admin can create new offers, filter by category, and toggle archive view.
**Layout Structure:**
```
┌──────────────────────────────────────────────────────────────┐
│ Offerte [+ Nuova Offerta] │
├──────────────────────────────────────────────────────────────┤
│ Filter row: │
│ [Tutti] [Entry Offer] [Signature Offer] [Retainer Offer] │
│ Mostra offerte archiviate: [toggle off] │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Offerta 1 │ │ Offerta 2 │ │ Offerta 3 │ │
│ │ Breve desc │ │ Breve desc │ │ Breve desc │ │
│ │ [Signature] │ │ [Entry] │ │ [Retainer] │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ [card 4] [card 5] [card 6] │
└──────────────────────────────────────────────────────────────┘
```
#### A. Page Header
- **Title:** "Offerte" (h2, 20px/600, black)
- **Primary CTA:** "+ Nuova Offerta" button (bg #1A463C, text white, right-aligned)
#### B. Filter Row
- **Category filter chips:** Buttons for "Tutti", "Entry Offer", "Signature Offer", "Retainer Offer"
- Inactive: border #e5e7eb, text #71717a, bg transparent
- Active: border #1A463C, text #1A463C, bg transparent (or solid #1A463C text white — planner decides)
- Spacing between chips: 8px (sm)
- **Archive toggle:** "Mostra offerte archiviate" text label + checkbox or toggle switch
- Position: right side of filter row OR below filter chips on tablet/mobile
- Label color: #71717a
- Toggle: standard checkbox or `<Switch>` component from shadcn
#### C. Offer Card Grid
- **Grid layout:** 3 columns on desktop (≥1024px), 2 on tablet (7681023px), 1 on mobile (<768px)
- **Card dimensions:** ~280px width, responsive height (min 140px)
- **Card structure:**
```
┌───────────────────────────┐
│ Offerta Name (editable?) │ ← h3/16px/600
│ │
│ Breve descrizione │ ← body/14px/400, 2 lines max, ellipsis
│ │
│ [Categoria Chip] │ ← single category badge
│ │
│ [archived badge] [edit] │ ← conditional
└───────────────────────────┘
```
- **Card styling:**
- Background: white (#ffffff)
- Border: #e5e7eb, 1px
- Padding: 16px (md)
- Border-radius: 8px
- Hover: `cursor-pointer`, subtle shadow (box-shadow: 0 4px 12px rgba(0,0,0,0.08))
- **Offer name:** Clickable, links to detail editor
- **Short description:** Body text, truncated at 2 lines
- **Category badge:** Single chip (Entry/Signature/Retainer), color-coded (TBD by planner; suggest brand palette shades)
- **Archived badge:** Red text "Archiviata" (only if offer is archived)
- **Edit icon:** Optional Lucide pencil icon (top-right corner), links to detail editor
#### D. Empty State
- When no offers exist: centered section with
- Icon: Lucide briefcase or package (48px, #71717a)
- Heading: "Nessuna offerta" (h3)
- Copy: "Inizia creando la tua prima offerta" (body, #71717a)
- CTA: "+ Nuova Offerta" button
---
### 2. Offer Editor (Detail Page) (`/admin/offers/[id]/edit` or modal)
**Purpose:** Create/edit a complete offer with 3 tiers (A/B/C), service composition via checkbox matrix, tag system, and transformation promise.
**Page Structure:**
```
┌───────────────────────────────────────────────────────────────┐
│ ← Indietro | Modifica Offerta │
├───────────────────────────────────────────────────────────────┤
│ │
│ [Offerta Name — editable inline] [Category] [Ticket] │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Tags (4 dimensions): │ │
│ │ Categoria: [Signature Offer] │ │
│ │ Ticket: [Low Ticket / Mid Ticket / High Ticket] │ │
│ │ Tipo: [Audit] [Done For You] [+ Crea Tipo] │ │
│ │ Obiettivo: [Primo Acquisto] [Up Scala] [+ Crea Obj] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ [Divider] │
│ │
│ SERVIZI INCLUSI │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Servizio Prezzo │ A │ B │ C │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ Service 1 €100 │ ☑ │ ☐ │ ☐ │ │ │
│ │ Service 2 €250 │ ☐ │ ☑ │ ☑ │ │ │
│ │ ... ... │...│...│... │ │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ Totale Servizi — │€350│€500│€250│ │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ Prezzo Pubblico — │€499│€699│€349│ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ [Divider] │
│ │
│ PROMESSA DI TRASFORMAZIONE │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Aiuto: [Client Ideal — editable inline] │ │
│ │ A ottenere: [Result — editable inline] │ │
│ │ In: [Estimated Time — editable inline] │ │
│ │ Senza: [Pain Point — editable inline] │ │
│ │ Grazie a: [Proprietary Method — editable inline] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ [Salva Offerta] [Annulla] [Archivia] │
└───────────────────────────────────────────────────────────────┘
```
#### A. Header & Navigation
- **Back button:** "← Indietro" (left-aligned, links to `/admin/offers`)
- **Page title:** "Modifica Offerta" (h2, 20px/600) OR "Crea Offerta" if new
#### B. Offer Name & Quick Category/Ticket Display
- **Name field:** EditableCell (text), required, displayed as h3
- **Category chip:** Single-select tag (Entry/Signature/Retainer), visually prominent
- **Ticket chip:** Single-select tag (Low/Mid/High Ticket), visually secondary
#### C. Tags Block (4 Dimensions)
**Structure:**
- Section label: "Tag" (h3, 16px/600)
- Four rows (one per dimension):
1. **Categoria** (single): "Entry Offer" / "Signature Offer" / "Retainer Offer"
2. **Ticket** (single): "Low Ticket" / "Mid Ticket" / "High Ticket"
3. **Tipo** (multi): "Audit", "Done For You", "+ Crea Tipo" (add new)
4. **Obiettivo** (multi): "Primo Acquisto", "Up Scala Valore", "Lead Generation", "+ Crea Obiettivo"
**Styling per dimension:**
- Dimension label: 12px/400, bold, #71717a
- Tags/chips: Use inherited TagMultiSelect component (from Phase 11)
- Single-select (Categoria, Ticket): radio-like, one active chip at a time
- Multi-select (Tipo, Obiettivo): checkbox-like, multiple active chips
- Color: deterministic hash-based colors (6-8 color palette, per Phase 11 pattern)
- Add action: "+ Crea [Dimension Name]" link/button inline
- Spacing between rows: 16px (md)
#### D. Services Matrix (Tier A/B/C)
**Section label:** "Servizi Inclusi" (h3)
**Table structure:**
- **Columns:** Service Name | Unit Price | Tier A (checkbox) | Tier B (checkbox) | Tier C (checkbox)
- **Header row:**
- Left: "Servizio" (12px/400, bold)
- Cols: "Prezzo" (right-aligned) + "A", "B", "C" (centered, column headers)
- Header background: #f9f9f9
- Header border-bottom: #e5e7eb
- **Service rows:**
- Service name (left, flex-grow)
- Unit price (right-aligned, tabular nums, "€X,XX")
- Checkbox per tier (centered)
- Style: standard `<input type="checkbox">`
- Color: #1A463C when checked
- Interaction: Click to toggle; live total updates immediately
- Row height: ~40px
- Border-bottom: #e5e7eb
- Hover: subtle background #f9f9f9 on the entire row
- **Services filtered by category:** Only show services whose `services.category` matches the offer's selected category (Categoria tag)
- **Row order:** Configurable (drag-to-reorder via @dnd-kit optional; planner decides)
**Totals rows** (sticky to bottom of table):
1. **Totale Servizi** (auto-calculated):
- Row label: "Totale Servizi" (bold, 14px/600)
- Per tier: SUM of unit_price for all services with checkbox ☑ in that tier
- Format: "€X,XXX.XX"
- Updates **live** as checkboxes are toggled (no page reload)
- Color: bold text, #1a1a1a
2. **Prezzo Pubblico** (manual input):
- Row label: "Prezzo Pubblico" (bold, 14px/600)
- Per tier: EditableCell (number type)
- Placeholder: "€0,00" or empty
- Format: "€X,XXX.XX"
- Interaction: Click to edit, blur/Enter to save, Esc to cancel
- Independent of "Totale Servizi" — user can set any value
- Validation: numeric, ≥ 0, max 2 decimals
**Empty state (no services matching category):**
- Placeholder text: "Nessun servizio disponibile per questa categoria" (#71717a)
- Table still visible but with placeholder row
---
#### E. Transformation Promise Block
**Section label:** "Promessa di Trasformazione" (h3)
**Structure:** 5 editable fields, each a key-value pair:
```
Aiuto: [Client Ideal — input text, 60 chars]
A ottenere: [Result — input text, 60 chars]
In: [Time — input text, 30 chars, e.g. "3 mesi"]
Senza: [Pain — input text, 60 chars]
Grazie a: [Method — input text, 60 chars]
```
**Implementation:**
- Each field is an EditableCell (text type, optional)
- Field label (dim): 12px/400, color #71717a
- Input placeholder: "Aggiungi [field name]" (muted)
- No asterisk (all optional)
- Layout: horizontal label-value pairs, or stacked label-over-input depending on space
- On desktop: consider 2-column or full-width labels
- Tablet/mobile: stack vertically
- Spacing between fields: 16px (md)
- Background: optional subtle bg #f9f9f9, or transparent (planner decides)
- Border: none, or bottom-border only on each field
- Focus ring: #1A463C when in edit mode (EditableCell pattern)
---
#### F. Action Buttons
**Bottom of editor:**
- **[Salva Offerta]** (primary CTA)
- Button: bg #1A463C, text white, 14px/600, padding 16px 24px
- Position: left-aligned
- On click: Submit form, validate all required fields (name + category + at least 1 service selected in 1 tier), save to DB, redirect to `/admin/offers`, show toast "Offerta salvata"
- Disabled state: if no tiers have services selected, button disabled with tooltip "Seleziona almeno un servizio"
- **[Annulla]** (secondary CTA)
- Button: border #e5e7eb, text #1a1a1a, no fill
- On click: Discard unsaved changes, redirect to `/admin/offers`
- Optional confirmation: if form is dirty, "Scartare le modifiche?" dialog
- **[Archivia]** (destructive action, visible only on edit, not on create)
- Button: text #dc2626, no fill
- On click: Toggle offer `is_archived` flag
- Confirmation required: "Sei sicuro? L'offerta non sarà visibile nella lista." [Annulla] [Archivia]
- After confirm: Save, show toast "Offerta archiviata", redirect to `/admin/offers`
**Spacing between buttons:** 8px (sm)
---
### 3. Tag Creation Inline ("Crea Tipo", "Crea Obiettivo")
**Interaction:** When user clicks "+ Crea [Dimension]", an input field appears inline (or in a small popover).
**Structure:**
- Text input: placeholder "Nome [Dimension]..."
- On Enter or click checkmark: Create tag (server action `addTagToOffer`), add to active tags list
- On Esc or click X: Cancel, close input
- On success: Input clears, new tag appears in the chip list with deterministic color
**Validation:**
- Required: tag name 1-30 chars
- Trimmed whitespace
- Case-insensitive duplicate check per dimension (warn: "Questo tag esiste già")
---
## Copywriting Contract
| Element | Copy | Context |
|---------|------|---------|
| **Offer list heading** | "Offerte" | Page title |
| **Create offer CTA** | "+ Nuova Offerta" | Primary action on list |
| **Back link** | "← Indietro" | Breadcrumb/nav on detail |
| **Archive toggle label** | "Mostra offerte archiviate" | Filter checkbox |
| **Edit page heading** | "Modifica Offerta" | Detail page title (or "Crea Offerta" if new) |
| **Tags section label** | "Tag" | Section heading |
| **Category dimension** | "Categoria" | Single-select label |
| **Ticket dimension** | "Ticket" | Single-select label |
| **Type dimension** | "Tipo" | Multi-select label |
| **Objective dimension** | "Obiettivo" | Multi-select label |
| **Create type link** | "+ Crea Tipo" | Inline tag creation |
| **Create objective link** | "+ Crea Obiettivo" | Inline tag creation |
| **Services section label** | "Servizi Inclusi" | Table heading |
| **Service column header** | "Servizio" | Table column |
| **Price column header** | "Prezzo" | Table column |
| **Tier headers** | "A", "B", "C" | Table columns (single letters) |
| **Services total label** | "Totale Servizi" | Calculated row |
| **Public price label** | "Prezzo Pubblico" | Manual input row |
| **Transformation block label** | "Promessa di Trasformazione" | Section heading |
| **Ideal client label** | "Aiuto" | Field label |
| **Result label** | "A ottenere" | Field label |
| **Duration label** | "In" | Field label (e.g., "3 mesi") |
| **Pain label** | "Senza" | Field label |
| **Method label** | "Grazie a" | Field label |
| **Save CTA** | "Salva Offerta" | Primary action (verb + noun) |
| **Cancel CTA** | "Annulla" | Secondary action |
| **Archive CTA** | "Archivia" | Destructive action (visible on edit only) |
| **Archive confirmation** | "Sei sicuro? L'offerta non sarà visibile nella lista." | Destructive confirmation copy + context |
| **Empty services message** | "Nessun servizio disponibile per questa categoria" | Conditional placeholder |
| **Empty offers message** | "Nessuna offerta" | List page empty state heading |
| **Empty offers hint** | "Inizia creando la tua prima offerta" | List page empty state body |
| **Save success toast** | "Offerta salvata" | Success feedback |
| **Archive success toast** | "Offerta archiviata" | Success feedback |
| **Save error toast** | "Errore nel salvataggio. Verifica i campi." | Error feedback |
| **Tag name placeholder** | "Nome tag..." | Create tag input |
| **Duplicate tag warning** | "Questo tag esiste già" | Validation error |
| **Disabled save tooltip** | "Seleziona almeno un servizio" | Constraint message |
---
## Interaction & State Management
### Checkbox Matrix Behavior
- **On checkbox toggle:** Live recalculation of "Totale Servizi" for that tier (no network call)
- **Debounce:** None — updates instant as user clicks
- **Persistence:** On "Salva Offerta" click, POST all tier compositions to backend
- **Validation on save:** At least 1 tier must have ≥1 service selected (backend confirms)
### Tag Dimensions
- **Categoria & Ticket:** Single-select (user can only have one value per offer)
- UI: Radio-button-like chips or dropdown — planner decides
- Switching to a different service category auto-filters the services matrix
- **Tipo & Obiettivo:** Multi-select (user can have 0..N values per offer)
- UI: Checkbox-like chips (inherited TagMultiSelect pattern)
- Tag creation inline: "+ Crea Tipo" / "+ Crea Obiettivo"
- Duplicate check: case-insensitive per dimension (warn and skip)
- Color: deterministic based on tag name hash (6-8 color palette, per Phase 11)
### Archive Workflow
- Archive is a toggle, not permanent deletion
- Archived offers are hidden from list by default (togglable via "Mostra offerte archiviate")
- On edit page, "Archivia" button only visible if offer is not already archived (or toggle text changes to "Ripristina")
- Confirmation required for destructive state change
### Edit State Management
- Form is dirty if any field changed since load
- On cancel with unsaved changes: confirmation dialog "Scartare le modifiche?"
- On back button navigation: same check (browser may warn too)
- Auto-save optional (planner decides) — recommend explicit "Salva" for clarity
---
## Visual States
### Offer Card States
| State | Visual |
|-------|--------|
| Default | White card, border #e5e7eb, normal shadow |
| Hover | `cursor-pointer`, shadow deepens (0 4px 12px rgba(0,0,0,0.08)) |
| Archived | Gray text, optional red "Archiviata" badge top-right |
### Checkbox States
| State | Visual |
|-------|--------|
| Unchecked | Empty square, border #e5e7eb |
| Checked | Filled square, bg #1A463C, white checkmark |
| Hover | Cursor pointer, subtle border darken |
| Disabled | Gray box, opacity-50, no cursor |
### Editable Field States (EditableCell)
| State | Visual |
|-------|--------|
| Display mode | Normal text, `cursor-pointer`, hover bg #f0f0f0 |
| Edit mode | Input with ring-1 ring-#1A463C on focus |
| Error | Ring-1 ring-#dc2626, error message below |
| Disabled | Gray text, opacity-50, no cursor |
### Tag States (TagMultiSelect)
| State | Visual |
|-------|--------|
| Display | Colored chip (6-8 colors), text inside, X icon to remove |
| Hover | Slight opacity change on X icon |
| Add mode | Input field + "+ Aggiungi" button (or Enter to confirm) |
| Create new | Temporary placeholder, then colored chip with new tag name |
### Category Filter Chip States (List Page)
| State | Visual |
|-------|--------|
| Inactive | Border #e5e7eb, text #71717a, bg transparent, `cursor-pointer` |
| Active | Border #1A463C, text #1A463C (or bg #1A463C text white — planner decides) |
| Hover | Border darken slightly |
---
## Responsive Behavior
**Desktop (≥1024px):**
- Offer list: 3-column card grid
- Filter chips: horizontal row
- Services matrix: full-width table, horizontal scroll if needed
- Editor sections: full-width, stacked vertically
**Tablet (768px1023px):**
- Offer list: 2-column card grid
- Filter chips: wrap horizontally or scroll if needed
- Services matrix: horizontal scroll (sticky left column optional)
- Tag fields: stack or 2-column layout (planner decides)
**Mobile (<768px):**
- Offer list: 1-column card stack
- Filter chips: vertical stack OR horizontal scroll in a container
- Services matrix: horizontal scroll with sticky "Servizio" + "Prezzo" columns
- Tag fields: stack vertically
- Buttons: full-width stack
- Archive toggle: move below filter chips
---
## Registry Safety
| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | button, input, dialog, badge, table, form, checkbox | not required (official shadcn) |
| @dnd-kit | core, sortable, utilities (optional for row reordering) | not required (npm package, not registry) |
---
## Checker Sign-Off
- [ ] Dimension 1 Copywriting: All labels/CTAs use verb+noun, destructive actions clear ("Archivia" + confirmation), tag creation inline
- [ ] Dimension 2 Visuals: Checkbox matrix clear, live totals update instant, archive badge visible, tag colors consistent
- [ ] Dimension 3 Color: Accent (#1A463C) reserved for primary CTA ("Salva Offerta"), focus ring, active filter chip; destructive (#dc2626) for archive only
- [ ] Dimension 4 Typography: Body 14px/400, labels 12px/400, headings 1620px/600 — all per Phase 11 contract (2 weights only)
- [ ] Dimension 5 Spacing: All gaps/padding multiples of 4px, card padding 16px (md), section gaps 24px (lg), row height ~40px
- [ ] Dimension 6 Registry Safety: shadcn official + npm packages, no third-party registries
**Approval:** pending
---
## Notes for Planner
1. **Service filtering by category:** Implement dynamic filter on the services matrix when Categoria tag changes. Query: fetch all services WHERE `services.category = offer.categoria_tag_value`.
2. **Live total calculation:** Client-side state, no server call. Recalculate sum of unit prices for all checked services in each tier on every checkbox toggle.
3. **Three tiers (A/B/C):** Map to `offer_micros` table, one micro per tier. Store which services are assigned via new `offer_tier_services` junction table (additive, not modifying legacy `offer_micro_services`).
4. **Tag dimensions:** Use Phase 11's polymorphic `tags` table with `entity_type = "offer_macros"`. Add optional `dimension` column to distinguish Categoria/Ticket/Tipo/Obiettivo (or use tag name prefixes, planner decides). Single-select for Categoria/Ticket likely stored on `offer_macros` columns directly (simpler).
5. **Tag creation on the fly:** User can create new tag values during editing. Server action: `addTagToOffer(offerId, dimension, tagName)`. Validation + idempotency (onConflictDoNothing).
6. **Archive flag:** Add boolean `is_archived` column to `offer_macros` (additive migration). Filter in list query: WHERE is_archived = false (or include if toggle is on).
7. **Edit page routing:** Consider `/admin/offers/[id]/edit` (new page) or modal overlay. Both work; full-page simpler for first iteration.
8. **Services matrix columns:** Each tier (A/B/C) is visually a column with checkboxes. Sticky header recommended. Sticky left column (service name + price) optional on mobile/tablet.
9. **Transformation promise fields:** All optional. Can be empty string. EditableCell (text) pattern from Phase 11.
10. **Disable "Salva" button validation:**
- Rule: at least 1 tier must have ≥1 service checked
- If all tiers unchecked: button disabled with tooltip
- On submit: backend also validates (server-side check)
11. **Category filter on list:** Clicking a chip sets active filter. API: `GET /admin/offers?category=entry` (or all if "Tutti"). Auto-refresh list.
12. **Migration strategy:** Additive only. Do NOT modify existing `offer_macros`, `offer_micros`, `offer_micro_services` — extend with:
- `offer_tier_services` (new junction: tier_id → service_id)
- `offer_macros.is_archived` (boolean, default false)
- Optionally: structured promise fields on `offer_macros` (cliente_ideale, risultato, tempo, pain, metodo)
- Optionally: `services.category` → verify not already used (check Phase 11 usage first)
13. **Dry run before prod:** Test with existing offer data. Verify no data loss. Run audit script to confirm migration integrity.
---
## Requirements Traceability
| Requirement | Phase 12 Coverage |
|-------------|------------------|
| OFFER-11 | Offer editor: checkbox matrix, 3 tiers (A/B/C), live totals ✓ |
| OFFER-15 | 4-dimensional tags (Categoria/Ticket/Tipo/Obiettivo), creatable on-the-fly ✓ |
| OFFER-16 | Manual "Prezzo Pubblico" per tier, independent of service total ✓ |
| OFFER-17 | Structured transformation promise (5 fields) ✓ |
| OFFER-18 | List page filterable by category, archive toggle ✓ |
---
## Deferred Items
- **CSV/Notion import** (OFFER-12): Deferred to future phase per user decision (2026-06-14)
- **Drag-to-reorder services** (optional @dnd-kit): Not required for MVP; can be added in post-phase enhancement
---
*UI-SPEC regenerated: 2026-06-14*
*Design system source: Phase 11 (inherited), no new tokens*
*Scope: Offer Editor (list + detail) with 3-tier checkbox matrix, multi-dimensional tags, manual pricing, transformation promise*