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