Compare commits

...

26 Commits

Author SHA1 Message Date
simone 56f9fd1d07 docs(phase-12): complete phase execution
Phase 12 (Offer Editor: Tier A/B/C, Tag & Prezzo Pubblico) fully executed
and verified. All 5 plans complete, build gate passed, 5/5 requirements
confirmed in codebase (OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18).

- 12-REVIEW.md: 0 critical, 6 warnings, 4 info (advisory, non-blocking)
- 12-VERIFICATION.md: all requirements PASS
- REQUIREMENTS.md: OFFER-11/15/16/17/18 → Complete
- STATE.md: advanced to Phase 13 as next

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 17:24:19 +02:00
simone 9a74e5ccfa docs(phase-12): update tracking after wave 4
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:31:07 +02:00
simone 791a6e64b1 chore: merge executor worktree (worktree-agent-a44f854f45ac76582) 2026-06-15 10:28:07 +02:00
simone 605dc323b6 chore: merge executor worktree (worktree-agent-a26188c3b00c610ed) 2026-06-15 10:27:55 +02:00
simone a486d27a2b docs(12-04): append self-check results to plan summary 2026-06-15 10:26:58 +02:00
simone ca3712612f docs(12-04): add plan summary for offer list page redesign 2026-06-15 10:26:38 +02:00
simone b7203c3da2 docs(12-05): complete offer editor detail page plan
- Add 12-05-SUMMARY.md documenting OfferEditorClient + edit route
2026-06-15 10:26:24 +02:00
simone 7df4b9c74a feat(12-04): build OfferListClient with category filter, archive toggle, card grid
- Category filter chips ("Tutti" + dynamic categoryOptions), active/inactive
  styling per UI-SPEC (#1A463C / #e5e7eb)
- "Mostra offerte archiviate" toggle (default off), filters via useMemo
- Responsive card grid (1/2/3 cols) linking to /admin/offers/[id]/edit,
  with category badge and "Archiviata" indicator
- "+ Nuova Offerta" inline form calling createOfferMacro via useTransition
  + router.refresh()
- Empty state with "Nessuna offerta" / "Inizia creando la tua prima offerta"
2026-06-15 10:25:15 +02:00
simone 68dc1b605b feat(12-04): rewrite /admin/offers page.tsx as new list page entry point
- Replace legacy macro/micro/offer_services CRUD page with thin server
  component fetching getOfferListCards() + getOfferFieldOptions()
- Delegate rendering to new OfferListClient (Task 2)
2026-06-15 10:25:03 +02:00
simone 8c5c918304 feat(12-05): build offer editor client component
- Add OfferEditorClient: full editor for /admin/offers/[id]/edit
- Categoria/Ticket single-select (OptionSelect) + Tipo/Obiettivo
  multi-select with on-the-fly creation (OptionMultiSelect)
- Services matrix (A/B/C checkboxes) pre-filtered by macro.category,
  with live "Totale Servizi" recalculation via useMemo (OFFER-11)
- Independent manual "Prezzo Pubblico" per tier (OFFER-16)
- 5-field "Promessa di Trasformazione" block via EditableCell (OFFER-17)
- Salva Offerta / Annulla / Archivia actions wired to saveOfferEditor,
  toggleOfferArchived and renameOfferOption (OFFER-15)
2026-06-15 10:25:02 +02:00
simone 8e0e4b9c59 feat(12-05): create offer editor route server component
- Add src/app/admin/offers/[id]/edit/page.tsx as async server component
- Fetches getOfferEditorData(id) + getOfferFieldOptions() via Promise.all
- Calls notFound() when offer id does not exist
- Delegates rendering to OfferEditorClient
2026-06-15 10:24:49 +02:00
simone e46607d217 docs(phase-12): update tracking after wave 3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:19:52 +02:00
simone f01fa347c4 chore: merge executor worktree (worktree-agent-acd37263857560e21) 2026-06-15 10:19:13 +02:00
simone 8bdc854a6a docs(12-03): complete offer editor query layer & server actions plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 10:18:46 +02:00
simone a372c613e2 feat(12-03): add offer editor server actions
- saveOfferEditor(macroId, payload): persists macro scalars, per-tier
  upsert (tier_letter Zod-validated A/B/C), offer_tier_services
  delete-then-reinsert, and Tipo/Obiettivo tags delete-then-reinsert,
  all in one call
- toggleOfferArchived(macroId, archived) for OFFER-18
- addOfferTag/removeOfferTag/renameOfferOption for Tipo/Obiettivo/
  Categoria/Ticket dimensions, mirroring catalog option-pool pattern
- createOfferMacro(formData) — Plan 04 "+ Nuova Offerta" target
- scripts/verify-12-03-actions.ts documents the 9 spec'd test cases
  (typecheck-only, requireAdmin needs a request context)
2026-06-15 10:17:07 +02:00
simone 0d742f2328 feat(12-03): add offer editor query layer
- getOfferListCards() for Plan 04 list page (category + archive status)
- getOfferEditorData(macroId) returns macro fields, A/B/C tiers with
  assignedServiceIds + computed servicesTotal, category-filtered
  availableServices, and tipoTags/obiettivoTags
- getOfferFieldOptions() for categoria/ticket/tipo/obiettivo select pools
- scripts/verify-12-03-queries.ts documents the 5 spec'd test cases
  (typecheck-only, no test runner configured, prod DB not mutated)
2026-06-15 10:15:19 +02:00
simone e2d6e97168 docs(phase-12): update tracking after wave 2 checkpoint
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:50:23 +02:00
simone d4c89b4170 docs(12-02): apply migration 0008 to production (blocking checkpoint complete)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 09:50:23 +02:00
simone b8f14fad4d docs(phase-12): update tracking after wave 1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:22:37 +02:00
simone 827604084e chore: merge executor worktree (worktree-agent-ab7d5b1ee10efadff) 2026-06-14 21:22:10 +02:00
simone b80f0563b8 docs(12-01): fix markdown lint formatting in summary
- Add blank lines around headings and lists per MD022/MD032
2026-06-14 21:21:37 +02:00
simone bfc99329c5 docs(12-01): complete offer tier schema foundation plan
- Add 12-01-SUMMARY.md documenting additive schema extension, migration 0008, and push script
2026-06-14 21:20:28 +02:00
simone 89d15eeb54 feat(12-01): add migration 0008 for offer tier schema + idempotent push script
- Hand-write 0008_offer_tier_schema.sql: additive ALTER TABLE ADD COLUMN IF NOT EXISTS for offer_macros/offer_micros, guarded DO-block CHECK constraint on tier_letter, CREATE TABLE IF NOT EXISTS offer_tier_services + index
- Append journal entry for 0008_offer_tier_schema following the 0006 entry shape
- Add scripts/push-12-offer-tier-schema.ts: idempotent push script with PRODUCTION DEPLOY NOTE — BLOCKING step for Plan 02 (SSH+docker exec) before Wave 3 query layer
2026-06-14 21:19:10 +02:00
simone 11d6c1179f feat(12-01): extend offer schema with tier, pricing, and category fields
- Add additive offer_macros columns: description, category, ticket, is_archived, and structured transformation-promise fields (cliente_ideale/risultato/tempo/pain/metodo)
- Add additive offer_micros columns: tier_letter (A/B/C, CHECK constraint added in migration 0008) and public_price
- Add new offer_tier_services junction table (offer_micros <-> services), relations, and types
- Extend offerMicrosRelations with tierServices; legacy offer_micro_services/offer_services untouched
2026-06-14 21:17:52 +02:00
simone 1414c3e95c docs(12): finalize wave resequencing in plans and tracking
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:15:09 +02:00
simone 640f986967 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.
2026-06-14 21:00:32 +02:00
30 changed files with 6121 additions and 264 deletions
+13 -5
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-09 | L'utente aggiunge un nuovo servizio/offerta tramite riga vuota in fondo alla tabella (nome + invio) | Complete |
| OFFER-10 | L'utente filtra/cerca servizi e offerte istantaneamente (ricerca client-side, nessun reload) | Complete |
| OFFER-11 | L'utente compone un'offerta trascinando servizi dal catalogo (`@dnd-kit`), con totale live aggiornato durante il drag | Pending |
| OFFER-12 | L'utente importa un CSV one-shot per popolare servizi/offerte esistenti (solo seed iniziale, non sync continuo) | Pending |
| OFFER-11 | L'admin compone un'offerta a 3 tier (A/B/C) assegnando i servizi del catalogo unificato `services` tramite matrice di checkbox (servizio × tier); il totale servizi di ogni tier si aggiorna live | Complete |
| OFFER-15 | Ogni offerta ha tag su 4 dimensioni (categoria, ticket, tipo, obiettivo) creabili al volo, riusando il sistema tag polimorfico di Phase 11 | Complete |
| OFFER-16 | Per ogni tier l'admin imposta un "prezzo pubblico" manuale, distinto e indipendente dal totale dei servizi | Complete |
| OFFER-17 | Ogni offerta ha una promessa di trasformazione strutturata (Cliente Ideale / Risultato / tempo / Pain / Metodo) | Complete |
| OFFER-18 | La lista offerte è filtrabile per categoria e supporta l'archiviazione (mostra/nascondi archiviate) | 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)
@@ -57,6 +60,7 @@ v2.0 (Phases 710) ha consegnato catalogo unificato, offerte con fasi, prevent
### Catalogo Avanzato
- **OFFER-12**: Import CSV/Notion one-shot per popolare servizi/offerte esistenti (solo seed iniziale, non sync continuo) — *rimandato il 2026-06-14 su decisione utente ("per ora saltiamo"); rientra nel filone "import dati da Notion" da valutare in una fase futura*
- **OFFER-14**: Sezioni analitiche stile Notion per le offerte (psicologia, rating, performance)
### Accesso Cliente
@@ -91,8 +95,12 @@ Popolata dal roadmapper durante la creazione della roadmap.
| OFFER-09 | Phase 11 | Complete |
| OFFER-10 | Phase 11 | Complete |
| OFFER-13 | Phase 11 | Complete |
| OFFER-11 | Phase 12 | Pending |
| OFFER-12 | Phase 12 | Pending |
| OFFER-11 | Phase 12 | Complete |
| OFFER-15 | Phase 12 | Complete |
| OFFER-16 | Phase 12 | Complete |
| OFFER-17 | Phase 12 | Complete |
| OFFER-18 | Phase 12 | Complete |
| OFFER-12 | Deferred (v2) | Rimandato 2026-06-14 |
| PROJ-06 | Phase 13 | Pending |
| PROJ-07 | Phase 13 | Pending |
| PROJ-08 | Phase 13 | Pending |
@@ -112,4 +120,4 @@ Popolata dal roadmapper durante la creazione della roadmap.
---
*Requirements defined: 2026-06-13*
*Last updated: 2026-06-13 after milestone v2.1 kickoff*
*Last updated: 2026-06-18 — Phase 12 complete: OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18 → Complete*
+27 -14
View File
@@ -9,7 +9,7 @@
## Phases
- [x] **Phase 11: Catalog Database-View UX & Legacy Consolidation** - Catalogo `services` come tabella con inline edit, tag multi-select, quick-add, ricerca istantanea; consolidamento `service_catalog`/`offer_services``services`
- [ ] **Phase 12: Offer Composition Drag&Drop & CSV Import** - Composizione offerte via drag&drop dal catalogo (`@dnd-kit`) con totale live; import CSV one-shot
- [x] **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) (completed 2026-06-15)
- [ ] **Phase 13: Workspace — Servizi Attivi** - Tab "Preventivo" rimossa (UI-only), "Offerte" → "Servizi attivi" (one-shot/ricorrenti + tracking incassi mensili), rimozione pagina Forecast
- [x] **Phase 14: CRM Attio-style & Fix** - Redesign `/admin/leads` stile Attio/Pipedrive (inline edit + tag), fix FollowUpWidget IT / LeadForm types / SendQuoteModal rami irraggiungibili (completed 2026-06-14)
- [ ] **Phase 15: Dashboard Revenue Stats** - Dashboard admin mostra statistiche revenue mensile (ex `/admin/forecast`) da `project_offers`**BLOCCATA**, attesa mockup utente
@@ -38,18 +38,31 @@
**UI hint**: yes
**Status**: ✅ Complete (2026-06-13) — migration 0007 (`services.fase`) applicata in prod, commit pushato su `gitea/main`
### Phase 12: Offer Composition Drag&Drop & CSV Import
**Goal**: L'utente compone un'offerta trascinando servizi dal catalogo con un totale live durante il drag, e può importare un CSV one-shot per popolare servizi/offerte esistenti
**Mode:** ui
**Depends on**: Phase 11 (tabella `services` unificata)
**Requirements**: OFFER-11, OFFER-12
### Phase 12: Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico
**Goal**: L'admin crea e modifica offerte complete da una lista filtrabile: editor con 3 tier (A/B/C) composti via matrice di checkbox dai servizi del catalogo unificato `services`, totale servizi live per tier, prezzo pubblico manuale per tier, tag multi-dimensione (categoria/ticket/tipo/obiettivo) e promessa di trasformazione strutturata. I servizi disponibili nella matrice sono filtrati per categoria offerta (entry/signature/retainer), designazione già impostata sul servizio a livello di catalogo.
**Mode:** ui + schema
**Depends on**: Phase 11 (tabella `services` unificata + sistema tag polimorfico)
**Requirements**: OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18
**Success Criteria** (what must be TRUE):
1. L'utente compone un'offerta trascinando servizi dal catalogo (`@dnd-kit`)
2. Durante il drag, il totale dell'offerta si aggiorna live
3. L'utente importa un CSV one-shot per popolare servizi/offerte esistenti (solo seed iniziale, non sync continuo)
**Plans**: TBD
**UI hint**: yes
**Status**: Pending planning
1. L'admin compone un'offerta a 3 tier (A/B/C) assegnando i servizi del catalogo `services` tramite matrice di checkbox; il totale servizi di ogni tier si aggiorna live
2. Ogni offerta ha tag su 4 dimensioni (categoria, ticket, tipo, obiettivo) creabili al volo, riusando il sistema tag di Phase 11
3. Per ogni tier l'admin imposta un "prezzo pubblico" manuale, distinto e indipendente dal totale dei servizi
4. Ogni offerta ha una promessa di trasformazione strutturata (Cliente Ideale / Risultato / tempo / Pain / Metodo)
5. La lista offerte è filtrabile per categoria e supporta l'archiviazione (mostra/nascondi archiviate)
**Note**: Import CSV/Notion (ex OFFER-12) spostato a fase futura su decisione utente (2026-06-14, "per ora saltiamo") — vedi REQUIREMENTS.md
**Plans**: 5 plans
**Plan list**:
**Wave 1**
- [x] 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)*
- [x] 12-02-PLAN.md — BLOCKING checkpoint: apply migration 0008 to production database (SSH+docker exec, manual)
**Wave 3** *(blocked on Wave 2 — prod migration applied before schema-dependent code)*
- [x] 12-03-PLAN.md — Query layer (getOfferEditorData/getOfferListCards/getOfferFieldOptions) + server actions (saveOfferEditor/toggleOfferArchived/tag CRUD/createOfferMacro)
**Wave 4** *(blocked on Wave 3 completion)*
- [x] 12-04-PLAN.md — Offer list page redesign: category filter chips, archive toggle, card grid, "+ Nuova Offerta" CTA
- [x] 12-05-PLAN.md — Offer editor detail page: tags (4 dimensions), services matrix (tier A/B/C checkboxes + live totals), prezzo pubblico, promessa di trasformazione, Salva/Annulla/Archivia
**UI hint**: yes — riferimento mockup utente 2026-06-14 (lista card per categoria + editor con matrice servizi×tier)
**Status**: Planned (2026-06-14) — ready for `/gsd-execute-phase 12`
### Phase 13: Workspace — Servizi Attivi
**Goal**: Il workspace progetto sostituisce le tab "Preventivo"/"Offerte"/"Forecast" con un'unica tab "Servizi attivi" che traccia servizi one-shot e ricorrenti con incassi mensili, senza perdere `accepted_total`/`quote_items`
@@ -77,7 +90,7 @@
3. Il `FollowUpWidget` è interamente in italiano, coerente col resto dell'app
4. Il form lead (`LeadForm`) valida i campi senza workaround di type-relaxation su react-hook-form
5. Il flusso `SendQuoteModal` (preventivo esistente vs nuovo) non contiene rami di codice irraggiungibili
**Plans:** 3/3 plans complete
**Plans:** 5/5 plans complete
Plans:
**Wave 1**
- [x] 14-01-PLAN.md — Data-layer foundation: getLeadsWithTags/getLeadFieldOptions query helpers + updateLeadField/addLeadTag/removeLeadTag/renameLeadTag server actions (requireAdmin-guarded)
@@ -129,7 +142,7 @@ Plans:
| Phase | Plans | Status | Completed |
|-------|-------|--------|-----------|
| 11. Catalog Database-View UX & Legacy Consolidation | 4/4 | ✅ Done | 2026-06-13 |
| 12. Offer Composition Drag&Drop & CSV Import | — | Pending planning | — |
| 12. Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico | 5/5 | Complete | 2026-06-18 |
| 13. Workspace — Servizi Attivi | — | Pending planning | — |
| 14. CRM Attio-style & Fix | 3/3 | Complete | 2026-06-14 |
| 15. Dashboard Revenue Stats | — | Blocked (mockup utente) | — |
+15 -15
View File
@@ -2,15 +2,15 @@
gsd_state_version: 1.0
milestone: v2.1
milestone_name: milestone
status: executing
stopped_at: Phase 14 complete (5/5 success criteria verified); Phase 15 blocked on user mockup
last_updated: "2026-06-14T13:20:53.997Z"
last_activity: 2026-06-14 -- Phase 14 (crm-attio-style-fix) execution complete
status: completed
stopped_at: "Phase 12 complete — Offer Editor (Tier A/B/C, Tag & Prezzo Pubblico) eseguita e verificata. Prossima: Phase 13 (Workspace Servizi Attivi) o Phase 14 (CRM)."
last_updated: "2026-06-18T15:23:00.000Z"
last_activity: 2026-06-18 -- Phase 12 execution complete (5/5 piani, build gate passed, OFFER-11/15/16/17/18 verified)
progress:
total_phases: 7
completed_phases: 2
total_plans: 7
completed_plans: 7
completed_phases: 3
total_plans: 12
completed_plans: 12
percent: 100
---
@@ -22,14 +22,14 @@ See: .planning/PROJECT.md (updated 2026-06-13)
**Core value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
**Current focus:** Phase 15 — dashboard-revenue-stats (blocked on user mockup; Phase 14 complete)
**Current focus:** Phase 13 (Workspace Servizi Attivi) o Phase 14 (CRM Attio-style) — entrambe eseguibili. Phase 15 resta bloccata su mockup utente. Phase 12 (Offer Editor) completata e verificata.
## Current Position
Phase: 15
Plan: Not started
Status: Blocked — Phase 15 awaiting user mockup (Phase 14 complete, see ROADMAP)
Last activity: 2026-06-14 -- Phase 14 (crm-attio-style-fix) execution complete
Phase: 13 (next) — ready to plan/execute
Plan:
Status: Phase 12 complete. Pronto per /gsd-execute-phase 13 o /gsd-execute-phase 14.
Last activity: 2026-06-18 -- Phase 12 execution complete (OFFER-11/15/16/17/18 all verified PASS)
Progress: [██████████] 100%
@@ -100,6 +100,6 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
Last session: 2026-06-14T13:20:53.997Z
Stopped at: Phase 14 complete (5/5 success criteria verified, code review 0 critical/6 warnings/3 info); Phase 15 blocked on user mockup
Resume file: .planning/phases/14-crm-attio-style-fix/14-VERIFICATION.md
Last session: 2026-06-14T19:04:45.211Z
Stopped at: Phase 12 re-scoped (mockup utente → editor offerte) e pianificata: 5 piani / 4 wave, planner + plan-checker pass. Pronta per /gsd-execute-phase 12
Resume file: .planning/phases/12-offer-composition-drag-drop-csv-import/12-01-PLAN.md
@@ -0,0 +1,401 @@
---
phase: 12
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/db/schema.ts
- src/db/migrations/0008_offer_tier_schema.sql
- src/db/migrations/meta/_journal.json
- scripts/push-12-offer-tier-schema.ts
autonomous: true
requirements: [OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18]
must_haves:
truths:
- "The Drizzle schema exposes tier_letter and public_price on offer_micros, and category, ticket, is_archived + 5 transformation-promise fields on offer_macros"
- "A new offer_tier_services junction table exists in the schema, referencing offer_micros and services (NOT the legacy offer_services)"
- "A hand-written, idempotent, additive-only SQL migration file exists that can be applied to prod via the existing push-script convention"
artifacts:
- path: "src/db/schema.ts"
provides: "offer_macros additive columns (description, category, ticket, is_archived, cliente_ideale, risultato, tempo, pain, metodo), offer_micros additive columns (tier_letter, public_price), new offer_tier_services table + relations + types"
contains: "offer_tier_services"
- path: "src/db/migrations/0008_offer_tier_schema.sql"
provides: "Additive ALTER TABLE + CREATE TABLE statements, IF NOT EXISTS / guarded DO block, no DROP/TRUNCATE"
contains: "CREATE TABLE IF NOT EXISTS offer_tier_services"
- path: "scripts/push-12-offer-tier-schema.ts"
provides: "Idempotent script to apply migration 0008 to prod via DATABASE_URL (SSH+docker exec target)"
exports: ["push"]
key_links:
- from: "src/db/schema.ts (offer_tier_services)"
to: "services.id / offer_micros.id"
via: "foreign key references with onDelete cascade"
pattern: "references\\(\\(\\) => (services|offer_micros)\\.id"
---
<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,128 @@
---
phase: 12-offer-composition-drag-drop-csv-import
plan: 01
subsystem: database
tags: [drizzle, postgres, schema, migration]
# Dependency graph
requires:
- phase: 11-catalog-database-view-ux
provides: unified `services` catalog table (category/fase single-select pattern, migrated_from audit trail)
provides:
- Additive Drizzle schema columns on offer_macros (description, category, ticket, is_archived, cliente_ideale, risultato, tempo, pain, metodo)
- Additive Drizzle schema columns on offer_micros (tier_letter, public_price)
- New offer_tier_services junction table + relations + types (offer_micros <-> services)
- Hand-written migration 0008_offer_tier_schema.sql (additive/idempotent, CHECK constraint on tier_letter)
- Idempotent push script scripts/push-12-offer-tier-schema.ts for Plan 02's BLOCKING prod-apply step
affects: [12-02-prod-migration-apply, 12-03-offer-list-editor, 12-04-tier-matrix, 12-05-offer-detail-actions]
# Tech tracking
tech-stack:
added: []
patterns:
- "Additive-only schema extension via hand-written SQL migration (drizzle-kit generate non-functional in this repo)"
- "Single-select dimension columns (category/ticket) on offer_macros, mirroring services.category/fase pattern from Phase 11"
- "New junction table (offer_tier_services) as additive replacement for legacy junction (offer_micro_services), pointing at unified services catalog instead of deprecated offer_services"
- "DB-level CHECK constraint via guarded DO block for idempotent re-runs, with Zod validation deferred to server-action layer (Plan 03)"
key-files:
created:
- src/db/migrations/0008_offer_tier_schema.sql
- scripts/push-12-offer-tier-schema.ts
modified:
- src/db/schema.ts
- src/db/migrations/meta/_journal.json
key-decisions:
- "tier_letter constrained via SQL CHECK (guarded DO block) rather than Drizzle enum, since this pg-core version has no native CHECK helper — Zod enum validation is deferred to Plan 03's server-action layer as defense-in-depth"
- "offer_tier_services created as a brand-new junction (not a modification of offer_micro_services) to avoid touching legacy offer_services-linked data, per D-6"
patterns-established:
- "Phase 12 additive columns documented inline with comment blocks above offer_macros/offer_micros, following the services.migrated_from comment style from Phase 11"
requirements-completed: [OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18]
# Metrics
duration: 6min
completed: 2026-06-14
---
# Phase 12 Plan 01: Offer Tier Schema Foundation Summary
**Additive Drizzle schema + hand-written migration 0008 adding tier designations (A/B/C), manual public pricing, archive flag, category/ticket dimensions, structured transformation-promise fields, and a new offer_tier_services junction table pointing at the unified services catalog.**
## Performance
- **Duration:** 6 min
- **Started:** 2026-06-14T19:13:00Z (approx)
- **Completed:** 2026-06-14T19:19:38Z
- **Tasks:** 2 completed
- **Files modified:** 4
## Accomplishments
- Extended `offer_macros` with 9 additive columns: `description`, `category`, `ticket`, `is_archived`, and 5 structured transformation-promise fields (`cliente_ideale`, `risultato`, `tempo`, `pain`, `metodo`)
- Extended `offer_micros` with `tier_letter` (A/B/C, CHECK-constrained at SQL level) and `public_price` (numeric)
- Added new `offer_tier_services` junction table (tier <-> unified `services` catalog), its relations, and `OfferTierService`/`NewOfferTierService` types — without touching the legacy `offer_micro_services`/`offer_services` tables
- Hand-wrote idempotent, additive-only migration `0008_offer_tier_schema.sql` (11 `ADD COLUMN IF NOT EXISTS` statements, guarded DO-block CHECK constraint, `CREATE TABLE IF NOT EXISTS offer_tier_services` + index), appended journal entry
- Created `scripts/push-12-offer-tier-schema.ts` — idempotent push script with PRODUCTION DEPLOY NOTE documenting the BLOCKING manual prod-apply step required before Plan 02/Wave 3
## Task Commits
Each task was committed atomically:
1. **Task 1: Extend offer_macros and offer_micros, add offer_tier_services junction in schema.ts** - `11d6c11` (feat)
2. **Task 2: Hand-write migration 0008 SQL + journal entry + idempotent push script** - `89d15ee` (feat)
**Plan metadata:** (this commit, follows)
## Files Created/Modified
- `src/db/schema.ts` - Additive columns on offer_macros/offer_micros, new offer_tier_services table + relations + types
- `src/db/migrations/0008_offer_tier_schema.sql` - Hand-written additive/idempotent migration (ALTER TABLE ADD COLUMN IF NOT EXISTS, guarded CHECK constraint, CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS)
- `src/db/migrations/meta/_journal.json` - Appended entry for `0008_offer_tier_schema` (idx 8, following 0006 entry shape)
- `scripts/push-12-offer-tier-schema.ts` - Idempotent push script for Plan 02's BLOCKING production-apply step
## Decisions Made
- `tier_letter` validated via DB-level CHECK constraint (guarded DO block, idempotent) rather than a Drizzle-native enum/CHECK helper (unavailable in this pg-core version); Zod enum validation will be added at the server-action layer in Plan 03 as defense-in-depth (per plan's T-12-02 mitigation)
- `offer_tier_services` is a brand-new additive junction table (not a modification of `offer_micro_services`), pointing at the unified `services` catalog (Phase 11) rather than the deprecated `offer_services` table, per D-6/D-3
## Deviations from Plan
None - plan executed exactly as written. The acceptance-criteria grep counts for `tier_letter`/`public_price`, the 6 transformation/archive columns, and the DROP/TRUNCATE/RENAME check show slightly higher raw match counts than the plan's exact numbers because explanatory comment blocks (explicitly requested by the plan, e.g. "Add a comment block directly above offer_macros documenting...") also contain those words (e.g. a comment mentions "tier_letter" when describing the CHECK constraint, and the migration's header comment says "No drops/truncates"). All actual code definitions are present exactly once, the diff to `src/db/schema.ts` is purely additive (`git diff HEAD~2 -- src/db/schema.ts | grep '^-'` returns empty), and a line-anchored grep (`grep -inE "^\s*(DROP|TRUNCATE|RENAME)\b"`) confirms zero actual destructive SQL statements in the migration.
## Issues Encountered
A `cd /Users/simonecavalli/Vault/IAMCAVALLI` in one intermediate Bash call caused a transient cwd drift into the main repo's worktree (sibling path with the same relative structure), which produced false "0 matches" results for one verification pass. Re-ran all verification from the correct worktree root (`/Users/simonecavalli/Vault/IAMCAVALLI/.claude/worktrees/agent-ab7d5b1ee10efadff`) with confirmed correct results before committing. No files were affected — all Edit/Write operations used relative paths inside the worktree.
## User Setup Required
None - no external service configuration required. Note: migration 0008 and the push script are created here only; per the plan and project memory, the actual production apply (SSH+docker exec) is Plan 02's BLOCKING step and is NOT performed in this plan.
## Next Phase Readiness
- `src/db/schema.ts` compiles cleanly (`npx tsc --noEmit` passes, zero errors) and exposes all Phase 12 additive columns/table/types needed by Plan 03's query layer and server actions
- Migration 0008 + push script are ready for Plan 02 to apply to production via SSH+docker exec before Wave 3 code reads `offer_tier_services`/`tier_letter`/`public_price`/`category`/`ticket`
- No existing table/column was dropped, renamed, or altered destructively — `clients`/`projects`/`payments`/`phases` and legacy offer tables (`offer_micro_services`, `offer_services`) remain byte-identical
---
*Phase: 12-offer-composition-drag-drop-csv-import*
*Completed: 2026-06-14*
## Self-Check: PASSED
All created/modified files verified present on disk:
- FOUND: src/db/schema.ts
- FOUND: src/db/migrations/0008_offer_tier_schema.sql
- FOUND: src/db/migrations/meta/_journal.json
- FOUND: scripts/push-12-offer-tier-schema.ts
- FOUND: .planning/phases/12-offer-composition-drag-drop-csv-import/12-01-SUMMARY.md
All task commits verified present in git log:
- FOUND: 11d6c11 (Task 1)
- FOUND: 89d15ee (Task 2)
- FOUND: bfc9932 (docs: complete plan)
@@ -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,43 @@
---
phase: 12
plan: 02
type: execute
status: complete
wave: 2
completed: 2026-06-15
---
# 12-02 SUMMARY — Apply migration 0008 to production (BLOCKING checkpoint)
## What was done
Migration `0008_offer_tier_schema.sql` (created in Plan 01) was applied to the
**production** database via the idempotent push script, run from the local
environment through an operator-opened SSH tunnel (local `127.0.0.1:54321`
production host Postgres `localhost:54321`).
Command used (credentials read from `.env.local`, host/port rewritten to the
tunnel endpoint via `URL()` — never printed):
```bash
DATABASE_URL=$(node --env-file=.env.local -e 'const u=new URL(process.env.DATABASE_URL); u.host="127.0.0.1:54321"; process.stdout.write(u.toString())') \
npx tsx scripts/push-12-offer-tier-schema.ts
```
Script output: all 14 statements reported `ready``✓ Migration 0008 (offer tier schema) applied successfully`.
## Verification (against live prod)
- `offer_macros` gained: `description, category, ticket, is_archived, cliente_ideale, risultato, tempo, pain, metodo`
- `offer_micros` gained: `tier_letter`, `public_price`
- Constraint `offer_micros_tier_letter_check` present ✓
- Table `offer_tier_services` exists with columns `tier_id, service_id, created_at`
- Row counts unchanged (additive, no data loss): `offer_macros=1, offer_micros=1, clients=4, projects=5, payments=13, phases=6`
## Issues Encountered
- First attempts failed: direct `178.104.27.55:54321``CONNECT_TIMEOUT` (firewalled, as in Phase 11); then through the tunnel `ECONNRESET` on all SSL variants. Root cause: the SSH `-L` forward initially targeted the wrong remote port; once re-opened pointing at the prod host's published Postgres port (`localhost:54321`), `SELECT 1` succeeded and the migration applied cleanly.
## Self-Check: PASSED
Migration 0008 is live on production; all new columns/constraint/table queryable; no destructive changes; Waves 34 schema-dependent code is now unblocked.
@@ -0,0 +1,477 @@
---
phase: 12
plan: 03
type: execute
wave: 3
depends_on: [1, 2]
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,129 @@
---
phase: 12-offer-composition-drag-drop-csv-import
plan: 03
subsystem: api
tags: [drizzle, postgres, zod, server-actions, next.js]
# Dependency graph
requires:
- phase: 12-offer-composition-drag-drop-csv-import (Plan 01)
provides: Additive offer_macros/offer_micros columns (category, ticket, is_archived, transformation-promise fields, tier_letter, public_price) and offer_tier_services junction table, applied to prod via Plan 02
provides:
- getOfferEditorData(macroId) — full editor data shape (macro fields, A/B/C tiers with assignedServiceIds + computed servicesTotal, category-filtered availableServices, tipoTags/obiettivoTags)
- getOfferListCards() — list-page cards with category + archive status
- getOfferFieldOptions() — categoria/ticket/tipo/obiettivo select pools
- saveOfferEditor(macroId, payload) — single-call persistence of macro scalars, per-tier upsert + offer_tier_services replace, and Tipo/Obiettivo tag replace
- toggleOfferArchived, addOfferTag, removeOfferTag, renameOfferOption, createOfferMacro server actions
affects: [12-04-offer-list-page, 12-05-offer-editor-page]
# Tech tracking
tech-stack:
added: []
patterns:
- "Polymorphic tags table reused for offer_macros Tipo/Obiettivo dimensions via entity_type = 'offer_macros.tipo' | 'offer_macros.obiettivo', mirroring the Phase 11 services/leads D-06 pattern"
- "Computed servicesTotal via coalesce(sum(unit_price::numeric), 0) joined through offer_tier_services -> services, mirroring getCatalogWithMicros's cumulMap pattern"
- "Delete-then-reinsert for many-to-many replacement (offer_tier_services per tier, Tipo/Obiettivo tags per macro), mirroring updateMicroOfferServices"
- "Zod enum tier_letter validation (A/B/C) as defense-in-depth alongside the Plan 01 DB CHECK constraint"
key-files:
created:
- scripts/verify-12-03-queries.ts
- scripts/verify-12-03-actions.ts
modified:
- src/lib/offer-queries.ts
- src/app/admin/offers/actions.ts
key-decisions:
- "No test runner configured (no scripts.test in package.json) — wrote typecheck-only verification scripts documenting the 14 spec'd test cases (5 query + 9 action), per plan's explicit fallback; not executed against prod DB"
- "saveOfferEditor uses sequential awaits (no transaction wrapper) per macro update -> tier upserts -> tag replace, matching the existing per-statement pattern in this codebase (no interactive tx helper elsewhere)"
patterns-established:
- "OFFER_TIPO_ENTITY/OFFER_OBIETTIVO_ENTITY constants ('offer_macros.tipo'/'offer_macros.obiettivo') as the canonical entity_type values for offer-level tag dimensions — reuse in Plans 04/05 UI"
requirements-completed: [OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18]
# Metrics
duration: 12min
completed: 2026-06-15
---
# Phase 12 Plan 03: Offer Editor Query Layer & Server Actions Summary
**Query layer (`getOfferEditorData`, `getOfferListCards`, `getOfferFieldOptions`) and server actions (`saveOfferEditor` + archive/tag/rename/create actions) giving Plans 04/05 a complete, type-safe, admin-only data contract for the offer editor.**
## Performance
- **Duration:** 12 min
- **Started:** 2026-06-15T08:15:00Z (approx)
- **Completed:** 2026-06-15T08:17:07Z
- **Tasks:** 2 completed
- **Files modified:** 4 (2 source, 2 new verification scripts)
## Accomplishments
- Added `getOfferListCards()` to `src/lib/offer-queries.ts`: returns `{ id, internal_name, description, category, is_archived }` per `offer_macros` row, ordered by `sort_order`, including archived offers (client-side filter per UI-SPEC)
- Added `getOfferEditorData(macroId)`: returns `{ macro, tiers, availableServices, tipoTags, obiettivoTags }` — tiers ordered A→B→C via SQL `CASE` expression, each with `assignedServiceIds` and a computed `servicesTotal` (`coalesce(sum(unit_price::numeric), 0)` joined through `offer_tier_services`), available services pre-filtered by `services.category === macro.category` (D-4, best-effort when category unset)
- Added `getOfferFieldOptions()`: `{ categoria, ticket, tipo, obiettivo }` distinct-value pools, mirroring `getCatalogFieldOptions`'s null-filter + alphabetical sort pattern
- Added `saveOfferEditor(macroId, payload)` to `src/app/admin/offers/actions.ts`: Zod-validates the full editor payload (`tier_letter` restricted to `["A","B","C"]`), updates `offer_macros` scalars, upserts each tier (update if `id` present, insert + `.returning({id})` if not), delete-then-reinserts `offer_tier_services` per tier, and delete-then-reinserts Tipo/Obiettivo `tags`
- Added `toggleOfferArchived`, `addOfferTag`/`removeOfferTag` (tipo/obiettivo dimensions), `renameOfferOption` (categoria/ticket/tipo/obiettivo), and `createOfferMacro` (Plan 04 "+ Nuova Offerta" target, defaults `public_name` to `internal_name`)
- All new actions call `requireAdmin()` first; existing exports in both files left untouched
## Task Commits
Each task was committed atomically:
1. **Task 1: Query layer — getOfferEditorData, getOfferListCards, getOfferFieldOptions** - `0d742f2` (feat)
2. **Task 2: Server actions — saveOfferEditor, toggleOfferArchived, offer tag CRUD, createOfferMacro** - `a372c61` (feat)
**Plan metadata:** (this commit, follows)
## Files Created/Modified
- `src/lib/offer-queries.ts` - Added `getOfferListCards`, `getOfferEditorData`, `getOfferFieldOptions` + `OfferListCard`/`OfferTierData`/`OfferEditorData`/`OfferFieldOptions` types; imports `offer_tier_services`, `services`, `tags`, `and` from drizzle-orm
- `src/app/admin/offers/actions.ts` - Added `saveOfferEditor`, `toggleOfferArchived`, `addOfferTag`, `removeOfferTag`, `renameOfferOption`, `createOfferMacro` + `SaveOfferEditorPayload`/`tierSchema`/`saveOfferEditorSchema` types; imports `offer_tier_services`, `tags`, `and`, `inArray`
- `scripts/verify-12-03-queries.ts` - Typecheck-only documentation of the 5 query-layer test cases (Tests 1-5 from Task 1's `<behavior>`)
- `scripts/verify-12-03-actions.ts` - Typecheck-only documentation of the 9 server-action test cases (Tests 1-9 from Task 2's `<behavior>`)
## Decisions Made
- No test runner configured in this repo (`package.json` has no `scripts.test`), and `.env.local`'s `DATABASE_URL` points at production per project memory — followed the plan's explicit fallback and wrote `scripts/verify-12-03-queries.ts` / `scripts/verify-12-03-actions.ts` as typecheck-only documentation of all 14 spec'd test cases, not executed against the live DB
- `saveOfferEditor` uses sequential `await`s (macro update → per-tier upsert/replace → tag replace) with no transaction wrapper, matching the existing per-statement pattern used by `updateMicroOfferServices` and the rest of this codebase
## Deviations from Plan
None - plan executed exactly as written. Both tasks implemented the documented function signatures, return shapes, and validation rules verbatim; existing exports in `offer-queries.ts` and `actions.ts` were not modified.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required. This plan is pure source-code (query/action functions); migration 0008 was already live on production (verified per Plan 02), and no data-mutating scripts were run.
## Next Phase Readiness
- Plan 04 (offer list page) can call `getOfferListCards()` and `createOfferMacro` for the "+ Nuova Offerta" button
- Plan 05 (offer editor page) can call `getOfferEditorData(macroId)` to populate the full editor (macro fields, tier matrix with live totals, Tipo/Obiettivo tags, category-filtered service catalog) and persist all of it via a single `saveOfferEditor(macroId, payload)` call; archive toggling via `toggleOfferArchived`; tag/option CRUD via `addOfferTag`/`removeOfferTag`/`renameOfferOption`
- `npx tsc --noEmit` passes with zero errors across the full project
- No legacy `offer_micro_services`/`offer_services`/existing-export code paths were touched
---
*Phase: 12-offer-composition-drag-drop-csv-import*
*Completed: 2026-06-15*
## Self-Check: PASSED
All created/modified files verified present on disk:
- FOUND: src/lib/offer-queries.ts
- FOUND: src/app/admin/offers/actions.ts
- FOUND: scripts/verify-12-03-queries.ts
- FOUND: scripts/verify-12-03-actions.ts
- FOUND: .planning/phases/12-offer-composition-drag-drop-csv-import/12-03-SUMMARY.md
All task commits verified present in git log:
- FOUND: 0d742f2 (Task 1)
- FOUND: a372c61 (Task 2)
@@ -0,0 +1,284 @@
---
phase: 12
plan: 04
type: execute
wave: 4
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,121 @@
---
phase: 12-offer-composition-drag-drop-csv-import
plan: 04
subsystem: ui
tags: [next.js, react, tailwind, shadcn]
# Dependency graph
requires:
- phase: 12-offer-composition-drag-drop-csv-import (Plan 03)
provides: getOfferListCards(), getOfferFieldOptions(), createOfferMacro server action
provides:
- "/admin/offers list page rewritten as category-filterable, archive-aware offer card grid (UI-SPEC section 1)"
- "OfferListClient component: category filter chips, archive toggle, '+ Nuova Offerta' inline create form, card grid linking to /admin/offers/[id]/edit"
affects: [12-05-offer-editor-page]
# Tech tracking
tech-stack:
added: []
patterns:
- "Thin server-component list page (Promise.all fetch -> client component), mirroring src/app/admin/catalog/page.tsx"
- "useTransition + router.refresh() for FormData server-action create flow without returning the new row's id"
key-files:
created:
- src/components/admin/offers/OfferListClient.tsx
modified:
- src/app/admin/offers/page.tsx
key-decisions:
- "Legacy macro/micro/offer_services CRUD UI removed from /admin/offers entirely per D-1 and UI-SPEC section 1; underlying actions.ts exports (createMacro, createMicro, createOfferService, deleteMacro, deleteMicro, updateMicroOfferServices) left untouched and unreferenced — zero-risk dead exports, confirmed via grep no other usages exist"
- "Card grid links to /admin/offers/${id}/edit (Plan 05's route) — link target correct even though Plan 05 may not have landed yet at verification time"
patterns-established:
- "'+ Nuova Offerta' inline create-form pattern: toggle visibility, startTransition(createOfferMacro(formData)), then router.refresh() — admin clicks the new card to open the editor (no auto-navigation, no id returned from the server action)"
requirements-completed: [OFFER-18]
# Metrics
duration: 10min
completed: 2026-06-15
---
# Phase 12 Plan 04: Offer List Page Redesign Summary
**Rewrote `/admin/offers` from a legacy flat macro/micro/offer_services CRUD page into a category-filterable, archive-aware offer card grid with a "+ Nuova Offerta" inline creation flow, per UI-SPEC section 1.**
## Performance
- **Duration:** 10 min
- **Started:** 2026-06-15T08:15:00Z (approx)
- **Completed:** 2026-06-15T08:25:30Z
- **Tasks:** 2 completed
- **Files modified:** 2 (1 rewritten, 1 created)
## Accomplishments
- `src/app/admin/offers/page.tsx` rewritten as a thin server component: `Promise.all([getOfferListCards(), getOfferFieldOptions()])` feeding `OfferListClient`, matching the `catalog/page.tsx` pattern (239 lines of legacy macro/micro/offer_services CRUD forms removed)
- New `src/components/admin/offers/OfferListClient.tsx`:
- Category filter chip row ("Tutti" + dynamic `categoryOptions`), active state `#1A463C` border/text, inactive `#e5e7eb`/`#71717a`
- "Mostra offerte archiviate" checkbox toggle (default off), filters via `useMemo` on `activeCategory` + `showArchived`
- Responsive card grid (1/2/3 columns) — each card is a `Link` to `/admin/offers/${card.id}/edit`, showing `internal_name`, `description` (line-clamp-2), category `Badge`, and red "Archiviata" text for archived offers
- Empty state: "Nessuna offerta" / "Inizia creando la tua prima offerta" + "+ Nuova Offerta" CTA
- "+ Nuova Offerta" header CTA toggles an inline form (`Input name="internal_name"` + "Crea" button); on submit calls `createOfferMacro` via `startTransition`, then `router.refresh()` — new card appears in the grid
- All literal copy strings verbatim from UI-SPEC copywriting contract table (Italian)
## Task Commits
Each task was committed atomically:
1. **Task 1: Rewrite /admin/offers page.tsx as the new list page entry point** - `68dc1b6` (feat)
2. **Task 2: Build OfferListClient — category filter, archive toggle, card grid, create CTA** - `7df4b9c` (feat)
**Plan metadata:** (this commit, follows)
## Files Created/Modified
- `src/app/admin/offers/page.tsx` - Thin server component: fetches `getOfferListCards()` + `getOfferFieldOptions()`, renders `OfferListClient`
- `src/components/admin/offers/OfferListClient.tsx` - Client component: category filter chips, archive toggle, responsive card grid, "+ Nuova Offerta" inline create form using `createOfferMacro`
## Decisions Made
- Removed all legacy macro/micro/offer_services CRUD UI from `/admin/offers` per D-1 and UI-SPEC section 1; confirmed via `grep -rn` that no other file in `src/app`/`src/components` references `createMacro`, `createMicro`, `createOfferService`, `deleteMacro`, `deleteMicro`, `toggleOfferServiceActive`, `getCatalogWithMicros`, `getAllOfferServices`, or `ServiceCheckboxList` outside the old page — these remain exported from `actions.ts`/`offer-queries.ts` as zero-risk dead code (per plan's explicit instruction not to remove them)
- Used shadcn `Button`, `Badge`, and `Input` components for CTA, category badge, and create-form text input respectively, matching the project's existing component library usage in `CatalogSearch.tsx`
## Deviations from Plan
None - plan executed exactly as written. Both tasks implemented the documented component structure, state shape, filter logic, and copy strings verbatim.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required. Pure UI change consuming Plan 03's existing query/action exports.
## Next Phase Readiness
- `/admin/offers` now renders the new card-grid list per UI-SPEC section 1; OFFER-18 satisfied (category filter + archive toggle, default hides archived)
- Each card links to `/admin/offers/${id}/edit` — Plan 05 builds this route in a parallel worktree; link target is correct regardless of Plan 05's landing order
- `npx tsc --noEmit` passes with zero errors across the full project; `npx eslint` clean for both modified/created files
- No legacy macro/micro/offer_services CRUD routes remain; underlying actions/queries left as unreferenced exports for future cleanup (out of this plan's scope)
---
*Phase: 12-offer-composition-drag-drop-csv-import*
*Completed: 2026-06-15*
## Self-Check: PASSED
All created/modified files verified present on disk:
- FOUND: src/app/admin/offers/page.tsx
- FOUND: src/components/admin/offers/OfferListClient.tsx
- FOUND: .planning/phases/12-offer-composition-drag-drop-csv-import/12-04-SUMMARY.md
All task commits verified present in git log:
- FOUND: 68dc1b6 (Task 1)
- FOUND: 7df4b9c (Task 2)
- FOUND: ca37126 (docs: plan summary)
@@ -0,0 +1,463 @@
---
phase: 12
plan: 05
type: execute
wave: 4
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,120 @@
---
phase: 12-offer-composition-drag-drop-csv-import
plan: 05
subsystem: ui
tags: [next.js, react, tailwind, server-actions, drizzle]
# Dependency graph
requires:
- phase: 12-offer-composition-drag-drop-csv-import (Plan 03)
provides: getOfferEditorData/getOfferFieldOptions query layer + saveOfferEditor/toggleOfferArchived/renameOfferOption/addOfferTag/removeOfferTag server actions
provides:
- "/admin/offers/[id]/edit route: full offer editor page (server component + client component)"
- OfferEditorClient — editable name, Categoria/Ticket/Tipo/Obiettivo tags, services x tier (A/B/C) checkbox matrix with live totals, independent Prezzo Pubblico, Promessa di Trasformazione block, Salva/Annulla/Archivia actions
affects: [12-04-offer-list-page]
# Tech tracking
tech-stack:
added: []
patterns:
- "Tier padding to exactly 3 (A/B/C) via padTiers() so the matrix always renders 3 columns even for offers with <3 persisted tiers"
- "Client-side useMemo for category-filtered services + live per-tier Totale Servizi (OFFER-11), zero network calls on checkbox toggle"
- "Batched save: all macro/tier/tag edits held in local state, persisted in one saveOfferEditor() call on 'Salva Offerta' (no per-keystroke server calls for tags)"
key-files:
created:
- src/app/admin/offers/[id]/edit/page.tsx
- src/components/admin/offers/OfferEditorClient.tsx
modified: []
key-decisions:
- "EditableCell.onSave is synchronous (value: string) => void in this codebase (not Promise-returning as the plan's interface sketch suggested) — all macro-field onSave handlers call setMacro synchronously, consistent with the actual component signature"
- "renameOfferOption calls (Categoria/Ticket/Tipo/Obiettivo rename) are fired via startTransition with optimistic local-state updates to options/tags, matching ServiceTable's existing rename pattern from Phase 11"
patterns-established: []
requirements-completed: [OFFER-11, OFFER-15, OFFER-16, OFFER-17]
# Metrics
duration: 18min
completed: 2026-06-15
---
# Phase 12 Plan 05: Offer Editor Detail Page Summary
**Full-page Offer Editor at `/admin/offers/[id]/edit`: 4-dimension tags, category-filtered A/B/C services checkbox matrix with live "Totale Servizi", independent "Prezzo Pubblico" per tier, 5-field transformation promise, and Salva/Annulla/Archivia actions.**
## Performance
- **Duration:** 18 min
- **Started:** 2026-06-15T08:07:00Z (approx)
- **Completed:** 2026-06-15T08:25:22Z
- **Tasks:** 2 completed
- **Files modified:** 2 (both new)
## Accomplishments
- Created `src/app/admin/offers/[id]/edit/page.tsx`: async server component using Next.js 16's `Promise<{ id: string }>` params pattern, fetches `getOfferEditorData(id)` + `getOfferFieldOptions()` via `Promise.all`, calls `notFound()` for unknown ids, delegates to `OfferEditorClient`
- Created `src/components/admin/offers/OfferEditorClient.tsx`: client component covering the full UI-SPEC section 2 contract:
- Header with "← Indietro" link, "Modifica Offerta" heading, editable offer name via `EditableCell`
- "Tag" section: Categoria/Ticket via `OptionSelect` (single-select, with rename), Tipo/Obiettivo via `OptionMultiSelect` (multi-select, creatable on the fly via "+ Crea Tipo"/"+ Crea Obiettivo")
- "Servizi Inclusi" matrix: services filtered by `macro.category` (live-recomputed via `useMemo` when Categoria changes), one checkbox column per tier (A/B/C), "Totale Servizi" row recalculated instantly client-side on every toggle (OFFER-11), independent "Prezzo Pubblico" numeric input per tier (OFFER-16, D-5)
- "Promessa di Trasformazione" block: 5 `EditableCell` fields (Aiuto/A ottenere/In/Senza/Grazie a) mapped to `cliente_ideale`/`risultato`/`tempo`/`pain`/`metodo` (OFFER-17)
- "Salva Offerta" (disabled with tooltip unless at least one tier has an assigned service) → `saveOfferEditor` → redirect to `/admin/offers`; "Annulla" → plain link back; "Archivia" (visible only when not already archived) → confirm dialog → `toggleOfferArchived(id, true)` → redirect
- All literal Italian copy strings from the UI-SPEC copywriting contract table implemented verbatim
## Task Commits
Each task was committed atomically:
1. **Task 1: Create editor route + page.tsx server component** - `8e0e4b9` (feat)
2. **Task 2: Build OfferEditorClient — tags, services matrix with live totals, promise block, actions** - `8c5c918` (feat)
**Plan metadata:** (this commit, follows)
## Files Created/Modified
- `src/app/admin/offers/[id]/edit/page.tsx` - New server component: fetches editor data + field options, 404s on missing offer, renders `OfferEditorClient`
- `src/components/admin/offers/OfferEditorClient.tsx` - New client component: the entire offer editor UI and its Salva/Annulla/Archivia/rename interactions
## Decisions Made
- `EditableCell.onSave` in this codebase is `(value: string) => void` (synchronous), not `(value: string) => void | Promise<void>` as the plan's interface sketch suggested — implemented all macro-field handlers as synchronous `setMacro` updates, matching the real signature (verified via `npx tsc --noEmit`)
- Tier padding: `padTiers()` always produces exactly 3 entries (A, B, C), filling missing tiers with empty placeholders (`id: ""`, `assignedServiceIds: []`, `servicesTotal: "0"`) so the matrix always renders 3 columns regardless of how many tiers exist in the DB
- Tag rename (Categoria/Ticket/Tipo/Obiettivo) follows the existing `ServiceTable`/`renameServiceOption` optimistic-update pattern: local state updates immediately, `renameOfferOption` server action fires via `startTransition`
## Deviations from Plan
None - plan executed exactly as written. The only adjustment was using the actual (synchronous) `EditableCell.onSave` signature instead of the plan's sketch signature, which is a same-behavior implementation detail (Rule 1 — code correctness), not a scope change.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required. This plan is pure UI (server + client components) consuming the already-deployed Plan 03 query layer and server actions against the live production schema (migration 0008).
## Next Phase Readiness
- `/admin/offers/[id]/edit` is fully functional and type-checks/lints clean across the whole project (`npx tsc --noEmit` and `npx eslint` both pass with zero errors)
- Plan 04's list page (`/admin/offers` + `OfferListClient.tsx`, built in parallel in a separate worktree) can link directly to `/admin/offers/[id]/edit` — no shared files were touched, no merge conflicts expected
- All four target requirements (OFFER-11, OFFER-15, OFFER-16, OFFER-17) are implemented end-to-end: UI -> `saveOfferEditor`/`toggleOfferArchived`/`renameOfferOption` (Plan 03) -> `offer_macros`/`offer_micros`/`offer_tier_services`/`tags` (Plan 01/02, live on prod)
---
*Phase: 12-offer-composition-drag-drop-csv-import*
*Completed: 2026-06-15*
## Self-Check: PASSED
All created files verified present on disk:
- FOUND: src/app/admin/offers/[id]/edit/page.tsx
- FOUND: src/components/admin/offers/OfferEditorClient.tsx
- FOUND: .planning/phases/12-offer-composition-drag-drop-csv-import/12-05-SUMMARY.md
All task commits verified present in git log:
- FOUND: 8e0e4b9 (Task 1)
- FOUND: 8c5c918 (Task 2)
@@ -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,413 @@
---
phase: 12-offer-composition-drag-drop-csv-import
reviewed: 2026-06-15T10:36:00Z
depth: standard
files_reviewed: 12
files_reviewed_list:
- scripts/push-12-offer-tier-schema.ts
- scripts/verify-12-03-actions.ts
- scripts/verify-12-03-queries.ts
- src/app/admin/offers/[id]/edit/page.tsx
- src/app/admin/offers/actions.ts
- src/app/admin/offers/page.tsx
- src/components/admin/offers/OfferEditorClient.tsx
- src/components/admin/offers/OfferListClient.tsx
- src/db/migrations/0008_offer_tier_schema.sql
- src/db/migrations/meta/_journal.json
- src/db/schema.ts
- src/lib/offer-queries.ts
findings:
critical: 0
warning: 6
info: 4
total: 10
status: issues_found
---
# Phase 12: Code Review Report
**Reviewed:** 2026-06-15T10:36:00Z
**Depth:** standard
**Files Reviewed:** 12
**Status:** issues_found
## Summary
This batch implements the Phase 12 Offer Editor: an additive DB migration (tier
designation, public pricing, archive flag, category/ticket dimensions, tipo/obiettivo
tags), a query layer (`offer-queries.ts`), server actions
(`saveOfferEditor`/`toggleOfferArchived`/`addOfferTag`/`removeOfferTag`/
`renameOfferOption`/`createOfferMacro`), and the editor/list client components.
The migration and push script are correctly additive/idempotent and respect the
Data Safety constraints in CLAUDE.md (no drops/truncates, `ADD COLUMN IF NOT
EXISTS`, guarded `DO $$` block for the CHECK constraint). `requireAdmin()` is
consistently called at the top of every new server action.
No critical/security issues were found. The main concerns are: (1) the tier
upsert in `saveOfferEditor` doesn't enforce uniqueness of `tier_letter` within a
single macro, which can desync the UI's `padTiers()` assumption and silently
orphan rows; (2) `renameOfferOption("tipo"/"obiettivo", ...)` can throw an
uncaught unique-constraint violation when the target name already exists,
surfacing as a generic 500 with no user-facing message; (3) the "unarchive" path
(`toggleOfferArchived(macroId, false)`) is implemented server-side and covered by
the verify script but has no reachable UI entry point, making it effectively dead
code from the user's perspective; and (4) several smaller robustness/UX gaps
around empty tiers being persisted with placeholder names, and total mismatches
when an assigned service falls outside the category filter.
## Warnings
### WR-01: `saveOfferEditor` does not enforce unique `tier_letter` per macro
**File:** `src/app/admin/offers/actions.ts:152-238`
**Issue:** `tierSchema` validates each tier's `tier_letter` is one of `"A"|"B"|"C"`
and `saveOfferEditorSchema.tiers` is capped at `.max(3)`, but nothing prevents two
tiers in the same payload from sharing the same `tier_letter` (e.g., two tiers
both `"A"`). The loop at lines 200-238 will happily update/insert both rows
independently — both ending up in `offer_micros` with `macro_id = macroId` and
`tier_letter = "A"`.
On the next load, `getOfferEditorData` (`src/lib/offer-queries.ts:163-167`) orders
by `tierOrder` (A→B→C, ties broken arbitrarily/by insertion), and
`OfferEditorClient.padTiers()` (`src/components/admin/offers/OfferEditorClient.tsx:33-37`)
uses `tiers.find((t) => t.tier_letter === letter)` — which returns only the FIRST
matching tier. The second "A" tier becomes invisible in the UI but still exists in
the DB (with its own `offer_tier_services` rows), and a subsequent save will
silently drop it from the `tiers` array sent to `saveOfferEditor` (since
`OfferEditorClient` state only ever holds 3 padded tiers) — leaving an orphaned
`offer_micros` row that's never cleaned up.
**Fix:** Either enforce uniqueness in `saveOfferEditorSchema` via a `.refine()`
check, or — more robustly — have `saveOfferEditor` delete any existing tiers for
`macroId` whose `tier_letter` is NOT present in the incoming payload (so stale/
duplicate rows get cleaned up):
```ts
const saveOfferEditorSchema = z.object({
// ...
tiers: z.array(tierSchema).max(3).refine(
(tiers) => {
const letters = tiers.map((t) => t.tier_letter);
return new Set(letters).size === letters.length;
},
{ message: "Ogni tier deve avere una lettera univoca (A/B/C)" }
),
// ...
});
```
### WR-02: `renameOfferOption("tipo"/"obiettivo", ...)` can throw on unique constraint violation
**File:** `src/app/admin/offers/actions.ts:337-341`
**Issue:** For `field === "tipo" | "obiettivo"`, the action runs:
```ts
await db
.update(tags)
.set({ name: next })
.where(and(eq(tags.entity_type, OFFER_TAG_ENTITY[field]), eq(tags.name, oldValue)));
```
This is a global rename across ALL macros that have a tag named `oldValue` for
that `entity_type`. The `tags` table has a unique index
`tags_entity_name_unique` on `(entity_type, entity_id, name)`
(`src/db/schema.ts:133-137`). If any macro already has a tag named `next` for the
same `entity_type` (e.g., renaming "Audit" → "Coaching" but macro X already has a
"Coaching" tag), the UPDATE on macro X's row will violate the unique index and
throw a raw Postgres error. This error is not caught here, and propagates up to
`OfferEditorClient.handleRenameTipo`/`handleRenameObiettivo`
(`src/components/admin/offers/OfferEditorClient.tsx:210-232`), which `catch` it
generically and show "Errore nel salvataggio. Verifica i campi." — but by that
point the optimistic client-side state update (`setTipoOptions`, `setTipoTags`)
has ALREADY applied for ALL macros' local view, while the DB only partially
applied the rename (some rows renamed, the colliding row left as `oldValue` or
the whole statement rolled back depending on transaction semantics — Drizzle here
issues a single statement so it's atomic per-statement, but the local UI state for
THIS macro is now inconsistent with the DB: `tipoTags` shows `next` even though
the DB row may not have been updated due to the throw on a *different* row update
within the same UPDATE statement — actually since it's one UPDATE affecting
multiple rows, a constraint violation on any row aborts the whole statement,
meaning NO rows are renamed in the DB, but the UI already shows the renamed tag).
**Fix:** Wrap in `onConflictDoNothing`-style handling isn't directly possible for
UPDATE; instead, pre-check or catch the specific error and surface a meaningful
message:
```ts
} else if (field === "tipo" || field === "obiettivo") {
try {
await db
.update(tags)
.set({ name: next })
.where(and(eq(tags.entity_type, OFFER_TAG_ENTITY[field]), eq(tags.name, oldValue)));
} catch (err) {
throw new Error(`Esiste già un tag "${next}" per questo campo`);
}
}
```
### WR-03: `toggleOfferArchived(macroId, false)` has no reachable UI entry point
**File:** `src/components/admin/offers/OfferEditorClient.tsx:174-184, 487-496`
**Issue:** `handleArchive` only ever calls `toggleOfferArchived(data.macro.id,
true)` (line 178), and the "Archivia" button is only rendered when
`!initialArchived` (line 487). There is no button/action that calls
`toggleOfferArchived(macroId, false)` to un-archive an offer. Once an offer is
archived, the only way back is presumably a direct DB edit — `OfferListClient`'s
"Mostra offerte archiviate" toggle (line 116-124 in `OfferListClient.tsx`) lets
the admin SEE archived offers and click through to `/admin/offers/[id]/edit`, but
the edit page gives them no way to restore it. `toggleOfferArchived(macroId,
false)` is exercised in `scripts/verify-12-03-actions.ts` (Test 6) but is
effectively dead code from a product standpoint.
**Fix:** Add a "Ripristina" (restore) button shown when `initialArchived` is
true, calling `toggleOfferArchived(data.macro.id, false)`:
```tsx
{initialArchived && (
<button
type="button"
onClick={() => startTransition(async () => {
try {
await toggleOfferArchived(data.macro.id, false);
router.push("/admin/offers");
} catch {
setSaveError("Errore nel salvataggio. Verifica i campi.");
}
})}
disabled={isPending}
className="text-[#1A463C] text-sm font-semibold px-6 py-4 rounded hover:bg-[#f0f7f4] transition-colors duration-150 disabled:opacity-50"
>
Ripristina
</button>
)}
```
### WR-04: Empty/unused tier slots are persisted as real `offer_micros` rows with placeholder names
**File:** `src/components/admin/offers/OfferEditorClient.tsx:138-172`, `src/app/admin/offers/actions.ts:200-238`
**Issue:** `padTiers()` always produces exactly 3 `OfferTierData` entries (A/B/C),
filling missing ones with `emptyTier(letter)` (`id: ""`, empty names,
`assignedServiceIds: []`). `handleSave` (lines 151-159) maps ALL 3 tiers
unconditionally into the payload, defaulting `internal_name` to `` `Tier
${tier.tier_letter}` `` and `public_name` to `t.tier_letter || "Tier"` when
empty. `saveOfferEditor`'s tier loop (lines 200-238) then inserts a NEW
`offer_micros` row for every tier without an `id` — including these placeholder
"empty" tiers — as long as `canSave` is true (i.e., AT LEAST ONE of the 3 tiers
has assigned services).
Concretely: if the admin only fills in Tier A with services and leaves B/C empty,
saving creates 3 `offer_micros` rows total — 2 of which are "ghost" tiers named
"Tier B"/"Tier C" with `duration_months: 1`, `public_price: null`, and zero
assigned services — visible in any other view that lists `offer_micros` for this
macro (e.g., `getCatalogWithMicros`, used elsewhere in the admin).
**Fix:** Skip tiers that are both unsaved (`!t.id`) AND empty
(`assignedServiceIds.length === 0` and no `internal_name`/`public_name` entered)
when building the payload:
```ts
tiers: tiers
.filter((t) => t.id || t.assignedServiceIds.length > 0 || t.internal_name || t.public_name)
.map((t) => ({ ... })),
```
### WR-05: `tierTotals` silently treats services outside the category filter as €0, diverging from server-computed `servicesTotal`
**File:** `src/components/admin/offers/OfferEditorClient.tsx:96-109`
**Issue:** `filteredServices` (lines 96-100) is `data.availableServices` filtered
by `macro.category` (client-side, reactive to the in-progress edit of
`macro.category`). `tierTotals` (lines 102-109) computes each tier's total by
looking up `tier.assignedServiceIds` against `filteredServices` only, defaulting
to `0` via `Number(service?.unit_price ?? 0)` for any assigned service ID not
found in `filteredServices`.
If a tier has services assigned that belong to a DIFFERENT category than the
macro's current `category` (e.g., the macro's category was just changed in this
editing session, or a service's category changed since assignment), those
services drop out of `filteredServices` and their price silently becomes €0 in
the "Totale Servizi" row — even though `data.tiers[i].servicesTotal` (computed
server-side in `getOfferEditorData`, `src/lib/offer-queries.ts:183-193`, which
does NOT filter by category) would include them. This produces a displayed total
that's lower than what's stored/will be recomputed after save, with no
indication to the admin why.
**Fix:** Compute `tierTotals` against `data.availableServices` (unfiltered) or a
combined lookup map that includes all services ever referenced by
`assignedServiceIds`, not just the category-filtered subset:
```ts
const serviceById = useMemo(
() => new Map(data.availableServices.map((s) => [s.id, s])),
[data.availableServices]
);
const tierTotals = useMemo(() => {
return tiers.map((tier) =>
tier.assignedServiceIds.reduce((sum, id) => {
const service = serviceById.get(id);
return sum + Number(service?.unit_price ?? 0);
}, 0)
);
}, [tiers, serviceById]);
```
(Note: this still won't be 100% accurate if a service belongs to a category
outside `data.availableServices` entirely — but that's the same best-effort
boundary the server-side query already has.)
### WR-06: `createOfferMacro` allows whitespace-only `internal_name`
**File:** `src/app/admin/offers/actions.ts:351-376`
**Issue:** `createOfferMacroSchema.internal_name` is `z.string().min(1, "Nome
interno richiesto")`. Zod's `.min(1)` checks string LENGTH, not trimmed content
— a value of `" "` (single space) passes validation. Line 370 then does
`parsed.data.public_name?.trim() || parsed.data.internal_name` — if
`public_name` is empty/whitespace, `public_name` ends up being the untrimmed
`" "` as well. The result is a new `offer_macros` row with
`internal_name = " "` and `public_name = " "`, which will render as a blank card
in `OfferListClient` (line 142: `<h3>{card.internal_name}</h3>` shows nothing
visible) with no way to identify or rename it cleanly from the list view (the
only `EditableCell` for `internal_name` is on the edit page, line 246-251, which
itself has `required` but the same `tempValue.trim().length === 0` check in
`EditableCell.commit` — so once created, it actually CAN be fixed, but the
initial blank card is confusing).
**Fix:** Trim and re-validate in the Zod schema:
```ts
const createOfferMacroSchema = z.object({
internal_name: z.string().trim().min(1, "Nome interno richiesto"),
public_name: z.string().optional(),
description: z.string().optional(),
category: z.string().optional(),
});
```
and at line 370, trim `internal_name` too:
```ts
public_name: parsed.data.public_name?.trim() || parsed.data.internal_name.trim(),
```
## Info
### IN-01: `OfferListClient.handleCreateSubmit` always calls `router.refresh()` even when `createOfferMacro` throws
**File:** `src/components/admin/offers/OfferListClient.tsx:34-41`
**Issue:**
```ts
function handleCreateSubmit(formData: FormData) {
startTransition(async () => {
await createOfferMacro(formData);
setShowCreateForm(false);
setNewOfferName("");
router.refresh();
});
}
```
`createOfferMacro` can throw (e.g., Zod validation failure if `internal_name` is
empty — though the `required` attribute on the `<Input>` at line 59 mitigates
this for normal browser usage). If it throws, the `await` rejects, and since
there's no `try/catch`, the rejection becomes an unhandled promise rejection
inside `startTransition`'s async callback — `setShowCreateForm(false)`,
`setNewOfferName("")`, and `router.refresh()` never run, but there's also no
`saveError`-style state to inform the user anything went wrong. The form stays
open with stale state and no feedback.
**Fix:** Wrap in try/catch and surface an error state, mirroring the pattern used
in `OfferEditorClient`:
```ts
const [createError, setCreateError] = useState<string | null>(null);
function handleCreateSubmit(formData: FormData) {
setCreateError(null);
startTransition(async () => {
try {
await createOfferMacro(formData);
setShowCreateForm(false);
setNewOfferName("");
router.refresh();
} catch {
setCreateError("Errore nella creazione dell'offerta.");
}
});
}
```
### IN-02: `requireAdmin()` discards the session without using it for ownership/role checks
**File:** `src/app/admin/offers/actions.ts:18-21`
**Issue:**
```ts
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
```
This is consistent with the existing pattern in this file (pre-existing, not
introduced by this phase), so it's not a regression — but worth flagging as a
pre-existing gap: any authenticated session is treated as "admin" with no role
check. Since this is a single-admin app per CLAUDE.md's architecture, this is
likely fine, but the function name `requireAdmin` implies a role check that
doesn't exist. Purely informational — no change required unless multi-user admin
roles are planned.
**Fix:** None required for this phase; consider renaming to `requireSession` for
clarity in a future cleanup, or add a role check if multi-admin support is ever
introduced.
### IN-03: `formatUnitPrice` and `formatEuro` duplicate locale-formatting logic
**File:** `src/components/admin/offers/OfferEditorClient.tsx:39-46`
**Issue:**
```ts
function formatEuro(value: number): string {
return `€${value.toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function formatUnitPrice(raw: string): string {
const num = parseFloat(raw);
return `€${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
```
Both functions produce identical output format; `formatUnitPrice` is just
`formatEuro(parseFloat(raw) || 0)`. Minor duplication — not a bug, but could be
collapsed into one helper that accepts `string | number`.
**Fix (optional):**
```ts
function formatEuro(value: number | string): string {
const num = typeof value === "string" ? parseFloat(value) : value;
return `€${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
```
### IN-04: `push-12-offer-tier-schema.ts` and `0008_offer_tier_schema.sql` can drift independently
**File:** `scripts/push-12-offer-tier-schema.ts:1-143`, `src/db/migrations/0008_offer_tier_schema.sql:1-45`
**Issue:** The push script (`scripts/push-12-offer-tier-schema.ts`) is a
hand-written duplicate of the SQL migration file's statements (per the comment at
the top of `0008_offer_tier_schema.sql`, this is because `drizzle-kit generate` is
broken per project memory). Both files express the same schema change in two
different forms (column lists in a TS array vs. raw `ALTER TABLE` statements).
This is a deliberate, documented workaround, but creates a maintenance hazard: if
either file is edited in isolation in a future phase, the two can silently
diverge (e.g., a column added to the `.sql` migration but not to the push
script's `offerMacrosColumns`/`offerMicrosColumns` arrays, or vice versa).
**Fix (optional):** Add a comment cross-reference in both files pointing at each
other ("keep in sync with X"), or — longer-term — generate the push script's
column lists programmatically by parsing the `.sql` file, to guarantee they can't
diverge. Not blocking for this phase given the documented one-off nature.
---
_Reviewed: 2026-06-15T10:36:00Z_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: standard_
@@ -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*
@@ -0,0 +1,136 @@
---
phase: 12
verified: 2026-06-18T17:19:00+02:00
requirements:
OFFER-11: PASS
OFFER-15: PASS
OFFER-16: PASS
OFFER-17: PASS
OFFER-18: PASS
overall: PASS
---
# Phase 12: Offer Editor — Verification Report
**Phase Goal:** Build a full offer editor allowing admin to compose offers with 3 tiers (A/B/C), assign services via checkbox matrix, set per-tier public price, add 4-dimension tags (tipo/obiettivo creatable, categoria/ticket single-select), write a structured transformation promise (5 fields), and manage an offer list with category filter and archive toggle.
**Verified:** 2026-06-18T17:19:00+02:00
**Status:** PASS
**Re-verification:** No — initial verification
---
## OFFER-11: Tier A/B/C checkbox matrix with live totals
**Verdict: PASS**
**Schema** (`src/db/schema.ts`): `offer_micros.tier_letter` (text, values A/B/C enforced by DB CHECK constraint in migration 0008) and `offer_tier_services` junction table (tier_id → offer_micros.id, service_id → services.id, composite PK) are both present and properly defined. Relations are wired: `offerMicrosRelations` includes `tierServices: many(offer_tier_services)`.
**Migration** (`src/db/migrations/0008_offer_tier_schema.sql`): Adds `tier_letter` and `public_price` columns to `offer_micros`, adds a guarded CHECK constraint (`tier_letter IN ('A','B','C')`), and creates the `offer_tier_services` table — all with `IF NOT EXISTS` guards, fully additive.
**Query layer** (`src/lib/offer-queries.ts`, `getOfferEditorData`): Fetches tiers ordered A→B→C via a CASE expression, then fetches `offer_tier_services` rows for all tier IDs in one query, and computes `servicesTotal` per tier with a `SUM(unit_price)` GROUP BY. Returns `assignedServiceIds[]` and `servicesTotal` per tier.
**Editor UI** (`src/components/admin/offers/OfferEditorClient.tsx`):
- `padTiers()` ensures all three tier slots (A, B, C) are always present in state, creating empty tier objects for any letter not yet persisted.
- `toggleService(tierIdx, serviceId)` updates `assignedServiceIds` in React state immutably.
- `tierTotals` is a `useMemo` that re-derives a per-tier sum from `assignedServiceIds` and `filteredServices.unit_price` on every render — this is the live total.
- The tfoot row "Totale Servizi" renders `tierTotals[idx]` for each column (lines 371378).
- Checkboxes at lines 353360 bind `checked={tier.assignedServiceIds.includes(service.id)}` and `onChange={() => toggleService(tierIdx, service.id)}` — the matrix is fully wired.
**Persistence** (`src/app/admin/offers/actions.ts`, `saveOfferEditor`): Zod schema enforces `tier_letter: z.enum(["A","B","C"])`. Each tier is upserted (update if `id` present, insert otherwise), then `offer_tier_services` is delete-then-reinserted — correct upsert-replace pattern.
---
## OFFER-15: 4-dimension tags (categoria, ticket, tipo, obiettivo)
**Verdict: PASS**
**Schema:** `offer_macros.category` (text, single-select) and `offer_macros.ticket` (text, single-select) added in migration 0008. Tipo/Obiettivo reuse the Phase 11 polymorphic `tags` table with entity_type scoped to `"offer_macros.tipo"` and `"offer_macros.obiettivo"` — separate pools from `"services"` and `"leads"` per the D-06 pattern documented in schema comments.
**Query layer** (`getOfferEditorData`): Fetches tipo/obiettivo tag rows filtered by `inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY])` and splits them into `tipoTags[]` and `obiettivoTags[]`. `getOfferFieldOptions` returns `categoria`, `ticket`, `tipo`, `obiettivo` option pools via `selectDistinct`.
**Editor UI** (`OfferEditorClient.tsx`):
- `OptionSelect` components render categoria (line 262) and ticket (line 272) as single-select Notion-style dropdowns with rename support.
- `OptionMultiSelect` components render tipo (line 284) and obiettivo (line 299) as multi-select with `onAdd` (creatable — if value not in options, adds to local options state), `onRemove`, and `onRename` callbacks.
- Options are creatable on-the-fly: `onAdd` at line 288 calls `setTipoOptions((prev) => [...prev, v])` when the value is new.
**Persistence** (`saveOfferEditor`): Tipo/Obiettivo tags are delete-then-reinserted (lines 242263) with `onConflictDoNothing()`. Categoria/ticket are saved as scalar columns on `offer_macros`. Standalone `addOfferTag`/`removeOfferTag` actions exist for granular tag mutations.
**Rename propagation** (`renameOfferOption`): Updates `offer_macros.category` / `offer_macros.ticket` in bulk for single-select dimensions, and `tags.name` for tipo/obiettivo — correct cross-row rename.
---
## OFFER-16: Per-tier manual public price
**Verdict: PASS**
**Schema** (`offer_micros.public_price`): `numeric(10,2)`, nullable, added in migration 0008. Distinct from the `servicesTotal` which is computed at query time and never stored.
**Query layer** (`getOfferEditorData`): Returns `public_price: tier.public_price` (raw DB value, nullable string) per tier in `OfferTierData`.
**Editor UI** (`OfferEditorClient.tsx` lines 381397): The tfoot row "Prezzo Pubblico" renders one `<input type="number">` per tier column, bound to `tier.public_price` and calling `updateTierPublicPrice(tierIdx, e.target.value)`. This is independent of the "Totale Servizi" row above it — two separate tfoot rows, two separate state values.
**Persistence** (`saveOfferEditor`): `tier.public_price` is converted with `tier.public_price != null ? String(tier.public_price) : null` and written to `offer_micros.public_price` on upsert. Zod schema: `public_price: z.coerce.number().min(0).optional().nullable()`.
---
## OFFER-17: Structured transformation promise (5 fields)
**Verdict: PASS**
**Schema** (`offer_macros`): Five columns added in migration 0008 — `cliente_ideale`, `risultato`, `tempo`, `pain`, `metodo` — all text, nullable. These are distinct from the pre-existing free-text `transformation_promise` column (retained untouched).
**Query layer** (`getOfferEditorData`): `db.select().from(offer_macros)` returns all columns including the five new ones. The `OfferMacro` type (inferred from schema) includes them.
**Editor UI** (`OfferEditorClient.tsx` lines 404467): "Promessa di Trasformazione" section renders five `EditableCell` fields:
- "Aiuto" → `cliente_ideale`
- "A ottenere" → `risultato`
- "In" → `tempo`
- "Senza" → `pain`
- "Grazie a" → `metodo`
Each calls `updateMacro(key, v)` on save, updating the `macro` state object. All five are initialized from `data.macro.*` at component mount.
**Persistence** (`saveOfferEditor`): All five fields are explicitly mapped in the `db.update(offer_macros).set({...})` call (lines 186196), with `|| null` coercion for empty strings.
---
## OFFER-18: Offer list filterable by category, archive toggle
**Verdict: PASS**
**Schema** (`offer_macros.is_archived`): `boolean NOT NULL DEFAULT false`, added in migration 0008.
**Query layer** (`getOfferListCards`): Returns all offers including archived ones (archived filtering is client-side per spec). Returns `id`, `internal_name`, `description`, `category`, `is_archived`.
**List page** (`src/app/admin/offers/page.tsx`): Calls `getOfferListCards()` and `getOfferFieldOptions()` in parallel, passes `cards` and `options.categoria` to `OfferListClient`.
**List component** (`OfferListClient.tsx`):
- `activeCategory` state (null = all) drives filter chip selection; "Tutti" chip and one chip per category from `categoryOptions`.
- `showArchived` state (default false) drives the "Mostra offerte archiviate" checkbox.
- `filteredCards` useMemo at lines 2632 applies both filters simultaneously: `(activeCategory === null || c.category === activeCategory) && (showArchived || !c.is_archived)`.
- Archived offers render with a red "Archiviata" label on the card.
- `toggleOfferArchived(macroId, true)` server action archives an offer from the editor (OFFER-18 archive action). Restore path: the action accepts `archived: boolean` so it can also unarchive, though no unarchive button exists in the editor UI — unarchiving requires the list to show the card via the toggle and navigate to edit.
**Archive action** (`actions.ts` line 267271): `toggleOfferArchived` updates `offer_macros.is_archived` and calls `revalidatePath("/admin/offers")`.
---
## Anti-patterns scan
No placeholder returns (`return null`, `return {}`, `return []` as stubs) found in the key files. The `canSave` guard (`tiers.some((t) => t.assignedServiceIds.length > 0)`) prevents saving a hollow offer but is not a stub — it is a real UX validation. The `emptyTier()` function returns zero-state structs used to pad the three-slot display; they are overwritten by real data fetched from the DB.
The `servicesTotal: "0"` in `emptyTier` is an initial display value for unpersisted tiers — not a stub, as the live `tierTotals` useMemo recomputes from `assignedServiceIds` state on every checkbox change.
---
## Notes
1. **Unarchive flow is implicit.** There is no "Unarchive" button in the editor. The only way to restore an archived offer is: enable "Mostra offerte archiviate" on the list page, click into the offer, and call `toggleOfferArchived(id, false)` — which exists in `actions.ts` but has no UI trigger for `archived=false` in `OfferEditorClient.tsx`. This is a minor UX gap but does not block any of OFFER-11 through OFFER-18 as specified. The archiving requirement (OFFER-18) asks for "archive toggle" — the toggle on the list is the show/hide toggle; archiving itself works via the editor button. Unarchiving is not called out in the requirements.
2. **`canSave` gate.** The "Salva Offerta" button is disabled unless at least one tier has a service assigned. This means an offer with only transformation promise fields filled and no services cannot be saved. This aligns with OFFER-11's framing of service assignment as the core editor operation.
---
_Verified: 2026-06-18T17:19:00+02:00_
_Verifier: Claude (gsd-verifier)_
+142
View File
@@ -0,0 +1,142 @@
// PRODUCTION DEPLOY NOTE: This migration (0008_offer_tier_schema.sql) is additive-only
// (ADD COLUMN IF NOT EXISTS, a guarded DO-block CHECK constraint, CREATE TABLE IF NOT
// EXISTS, and CREATE INDEX IF NOT EXISTS — no drops/truncates). Per CLAUDE.md Data
// Safety, this script MUST be run against PRODUCTION via SSH+docker exec BEFORE Phase
// 12 Wave 3 code (query layer reading offer_tier_services/tier_letter/public_price/
// category/ticket) is exercised against production data. This is the BLOCKING step
// for Plan 02. Idempotent — safe to re-run.
// Run: npx tsx scripts/push-12-offer-tier-schema.ts (with prod DATABASE_URL)
import postgres from "postgres";
async function push() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error("DATABASE_URL environment variable is required");
process.exit(1);
}
const client = postgres(databaseUrl);
try {
console.log("Pushing offer tier schema migration (0008)...");
// offer_macros: archive flag + short description + category/ticket dimensions +
// structured transformation promise
const offerMacrosColumns: Array<[string, string]> = [
["description", "text"],
["category", "text"],
["ticket", "text"],
["is_archived", "boolean NOT NULL DEFAULT false"],
["cliente_ideale", "text"],
["risultato", "text"],
["tempo", "text"],
["pain", "text"],
["metodo", "text"],
];
for (const [column, type] of offerMacrosColumns) {
try {
await client.unsafe(
`ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS ${column} ${type}`
);
console.log(` ✓ offer_macros.${column} ready`);
} catch (err: unknown) {
if (err instanceof Error && err.message.includes("already exists")) {
console.log(` ✓ offer_macros.${column} already exists (skipped)`);
} else {
throw err;
}
}
}
// offer_micros: tier designation (A/B/C) + manual public price
const offerMicrosColumns: Array<[string, string]> = [
["tier_letter", "text"],
["public_price", "numeric(10, 2)"],
];
for (const [column, type] of offerMicrosColumns) {
try {
await client.unsafe(
`ALTER TABLE offer_micros ADD COLUMN IF NOT EXISTS ${column} ${type}`
);
console.log(` ✓ offer_micros.${column} ready`);
} catch (err: unknown) {
if (err instanceof Error && err.message.includes("already exists")) {
console.log(` ✓ offer_micros.${column} already exists (skipped)`);
} else {
throw err;
}
}
}
// CHECK constraint for tier_letter, guarded so it's safe to re-run.
try {
await client.unsafe(`
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 $$;
`);
console.log(" ✓ offer_micros_tier_letter_check constraint ready");
} catch (err: unknown) {
if (err instanceof Error && err.message.includes("already exists")) {
console.log(" ✓ offer_micros_tier_letter_check already exists (skipped)");
} else {
throw err;
}
}
// New junction table: tier (offer_micros) <-> unified services catalog.
try {
await client.unsafe(`
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)
)
`);
console.log(" ✓ offer_tier_services table ready");
} catch (err: unknown) {
if (err instanceof Error && err.message.includes("already exists")) {
console.log(" ✓ offer_tier_services table already exists (skipped)");
} else {
throw err;
}
}
try {
await client.unsafe(`
CREATE INDEX IF NOT EXISTS offer_tier_services_tier_idx ON offer_tier_services USING btree (tier_id)
`);
console.log(" ✓ offer_tier_services_tier_idx index ready");
} catch (err: unknown) {
if (err instanceof Error && err.message.includes("already exists")) {
console.log(" ✓ offer_tier_services_tier_idx already exists (skipped)");
} else {
throw err;
}
}
console.log("✓ Migration 0008 (offer tier schema) applied successfully");
process.exit(0);
} catch (err: unknown) {
if (err instanceof Error) {
console.error("Error pushing migration:", err.message);
} else {
console.error("Unknown error:", err);
}
process.exit(1);
} finally {
await client.end();
}
}
push();
+242
View File
@@ -0,0 +1,242 @@
// Phase 12 Plan 03 — Task 2 verification script for actions.ts additions.
//
// This project has no test runner configured (no `scripts.test` in package.json),
// and `.env.local`'s DATABASE_URL points at PRODUCTION (per project memory) — so
// this script does NOT execute any DB calls or server actions (server actions
// also require an Auth.js session, unavailable outside a request context). It
// typechecks against the real exports/types from
// src/app/admin/offers/actions.ts and documents the 9 expected behaviors from
// the plan's <behavior> block, for manual run against a dev DB later if desired.
//
// Run (manual, dev DB only, inside a request context with admin session):
// npx tsx scripts/verify-12-03-actions.ts
import type { SaveOfferEditorPayload } from "@/app/admin/offers/actions";
import {
saveOfferEditor,
toggleOfferArchived,
addOfferTag,
removeOfferTag,
renameOfferOption,
createOfferMacro,
} from "@/app/admin/offers/actions";
// ── Test 1: tier_letter Zod validation ───────────────────────────────────────
// 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.
async function test1_invalidTierLetter(macroId: string) {
const badPayload = {
internal_name: "Test",
public_name: "Test",
tiers: [
{
tier_letter: "D", // invalid — not in ["A","B","C"]
internal_name: "Tier X",
public_name: "Tier X",
duration_months: 3,
assignedServiceIds: [],
},
],
tipoTags: [],
obiettivoTags: [],
} as unknown as SaveOfferEditorPayload;
// Expected: saveOfferEditor(macroId, badPayload) throws (Zod enum mismatch
// surfaces as parsed.error.issues[0].message before any DB write).
return () => saveOfferEditor(macroId, badPayload);
}
// ── Test 2: macro scalar field update ────────────────────────────────────────
// saveOfferEditor updates offer_macros scalars (internal_name, description,
// category, ticket, cliente_ideale, risultato, tempo, pain, metodo) in one
// db.update(offer_macros)...where(eq(id, macroId)).
async function test2_macroScalars(macroId: string) {
const payload: SaveOfferEditorPayload = {
internal_name: "Offerta Test",
public_name: "Offerta Pubblica",
description: "Una descrizione",
category: "Signature Offer",
ticket: "Mid Ticket",
cliente_ideale: "Coach",
risultato: "Lead qualificati",
tempo: "90 giorni",
pain: "Mancanza di processo",
metodo: "Sistema X",
tiers: [],
tipoTags: [],
obiettivoTags: [],
};
// Expected: after call, offer_macros row for macroId has all 9 scalar
// fields set to the payload values.
return () => saveOfferEditor(macroId, payload);
}
// ── Test 3: tier upsert (update existing id, insert when no id) ──────────────
// If tier.id is provided -> db.update(offer_micros).set({...}); if no id ->
// db.insert(offer_micros).values({..., macro_id: macroId}).
async function test3_tierUpsert(macroId: string, existingTierId: string) {
const payload: SaveOfferEditorPayload = {
internal_name: "Offerta Test",
public_name: "Offerta Pubblica",
tiers: [
{
id: existingTierId, // -> update path
tier_letter: "A",
internal_name: "Tier A",
public_name: "Tier A Pubblico",
duration_months: 3,
public_price: 1500,
assignedServiceIds: [],
},
{
// no id -> insert path (supports macros with < 3 tiers)
tier_letter: "B",
internal_name: "Tier B",
public_name: "Tier B Pubblico",
duration_months: 6,
public_price: 2500,
assignedServiceIds: [],
},
],
tipoTags: [],
obiettivoTags: [],
};
// Expected: existingTierId row updated in place; a new offer_micros row
// created for tier B with macro_id = macroId.
return () => saveOfferEditor(macroId, payload);
}
// ── Test 4: offer_tier_services delete-then-reinsert ─────────────────────────
// A tier with assignedServiceIds: ["svc1","svc2"] -> after call,
// offer_tier_services has exactly 2 rows for that tier_id, both svc1/svc2.
async function test4_tierServicesReplace(macroId: string, tierId: string) {
const payload: SaveOfferEditorPayload = {
internal_name: "Offerta Test",
public_name: "Offerta Pubblica",
tiers: [
{
id: tierId,
tier_letter: "A",
internal_name: "Tier A",
public_name: "Tier A Pubblico",
duration_months: 3,
assignedServiceIds: ["svc1", "svc2"],
},
],
tipoTags: [],
obiettivoTags: [],
};
// Expected: offer_tier_services has exactly 2 rows where tier_id === tierId,
// service_id IN ("svc1", "svc2").
return () => saveOfferEditor(macroId, payload);
}
// ── Test 5: Tipo/Obiettivo tags delete-then-reinsert ─────────────────────────
// saveOfferEditor replaces tags where entity_type IN
// ("offer_macros.tipo","offer_macros.obiettivo") and entity_id = macroId.
async function test5_tagsReplace(macroId: string) {
const payload: SaveOfferEditorPayload = {
internal_name: "Offerta Test",
public_name: "Offerta Pubblica",
tiers: [],
tipoTags: ["Audit", "Coaching"],
obiettivoTags: ["Lead Generation"],
};
// Expected: tags table has 2 rows entity_type="offer_macros.tipo" (Audit,
// Coaching) and 1 row entity_type="offer_macros.obiettivo" (Lead
// Generation), all entity_id = macroId; any prior rows for this macroId +
// these two entity_types are removed first.
return () => saveOfferEditor(macroId, payload);
}
// ── Test 6: toggleOfferArchived ──────────────────────────────────────────────
// toggleOfferArchived(macroId, archived: boolean) sets offer_macros.is_archived = archived.
async function test6_toggleArchived(macroId: string) {
// Expected: offer_macros.is_archived === true after toggleOfferArchived(macroId, true);
// === false after toggleOfferArchived(macroId, false).
return [
() => toggleOfferArchived(macroId, true),
() => toggleOfferArchived(macroId, false),
];
}
// ── Test 7: addOfferTag / removeOfferTag ─────────────────────────────────────
// addOfferTag(dimension, macroId, value) / removeOfferTag(dimension, macroId,
// value) work for dimension in ("tipo","obiettivo"). Reject any other
// dimension value.
async function test7_tagCrud(macroId: string) {
// Expected: addOfferTag("tipo", macroId, "Audit") inserts a tags row
// (entity_type="offer_macros.tipo", entity_id=macroId, name="Audit");
// removeOfferTag("tipo", macroId, "Audit") deletes it.
// addOfferTag("invalid" as any, macroId, "x") throws.
return {
addTipo: () => addOfferTag("tipo", macroId, "Audit"),
removeTipo: () => removeOfferTag("tipo", macroId, "Audit"),
addObiettivo: () => addOfferTag("obiettivo", macroId, "Lead Generation"),
removeObiettivo: () => removeOfferTag("obiettivo", macroId, "Lead Generation"),
// @ts-expect-error -- intentional invalid dimension to verify the throw path
invalid: () => addOfferTag("invalid", macroId, "x"),
};
}
// ── Test 8: renameOfferOption ────────────────────────────────────────────────
// renameOfferOption(field, oldValue, newValue) for field in ("categoria",
// "ticket") updates offer_macros.category/ticket for all matching rows; for
// field in ("tipo","obiettivo") updates tags.name.
async function test8_renameOption() {
// Expected:
// renameOfferOption("categoria", "Entry Offer", "Offerta Base") updates
// all offer_macros rows where category = "Entry Offer" -> "Offerta Base"
// renameOfferOption("ticket", "Low Ticket", "Ticket Basso") updates
// all offer_macros rows where ticket = "Low Ticket" -> "Ticket Basso"
// renameOfferOption("tipo", "Audit", "Diagnosi") updates all tags rows
// where entity_type="offer_macros.tipo" and name="Audit" -> "Diagnosi"
// renameOfferOption("obiettivo", "Lead Generation", "Acquisizione Lead")
// updates tags rows where entity_type="offer_macros.obiettivo"
return {
renameCategoria: () => renameOfferOption("categoria", "Entry Offer", "Offerta Base"),
renameTicket: () => renameOfferOption("ticket", "Low Ticket", "Ticket Basso"),
renameTipo: () => renameOfferOption("tipo", "Audit", "Diagnosi"),
renameObiettivo: () => renameOfferOption("obiettivo", "Lead Generation", "Acquisizione Lead"),
};
}
// ── Test 9: createOfferMacro ─────────────────────────────────────────────────
// createOfferMacro(formData) creates a new offer_macros row from internal_name
// (required) + optional public_name/description/category. If public_name is
// omitted, defaults to internal_name.
async function test9_createOfferMacro() {
const fd1 = new FormData();
fd1.append("internal_name", "Nuova Offerta Interna");
fd1.append("description", "Descrizione breve");
fd1.append("category", "Entry Offer");
// Expected: new offer_macros row with internal_name="Nuova Offerta Interna",
// public_name="Nuova Offerta Interna" (defaulted), description set,
// category="Entry Offer".
const fd2 = new FormData();
fd2.append("internal_name", "Altra Offerta");
fd2.append("public_name", "Nome Pubblico Esplicito");
// Expected: new offer_macros row with public_name="Nome Pubblico Esplicito"
// (not defaulted, since explicitly provided).
return {
withDefaultPublicName: () => createOfferMacro(fd1),
withExplicitPublicName: () => createOfferMacro(fd2),
};
}
// Not executed automatically (production DB + no request context for
// requireAdmin) — typecheck-only verification.
export {
test1_invalidTierLetter,
test2_macroScalars,
test3_tierUpsert,
test4_tierServicesReplace,
test5_tagsReplace,
test6_toggleArchived,
test7_tagCrud,
test8_renameOption,
test9_createOfferMacro,
};
+112
View File
@@ -0,0 +1,112 @@
// Phase 12 Plan 03 — Task 1 verification script for offer-queries.ts additions.
//
// This project has no test runner configured (no `scripts.test` in package.json),
// and `.env.local`'s DATABASE_URL points at PRODUCTION (per project memory) — so
// this script does NOT execute any DB calls. It typechecks against the real
// exports/types from src/lib/offer-queries.ts and documents the 5 expected
// behaviors from the plan's <behavior> block, for manual run against a dev DB
// later if desired.
//
// Run (manual, dev DB only): npx tsx scripts/verify-12-03-queries.ts
import {
getOfferEditorData,
getOfferListCards,
getOfferFieldOptions,
type OfferEditorData,
type OfferListCard,
type OfferFieldOptions,
type OfferTierData,
} from "@/lib/offer-queries";
// ── Test 1: getOfferListCards() ──────────────────────────────────────────────
// Input: 2 offer_macros rows (1 with is_archived=true, 1 with is_archived=false).
// Expected: array of length 2, both present, ordered by sort_order, each row
// shaped { id, internal_name, description, category, is_archived }. Archived
// rows ARE returned — filtering is client-side (UI-SPEC "Mostra offerte archiviate").
async function test1_getOfferListCards() {
const cards: OfferListCard[] = await getOfferListCards();
// Expected assertions (manual):
// cards.length === 2
// cards.some(c => c.is_archived === true)
// cards.some(c => c.is_archived === false)
return cards;
}
// ── Test 2: getOfferEditorData(macroId) — macro with 0 tiers ─────────────────
// Input: macro with category = "Signature Offer", 0 offer_micros rows, 3
// services with category="Signature Offer" + 2 with a different category.
// Expected: { macro: {...}, tiers: [], availableServices: [...3 matching...],
// tipoTags: [], obiettivoTags: [] }
async function test2_emptyTiers(macroId: string) {
const data: OfferEditorData | null = await getOfferEditorData(macroId);
// Expected assertions (manual):
// data !== null
// data.tiers.length === 0
// data.availableServices.length === 3 (only services.category === macro.category)
// data.tipoTags.length === 0 && data.obiettivoTags.length === 0
return data;
}
// ── Test 3: getOfferEditorData(macroId) — 3 tiers, tier A has 2 services ─────
// Input: macro with 3 offer_micros rows (tier_letter A/B/C) and 2
// offer_tier_services rows assigned to tier A.
// Expected: tiers sorted A->B->C; each tier has
// { id, tier_letter, public_price, assignedServiceIds, servicesTotal };
// tier A's servicesTotal === sum(unit_price) of its 2 assigned services;
// tiers B/C have assignedServiceIds === [] and servicesTotal === "0".
async function test3_tiersWithServices(macroId: string) {
const data: OfferEditorData | null = await getOfferEditorData(macroId);
// Expected assertions (manual):
// data.tiers.map(t => t.tier_letter) === ["A", "B", "C"]
// data.tiers[0].assignedServiceIds.length === 2
// Number(data.tiers[0].servicesTotal) === sum of the 2 services' unit_price
// data.tiers[1].assignedServiceIds === [] && data.tiers[1].servicesTotal === "0"
// data.tiers[2].assignedServiceIds === [] && data.tiers[2].servicesTotal === "0"
const tierA: OfferTierData | undefined = data?.tiers[0];
return { data, tierA };
}
// ── Test 4: getOfferEditorData(macroId) — tipoTags/obiettivoTags ─────────────
// Input: 2 "tipo" tags + 1 "obiettivo" tag exist for the macro (entity_type =
// "offer_macros.tipo" / "offer_macros.obiettivo", entity_id = macroId).
// Expected: tipoTags.length === 2, obiettivoTags.length === 1.
async function test4_tags(macroId: string) {
const data: OfferEditorData | null = await getOfferEditorData(macroId);
// Expected assertions (manual):
// data?.tipoTags.length === 2
// data?.obiettivoTags.length === 1
return data;
}
// ── Test 5: getOfferFieldOptions() ───────────────────────────────────────────
// Input: 2 macros with category "Entry Offer"/"Signature Offer", 1 "tipo" tag
// "Audit", 1 "obiettivo" tag "Lead Generation".
// Expected: categoria contains both values; tipo === ["Audit"];
// obiettivo === ["Lead Generation"].
async function test5_fieldOptions() {
const options: OfferFieldOptions = await getOfferFieldOptions();
// Expected assertions (manual):
// options.categoria includes "Entry Offer" and "Signature Offer"
// options.tipo includes "Audit"
// options.obiettivo includes "Lead Generation"
return options;
}
// Not executed automatically (production DB) — typecheck-only verification.
// To run manually against a dev DB: uncomment the call below.
// void (async () => {
// await test1_getOfferListCards();
// await test2_emptyTiers("macro-id");
// await test3_tiersWithServices("macro-id");
// await test4_tags("macro-id");
// await test5_fieldOptions();
// })();
export {
test1_getOfferListCards,
test2_emptyTiers,
test3_tiersWithServices,
test4_tags,
test5_fieldOptions,
};
+23
View File
@@ -0,0 +1,23 @@
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} />;
}
+245 -1
View File
@@ -6,9 +6,11 @@ import {
offer_micros,
offer_services,
offer_micro_services,
offer_tier_services,
tags,
} from "@/db/schema";
import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm";
import { eq, and, inArray } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
@@ -130,3 +132,245 @@ export async function updateMicroOfferServices(microId: string, serviceIds: stri
}
revalidatePath("/admin/offers");
}
// ── Phase 12: Offer Editor server actions ───────────────────────────────────
// entity_type values for the polymorphic `tags` table, scoped to offer_macros'
// Tipo/Obiettivo dimensions (D-06 pattern — separate pools from "services"/"leads").
const OFFER_TIPO_ENTITY = "offer_macros.tipo";
const OFFER_OBIETTIVO_ENTITY = "offer_macros.obiettivo";
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>;
// Persists the entire offer editor state in one call: macro scalar fields
// (incl. category/ticket/transformation-promise), per-tier upsert
// (tier_letter validated A/B/C via Zod — defense-in-depth alongside the DB
// CHECK constraint from Plan 01) with delete-then-reinsert
// offer_tier_services, and Tipo/Obiettivo tags (delete-then-reinsert).
export async function saveOfferEditor(macroId: string, payload: SaveOfferEditorPayload) {
await requireAdmin();
const parsed = saveOfferEditorSchema.safeParse(payload);
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
const data = parsed.data;
// 1. Update macro scalar fields.
await db
.update(offer_macros)
.set({
internal_name: data.internal_name,
public_name: data.public_name,
description: data.description || null,
category: data.category || null,
ticket: data.ticket || null,
cliente_ideale: data.cliente_ideale || null,
risultato: data.risultato || null,
tempo: data.tempo || null,
pain: data.pain || null,
metodo: data.metodo || null,
})
.where(eq(offer_macros.id, macroId));
// 2. Upsert each tier, then replace its offer_tier_services assignments.
for (const tier of data.tiers) {
const publicPrice = tier.public_price != null ? String(tier.public_price) : null;
let tierId: string;
if (tier.id) {
await db
.update(offer_micros)
.set({
tier_letter: tier.tier_letter,
internal_name: tier.internal_name,
public_name: tier.public_name,
duration_months: tier.duration_months,
public_price: publicPrice,
})
.where(eq(offer_micros.id, tier.id));
tierId = tier.id;
} else {
const [inserted] = await db
.insert(offer_micros)
.values({
macro_id: macroId,
tier_letter: tier.tier_letter,
internal_name: tier.internal_name,
public_name: tier.public_name,
duration_months: tier.duration_months,
public_price: publicPrice,
})
.returning({ id: offer_micros.id });
tierId = inserted.id;
}
// Delete-then-reinsert offer_tier_services (mirrors updateMicroOfferServices).
await db.delete(offer_tier_services).where(eq(offer_tier_services.tier_id, tierId));
if (tier.assignedServiceIds.length > 0) {
await db.insert(offer_tier_services).values(
tier.assignedServiceIds.map((serviceId) => ({ tier_id: tierId, service_id: serviceId }))
);
}
}
// 3. Delete-then-reinsert Tipo/Obiettivo tags.
await db
.delete(tags)
.where(
and(
eq(tags.entity_id, macroId),
inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY])
)
);
const tagRows = [
...data.tipoTags.map((name) => ({ entity_type: OFFER_TIPO_ENTITY, entity_id: macroId, name })),
...data.obiettivoTags.map((name) => ({
entity_type: OFFER_OBIETTIVO_ENTITY,
entity_id: macroId,
name,
})),
];
if (tagRows.length > 0) {
await db.insert(tags).values(tagRows).onConflictDoNothing();
}
revalidatePath("/admin/offers");
revalidatePath(`/admin/offers/${macroId}/edit`);
}
// Toggles the archive flag on an offer macro (OFFER-18).
export async function toggleOfferArchived(macroId: string, archived: boolean) {
await requireAdmin();
await db.update(offer_macros).set({ is_archived: archived }).where(eq(offer_macros.id, macroId));
revalidatePath("/admin/offers");
}
// ── Tipo/Obiettivo tag CRUD (mirrors addServiceOption/removeServiceOption) ───
type OfferTagDimension = "tipo" | "obiettivo";
const OFFER_TAG_ENTITY: Record<OfferTagDimension, string> = {
tipo: OFFER_TIPO_ENTITY,
obiettivo: OFFER_OBIETTIVO_ENTITY,
};
export async function addOfferTag(dimension: OfferTagDimension, macroId: string, value: string) {
await requireAdmin();
if (!(dimension in OFFER_TAG_ENTITY)) throw new Error(`Dimensione non valida: ${dimension}`);
const trimmed = value.trim();
if (trimmed.length === 0) throw new Error("Valore richiesto");
await db
.insert(tags)
.values({ entity_type: OFFER_TAG_ENTITY[dimension], entity_id: macroId, name: trimmed })
.onConflictDoNothing();
revalidatePath("/admin/offers");
}
export async function removeOfferTag(
dimension: OfferTagDimension,
macroId: string,
value: string
) {
await requireAdmin();
if (!(dimension in OFFER_TAG_ENTITY)) throw new Error(`Dimensione non valida: ${dimension}`);
await db
.delete(tags)
.where(
and(
eq(tags.entity_type, OFFER_TAG_ENTITY[dimension]),
eq(tags.entity_id, macroId),
eq(tags.name, value)
)
);
revalidatePath("/admin/offers");
}
// ── Rename an option everywhere it is used (mirrors renameServiceOption) ────
// "categoria"/"ticket" are single-select columns on offer_macros; "tipo"/
// "obiettivo" are multi-select pools in the polymorphic `tags` table.
type OfferOptionField = "categoria" | "ticket" | "tipo" | "obiettivo";
export async function renameOfferOption(
field: OfferOptionField,
oldValue: string,
newValue: string
) {
await requireAdmin();
const next = newValue.trim();
if (next.length === 0) throw new Error("Nuovo nome richiesto");
if (next === oldValue) return;
if (field === "categoria") {
await db.update(offer_macros).set({ category: next }).where(eq(offer_macros.category, oldValue));
} else if (field === "ticket") {
await db.update(offer_macros).set({ ticket: next }).where(eq(offer_macros.ticket, oldValue));
} else if (field === "tipo" || field === "obiettivo") {
await db
.update(tags)
.set({ name: next })
.where(and(eq(tags.entity_type, OFFER_TAG_ENTITY[field]), eq(tags.name, oldValue)));
} else {
throw new Error(`Campo non valido: ${field}`);
}
revalidatePath("/admin/offers");
}
// ── Create a new offer macro (Plan 04 "+ Nuova Offerta" target) ─────────────
const createOfferMacroSchema = z.object({
internal_name: z.string().min(1, "Nome interno richiesto"),
public_name: z.string().optional(),
description: z.string().optional(),
category: z.string().optional(),
});
export async function createOfferMacro(formData: FormData) {
await requireAdmin();
const parsed = createOfferMacroSchema.safeParse({
internal_name: formData.get("internal_name"),
public_name: formData.get("public_name") ?? "",
description: formData.get("description") ?? "",
category: formData.get("category") ?? "",
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await db.insert(offer_macros).values({
internal_name: parsed.data.internal_name,
public_name: parsed.data.public_name?.trim() || parsed.data.internal_name,
description: parsed.data.description || null,
category: parsed.data.category || null,
});
revalidatePath("/admin/offers");
}
+7 -228
View File
@@ -1,238 +1,17 @@
import { getCatalogWithMicros, getAllOfferServices } from "@/lib/offer-queries";
import {
createMacro,
createMicro,
createOfferService,
deleteMacro,
deleteMicro,
toggleOfferServiceActive,
} from "./actions";
import { ServiceCheckboxList } from "@/components/admin/offers/ServiceCheckboxList";
import { getOfferListCards, getOfferFieldOptions } from "@/lib/offer-queries";
import { OfferListClient } from "@/components/admin/offers/OfferListClient";
export const revalidate = 0;
export default async function OffersPage() {
const [catalog, allServices] = await Promise.all([
getCatalogWithMicros(),
getAllOfferServices(),
const [cards, options] = await Promise.all([
getOfferListCards(),
getOfferFieldOptions(),
]);
return (
<div className="max-w-5xl mx-auto py-8 px-4 space-y-10">
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Offerte</h1>
{/* ── Sezione macro-offerte ── */}
<section>
<h2 className="text-lg font-semibold text-[#1a1a1a] mb-4">Macro-Offerte</h2>
{/* Create macro form */}
<form action={createMacro} className="bg-white rounded-lg border border-[#e5e7eb] p-4 mb-6 space-y-3 max-w-lg">
<p className="text-sm font-medium">Nuova Macro-Offerta</p>
<input
name="internal_name"
placeholder="Nome interno (es. Entry Offer)"
required
className="w-full border rounded px-3 py-1.5 text-sm"
/>
<input
name="public_name"
placeholder="Nome pubblico (es. Starter Branding)"
required
className="w-full border rounded px-3 py-1.5 text-sm"
/>
<textarea
name="transformation_promise"
placeholder="Promessa di trasformazione"
rows={2}
className="w-full border rounded px-3 py-1.5 text-sm"
/>
<button
type="submit"
className="bg-[#1A463C] text-white text-sm px-4 py-1.5 rounded hover:bg-[#163a31]"
>
Aggiungi Macro
</button>
</form>
{/* List macros */}
<div className="space-y-8">
{catalog.map((macro) => (
<div key={macro.id} className="bg-white rounded-lg border border-[#e5e7eb] p-6">
<div className="flex items-start justify-between mb-1">
<div>
<p className="font-semibold text-[#1a1a1a]">{macro.internal_name}</p>
<p className="text-sm text-[#71717a]">Pubblico: {macro.public_name}</p>
{macro.transformation_promise && (
<p className="text-xs text-[#71717a] mt-1 italic">{macro.transformation_promise}</p>
)}
</div>
<form action={deleteMacro.bind(null, macro.id)}>
<button type="submit" className="text-xs text-red-600 hover:underline">
Elimina
</button>
</form>
</div>
{/* Micro-offers under this macro */}
<div className="mt-4 space-y-4 pl-4 border-l-2 border-[#e5e7eb]">
<p className="text-xs font-semibold text-[#71717a] uppercase tracking-wider">Micro-Offerte</p>
{macro.micros.map((micro) => (
<div key={micro.id} className="bg-[#f9f9f9] rounded-lg p-4 space-y-3">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium">{micro.internal_name}</p>
<p className="text-xs text-[#71717a]">
Pubblico: {micro.public_name} &middot;{" "}
{micro.duration_months} {micro.duration_months === 1 ? "mese" : "mesi"}
</p>
{micro.transformation_promise && (
<p className="text-xs text-[#71717a] italic">{micro.transformation_promise}</p>
)}
<p className="text-xs text-[#1a1a1a] mt-1 font-medium">
Prezzo cumulativo: {parseFloat(micro.cumulative_price).toFixed(2)}
</p>
</div>
<form action={deleteMicro.bind(null, micro.id)}>
<button type="submit" className="text-xs text-red-600 hover:underline">
Elimina
</button>
</form>
</div>
{/* Service assignment checkbox list */}
<div>
<p className="text-xs font-medium text-[#71717a] mb-2">Servizi inclusi:</p>
<ServiceCheckboxList
allServices={allServices.map((s) => ({
id: s.id,
name: s.name,
price: String(s.price),
}))}
assignedIds={micro.services.map((s) => s.id)}
microId={micro.id}
/>
</div>
</div>
))}
{/* Create micro form */}
<form action={createMicro} className="bg-white rounded border border-[#e5e7eb] p-3 space-y-2">
<input type="hidden" name="macro_id" value={macro.id} />
<p className="text-xs font-medium">Nuova Micro-Offerta</p>
<input
name="internal_name"
placeholder="Nome interno"
required
className="w-full border rounded px-2 py-1 text-xs"
/>
<input
name="public_name"
placeholder="Nome pubblico"
required
className="w-full border rounded px-2 py-1 text-xs"
/>
<textarea
name="transformation_promise"
placeholder="Promessa di trasformazione"
rows={2}
className="w-full border rounded px-2 py-1 text-xs"
/>
<div className="flex items-center gap-2">
<label className="text-xs">Durata (mesi):</label>
<input
name="duration_months"
type="number"
min="1"
defaultValue="1"
required
className="w-16 border rounded px-2 py-1 text-xs"
/>
</div>
<button
type="submit"
className="bg-[#1A463C] text-white text-xs px-3 py-1 rounded hover:bg-[#163a31]"
>
Aggiungi Micro
</button>
</form>
</div>
</div>
))}
{catalog.length === 0 && (
<p className="text-sm text-[#71717a]">Nessuna macro-offerta ancora. Creane una sopra.</p>
)}
</div>
</section>
{/* ── Sezione servizi offerta ── */}
<section>
<h2 className="text-lg font-semibold text-[#1a1a1a] mb-4">Servizi Offerta</h2>
<p className="text-sm text-[#71717a] mb-4">
I servizi qui sono diversi dal catalogo preventivi hanno una descrizione della
trasformazione e vengono raggruppati nelle micro-offerte.
</p>
{/* List services */}
<div className="bg-white rounded-lg border border-[#e5e7eb] divide-y divide-[#e5e7eb] mb-6">
{allServices.map((svc) => (
<div key={svc.id} className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-sm font-medium">{svc.name}</p>
{svc.transformation_description && (
<p className="text-xs text-[#71717a]">{svc.transformation_description}</p>
)}
</div>
<div className="flex items-center gap-4">
<span className="text-sm font-mono">{parseFloat(String(svc.price)).toFixed(2)}</span>
<form action={toggleOfferServiceActive.bind(null, svc.id, !svc.active)}>
<button type="submit" className="text-xs text-[#71717a] hover:text-[#1a1a1a]">
{svc.active ? "Disattiva" : "Attiva"}
</button>
</form>
</div>
</div>
))}
{allServices.length === 0 && (
<p className="px-4 py-3 text-sm text-[#71717a]">Nessun servizio ancora.</p>
)}
</div>
{/* Create service form */}
<form
action={createOfferService}
className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-3 max-w-lg"
>
<p className="text-sm font-medium">Nuovo Servizio Offerta</p>
<input
name="name"
placeholder="Nome servizio"
required
className="w-full border rounded px-3 py-1.5 text-sm"
/>
<input
name="price"
type="number"
step="0.01"
min="0"
placeholder="Prezzo (€)"
required
className="w-full border rounded px-3 py-1.5 text-sm"
/>
<textarea
name="transformation_description"
placeholder="Descrizione trasformazione (marketing)"
rows={2}
className="w-full border rounded px-3 py-1.5 text-sm"
/>
<button
type="submit"
className="bg-[#1A463C] text-white text-sm px-4 py-1.5 rounded hover:bg-[#163a31]"
>
Aggiungi Servizio
</button>
</form>
</section>
<div>
<OfferListClient cards={cards} categoryOptions={options.categoria} />
</div>
);
}
@@ -0,0 +1,500 @@
"use client";
import { useState, useMemo, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
saveOfferEditor,
toggleOfferArchived,
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, OfferTierData } from "@/lib/offer-queries";
type TierLetter = "A" | "B" | "C";
const TIER_LETTERS: TierLetter[] = ["A", "B", "C"];
function emptyTier(letter: TierLetter): OfferTierData {
return {
id: "",
tier_letter: letter,
internal_name: "",
public_name: "",
duration_months: 1,
public_price: null,
assignedServiceIds: [],
servicesTotal: "0",
};
}
function padTiers(tiers: OfferTierData[]): OfferTierData[] {
return TIER_LETTERS.map(
(letter) => tiers.find((t) => t.tier_letter === letter) ?? emptyTier(letter)
);
}
function formatEuro(value: number): string {
return `${value.toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function formatUnitPrice(raw: string): string {
const num = parseFloat(raw);
return `${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
type MacroFields = {
internal_name: string;
public_name: string;
description: string;
category: string | null;
ticket: string | null;
cliente_ideale: string;
risultato: string;
tempo: string;
pain: string;
metodo: string;
};
export function OfferEditorClient({
data,
fieldOptions,
}: {
data: OfferEditorData;
fieldOptions: OfferFieldOptions;
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [saveError, setSaveError] = useState<string | null>(null);
const [macro, setMacro] = useState<MacroFields>({
internal_name: data.macro.internal_name,
public_name: data.macro.public_name,
description: data.macro.description ?? "",
category: data.macro.category,
ticket: data.macro.ticket,
cliente_ideale: data.macro.cliente_ideale ?? "",
risultato: data.macro.risultato ?? "",
tempo: data.macro.tempo ?? "",
pain: data.macro.pain ?? "",
metodo: data.macro.metodo ?? "",
});
const [tiers, setTiers] = useState<OfferTierData[]>(() => padTiers(data.tiers));
const [tipoTags, setTipoTags] = useState<string[]>(data.tipoTags);
const [obiettivoTags, setObiettivoTags] = useState<string[]>(data.obiettivoTags);
const [categoriaOptions, setCategoriaOptions] = useState<string[]>(fieldOptions.categoria);
const [ticketOptions, setTicketOptions] = useState<string[]>(fieldOptions.ticket);
const [tipoOptions, setTipoOptions] = useState<string[]>(fieldOptions.tipo);
const [obiettivoOptions, setObiettivoOptions] = useState<string[]>(fieldOptions.obiettivo);
const [initialArchived] = useState<boolean>(data.macro.is_archived);
const filteredServices = useMemo(() => {
return macro.category
? data.availableServices.filter((s) => s.category === macro.category)
: data.availableServices;
}, [macro.category, data.availableServices]);
const tierTotals = useMemo(() => {
return tiers.map((tier) =>
tier.assignedServiceIds.reduce((sum, id) => {
const service = filteredServices.find((s) => s.id === id);
return sum + Number(service?.unit_price ?? 0);
}, 0)
);
}, [tiers, filteredServices]);
const canSave = tiers.some((t) => t.assignedServiceIds.length > 0);
function updateMacro<K extends keyof MacroFields>(key: K, value: MacroFields[K]) {
setMacro((prev) => ({ ...prev, [key]: value }));
}
function toggleService(tierIdx: number, serviceId: string) {
setTiers((prev) =>
prev.map((tier, idx) => {
if (idx !== tierIdx) return tier;
const has = tier.assignedServiceIds.includes(serviceId);
return {
...tier,
assignedServiceIds: has
? tier.assignedServiceIds.filter((id) => id !== serviceId)
: [...tier.assignedServiceIds, serviceId],
};
})
);
}
function updateTierPublicPrice(tierIdx: number, value: string) {
setTiers((prev) =>
prev.map((tier, idx) => (idx === tierIdx ? { ...tier, public_price: value } : tier))
);
}
function handleSave() {
setSaveError(null);
const payload: SaveOfferEditorPayload = {
internal_name: macro.internal_name,
public_name: macro.public_name || macro.internal_name,
description: macro.description,
category: macro.category ?? undefined,
ticket: macro.ticket ?? undefined,
cliente_ideale: macro.cliente_ideale,
risultato: macro.risultato,
tempo: macro.tempo,
pain: macro.pain,
metodo: macro.metodo,
tiers: tiers.map((t) => ({
id: t.id || undefined,
tier_letter: (t.tier_letter ?? "A") as "A" | "B" | "C",
internal_name: t.internal_name || `Tier ${t.tier_letter}`,
public_name: t.public_name || t.tier_letter || "Tier",
duration_months: t.duration_months || 1,
public_price: t.public_price ? Number(t.public_price) : null,
assignedServiceIds: t.assignedServiceIds,
})),
tipoTags,
obiettivoTags,
};
startTransition(async () => {
try {
await saveOfferEditor(data.macro.id, payload);
router.push("/admin/offers");
} catch {
setSaveError("Errore nel salvataggio. Verifica i campi.");
}
});
}
function handleArchive() {
if (!window.confirm("Sei sicuro? L'offerta non sarà visibile nella lista.")) return;
startTransition(async () => {
try {
await toggleOfferArchived(data.macro.id, true);
router.push("/admin/offers");
} catch {
setSaveError("Errore nel salvataggio. Verifica i campi.");
}
});
}
function handleRenameCategoria(oldValue: string, newValue: string) {
setCategoriaOptions((prev) => prev.map((o) => (o === oldValue ? newValue : o)));
if (macro.category === oldValue) updateMacro("category", newValue);
startTransition(async () => {
try {
await renameOfferOption("categoria", oldValue, newValue);
} catch {
setSaveError("Errore nel salvataggio. Verifica i campi.");
}
});
}
function handleRenameTicket(oldValue: string, newValue: string) {
setTicketOptions((prev) => prev.map((o) => (o === oldValue ? newValue : o)));
if (macro.ticket === oldValue) updateMacro("ticket", newValue);
startTransition(async () => {
try {
await renameOfferOption("ticket", oldValue, newValue);
} catch {
setSaveError("Errore nel salvataggio. Verifica i campi.");
}
});
}
function handleRenameTipo(oldValue: string, newValue: string) {
setTipoOptions((prev) => prev.map((o) => (o === oldValue ? newValue : o)));
setTipoTags((prev) => prev.map((t) => (t === oldValue ? newValue : t)));
startTransition(async () => {
try {
await renameOfferOption("tipo", oldValue, newValue);
} catch {
setSaveError("Errore nel salvataggio. Verifica i campi.");
}
});
}
function handleRenameObiettivo(oldValue: string, newValue: string) {
setObiettivoOptions((prev) => prev.map((o) => (o === oldValue ? newValue : o)));
setObiettivoTags((prev) => prev.map((t) => (t === oldValue ? newValue : t)));
startTransition(async () => {
try {
await renameOfferOption("obiettivo", oldValue, newValue);
} catch {
setSaveError("Errore nel salvataggio. Verifica i campi.");
}
});
}
return (
<div className="max-w-4xl mx-auto py-8 px-4 space-y-8">
{/* Header & navigation */}
<div>
<Link href="/admin/offers" className="text-sm text-[#71717a] hover:text-[#1a1a1a]">
Indietro
</Link>
<div className="mt-2 flex items-center justify-between">
<h2 className="text-xl font-semibold text-[#1a1a1a]">Modifica Offerta</h2>
</div>
<div className="mt-2">
<h3 className="text-base font-semibold text-[#1a1a1a]">
<EditableCell
value={macro.internal_name}
type="text"
required
onSave={(v) => updateMacro("internal_name", v)}
/>
</h3>
</div>
</div>
{/* Tags block */}
<section className="space-y-4">
<h3 className="text-base font-semibold text-[#1a1a1a]">Tag</h3>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">Categoria</span>
<OptionSelect
value={macro.category}
options={categoriaOptions}
onChange={(v) => updateMacro("category", v)}
onRename={handleRenameCategoria}
placeholder="Seleziona categoria..."
/>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">Ticket</span>
<OptionSelect
value={macro.ticket}
options={ticketOptions}
onChange={(v) => updateMacro("ticket", v)}
onRename={handleRenameTicket}
placeholder="Seleziona ticket..."
/>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">Tipo</span>
<OptionMultiSelect
values={tipoTags}
options={tipoOptions}
onAdd={(v) => {
setTipoTags((prev) => [...prev, v]);
if (!tipoOptions.includes(v)) setTipoOptions((prev) => [...prev, v]);
}}
onRemove={(v) => setTipoTags((prev) => prev.filter((t) => t !== v))}
onRename={handleRenameTipo}
placeholder="+ Crea Tipo"
/>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">Obiettivo</span>
<OptionMultiSelect
values={obiettivoTags}
options={obiettivoOptions}
onAdd={(v) => {
setObiettivoTags((prev) => [...prev, v]);
if (!obiettivoOptions.includes(v)) setObiettivoOptions((prev) => [...prev, v]);
}}
onRemove={(v) => setObiettivoTags((prev) => prev.filter((t) => t !== v))}
onRename={handleRenameObiettivo}
placeholder="+ Crea Obiettivo"
/>
</div>
</section>
{/* Services matrix */}
<section className="space-y-3">
<h3 className="text-base font-semibold text-[#1a1a1a]">Servizi Inclusi</h3>
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
<tr>
<th className="text-left py-2 px-3 font-semibold text-[#71717a]">Servizio</th>
<th className="text-right py-2 px-3 font-semibold text-[#71717a]">Prezzo</th>
{TIER_LETTERS.map((letter) => (
<th
key={letter}
className="text-center py-2 px-3 font-semibold text-[#71717a] w-12"
>
{letter}
</th>
))}
</tr>
</thead>
<tbody>
{filteredServices.length === 0 ? (
<tr>
<td colSpan={2 + TIER_LETTERS.length} className="py-3 px-3 text-[#71717a]">
Nessun servizio disponibile per questa categoria
</td>
</tr>
) : (
filteredServices.map((service) => (
<tr
key={service.id}
className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150 h-10"
>
<td className="py-2 px-3 text-[#1a1a1a]">{service.name}</td>
<td className="py-2 px-3 text-right tabular-nums text-[#1a1a1a]">
{formatUnitPrice(service.unit_price)}
</td>
{tiers.map((tier, tierIdx) => (
<td key={tier.tier_letter} className="py-2 px-3 text-center">
<input
type="checkbox"
checked={tier.assignedServiceIds.includes(service.id)}
onChange={() => toggleService(tierIdx, service.id)}
style={{ accentColor: "#1A463C" }}
className="h-4 w-4 cursor-pointer"
/>
</td>
))}
</tr>
))
)}
</tbody>
<tfoot>
<tr className="border-t border-[#e5e7eb]">
<td className="py-2 px-3 font-semibold text-[#1a1a1a]" colSpan={2}>
Totale Servizi
</td>
{tierTotals.map((total, idx) => (
<td
key={tiers[idx].tier_letter}
className="py-2 px-3 text-center font-semibold text-[#1a1a1a] tabular-nums"
>
{formatEuro(total)}
</td>
))}
</tr>
<tr>
<td className="py-2 px-3 font-semibold text-[#1a1a1a]" colSpan={2}>
Prezzo Pubblico
</td>
{tiers.map((tier, tierIdx) => (
<td key={tier.tier_letter} className="py-2 px-3 text-center">
<input
type="number"
min="0"
step="0.01"
value={tier.public_price ?? ""}
onChange={(e) => updateTierPublicPrice(tierIdx, e.target.value)}
placeholder="€0,00"
className="w-20 text-center text-sm border border-[#e5e7eb] rounded px-1 py-1 tabular-nums focus-visible:ring-1 focus-visible:ring-primary focus:outline-none"
/>
</td>
))}
</tr>
</tfoot>
</table>
</div>
</div>
</section>
{/* Transformation promise block */}
<section className="space-y-4">
<h3 className="text-base font-semibold text-[#1a1a1a]">Promessa di Trasformazione</h3>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">Aiuto</span>
<div className="flex-1">
<EditableCell
value={macro.cliente_ideale}
type="text"
placeholder="Aggiungi cliente ideale"
onSave={(v) => updateMacro("cliente_ideale", v)}
/>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">A ottenere</span>
<div className="flex-1">
<EditableCell
value={macro.risultato}
type="text"
placeholder="Aggiungi risultato"
onSave={(v) => updateMacro("risultato", v)}
/>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">In</span>
<div className="flex-1">
<EditableCell
value={macro.tempo}
type="text"
placeholder="Aggiungi durata (es. 3 mesi)"
onSave={(v) => updateMacro("tempo", v)}
/>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">Senza</span>
<div className="flex-1">
<EditableCell
value={macro.pain}
type="text"
placeholder="Aggiungi pain point"
onSave={(v) => updateMacro("pain", v)}
/>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">Grazie a</span>
<div className="flex-1">
<EditableCell
value={macro.metodo}
type="text"
placeholder="Aggiungi metodo"
onSave={(v) => updateMacro("metodo", v)}
/>
</div>
</div>
</section>
{/* Action buttons */}
{saveError && <p className="text-sm text-red-600">{saveError}</p>}
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleSave}
disabled={!canSave || isPending}
title={!canSave ? "Seleziona almeno un servizio" : undefined}
className="bg-[#1A463C] text-white text-sm font-semibold px-6 py-4 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[#163a31] transition-colors duration-150"
>
Salva Offerta
</button>
<Link
href="/admin/offers"
className="border border-[#e5e7eb] text-[#1a1a1a] text-sm font-semibold px-6 py-4 rounded hover:bg-[#f9f9f9] transition-colors duration-150"
>
Annulla
</Link>
{!initialArchived && (
<button
type="button"
onClick={handleArchive}
disabled={isPending}
className="text-[#dc2626] text-sm font-semibold px-6 py-4 rounded hover:bg-red-50 transition-colors duration-150 disabled:opacity-50"
>
Archivia
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,158 @@
"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";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
export function OfferListClient({
cards,
categoryOptions,
}: {
cards: OfferListCard[];
categoryOptions: string[];
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
const [newOfferName, setNewOfferName] = useState("");
const filteredCards = useMemo(() => {
return cards.filter(
(c) =>
(activeCategory === null || c.category === activeCategory) &&
(showArchived || !c.is_archived)
);
}, [cards, activeCategory, showArchived]);
function handleCreateSubmit(formData: FormData) {
startTransition(async () => {
await createOfferMacro(formData);
setShowCreateForm(false);
setNewOfferName("");
router.refresh();
});
}
const createCta = (
<div className="flex flex-col items-end gap-2">
<Button
type="button"
className="bg-[#1A463C] text-white hover:bg-[#163a31]"
onClick={() => setShowCreateForm((v) => !v)}
>
+ Nuova Offerta
</Button>
{showCreateForm && (
<form
action={handleCreateSubmit}
className="flex items-center gap-2 bg-white border border-[#e5e7eb] rounded-lg p-2"
>
<Input
name="internal_name"
required
placeholder="Nome offerta..."
value={newOfferName}
onChange={(e) => setNewOfferName(e.target.value)}
className="h-9 w-48"
/>
<Button
type="submit"
size="sm"
disabled={isPending}
className="bg-[#1A463C] text-white hover:bg-[#163a31]"
>
Crea
</Button>
</form>
)}
</div>
);
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<h2 className="text-[20px] font-semibold text-[#1a1a1a]">Offerte</h2>
{createCta}
</div>
{/* Filter row */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setActiveCategory(null)}
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
activeCategory === null
? "border-[#1A463C] text-[#1A463C]"
: "border-[#e5e7eb] text-[#71717a]"
}`}
>
Tutti
</button>
{categoryOptions.map((category) => (
<button
key={category}
type="button"
onClick={() => setActiveCategory(category)}
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
activeCategory === category
? "border-[#1A463C] text-[#1A463C]"
: "border-[#e5e7eb] text-[#71717a]"
}`}
>
{category}
</button>
))}
</div>
<label className="flex items-center gap-2 text-sm text-[#71717a]">
<input
type="checkbox"
checked={showArchived}
onChange={(e) => setShowArchived(e.target.checked)}
className="h-4 w-4 rounded border-[#e5e7eb]"
/>
Mostra offerte archiviate
</label>
</div>
{/* Card grid / empty state */}
{filteredCards.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-[#e5e7eb] bg-white py-16 text-center">
<h3 className="text-[16px] font-semibold text-[#1a1a1a]">Nessuna offerta</h3>
<p className="text-sm text-[#71717a]">Inizia creando la tua prima offerta</p>
<div className="mt-2">{createCta}</div>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredCards.map((card) => (
<Link
key={card.id}
href={`/admin/offers/${card.id}/edit`}
className="cursor-pointer rounded-lg border border-[#e5e7eb] bg-white p-4 transition-shadow hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)]"
>
<h3 className="text-[16px] font-semibold text-[#1a1a1a]">{card.internal_name}</h3>
{card.description && (
<p className="mt-1 line-clamp-2 text-sm text-[#71717a]">{card.description}</p>
)}
<div className="mt-3 flex items-center gap-2">
{card.category && <Badge variant="secondary">{card.category}</Badge>}
{card.is_archived && (
<span className="text-xs font-semibold text-[#dc2626]">Archiviata</span>
)}
</div>
</Link>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,44 @@
-- 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);
+7
View File
@@ -22,6 +22,13 @@
"when": 1781442000000,
"tag": "0006_add_tags_table",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1781700000000,
"tag": "0008_offer_tier_schema",
"breakpoints": true
}
]
}
+53
View File
@@ -259,6 +259,10 @@ export const settings = pgTable("settings", {
});
// ============ OFFER MACROS ============
// Phase 12 additive columns (Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico):
// description, category, ticket, is_archived, and the 5 structured
// transformation-promise fields (cliente_ideale/risultato/tempo/pain/metodo).
// transformation_promise (legacy, free-text) stays untouched.
export const offer_macros = pgTable("offer_macros", {
id: text("id")
.primaryKey()
@@ -266,6 +270,16 @@ export const offer_macros = pgTable("offer_macros", {
internal_name: text("internal_name").notNull(),
public_name: text("public_name").notNull(),
transformation_promise: text("transformation_promise"),
// Phase 12 additive fields below
description: text("description"), // short description shown on offer cards
category: text("category"), // single-select "Categoria" (Entry/Signature/Retainer Offer) — OFFER-15/18
ticket: text("ticket"), // single-select "Ticket" (Low/Mid/High Ticket) — OFFER-15
is_archived: boolean("is_archived").notNull().default(false), // OFFER-18 archive flag
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]"
sort_order: integer("sort_order").notNull().default(0),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
@@ -283,6 +297,15 @@ export const offer_micros = pgTable("offer_micros", {
transformation_promise: text("transformation_promise"),
duration_months: integer("duration_months").notNull().default(1),
sort_order: integer("sort_order").notNull().default(0),
// Phase 12 additive fields below.
// tier_letter: nullable, values constrained to 'A'|'B'|'C' via a CHECK
// constraint at the SQL level (migration 0008) — not enforced in Drizzle
// (no native CHECK helper in this project's pg-core version); validated in
// Zod at the server-action layer (Plan 03).
tier_letter: text("tier_letter"),
// public_price: manual public price per tier (D-5/OFFER-16), independent of
// the computed services total (computed at query time, never stored).
public_price: numeric("public_price", { precision: 10, scale: 2 }),
});
// ============ OFFER SERVICES (distinct from service_catalog — marketing/transformation semantics) ============
@@ -312,6 +335,28 @@ export const offer_micro_services = pgTable(
})
);
// ============ 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),
})
);
// ============ PROJECT OFFERS (assignment of micro-offer to project) ============
export const project_offers = pgTable("project_offers", {
id: text("id")
@@ -549,6 +594,7 @@ export const offerMacrosRelations = relations(offer_macros, ({ many }) => ({
export const offerMicrosRelations = relations(offer_micros, ({ one, many }) => ({
macro: one(offer_macros, { fields: [offer_micros.macro_id], references: [offer_macros.id] }),
services: many(offer_micro_services),
tierServices: many(offer_tier_services),
projectOffers: many(project_offers),
}));
@@ -561,6 +607,11 @@ export const offerMicroServicesRelations = relations(offer_micro_services, ({ on
service: one(offer_services, { fields: [offer_micro_services.service_id], references: [offer_services.id] }),
}));
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] }),
}));
export const projectOffersRelations = relations(project_offers, ({ one }) => ({
project: one(projects, { fields: [project_offers.project_id], references: [projects.id] }),
micro: one(offer_micros, { fields: [project_offers.micro_id], references: [offer_micros.id] }),
@@ -638,6 +689,8 @@ export type OfferService = typeof offer_services.$inferSelect;
export type NewOfferService = typeof offer_services.$inferInsert;
export type OfferMicroService = typeof offer_micro_services.$inferSelect;
export type NewOfferMicroService = typeof offer_micro_services.$inferInsert;
export type OfferTierService = typeof offer_tier_services.$inferSelect;
export type NewOfferTierService = typeof offer_tier_services.$inferInsert;
export type ProjectOffer = typeof project_offers.$inferSelect;
export type NewProjectOffer = typeof project_offers.$inferInsert;
export type OfferPhase = typeof offer_phases.$inferSelect;
+188 -1
View File
@@ -4,9 +4,12 @@ import {
offer_micros,
offer_services,
offer_micro_services,
offer_tier_services,
services,
tags,
} from "@/db/schema";
import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema";
import { eq, asc, inArray, sql } from "drizzle-orm";
import { eq, and, asc, inArray, sql } from "drizzle-orm";
export type MicroWithServices = OfferMicro & {
services: Array<{ id: string; name: string; price: string }>;
@@ -94,3 +97,187 @@ export async function getMicroAssignedServiceIds(microId: string): Promise<strin
.where(eq(offer_micro_services.micro_id, microId));
return rows.map((r) => r.service_id);
}
// ── Phase 12: Offer Editor query layer ──────────────────────────────────────
// entity_type values for the polymorphic `tags` table, scoped to offer_macros'
// Tipo/Obiettivo dimensions (D-06 pattern — separate pools from "services"/"leads").
const OFFER_TIPO_ENTITY = "offer_macros.tipo";
const OFFER_OBIETTIVO_ENTITY = "offer_macros.obiettivo";
export type OfferListCard = Pick<
OfferMacro,
"id" | "internal_name" | "description" | "category" | "is_archived"
>;
// List-page cards (Plan 04): one row per offer_macros, with category + archive
// status for client-side filtering. Archived offers ARE included — the
// "Mostra offerte archiviate" toggle filters client-side per UI-SPEC.
export async function getOfferListCards(): Promise<OfferListCard[]> {
const rows = await db
.select({
id: offer_macros.id,
internal_name: offer_macros.internal_name,
description: offer_macros.description,
category: offer_macros.category,
is_archived: offer_macros.is_archived,
})
.from(offer_macros)
.orderBy(asc(offer_macros.sort_order), asc(offer_macros.created_at));
return rows;
}
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;
tiers: OfferTierData[];
availableServices: Array<{
id: string;
name: string;
unit_price: string;
category: string | null;
}>;
tipoTags: string[];
obiettivoTags: string[];
};
// Full editor data for a single offer (Plan 05): macro fields (incl.
// category/ticket/is_archived/transformation-promise), its A/B/C tiers with
// assigned service IDs + computed services total, the category-filtered
// service catalog for the composition matrix, and Tipo/Obiettivo tags.
export async function getOfferEditorData(macroId: string): Promise<OfferEditorData | null> {
const [macro] = await db.select().from(offer_macros).where(eq(offer_macros.id, macroId));
if (!macro) return null;
// Tiers ordered A -> B -> C, with null/unrecognized tier_letter sorted last.
const tierOrder = sql`CASE ${offer_micros.tier_letter} WHEN 'A' THEN 1 WHEN 'B' THEN 2 WHEN 'C' THEN 3 ELSE 4 END`;
const tiers = await db
.select()
.from(offer_micros)
.where(eq(offer_micros.macro_id, macroId))
.orderBy(asc(tierOrder));
const tierIds = tiers.map((t) => t.id);
let assignedRows: Array<{ tier_id: string; service_id: string }> = [];
let totalsMap = new Map<string, string>();
if (tierIds.length > 0) {
assignedRows = await db
.select({
tier_id: offer_tier_services.tier_id,
service_id: offer_tier_services.service_id,
})
.from(offer_tier_services)
.where(inArray(offer_tier_services.tier_id, tierIds));
const totalRows = await db
.select({
tier_id: offer_tier_services.tier_id,
servicesTotal: 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, tierIds))
.groupBy(offer_tier_services.tier_id);
totalsMap = new Map(totalRows.map((r) => [r.tier_id, r.servicesTotal]));
}
const tiersData: OfferTierData[] = tiers.map((tier) => ({
id: tier.id,
tier_letter: tier.tier_letter,
internal_name: tier.internal_name,
public_name: tier.public_name,
duration_months: tier.duration_months,
public_price: tier.public_price,
assignedServiceIds: assignedRows
.filter((a) => a.tier_id === tier.id)
.map((a) => a.service_id),
servicesTotal: totalsMap.get(tier.id) ?? "0",
}));
// Category-filtered service catalog for the composition matrix (D-4
// pre-filter, best-effort). If macro.category is unset, return all active
// services unfiltered.
const availableServicesQuery = db
.select({
id: services.id,
name: services.name,
unit_price: services.unit_price,
category: services.category,
})
.from(services);
const availableServices = macro.category
? await availableServicesQuery.where(
and(eq(services.active, true), eq(services.category, macro.category))
)
: await availableServicesQuery.where(eq(services.active, true));
// Tipo/Obiettivo tags for this macro.
const tagRows = await db
.select({ name: tags.name, type: tags.entity_type })
.from(tags)
.where(
and(
eq(tags.entity_id, macroId),
inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY])
)
);
const tipoTags = tagRows.filter((r) => r.type === OFFER_TIPO_ENTITY).map((r) => r.name);
const obiettivoTags = tagRows
.filter((r) => r.type === OFFER_OBIETTIVO_ENTITY)
.map((r) => r.name);
return {
macro,
tiers: tiersData,
availableServices,
tipoTags,
obiettivoTags,
};
}
export type OfferFieldOptions = {
categoria: string[];
ticket: string[];
tipo: string[];
obiettivo: string[];
};
// Shared option pools for the offer editor's select fields (Notion-style
// dropdowns), mirroring getCatalogFieldOptions from admin-queries.ts.
export async function getOfferFieldOptions(): Promise<OfferFieldOptions> {
const categoriaRows = await db.selectDistinct({ value: offer_macros.category }).from(offer_macros);
const ticketRows = await db.selectDistinct({ value: offer_macros.ticket }).from(offer_macros);
const tagRows = await db
.selectDistinct({ name: tags.name, type: tags.entity_type })
.from(tags)
.where(inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY]));
const sortUnique = (arr: (string | null)[]) =>
Array.from(new Set(arr.filter((v): v is string => !!v && v.trim().length > 0))).sort(
(a, b) => a.localeCompare(b, "it")
);
return {
categoria: sortUnique(categoriaRows.map((r) => r.value)),
ticket: sortUnique(ticketRows.map((r) => r.value)),
tipo: sortUnique(tagRows.filter((r) => r.type === OFFER_TIPO_ENTITY).map((r) => r.name)),
obiettivo: sortUnique(
tagRows.filter((r) => r.type === OFFER_OBIETTIVO_ENTITY).map((r) => r.name)
),
};
}