Compare commits
26 Commits
9bff7623da
...
56f9fd1d07
| Author | SHA1 | Date | |
|---|---|---|---|
| 56f9fd1d07 | |||
| 9a74e5ccfa | |||
| 791a6e64b1 | |||
| 605dc323b6 | |||
| a486d27a2b | |||
| ca3712612f | |||
| b7203c3da2 | |||
| 7df4b9c74a | |||
| 68dc1b605b | |||
| 8c5c918304 | |||
| 8e0e4b9c59 | |||
| e46607d217 | |||
| f01fa347c4 | |||
| 8bdc854a6a | |||
| a372c613e2 | |||
| 0d742f2328 | |||
| e2d6e97168 | |||
| d4c89b4170 | |||
| b8f14fad4d | |||
| 827604084e | |||
| b80f0563b8 | |||
| bfc99329c5 | |||
| 89d15eeb54 | |||
| 11d6c1179f | |||
| 1414c3e95c | |||
| 640f986967 |
@@ -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 | Complete |
|
||||
| OFFER-15 | Ogni offerta ha tag su 4 dimensioni (categoria, ticket, tipo, obiettivo) creabili al volo, riusando il sistema tag polimorfico di Phase 11 | Complete |
|
||||
| OFFER-16 | Per ogni tier l'admin imposta un "prezzo pubblico" manuale, distinto e indipendente dal totale dei servizi | Complete |
|
||||
| OFFER-17 | Ogni offerta ha una promessa di trasformazione strutturata (Cliente Ideale / Risultato / tempo / Pain / Metodo) | Complete |
|
||||
| OFFER-18 | La lista offerte è filtrabile per categoria e supporta l'archiviazione (mostra/nascondi archiviate) | Complete |
|
||||
| OFFER-13 | I dati legacy di `service_catalog` e `offer_services` sono consolidati in `services` senza perdita (migration additiva) | Complete |
|
||||
|
||||
## Workspace Progetto — Servizi Attivi (v1)
|
||||
@@ -57,6 +60,7 @@ v2.0 (Phases 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
|
||||
@@ -91,8 +95,12 @@ Popolata dal roadmapper durante la creazione della roadmap.
|
||||
| OFFER-09 | Phase 11 | Complete |
|
||||
| OFFER-10 | Phase 11 | Complete |
|
||||
| OFFER-13 | Phase 11 | Complete |
|
||||
| OFFER-11 | Phase 12 | Pending |
|
||||
| OFFER-12 | Phase 12 | Pending |
|
||||
| OFFER-11 | Phase 12 | Complete |
|
||||
| OFFER-15 | Phase 12 | Complete |
|
||||
| OFFER-16 | Phase 12 | Complete |
|
||||
| OFFER-17 | Phase 12 | Complete |
|
||||
| OFFER-18 | Phase 12 | Complete |
|
||||
| OFFER-12 | Deferred (v2) | Rimandato 2026-06-14 |
|
||||
| PROJ-06 | Phase 13 | Pending |
|
||||
| PROJ-07 | Phase 13 | Pending |
|
||||
| PROJ-08 | Phase 13 | Pending |
|
||||
@@ -112,4 +120,4 @@ Popolata dal roadmapper durante la creazione della roadmap.
|
||||
|
||||
---
|
||||
*Requirements defined: 2026-06-13*
|
||||
*Last updated: 2026-06-13 after milestone v2.1 kickoff*
|
||||
*Last updated: 2026-06-18 — Phase 12 complete: OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18 → Complete*
|
||||
|
||||
+27
-14
@@ -9,7 +9,7 @@
|
||||
## Phases
|
||||
|
||||
- [x] **Phase 11: Catalog Database-View UX & Legacy Consolidation** - Catalogo `services` come tabella con inline edit, tag multi-select, quick-add, ricerca istantanea; consolidamento `service_catalog`/`offer_services` → `services`
|
||||
- [ ] **Phase 12: Offer Composition Drag&Drop & CSV Import** - Composizione offerte via drag&drop dal catalogo (`@dnd-kit`) con totale live; import CSV one-shot
|
||||
- [x] **Phase 12: Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico** - Editor offerte completo: lista filtrabile + 3 tier (A/B/C) via matrice checkbox sui servizi `services`, totale live per tier, prezzo pubblico manuale, tag multi-dimensione, promessa di trasformazione (import CSV/Notion rimandato) (completed 2026-06-15)
|
||||
- [ ] **Phase 13: Workspace — Servizi Attivi** - Tab "Preventivo" rimossa (UI-only), "Offerte" → "Servizi attivi" (one-shot/ricorrenti + tracking incassi mensili), rimozione pagina Forecast
|
||||
- [x] **Phase 14: CRM Attio-style & Fix** - Redesign `/admin/leads` stile Attio/Pipedrive (inline edit + tag), fix FollowUpWidget IT / LeadForm types / SendQuoteModal rami irraggiungibili (completed 2026-06-14)
|
||||
- [ ] **Phase 15: Dashboard Revenue Stats** - Dashboard admin mostra statistiche revenue mensile (ex `/admin/forecast`) da `project_offers` — **BLOCCATA**, attesa mockup utente
|
||||
@@ -38,18 +38,31 @@
|
||||
**UI hint**: yes
|
||||
**Status**: ✅ Complete (2026-06-13) — migration 0007 (`services.fase`) applicata in prod, commit pushato su `gitea/main`
|
||||
|
||||
### Phase 12: Offer Composition Drag&Drop & CSV Import
|
||||
**Goal**: L'utente compone un'offerta trascinando servizi dal catalogo con un totale live durante il drag, e può importare un CSV one-shot per popolare servizi/offerte esistenti
|
||||
**Mode:** ui
|
||||
**Depends on**: Phase 11 (tabella `services` unificata)
|
||||
**Requirements**: OFFER-11, OFFER-12
|
||||
### Phase 12: Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico
|
||||
**Goal**: L'admin crea e modifica offerte complete da una lista filtrabile: editor con 3 tier (A/B/C) composti via matrice di checkbox dai servizi del catalogo unificato `services`, totale servizi live per tier, prezzo pubblico manuale per tier, tag multi-dimensione (categoria/ticket/tipo/obiettivo) e promessa di trasformazione strutturata. I servizi disponibili nella matrice sono filtrati per categoria offerta (entry/signature/retainer), designazione già impostata sul servizio a livello di catalogo.
|
||||
**Mode:** ui + schema
|
||||
**Depends on**: Phase 11 (tabella `services` unificata + sistema tag polimorfico)
|
||||
**Requirements**: OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. L'utente compone un'offerta trascinando servizi dal catalogo (`@dnd-kit`)
|
||||
2. Durante il drag, il totale dell'offerta si aggiorna live
|
||||
3. L'utente importa un CSV one-shot per popolare servizi/offerte esistenti (solo seed iniziale, non sync continuo)
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
**Status**: Pending planning
|
||||
1. L'admin compone un'offerta a 3 tier (A/B/C) assegnando i servizi del catalogo `services` tramite matrice di checkbox; il totale servizi di ogni tier si aggiorna live
|
||||
2. Ogni offerta ha tag su 4 dimensioni (categoria, ticket, tipo, obiettivo) creabili al volo, riusando il sistema tag di Phase 11
|
||||
3. Per ogni tier l'admin imposta un "prezzo pubblico" manuale, distinto e indipendente dal totale dei servizi
|
||||
4. Ogni offerta ha una promessa di trasformazione strutturata (Cliente Ideale / Risultato / tempo / Pain / Metodo)
|
||||
5. La lista offerte è filtrabile per categoria e supporta l'archiviazione (mostra/nascondi archiviate)
|
||||
**Note**: Import CSV/Notion (ex OFFER-12) spostato a fase futura su decisione utente (2026-06-14, "per ora saltiamo") — vedi REQUIREMENTS.md
|
||||
**Plans**: 5 plans
|
||||
**Plan list**:
|
||||
**Wave 1**
|
||||
- [x] 12-01-PLAN.md — Schema: offer_macros/offer_micros additive columns (category, ticket, is_archived, transformation promise, tier_letter, public_price) + offer_tier_services junction table, migration 0008 + idempotent push script
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [x] 12-02-PLAN.md — BLOCKING checkpoint: apply migration 0008 to production database (SSH+docker exec, manual)
|
||||
**Wave 3** *(blocked on Wave 2 — prod migration applied before schema-dependent code)*
|
||||
- [x] 12-03-PLAN.md — Query layer (getOfferEditorData/getOfferListCards/getOfferFieldOptions) + server actions (saveOfferEditor/toggleOfferArchived/tag CRUD/createOfferMacro)
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [x] 12-04-PLAN.md — Offer list page redesign: category filter chips, archive toggle, card grid, "+ Nuova Offerta" CTA
|
||||
- [x] 12-05-PLAN.md — Offer editor detail page: tags (4 dimensions), services matrix (tier A/B/C checkboxes + live totals), prezzo pubblico, promessa di trasformazione, Salva/Annulla/Archivia
|
||||
**UI hint**: yes — riferimento mockup utente 2026-06-14 (lista card per categoria + editor con matrice servizi×tier)
|
||||
**Status**: Planned (2026-06-14) — ready for `/gsd-execute-phase 12`
|
||||
|
||||
### Phase 13: Workspace — Servizi Attivi
|
||||
**Goal**: Il workspace progetto sostituisce le tab "Preventivo"/"Offerte"/"Forecast" con un'unica tab "Servizi attivi" che traccia servizi one-shot e ricorrenti con incassi mensili, senza perdere `accepted_total`/`quote_items`
|
||||
@@ -77,7 +90,7 @@
|
||||
3. Il `FollowUpWidget` è interamente in italiano, coerente col resto dell'app
|
||||
4. Il form lead (`LeadForm`) valida i campi senza workaround di type-relaxation su react-hook-form
|
||||
5. Il flusso `SendQuoteModal` (preventivo esistente vs nuovo) non contiene rami di codice irraggiungibili
|
||||
**Plans:** 3/3 plans complete
|
||||
**Plans:** 5/5 plans complete
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 14-01-PLAN.md — Data-layer foundation: getLeadsWithTags/getLeadFieldOptions query helpers + updateLeadField/addLeadTag/removeLeadTag/renameLeadTag server actions (requireAdmin-guarded)
|
||||
@@ -129,7 +142,7 @@ Plans:
|
||||
| Phase | Plans | Status | Completed |
|
||||
|-------|-------|--------|-----------|
|
||||
| 11. Catalog Database-View UX & Legacy Consolidation | 4/4 | ✅ Done | 2026-06-13 |
|
||||
| 12. Offer Composition Drag&Drop & CSV Import | — | Pending planning | — |
|
||||
| 12. Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico | 5/5 | Complete | 2026-06-18 |
|
||||
| 13. Workspace — Servizi Attivi | — | Pending planning | — |
|
||||
| 14. CRM Attio-style & Fix | 3/3 | Complete | 2026-06-14 |
|
||||
| 15. Dashboard Revenue Stats | — | Blocked (mockup utente) | — |
|
||||
|
||||
+15
-15
@@ -2,15 +2,15 @@
|
||||
gsd_state_version: 1.0
|
||||
milestone: v2.1
|
||||
milestone_name: milestone
|
||||
status: executing
|
||||
stopped_at: Phase 14 complete (5/5 success criteria verified); Phase 15 blocked on user mockup
|
||||
last_updated: "2026-06-14T13:20:53.997Z"
|
||||
last_activity: 2026-06-14 -- Phase 14 (crm-attio-style-fix) execution complete
|
||||
status: completed
|
||||
stopped_at: "Phase 12 complete — Offer Editor (Tier A/B/C, Tag & Prezzo Pubblico) eseguita e verificata. Prossima: Phase 13 (Workspace Servizi Attivi) o Phase 14 (CRM)."
|
||||
last_updated: "2026-06-18T15:23:00.000Z"
|
||||
last_activity: 2026-06-18 -- Phase 12 execution complete (5/5 piani, build gate passed, OFFER-11/15/16/17/18 verified)
|
||||
progress:
|
||||
total_phases: 7
|
||||
completed_phases: 2
|
||||
total_plans: 7
|
||||
completed_plans: 7
|
||||
completed_phases: 3
|
||||
total_plans: 12
|
||||
completed_plans: 12
|
||||
percent: 100
|
||||
---
|
||||
|
||||
@@ -22,14 +22,14 @@ See: .planning/PROJECT.md (updated 2026-06-13)
|
||||
|
||||
**Core value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
|
||||
|
||||
**Current focus:** Phase 15 — dashboard-revenue-stats (blocked on user mockup; Phase 14 complete)
|
||||
**Current focus:** Phase 13 (Workspace Servizi Attivi) o Phase 14 (CRM Attio-style) — entrambe eseguibili. Phase 15 resta bloccata su mockup utente. Phase 12 (Offer Editor) completata e verificata.
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 15
|
||||
Plan: Not started
|
||||
Status: Blocked — Phase 15 awaiting user mockup (Phase 14 complete, see ROADMAP)
|
||||
Last activity: 2026-06-14 -- Phase 14 (crm-attio-style-fix) execution complete
|
||||
Phase: 13 (next) — ready to plan/execute
|
||||
Plan: —
|
||||
Status: Phase 12 complete. Pronto per /gsd-execute-phase 13 o /gsd-execute-phase 14.
|
||||
Last activity: 2026-06-18 -- Phase 12 execution complete (OFFER-11/15/16/17/18 all verified PASS)
|
||||
|
||||
Progress: [██████████] 100%
|
||||
|
||||
@@ -100,6 +100,6 @@ Items acknowledged and carried forward from previous milestone close:
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-06-14T13:20:53.997Z
|
||||
Stopped at: Phase 14 complete (5/5 success criteria verified, code review 0 critical/6 warnings/3 info); Phase 15 blocked on user mockup
|
||||
Resume file: .planning/phases/14-crm-attio-style-fix/14-VERIFICATION.md
|
||||
Last session: 2026-06-14T19:04:45.211Z
|
||||
Stopped at: Phase 12 re-scoped (mockup utente → editor offerte) e pianificata: 5 piani / 4 wave, planner + plan-checker pass. Pronta per /gsd-execute-phase 12
|
||||
Resume file: .planning/phases/12-offer-composition-drag-drop-csv-import/12-01-PLAN.md
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
---
|
||||
phase: 12
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- src/db/schema.ts
|
||||
- src/db/migrations/0008_offer_tier_schema.sql
|
||||
- src/db/migrations/meta/_journal.json
|
||||
- scripts/push-12-offer-tier-schema.ts
|
||||
autonomous: true
|
||||
requirements: [OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "The Drizzle schema exposes tier_letter and public_price on offer_micros, and category, ticket, is_archived + 5 transformation-promise fields on offer_macros"
|
||||
- "A new offer_tier_services junction table exists in the schema, referencing offer_micros and services (NOT the legacy offer_services)"
|
||||
- "A hand-written, idempotent, additive-only SQL migration file exists that can be applied to prod via the existing push-script convention"
|
||||
artifacts:
|
||||
- path: "src/db/schema.ts"
|
||||
provides: "offer_macros additive columns (description, category, ticket, is_archived, cliente_ideale, risultato, tempo, pain, metodo), offer_micros additive columns (tier_letter, public_price), new offer_tier_services table + relations + types"
|
||||
contains: "offer_tier_services"
|
||||
- path: "src/db/migrations/0008_offer_tier_schema.sql"
|
||||
provides: "Additive ALTER TABLE + CREATE TABLE statements, IF NOT EXISTS / guarded DO block, no DROP/TRUNCATE"
|
||||
contains: "CREATE TABLE IF NOT EXISTS offer_tier_services"
|
||||
- path: "scripts/push-12-offer-tier-schema.ts"
|
||||
provides: "Idempotent script to apply migration 0008 to prod via DATABASE_URL (SSH+docker exec target)"
|
||||
exports: ["push"]
|
||||
key_links:
|
||||
- from: "src/db/schema.ts (offer_tier_services)"
|
||||
to: "services.id / offer_micros.id"
|
||||
via: "foreign key references with onDelete cascade"
|
||||
pattern: "references\\(\\(\\) => (services|offer_micros)\\.id"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Extend the Drizzle schema additively for the Phase 12 Offer Editor: add tier designation
|
||||
(`tier_letter`) and manual public pricing (`public_price`) to `offer_micros`; add archive flag,
|
||||
single-select category/ticket dimensions, and structured transformation-promise fields to
|
||||
`offer_macros`; create a new junction table `offer_tier_services` (tier ↔ unified `services`
|
||||
catalog) WITHOUT touching the legacy `offer_micro_services`/`offer_services` tables.
|
||||
Hand-write the corresponding SQL migration (0008) following the 0003-0007 convention
|
||||
(drizzle-kit generate is broken in this repo), and create the idempotent push script that
|
||||
Plan 02 will run against production via SSH+docker exec.
|
||||
|
||||
Purpose: Lay the additive data-model foundation (D-6, OFFER-11/15/16/17/18) so the Wave 2/3
|
||||
query layer, server actions, and editor UI have real columns/tables to read and write —
|
||||
including the `category`/`ticket` single-select dimensions needed for list filtering
|
||||
(OFFER-18) and matrix pre-filtering by offer category (D-4).
|
||||
Output: Updated `src/db/schema.ts` (types + relations), `src/db/migrations/0008_offer_tier_schema.sql`,
|
||||
updated `_journal.json`, and `scripts/push-12-offer-tier-schema.ts`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/12-offer-composition-drag-drop-csv-import/12-CONTEXT.md
|
||||
@.planning/phases/12-offer-composition-drag-drop-csv-import/12-RESEARCH.md
|
||||
@src/db/schema.ts
|
||||
@src/db/migrations/0006_add_tags_table.sql
|
||||
@src/db/migrations/0007_add_services_fase.sql
|
||||
@src/db/migrations/meta/_journal.json
|
||||
@scripts/push-11b-fase-column.ts
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- Current relevant schema slices the executor will extend. Drizzle import style:
|
||||
pgTable / text / integer / numeric / timestamp / boolean / primaryKey / uniqueIndex / index
|
||||
are already imported at the top of src/db/schema.ts (lines 1-13). Reuse, do not re-import. -->
|
||||
|
||||
From src/db/schema.ts (offer_macros, current — lines 262-271):
|
||||
```typescript
|
||||
export const offer_macros = pgTable("offer_macros", {
|
||||
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||
internal_name: text("internal_name").notNull(),
|
||||
public_name: text("public_name").notNull(),
|
||||
transformation_promise: text("transformation_promise"),
|
||||
sort_order: integer("sort_order").notNull().default(0),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
```
|
||||
|
||||
From src/db/schema.ts (offer_micros, current — lines 274-286):
|
||||
```typescript
|
||||
export const offer_micros = pgTable("offer_micros", {
|
||||
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||
macro_id: text("macro_id").notNull().references(() => offer_macros.id, { onDelete: "cascade" }),
|
||||
internal_name: text("internal_name").notNull(),
|
||||
public_name: text("public_name").notNull(),
|
||||
transformation_promise: text("transformation_promise"),
|
||||
duration_months: integer("duration_months").notNull().default(1),
|
||||
sort_order: integer("sort_order").notNull().default(0),
|
||||
});
|
||||
```
|
||||
|
||||
From src/db/schema.ts (services, Phase 11 unified catalog — lines 212-225):
|
||||
```typescript
|
||||
export const services = pgTable("services", {
|
||||
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
|
||||
category: text("category"),
|
||||
fase: text("fase"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
migrated_from: text("migrated_from"),
|
||||
migrated_id: text("migrated_id"),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
```
|
||||
|
||||
From src/db/schema.ts (tags, polymorphic — lines 119-140, NO schema change needed for tag
|
||||
dimensions — Phase 12 reuses this table with new entity_type string values for the two
|
||||
MULTI-select dimensions only: "offer_macros.tipo" | "offer_macros.obiettivo". The two
|
||||
SINGLE-select dimensions, Categoria and Ticket, become plain columns on `offer_macros`
|
||||
in this plan — see Task 1 step 1):
|
||||
```typescript
|
||||
export const tags = pgTable("tags", {
|
||||
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||
entity_type: text("entity_type").notNull(),
|
||||
entity_id: text("entity_id").notNull(),
|
||||
name: text("name").notNull(),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
entityTagUnique: uniqueIndex("tags_entity_name_unique").on(t.entity_type, t.entity_id, t.name),
|
||||
entityIdx: index("tags_entity_idx").on(t.entity_type, t.entity_id),
|
||||
}));
|
||||
```
|
||||
|
||||
Existing offer_micro_services junction (legacy — DO NOT MODIFY, lines 300-313):
|
||||
```typescript
|
||||
export const offer_micro_services = pgTable("offer_micro_services", {
|
||||
micro_id: text("micro_id").notNull().references(() => offer_micros.id, { onDelete: "cascade" }),
|
||||
service_id: text("service_id").notNull().references(() => offer_services.id, { onDelete: "cascade" }),
|
||||
}, (t) => ({ pk: primaryKey({ columns: [t.micro_id, t.service_id] }) }));
|
||||
```
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Extend offer_macros and offer_micros, add offer_tier_services junction in schema.ts</name>
|
||||
<files>src/db/schema.ts</files>
|
||||
<read_first>
|
||||
- src/db/schema.ts (full file — read once; note existing imports, offer_macros lines
|
||||
262-271, offer_micros lines 274-286, offer_micro_services lines 300-313 for the
|
||||
"do not touch" pattern, services lines 212-225, relations section ~545-578, type
|
||||
exports section ~633-646)
|
||||
- .planning/phases/12-offer-composition-drag-drop-csv-import/12-CONTEXT.md (D-1..D-6 — locked decisions)
|
||||
- .planning/phases/12-offer-composition-drag-drop-csv-import/12-RESEARCH.md (Pattern 1: Additive Schema, section "Example migrations")
|
||||
</read_first>
|
||||
<action>
|
||||
Make the following ADDITIVE-ONLY changes to `src/db/schema.ts`. Do not remove, rename, or
|
||||
alter any existing column on `offer_macros`, `offer_micros`, `offer_micro_services`,
|
||||
`offer_services`, `clients`, `projects`, `payments`, or `phases` (Data Safety LOCKED).
|
||||
|
||||
1. **Extend `offer_macros`** (after `transformation_promise`, before `sort_order` —
|
||||
order within the object is cosmetic but keep additive fields grouped together for
|
||||
readability) with:
|
||||
- `description: text("description")` — short description shown on offer cards (D-1 UI)
|
||||
- `category: text("category")` — single-select offer category (Entry Offer / Signature
|
||||
Offer / Retainer Offer), OFFER-15 "Categoria" dimension + OFFER-18 list filter. Same
|
||||
Notion-style single-select pool pattern as `services.category`/`services.fase`
|
||||
(editable via `OptionSelect` + `renameServiceOption`-equivalent in Plan 03, scoped by
|
||||
a distinct `entity_type` in the `tags` pool table — e.g. `"offer_macros.categoria"` —
|
||||
so the option pool is independent of the catalog's `services.category` pool, even
|
||||
though admins are expected to use matching label text per D-4).
|
||||
- `ticket: text("ticket")` — single-select "Ticket" dimension (Low/Mid/High Ticket),
|
||||
OFFER-15. Same pattern as `category` above, pool entity_type `"offer_macros.ticket"`.
|
||||
- `is_archived: boolean("is_archived").notNull().default(false)` — OFFER-18 archive flag
|
||||
- Five structured transformation-promise fields (OFFER-17), each nullable text,
|
||||
additive alongside the existing legacy `transformation_promise` column (leave
|
||||
`transformation_promise` untouched — it remains on `offer_micros` only as legacy):
|
||||
- `cliente_ideale: text("cliente_ideale")` — "Aiuto: [Cliente Ideale]"
|
||||
- `risultato: text("risultato")` — "A ottenere: [Risultato]"
|
||||
- `tempo: text("tempo")` — "In: [tempo]"
|
||||
- `pain: text("pain")` — "Senza: [Pain]"
|
||||
- `metodo: text("metodo")` — "Grazie a: [Metodo]"
|
||||
|
||||
Add a comment block directly above `offer_macros` documenting that these are Phase 12
|
||||
additive columns (mirror the existing comment style used for `services.migrated_from`).
|
||||
|
||||
2. **Extend `offer_micros`** with:
|
||||
- `tier_letter: text("tier_letter")` — nullable text, values constrained to 'A'|'B'|'C'
|
||||
via a CHECK constraint at the SQL level (Task 2 migration), NOT enforced in Drizzle
|
||||
(Drizzle pg-core has no native CHECK helper in this project's version — validate in
|
||||
Zod at the server-action layer in Plan 03). Add a TS comment noting the CHECK
|
||||
constraint lives in the migration.
|
||||
- `public_price: numeric("public_price", { precision: 10, scale: 2 })` — nullable,
|
||||
manual public price per tier (D-5/OFFER-16); independent of the computed services
|
||||
total (computed at query time in Plan 03, never stored).
|
||||
|
||||
3. **Add new table `offer_tier_services`** (place it directly after
|
||||
`offer_micro_services` for proximity, with a comment block explaining it is the
|
||||
Phase 12 additive replacement junction — NOT a modification of
|
||||
`offer_micro_services`, which stays untouched and points to the legacy
|
||||
`offer_services`):
|
||||
```typescript
|
||||
// ============ OFFER TIER SERVICES (Phase 12 — junction: offer_micros <-> services) ============
|
||||
// Additive replacement for the legacy offer_micro_services (which points to the
|
||||
// deprecated offer_services table). This junction connects a tier (offer_micros row
|
||||
// with tier_letter set) to the unified services catalog (Phase 11). Do NOT modify
|
||||
// offer_micro_services — it remains untouched for legacy data.
|
||||
export const offer_tier_services = pgTable(
|
||||
"offer_tier_services",
|
||||
{
|
||||
tier_id: text("tier_id")
|
||||
.notNull()
|
||||
.references(() => offer_micros.id, { onDelete: "cascade" }),
|
||||
service_id: text("service_id")
|
||||
.notNull()
|
||||
.references(() => services.id, { onDelete: "cascade" }),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
pk: primaryKey({ columns: [t.tier_id, t.service_id] }),
|
||||
tierIdx: index("offer_tier_services_tier_idx").on(t.tier_id),
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
4. **Add relations** for `offer_tier_services` (near `offerMicroServicesRelations`,
|
||||
~line 559-562):
|
||||
```typescript
|
||||
export const offerTierServicesRelations = relations(offer_tier_services, ({ one }) => ({
|
||||
tier: one(offer_micros, { fields: [offer_tier_services.tier_id], references: [offer_micros.id] }),
|
||||
service: one(services, { fields: [offer_tier_services.service_id], references: [services.id] }),
|
||||
}));
|
||||
```
|
||||
Also extend `offerMicrosRelations` (~line 549-553) to add a `tierServices: many(offer_tier_services)`
|
||||
entry alongside the existing `services: many(offer_micro_services)` (keep both — do not
|
||||
remove the legacy relation).
|
||||
|
||||
5. **Add TypeScript types** in the type-exports section (~line 633-646), following the
|
||||
exact `$inferSelect`/`$inferInsert` pattern used for every other table:
|
||||
```typescript
|
||||
export type OfferTierService = typeof offer_tier_services.$inferSelect;
|
||||
export type NewOfferTierService = typeof offer_tier_services.$inferInsert;
|
||||
```
|
||||
`OfferMacro`/`NewOfferMacro` and `OfferMicro`/`NewOfferMicro` types already exist and
|
||||
automatically pick up the new columns via `$inferSelect` — no changes needed to those
|
||||
two type aliases themselves.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npx tsc --noEmit` passes with zero errors (full project)
|
||||
- `grep -c "offer_tier_services" src/db/schema.ts` >= 4 (table def, relations, both type exports)
|
||||
- `grep -c "tier_letter\|public_price" src/db/schema.ts` == 2 (both new offer_micros columns present exactly once)
|
||||
- `grep -c "is_archived\|cliente_ideale\|risultato\|tempo\|pain\|metodo" src/db/schema.ts` == 6 (all 6 new offer_macros transformation/archive columns present)
|
||||
- `grep -cE '"(category|ticket)"' src/db/schema.ts` >= 2 (new offer_macros category/ticket columns present — note `services.category` also matches "category", so this is a >= check, not ==)
|
||||
- `git diff HEAD -- src/db/schema.ts | grep -E '^-' | grep -v '^---'` returns empty (no existing lines removed — additive diff only)
|
||||
</acceptance_criteria>
|
||||
<done>schema.ts compiles, exposes all new additive columns/table/types, and the legacy offer_micro_services/offer_services definitions are byte-identical to their pre-task state.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Hand-write migration 0008 SQL + journal entry + idempotent push script</name>
|
||||
<files>src/db/migrations/0008_offer_tier_schema.sql, src/db/migrations/meta/_journal.json, scripts/push-12-offer-tier-schema.ts</files>
|
||||
<read_first>
|
||||
- src/db/migrations/0007_add_services_fase.sql (simplest additive precedent: `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`)
|
||||
- src/db/migrations/0006_add_tags_table.sql (CREATE TABLE + indexes precedent, Drizzle output format with `--> statement-breakpoint`)
|
||||
- src/db/migrations/meta/_journal.json (current entries — note it already has gaps for 0003-0005/0007; append 0008 entry following the 0006 entry's shape)
|
||||
- scripts/push-11-tags-migration.ts (idempotent CREATE TABLE IF NOT EXISTS + indexes pattern, with the PRODUCTION DEPLOY NOTE comment header)
|
||||
- scripts/push-11b-fase-column.ts (idempotent ADD COLUMN IF NOT EXISTS pattern, shorter form)
|
||||
</read_first>
|
||||
<action>
|
||||
1. Create `src/db/migrations/0008_offer_tier_schema.sql` — hand-written, additive-only,
|
||||
idempotent (every statement uses `IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`), no
|
||||
DROP/TRUNCATE/RENAME. Content:
|
||||
|
||||
```sql
|
||||
-- Phase 12: Offer Editor — additive schema for tier designations, public pricing,
|
||||
-- archive flag, category/ticket dimensions, structured transformation promise, and
|
||||
-- tier<->services junction. All statements additive/idempotent. No drops/truncates
|
||||
-- (Data Safety LOCKED).
|
||||
|
||||
-- offer_macros: archive flag + short description + category/ticket dimensions +
|
||||
-- structured transformation promise
|
||||
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS description text;
|
||||
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS category text;
|
||||
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS ticket text;
|
||||
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS is_archived boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS cliente_ideale text;
|
||||
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS risultato text;
|
||||
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS tempo text;
|
||||
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS pain text;
|
||||
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS metodo text;
|
||||
|
||||
-- offer_micros: tier designation (A/B/C) + manual public price
|
||||
ALTER TABLE offer_micros ADD COLUMN IF NOT EXISTS tier_letter text;
|
||||
ALTER TABLE offer_micros ADD COLUMN IF NOT EXISTS public_price numeric(10, 2);
|
||||
|
||||
-- CHECK constraint for tier_letter, added separately so the ADD COLUMN above stays
|
||||
-- idempotent even if the constraint already exists (guarded via DO block).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'offer_micros_tier_letter_check'
|
||||
) THEN
|
||||
ALTER TABLE offer_micros
|
||||
ADD CONSTRAINT offer_micros_tier_letter_check
|
||||
CHECK (tier_letter IS NULL OR tier_letter IN ('A', 'B', 'C'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- New junction table: tier (offer_micros) <-> unified services catalog.
|
||||
-- Additive replacement for legacy offer_micro_services (untouched, points to offer_services).
|
||||
CREATE TABLE IF NOT EXISTS offer_tier_services (
|
||||
tier_id text NOT NULL REFERENCES offer_micros(id) ON DELETE CASCADE,
|
||||
service_id text NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY (tier_id, service_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS offer_tier_services_tier_idx ON offer_tier_services USING btree (tier_id);
|
||||
```
|
||||
|
||||
2. Append a journal entry for `0008_offer_tier_schema` to `src/db/migrations/meta/_journal.json`,
|
||||
following the exact shape of the existing `0006_add_tags_table` entry (idx: 8, version: "7",
|
||||
a `when` timestamp larger than the 0006 entry's, tag: "0008_offer_tier_schema", breakpoints: true).
|
||||
This file already has gaps (no entries for 0003-0005/0007) — do not attempt to backfill
|
||||
those; only append the new 0008 entry to the `entries` array.
|
||||
|
||||
3. Create `scripts/push-12-offer-tier-schema.ts` — idempotent push script following the
|
||||
`push-11-tags-migration.ts` / `push-11b-fase-column.ts` pattern: reads `DATABASE_URL`
|
||||
from env, uses the `postgres` package, runs each statement from migration 0008 (the 11
|
||||
`ALTER TABLE ... ADD COLUMN IF NOT EXISTS` statements, the DO-block CHECK constraint
|
||||
guard, `CREATE TABLE IF NOT EXISTS offer_tier_services`, and
|
||||
`CREATE INDEX IF NOT EXISTS offer_tier_services_tier_idx`), logs success per statement
|
||||
group, and exits 0/1. Include the same "PRODUCTION DEPLOY NOTE" comment header as
|
||||
`push-11-tags-migration.ts`, referencing this script and migration 0008, and stating
|
||||
that Plan 02 (BLOCKING, SSH+docker exec) must run this before Wave 3 code (query layer
|
||||
reading `offer_tier_services`/`tier_letter`/`public_price`/`category`/`ticket`) is
|
||||
exercised against production data.
|
||||
|
||||
Catch and tolerate "already exists" / duplicate-column / duplicate-constraint errors
|
||||
per-statement (same try/catch-per-block resilience as the precedent scripts) so the
|
||||
script is safely re-runnable. Export an async `push()` function as the module's main
|
||||
entry point (called at the bottom of the file, matching precedent scripts).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -20; test -f src/db/migrations/0008_offer_tier_schema.sql && echo "MIGRATION_EXISTS"; node -e "const j=require('./src/db/migrations/meta/_journal.json'); console.log(j.entries.find(e=>e.tag==='0008_offer_tier_schema') ? 'JOURNAL_OK' : 'JOURNAL_MISSING')"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -f src/db/migrations/0008_offer_tier_schema.sql` succeeds
|
||||
- The migration file contains zero occurrences of `DROP`, `TRUNCATE`, or `RENAME` (case-insensitive grep returns no matches)
|
||||
- Every `ALTER TABLE ... ADD COLUMN` statement includes `IF NOT EXISTS`: `grep -c "ADD COLUMN IF NOT EXISTS" src/db/migrations/0008_offer_tier_schema.sql` == 11
|
||||
- `grep -c "CREATE TABLE IF NOT EXISTS offer_tier_services" src/db/migrations/0008_offer_tier_schema.sql` == 1
|
||||
- `_journal.json` parses as valid JSON and contains an entry with `"tag": "0008_offer_tier_schema"`
|
||||
- `npx tsc --noEmit` (full project, including the new script) passes with zero errors
|
||||
- `scripts/push-12-offer-tier-schema.ts` contains a "PRODUCTION DEPLOY NOTE" comment referencing migration 0008 and the BLOCKING SSH+docker exec apply step
|
||||
</acceptance_criteria>
|
||||
<done>Migration 0008 SQL file exists, is additive/idempotent (IF NOT EXISTS guards throughout, no destructive statements), journal has a corresponding entry, and the push script typechecks and is ready for Plan 02 to execute against production.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Migration script -> Production DB | `scripts/push-12-offer-tier-schema.ts` executes raw SQL via `DATABASE_URL`; the only "input" is the hardcoded migration SQL (no user input at this layer) |
|
||||
| schema.ts -> downstream query/action code (Plan 03+) | New columns/table become part of the type-safe Drizzle surface consumed by admin-only server actions |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-12-01 | Tampering | `0008_offer_tier_schema.sql` accidentally includes a destructive statement | mitigate | Acceptance criteria grep-gate explicitly rejects DROP/TRUNCATE/RENAME before this plan is considered done; CLAUDE.md Data Safety LOCKED reviewed before writing the migration |
|
||||
| T-12-02 | Tampering | `offer_micros.tier_letter` accepts arbitrary strings at the DB layer | mitigate | SQL-level CHECK constraint (`tier_letter IN ('A','B','C')` or NULL) added via guarded DO block; Zod enum validation added at the server-action layer in Plan 03 as defense-in-depth |
|
||||
| T-12-03 | Repudiation | Push script silently no-ops on errors, masking a failed migration | accept | Script logs per-statement success/failure and exits non-zero on unexpected errors (only "already exists"/duplicate errors are tolerated); operator (Plan 02) reviews script output before proceeding |
|
||||
| T-12-04 | Information Disclosure | `offer_tier_services`/new columns become queryable but must remain admin-only (`/admin/offers`) | accept | No client-facing route reads these tables in this plan; enforcement of admin-only access happens in Plan 03 (`requireAdmin`) — tracked, not this plan's scope |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `npx tsc --noEmit` passes for the full project (schema.ts changes compile cleanly with all existing consumers).
|
||||
2. `src/db/migrations/0008_offer_tier_schema.sql` exists, is additive-only, and every ALTER/CREATE statement is idempotent (`IF NOT EXISTS` / guarded DO block).
|
||||
3. `scripts/push-12-offer-tier-schema.ts` typechecks and documents the BLOCKING manual prod-apply step for Plan 02.
|
||||
4. `git diff HEAD -- src/db/schema.ts` shows ONLY additions (new columns, new table, new relations, new types) — no removed or renamed lines for `offer_macros`, `offer_micros`, `offer_micro_services`, `offer_services`, `clients`, `projects`, `payments`, `phases`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `offer_macros` and `offer_micros` expose all Phase 12 additive columns in the Drizzle schema and TypeScript types.
|
||||
- `offer_tier_services` junction table is defined, related, and typed — pointing at `services` (not `offer_services`).
|
||||
- Migration 0008 + push script are ready for Plan 02's BLOCKING production-apply step.
|
||||
- No existing table/column was dropped, renamed, or altered destructively.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
phase: 12-offer-composition-drag-drop-csv-import
|
||||
plan: 01
|
||||
subsystem: database
|
||||
tags: [drizzle, postgres, schema, migration]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 11-catalog-database-view-ux
|
||||
provides: unified `services` catalog table (category/fase single-select pattern, migrated_from audit trail)
|
||||
provides:
|
||||
- Additive Drizzle schema columns on offer_macros (description, category, ticket, is_archived, cliente_ideale, risultato, tempo, pain, metodo)
|
||||
- Additive Drizzle schema columns on offer_micros (tier_letter, public_price)
|
||||
- New offer_tier_services junction table + relations + types (offer_micros <-> services)
|
||||
- Hand-written migration 0008_offer_tier_schema.sql (additive/idempotent, CHECK constraint on tier_letter)
|
||||
- Idempotent push script scripts/push-12-offer-tier-schema.ts for Plan 02's BLOCKING prod-apply step
|
||||
affects: [12-02-prod-migration-apply, 12-03-offer-list-editor, 12-04-tier-matrix, 12-05-offer-detail-actions]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Additive-only schema extension via hand-written SQL migration (drizzle-kit generate non-functional in this repo)"
|
||||
- "Single-select dimension columns (category/ticket) on offer_macros, mirroring services.category/fase pattern from Phase 11"
|
||||
- "New junction table (offer_tier_services) as additive replacement for legacy junction (offer_micro_services), pointing at unified services catalog instead of deprecated offer_services"
|
||||
- "DB-level CHECK constraint via guarded DO block for idempotent re-runs, with Zod validation deferred to server-action layer (Plan 03)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- src/db/migrations/0008_offer_tier_schema.sql
|
||||
- scripts/push-12-offer-tier-schema.ts
|
||||
modified:
|
||||
- src/db/schema.ts
|
||||
- src/db/migrations/meta/_journal.json
|
||||
|
||||
key-decisions:
|
||||
- "tier_letter constrained via SQL CHECK (guarded DO block) rather than Drizzle enum, since this pg-core version has no native CHECK helper — Zod enum validation is deferred to Plan 03's server-action layer as defense-in-depth"
|
||||
- "offer_tier_services created as a brand-new junction (not a modification of offer_micro_services) to avoid touching legacy offer_services-linked data, per D-6"
|
||||
|
||||
patterns-established:
|
||||
- "Phase 12 additive columns documented inline with comment blocks above offer_macros/offer_micros, following the services.migrated_from comment style from Phase 11"
|
||||
|
||||
requirements-completed: [OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18]
|
||||
|
||||
# Metrics
|
||||
duration: 6min
|
||||
completed: 2026-06-14
|
||||
---
|
||||
|
||||
# Phase 12 Plan 01: Offer Tier Schema Foundation Summary
|
||||
|
||||
**Additive Drizzle schema + hand-written migration 0008 adding tier designations (A/B/C), manual public pricing, archive flag, category/ticket dimensions, structured transformation-promise fields, and a new offer_tier_services junction table pointing at the unified services catalog.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 6 min
|
||||
- **Started:** 2026-06-14T19:13:00Z (approx)
|
||||
- **Completed:** 2026-06-14T19:19:38Z
|
||||
- **Tasks:** 2 completed
|
||||
- **Files modified:** 4
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Extended `offer_macros` with 9 additive columns: `description`, `category`, `ticket`, `is_archived`, and 5 structured transformation-promise fields (`cliente_ideale`, `risultato`, `tempo`, `pain`, `metodo`)
|
||||
- Extended `offer_micros` with `tier_letter` (A/B/C, CHECK-constrained at SQL level) and `public_price` (numeric)
|
||||
- Added new `offer_tier_services` junction table (tier <-> unified `services` catalog), its relations, and `OfferTierService`/`NewOfferTierService` types — without touching the legacy `offer_micro_services`/`offer_services` tables
|
||||
- Hand-wrote idempotent, additive-only migration `0008_offer_tier_schema.sql` (11 `ADD COLUMN IF NOT EXISTS` statements, guarded DO-block CHECK constraint, `CREATE TABLE IF NOT EXISTS offer_tier_services` + index), appended journal entry
|
||||
- Created `scripts/push-12-offer-tier-schema.ts` — idempotent push script with PRODUCTION DEPLOY NOTE documenting the BLOCKING manual prod-apply step required before Plan 02/Wave 3
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Extend offer_macros and offer_micros, add offer_tier_services junction in schema.ts** - `11d6c11` (feat)
|
||||
2. **Task 2: Hand-write migration 0008 SQL + journal entry + idempotent push script** - `89d15ee` (feat)
|
||||
|
||||
**Plan metadata:** (this commit, follows)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `src/db/schema.ts` - Additive columns on offer_macros/offer_micros, new offer_tier_services table + relations + types
|
||||
- `src/db/migrations/0008_offer_tier_schema.sql` - Hand-written additive/idempotent migration (ALTER TABLE ADD COLUMN IF NOT EXISTS, guarded CHECK constraint, CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS)
|
||||
- `src/db/migrations/meta/_journal.json` - Appended entry for `0008_offer_tier_schema` (idx 8, following 0006 entry shape)
|
||||
- `scripts/push-12-offer-tier-schema.ts` - Idempotent push script for Plan 02's BLOCKING production-apply step
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- `tier_letter` validated via DB-level CHECK constraint (guarded DO block, idempotent) rather than a Drizzle-native enum/CHECK helper (unavailable in this pg-core version); Zod enum validation will be added at the server-action layer in Plan 03 as defense-in-depth (per plan's T-12-02 mitigation)
|
||||
- `offer_tier_services` is a brand-new additive junction table (not a modification of `offer_micro_services`), pointing at the unified `services` catalog (Phase 11) rather than the deprecated `offer_services` table, per D-6/D-3
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written. The acceptance-criteria grep counts for `tier_letter`/`public_price`, the 6 transformation/archive columns, and the DROP/TRUNCATE/RENAME check show slightly higher raw match counts than the plan's exact numbers because explanatory comment blocks (explicitly requested by the plan, e.g. "Add a comment block directly above offer_macros documenting...") also contain those words (e.g. a comment mentions "tier_letter" when describing the CHECK constraint, and the migration's header comment says "No drops/truncates"). All actual code definitions are present exactly once, the diff to `src/db/schema.ts` is purely additive (`git diff HEAD~2 -- src/db/schema.ts | grep '^-'` returns empty), and a line-anchored grep (`grep -inE "^\s*(DROP|TRUNCATE|RENAME)\b"`) confirms zero actual destructive SQL statements in the migration.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
A `cd /Users/simonecavalli/Vault/IAMCAVALLI` in one intermediate Bash call caused a transient cwd drift into the main repo's worktree (sibling path with the same relative structure), which produced false "0 matches" results for one verification pass. Re-ran all verification from the correct worktree root (`/Users/simonecavalli/Vault/IAMCAVALLI/.claude/worktrees/agent-ab7d5b1ee10efadff`) with confirmed correct results before committing. No files were affected — all Edit/Write operations used relative paths inside the worktree.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required. Note: migration 0008 and the push script are created here only; per the plan and project memory, the actual production apply (SSH+docker exec) is Plan 02's BLOCKING step and is NOT performed in this plan.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- `src/db/schema.ts` compiles cleanly (`npx tsc --noEmit` passes, zero errors) and exposes all Phase 12 additive columns/table/types needed by Plan 03's query layer and server actions
|
||||
- Migration 0008 + push script are ready for Plan 02 to apply to production via SSH+docker exec before Wave 3 code reads `offer_tier_services`/`tier_letter`/`public_price`/`category`/`ticket`
|
||||
- No existing table/column was dropped, renamed, or altered destructively — `clients`/`projects`/`payments`/`phases` and legacy offer tables (`offer_micro_services`, `offer_services`) remain byte-identical
|
||||
|
||||
---
|
||||
|
||||
*Phase: 12-offer-composition-drag-drop-csv-import*
|
||||
*Completed: 2026-06-14*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All created/modified files verified present on disk:
|
||||
|
||||
- FOUND: src/db/schema.ts
|
||||
- FOUND: src/db/migrations/0008_offer_tier_schema.sql
|
||||
- FOUND: src/db/migrations/meta/_journal.json
|
||||
- FOUND: scripts/push-12-offer-tier-schema.ts
|
||||
- FOUND: .planning/phases/12-offer-composition-drag-drop-csv-import/12-01-SUMMARY.md
|
||||
|
||||
All task commits verified present in git log:
|
||||
|
||||
- FOUND: 11d6c11 (Task 1)
|
||||
- FOUND: 89d15ee (Task 2)
|
||||
- FOUND: bfc9932 (docs: complete plan)
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
phase: 12
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [1]
|
||||
files_modified: []
|
||||
autonomous: false
|
||||
requirements: [OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Migration 0008 (offer_macros/offer_micros additive columns + offer_tier_services table) has been applied to the production database"
|
||||
- "Plan 03's query layer and Plan 04/05's UI can read/write tier_letter, public_price, offer_macros category/ticket/transformation-promise fields, and offer_tier_services against real production data"
|
||||
artifacts: []
|
||||
key_links:
|
||||
- from: "scripts/push-12-offer-tier-schema.ts"
|
||||
to: "production Postgres (178.104.27.55:54321, via SSH+docker exec)"
|
||||
via: "manual operator run of the idempotent push script against prod DATABASE_URL"
|
||||
pattern: "tier_letter|public_price|offer_tier_services"
|
||||
---
|
||||
|
||||
<objective>
|
||||
BLOCKING checkpoint: apply migration 0008 (`src/db/migrations/0008_offer_tier_schema.sql`,
|
||||
created in Plan 01) to the production database. Per CLAUDE.md Data Safety and project memory
|
||||
(`project_clienthub_deploy_db.md`: Gitea→Coolify, prod Postgres reachable only via SSH+docker
|
||||
exec, port 54321 firewalled from outside), this migration MUST be applied to prod BEFORE
|
||||
Plans 03-05 (query layer, server actions, and UI that read/write `tier_letter`, `public_price`,
|
||||
the new `offer_macros` columns, and `offer_tier_services`) are exercised against production
|
||||
data.
|
||||
|
||||
Purpose: Unblock Wave 2/3 schema-dependent code per the project's locked migration convention
|
||||
(migrations applied to prod before schema-dependent code ships).
|
||||
Output: Migration 0008 applied to production; confirmation that `offer_tier_services` exists
|
||||
and the new columns are queryable on the live DB.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@scripts/push-12-offer-tier-schema.ts
|
||||
@src/db/migrations/0008_offer_tier_schema.sql
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking">
|
||||
<name>Task 1: Apply migration 0008 to production database</name>
|
||||
<what-built>
|
||||
Plan 01 created `src/db/migrations/0008_offer_tier_schema.sql` (additive-only: 11
|
||||
`ALTER TABLE ... ADD COLUMN IF NOT EXISTS` statements on `offer_macros`/`offer_micros`, a
|
||||
guarded `CHECK` constraint on `offer_micros.tier_letter`, and
|
||||
`CREATE TABLE IF NOT EXISTS offer_tier_services` + index) and the idempotent push script
|
||||
`scripts/push-12-offer-tier-schema.ts` that applies it.
|
||||
|
||||
This script CANNOT be run from this environment: the production DATABASE_URL
|
||||
(`178.104.27.55:54321`) is firewalled and reachable only via SSH to the production host
|
||||
(per `project_clienthub_deploy_db.md`). Opening a root SSH session to the shared
|
||||
production host is outside this agent's authorization.
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
On your machine (or via your existing SSH access to the production host), run the
|
||||
migration push script against the production database, then confirm the new schema
|
||||
objects exist:
|
||||
|
||||
1. SSH to the production host and exec into the Postgres container (same pattern used
|
||||
for the Phase 11 migration apply):
|
||||
```bash
|
||||
ssh root@178.104.27.55
|
||||
docker exec -it <postgres-container-name> psql -U clienthub -d clienthub
|
||||
```
|
||||
OR, if you prefer running the idempotent TypeScript push script with
|
||||
`DATABASE_URL` pointed at prod (via SSH port-forward / tunnel, same as Phase 11):
|
||||
```bash
|
||||
npx tsx scripts/push-12-offer-tier-schema.ts
|
||||
```
|
||||
|
||||
2. If using `psql` directly, run the contents of
|
||||
`src/db/migrations/0008_offer_tier_schema.sql` (copy/paste — it is additive-only and
|
||||
idempotent, safe to run even if partially applied already).
|
||||
|
||||
3. Confirm the new columns and table exist:
|
||||
```sql
|
||||
\d offer_macros
|
||||
\d offer_micros
|
||||
\d offer_tier_services
|
||||
```
|
||||
Expect: `offer_macros` has `description`, `category`, `ticket`, `is_archived`,
|
||||
`cliente_ideale`, `risultato`, `tempo`, `pain`, `metodo`; `offer_micros` has
|
||||
`tier_letter` (with a CHECK constraint `offer_micros_tier_letter_check`) and
|
||||
`public_price`; `offer_tier_services` table exists with columns `tier_id`,
|
||||
`service_id`, `created_at` and a composite primary key on (`tier_id`, `service_id`).
|
||||
|
||||
4. Confirm no existing data was affected:
|
||||
```sql
|
||||
SELECT count(*) FROM offer_macros;
|
||||
SELECT count(*) FROM offer_micros;
|
||||
SELECT count(*) FROM clients;
|
||||
SELECT count(*) FROM projects;
|
||||
```
|
||||
Row counts should match pre-migration counts (migration is additive — no rows
|
||||
deleted).
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "applied" once migration 0008 has been run against production and you've confirmed the new columns/table exist (or describe any errors encountered).</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Operator (human) -> Production Postgres | Manual SSH+docker exec session per project convention; only additive, idempotent SQL from migration 0008 is executed |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-12-05 | Denial of Service / Tampering | Manual prod migration apply | accept | Operator runs only the additive, idempotent migration 0008 (already grep-gated in Plan 01 against DROP/TRUNCATE/RENAME); per project convention this is a manual, out-of-band step not reachable via any HTTP endpoint |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. Operator confirms (via `\d offer_macros`, `\d offer_micros`, `\d offer_tier_services` on prod) that all Plan 01 additive columns and the new junction table exist.
|
||||
2. Operator confirms row counts for `offer_macros`, `offer_micros`, `clients`, `projects` are unchanged (additive migration, no data loss).
|
||||
3. Resume-signal "applied" received before Wave 3 (Plans 04/05) begins relying on these columns/table against production data.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Migration 0008 is live on production.
|
||||
- `offer_tier_services` table and all new `offer_macros`/`offer_micros` columns are queryable on prod.
|
||||
- No row in `clients`, `projects`, `payments`, `phases`, `offer_macros`, or `offer_micros` was deleted or modified destructively.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-02-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
phase: 12
|
||||
plan: 02
|
||||
type: execute
|
||||
status: complete
|
||||
wave: 2
|
||||
completed: 2026-06-15
|
||||
---
|
||||
|
||||
# 12-02 SUMMARY — Apply migration 0008 to production (BLOCKING checkpoint)
|
||||
|
||||
## What was done
|
||||
|
||||
Migration `0008_offer_tier_schema.sql` (created in Plan 01) was applied to the
|
||||
**production** database via the idempotent push script, run from the local
|
||||
environment through an operator-opened SSH tunnel (local `127.0.0.1:54321` →
|
||||
production host Postgres `localhost:54321`).
|
||||
|
||||
Command used (credentials read from `.env.local`, host/port rewritten to the
|
||||
tunnel endpoint via `URL()` — never printed):
|
||||
|
||||
```bash
|
||||
DATABASE_URL=$(node --env-file=.env.local -e 'const u=new URL(process.env.DATABASE_URL); u.host="127.0.0.1:54321"; process.stdout.write(u.toString())') \
|
||||
npx tsx scripts/push-12-offer-tier-schema.ts
|
||||
```
|
||||
|
||||
Script output: all 14 statements reported `ready` — `✓ Migration 0008 (offer tier schema) applied successfully`.
|
||||
|
||||
## Verification (against live prod)
|
||||
|
||||
- `offer_macros` gained: `description, category, ticket, is_archived, cliente_ideale, risultato, tempo, pain, metodo` ✓
|
||||
- `offer_micros` gained: `tier_letter`, `public_price` ✓
|
||||
- Constraint `offer_micros_tier_letter_check` present ✓
|
||||
- Table `offer_tier_services` exists with columns `tier_id, service_id, created_at` ✓
|
||||
- Row counts unchanged (additive, no data loss): `offer_macros=1, offer_micros=1, clients=4, projects=5, payments=13, phases=6` ✓
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- First attempts failed: direct `178.104.27.55:54321` → `CONNECT_TIMEOUT` (firewalled, as in Phase 11); then through the tunnel `ECONNRESET` on all SSL variants. Root cause: the SSH `-L` forward initially targeted the wrong remote port; once re-opened pointing at the prod host's published Postgres port (`localhost:54321`), `SELECT 1` succeeded and the migration applied cleanly.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Migration 0008 is live on production; all new columns/constraint/table queryable; no destructive changes; Waves 3–4 schema-dependent code is now unblocked.
|
||||
@@ -0,0 +1,477 @@
|
||||
---
|
||||
phase: 12
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [1, 2]
|
||||
files_modified:
|
||||
- src/lib/offer-queries.ts
|
||||
- src/app/admin/offers/actions.ts
|
||||
autonomous: true
|
||||
requirements: [OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Admin can fetch the full offer-editor data shape for a single offer: macro fields (incl. category, ticket, archive flag, transformation-promise fields), its A/B/C tiers, each tier's assigned service IDs + computed services total, and the offer's Tipo/Obiettivo tag values"
|
||||
- "Admin can fetch the offer list (card grid) with category + archive status, filterable client-side"
|
||||
- "Admin can save the full editor state in one server action: macro scalar fields, category/ticket, transformation-promise fields, per-tier public_price, per-tier service assignments (offer_tier_services), and Tipo/Obiettivo tags — all validated server-side (requireAdmin + Zod, tier_letter in A/B/C)"
|
||||
- "Admin can toggle is_archived on an offer"
|
||||
artifacts:
|
||||
- path: "src/lib/offer-queries.ts"
|
||||
provides: "getOfferEditorData(macroId), getOfferListCards(), getOfferFieldOptions() — query layer for Plans 04/05"
|
||||
exports: ["getOfferEditorData", "getOfferListCards", "getOfferFieldOptions"]
|
||||
- path: "src/app/admin/offers/actions.ts"
|
||||
provides: "saveOfferEditor, toggleOfferArchived, addOfferTag, removeOfferTag, renameOfferOption — server actions for Plans 04/05"
|
||||
exports: ["saveOfferEditor", "toggleOfferArchived", "addOfferTag", "removeOfferTag", "renameOfferOption", "createOfferMacro"]
|
||||
key_links:
|
||||
- from: "src/app/admin/offers/actions.ts (saveOfferEditor)"
|
||||
to: "offer_tier_services / offer_micros / offer_macros"
|
||||
via: "delete-then-reinsert per tier (mirrors updateMicroOfferServices upsert-replace pattern) + update on offer_macros/offer_micros"
|
||||
pattern: "offer_tier_services"
|
||||
- from: "src/lib/offer-queries.ts (getOfferEditorData)"
|
||||
to: "services (Phase 11 unified catalog)"
|
||||
via: "filter by services.category matching offer_macros.category (D-4 pre-filter)"
|
||||
pattern: "eq\\(services\\.category"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the query layer and server actions that power the Phase 12 Offer Editor: a single
|
||||
`getOfferEditorData(macroId)` query returning everything Plan 05's editor page needs (macro
|
||||
fields including `category`/`ticket`/`is_archived`/transformation-promise, its tiers with
|
||||
`tier_letter`/`public_price`/assigned service IDs/computed totals, the category-filtered
|
||||
service catalog for the matrix, and Tipo/Obiettivo tag values); `getOfferListCards()` for
|
||||
Plan 04's list page; and a `saveOfferEditor` server action that persists the entire editor
|
||||
state (macro scalars, tier public prices, tier↔service assignments via
|
||||
`offer_tier_services`, and Tipo/Obiettivo tags) in one call, plus smaller actions for archive
|
||||
toggling and tag CRUD.
|
||||
|
||||
Purpose: Give Plans 04/05 (Wave 3, UI) a complete, type-safe, admin-only data contract so they
|
||||
can be built without touching the DB layer directly — covers OFFER-11 (matrix composition +
|
||||
live totals via computed `services_total`), OFFER-15 (4-dimension tags), OFFER-16 (public
|
||||
price), OFFER-17 (transformation promise), OFFER-18 (list filter + archive).
|
||||
Output: `src/lib/offer-queries.ts` additions, `src/app/admin/offers/actions.ts` additions.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/12-offer-composition-drag-drop-csv-import/12-CONTEXT.md
|
||||
@.planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md
|
||||
@src/lib/offer-queries.ts
|
||||
@src/app/admin/offers/actions.ts
|
||||
@src/app/admin/catalog/actions.ts
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- Schema additions from Plan 01 (already applied to schema.ts; migration 0008 applied to
|
||||
prod by Plan 02). Use these directly — no further exploration needed. -->
|
||||
|
||||
From src/db/schema.ts (after Plan 01 — offer_macros additive columns):
|
||||
```typescript
|
||||
// offer_macros now also has (all nullable except is_archived):
|
||||
// description: text
|
||||
// category: text — single-select "Entry Offer" | "Signature Offer" | "Retainer Offer" (free pool, OFFER-15/18)
|
||||
// ticket: text — single-select "Low Ticket" | "Mid Ticket" | "High Ticket" (free pool, OFFER-15)
|
||||
// is_archived: boolean — not null, default false (OFFER-18)
|
||||
// cliente_ideale, risultato, tempo, pain, metodo: text (OFFER-17)
|
||||
```
|
||||
|
||||
From src/db/schema.ts (after Plan 01 — offer_micros additive columns):
|
||||
```typescript
|
||||
// offer_micros now also has:
|
||||
// tier_letter: text — 'A' | 'B' | 'C' | null, CHECK constraint at DB level
|
||||
// public_price: numeric(10,2) | null (OFFER-16, manual, independent of services total)
|
||||
```
|
||||
|
||||
From src/db/schema.ts (after Plan 01 — new junction table + types):
|
||||
```typescript
|
||||
export const offer_tier_services = pgTable("offer_tier_services", {
|
||||
tier_id: text("tier_id").notNull().references(() => offer_micros.id, { onDelete: "cascade" }),
|
||||
service_id: text("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (t) => ({ pk: primaryKey({ columns: [t.tier_id, t.service_id] }), tierIdx: index(...) }));
|
||||
|
||||
export type OfferTierService = typeof offer_tier_services.$inferSelect;
|
||||
export type NewOfferTierService = typeof offer_tier_services.$inferInsert;
|
||||
```
|
||||
|
||||
From src/db/schema.ts (services, Phase 11 unified catalog — unchanged):
|
||||
```typescript
|
||||
export const services = pgTable("services", {
|
||||
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
|
||||
category: text("category"),
|
||||
fase: text("fase"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
From src/db/schema.ts (tags, polymorphic — unchanged, reused for Tipo/Obiettivo only):
|
||||
```typescript
|
||||
export const tags = pgTable("tags", {
|
||||
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||
entity_type: text("entity_type").notNull(),
|
||||
entity_id: text("entity_id").notNull(),
|
||||
name: text("name").notNull(),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
entityTagUnique: uniqueIndex("tags_entity_name_unique").on(t.entity_type, t.entity_id, t.name),
|
||||
entityIdx: index("tags_entity_idx").on(t.entity_type, t.entity_id),
|
||||
}));
|
||||
```
|
||||
|
||||
From src/lib/offer-queries.ts (current — DO NOT MODIFY these existing exports, they serve the
|
||||
legacy `/admin/offers` page and `offer_services`; this plan ADDS new exports alongside):
|
||||
```typescript
|
||||
export type MicroWithServices = OfferMicro & { services: Array<{...}>; cumulative_price: string };
|
||||
export type MacroWithMicros = OfferMacro & { micros: MicroWithServices[] };
|
||||
export async function getCatalogWithMicros(): Promise<MacroWithMicros[]>;
|
||||
export async function getAllOfferServices(): Promise<OfferService[]>;
|
||||
export async function getMicroAssignedServiceIds(microId: string): Promise<string[]>;
|
||||
```
|
||||
|
||||
From src/app/admin/catalog/actions.ts (pattern reference — Notion-style single/multi-select
|
||||
option pools via the polymorphic `tags` table; reuse this EXACT pattern for offer tags):
|
||||
```typescript
|
||||
// addServiceOption(field, serviceId, value) -> tags.insert().onConflictDoNothing()
|
||||
// removeServiceOption(field, serviceId, value) -> tags.delete().where(entity_type, entity_id, name)
|
||||
// renameServiceOption(field, oldValue, newValue) -> tags.update({name}).where(entity_type, name=old)
|
||||
// OR services.update({category|fase}).where(category|fase = old) for single-select columns
|
||||
```
|
||||
|
||||
From src/app/admin/offers/actions.ts (current — DO NOT MODIFY these existing exports, they
|
||||
serve the legacy macro/micro CRUD; this plan ADDS new exports alongside):
|
||||
```typescript
|
||||
// requireAdmin() — async, throws "Non autorizzato" if no session. Reuse identically.
|
||||
export async function createMacro(formData: FormData): Promise<void>;
|
||||
export async function deleteMacro(macroId: string): Promise<void>;
|
||||
export async function createMicro(formData: FormData): Promise<void>;
|
||||
export async function deleteMicro(microId: string): Promise<void>;
|
||||
export async function updateMicroOfferServices(microId: string, serviceIds: string[]): Promise<void>;
|
||||
// ^ delete-then-reinsert pattern — saveOfferEditor's per-tier service assignment follows this.
|
||||
```
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Query layer — getOfferEditorData, getOfferListCards, getOfferFieldOptions</name>
|
||||
<files>src/lib/offer-queries.ts</files>
|
||||
<behavior>
|
||||
Add to `src/lib/offer-queries.ts` (alongside existing exports, do not remove them):
|
||||
|
||||
- Test 1: `getOfferListCards()` returns one card per `offer_macros` row with
|
||||
`{ id, internal_name, description, category, is_archived }`, ordered by `sort_order`.
|
||||
An offer with `is_archived = true` is still returned (filtering happens client-side
|
||||
per UI-SPEC "Mostra offerte archiviate" toggle) — input: 2 macros (1 archived, 1 not)
|
||||
-> output: array of length 2, both present, `is_archived` flags correct.
|
||||
- Test 2: `getOfferEditorData(macroId)` for a macro with 0 `offer_micros` rows returns
|
||||
`{ macro: {...all fields incl. category/ticket/transformation promise...}, tiers: [],
|
||||
availableServices: [...services filtered by services.category === macro.category...],
|
||||
tipoTags: [], obiettivoTags: [] }` — input: macro with no tiers, `category =
|
||||
"Signature Offer"`, 3 services exist with `services.category = "Signature Offer"` and
|
||||
2 with a different category -> output: `tiers.length === 0`,
|
||||
`availableServices.length === 3` (only matching-category services).
|
||||
- Test 3: `getOfferEditorData(macroId)` for a macro with 3 `offer_micros` rows
|
||||
(`tier_letter` = 'A'/'B'/'C') and 2 `offer_tier_services` rows assigned to tier A
|
||||
returns `tiers` sorted A→B→C, each tier has `{ id, tier_letter, public_price,
|
||||
assignedServiceIds: string[], servicesTotal: string }`; tier A's `servicesTotal`
|
||||
equals the sum of `unit_price` for its 2 assigned services (computed via SQL
|
||||
`sum(...)`, matching the `cumulative_price` pattern in `getCatalogWithMicros`); tiers
|
||||
B/C have `assignedServiceIds: []` and `servicesTotal: "0"`.
|
||||
- Test 4: `getOfferEditorData(macroId)` returns `tipoTags`/`obiettivoTags` as `string[]`
|
||||
sourced from the polymorphic `tags` table with `entity_type = "offer_macros.tipo"` /
|
||||
`"offer_macros.obiettivo"` and `entity_id = macroId` — input: 2 "tipo" tags + 1
|
||||
"obiettivo" tag exist for the macro -> output: `tipoTags.length === 2`,
|
||||
`obiettivoTags.length === 1`.
|
||||
- Test 5: `getOfferFieldOptions()` returns
|
||||
`{ categoria: string[], ticket: string[], tipo: string[], obiettivo: string[] }` —
|
||||
distinct `offer_macros.category` / `offer_macros.ticket` values (non-null, like
|
||||
`getCatalogFieldOptions`'s `categoria`/`fase` pattern) plus distinct `tags.name` where
|
||||
`entity_type IN ("offer_macros.tipo", "offer_macros.obiettivo")`, split by
|
||||
`entity_type`. Input: 2 macros with categories "Entry Offer"/"Signature Offer", 1
|
||||
"tipo" tag "Audit", 1 "obiettivo" tag "Lead Generation" -> output: `categoria` contains
|
||||
both values, `tipo === ["Audit"]`, `obiettivo === ["Lead Generation"]`.
|
||||
|
||||
Write these as a Vitest/Jest test file if a test runner is configured (`npm test` —
|
||||
check `package.json` `scripts.test` first); if no test runner exists in this repo,
|
||||
SKIP the dedicated test file (do not introduce a new test framework — out of scope) and
|
||||
instead write a one-off `scripts/verify-12-03-queries.ts` that calls each function
|
||||
against a temporary in-memory assertion of the SQL shape (or, if `DATABASE_URL` is
|
||||
unreachable from this environment per project memory, write the script so it TYPECHECKS
|
||||
and documents expected behavior in comments matching the 5 cases above — to be run
|
||||
manually by the operator against a dev DB later). Either path satisfies the `tdd="true"`
|
||||
intent: behavior is specified before/alongside implementation.
|
||||
</behavior>
|
||||
<action>
|
||||
Implement in `src/lib/offer-queries.ts`:
|
||||
|
||||
1. **`getOfferListCards()`**: select `id, internal_name, description, category,
|
||||
is_archived, sort_order` from `offer_macros`, `orderBy(asc(sort_order),
|
||||
asc(created_at))`. Return type `OfferListCard[]` where
|
||||
`OfferListCard = Pick<OfferMacro, "id" | "internal_name" | "description" | "category" | "is_archived">`.
|
||||
|
||||
2. **`getOfferEditorData(macroId: string)`**:
|
||||
- Fetch the `offer_macros` row by id (return `null` if not found — Plan 05 handles
|
||||
404).
|
||||
- Fetch its `offer_micros` rows ordered by `tier_letter` (A, B, C — use
|
||||
`sql\`CASE tier_letter WHEN 'A' THEN 1 WHEN 'B' THEN 2 WHEN 'C' THEN 3 ELSE 4 END\``
|
||||
for ordering, since plain `asc(tier_letter)` would sort nulls/letters
|
||||
alphabetically which happens to match A<B<C but nulls need explicit handling —
|
||||
tiers without `tier_letter` set sort last).
|
||||
- For each tier, fetch assigned `offer_tier_services` rows (service_id list) and
|
||||
compute `servicesTotal` via the same `coalesce(sum(unit_price::numeric), 0)`
|
||||
pattern as `getCatalogWithMicros`'s `cumulMap`, joined through
|
||||
`offer_tier_services` -> `services`.
|
||||
- Fetch `availableServices`: all `services` where `active = true` AND (if
|
||||
`macro.category` is set) `services.category = macro.category`; if
|
||||
`macro.category` is null, return all active services (no pre-filter — D-4 filter
|
||||
is best-effort, not a hard constraint when category is unset). Shape:
|
||||
`{ id, name, unit_price, category }[]`.
|
||||
- Fetch `tipoTags`/`obiettivoTags`: `select distinct name from tags where entity_type
|
||||
in ("offer_macros.tipo","offer_macros.obiettivo") and entity_id = macroId`, split
|
||||
by `entity_type` into two `string[]`.
|
||||
- Return shape:
|
||||
```typescript
|
||||
export type OfferTierData = {
|
||||
id: string;
|
||||
tier_letter: string | null;
|
||||
internal_name: string;
|
||||
public_name: string;
|
||||
duration_months: number;
|
||||
public_price: string | null;
|
||||
assignedServiceIds: string[];
|
||||
servicesTotal: string;
|
||||
};
|
||||
export type OfferEditorData = {
|
||||
macro: OfferMacro; // includes category, ticket, is_archived, cliente_ideale, risultato, tempo, pain, metodo, description
|
||||
tiers: OfferTierData[];
|
||||
availableServices: Array<{ id: string; name: string; unit_price: string; category: string | null }>;
|
||||
tipoTags: string[];
|
||||
obiettivoTags: string[];
|
||||
};
|
||||
export async function getOfferEditorData(macroId: string): Promise<OfferEditorData | null>;
|
||||
```
|
||||
|
||||
3. **`getOfferFieldOptions()`**:
|
||||
```typescript
|
||||
export type OfferFieldOptions = {
|
||||
categoria: string[];
|
||||
ticket: string[];
|
||||
tipo: string[];
|
||||
obiettivo: string[];
|
||||
};
|
||||
export async function getOfferFieldOptions(): Promise<OfferFieldOptions>;
|
||||
```
|
||||
`categoria`/`ticket`: `selectDistinct` on `offer_macros.category` /
|
||||
`offer_macros.ticket`, filter out nulls, sort alphabetically (mirror
|
||||
`getCatalogFieldOptions`'s null-filter pattern for `categoria`/`fase`).
|
||||
`tipo`/`obiettivo`: `selectDistinct({ name: tags.name, type: tags.entity_type })` where
|
||||
`entity_type in ("offer_macros.tipo", "offer_macros.obiettivo")`, split by type.
|
||||
|
||||
Use existing imports (`db`, `eq`, `asc`, `inArray`, `sql`, `and`) — add `desc` or others
|
||||
only if genuinely needed. Import `offer_tier_services`, `tags`, `services` from
|
||||
`@/db/schema` (services/tags likely not yet imported in this file — check current
|
||||
imports first).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npx tsc --noEmit` passes with zero errors
|
||||
- `grep -c "export async function getOfferEditorData\|export async function getOfferListCards\|export async function getOfferFieldOptions" src/lib/offer-queries.ts` == 3
|
||||
- `grep -c "export async function getCatalogWithMicros\|export async function getAllOfferServices\|export async function getMicroAssignedServiceIds" src/lib/offer-queries.ts` == 3 (existing exports untouched)
|
||||
- `grep -c "offer_tier_services" src/lib/offer-queries.ts` >= 1
|
||||
</acceptance_criteria>
|
||||
<done>offer-queries.ts exports getOfferEditorData, getOfferListCards, getOfferFieldOptions with the documented shapes; existing exports unchanged; file typechecks.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Server actions — saveOfferEditor, toggleOfferArchived, offer tag CRUD, createOfferMacro</name>
|
||||
<files>src/app/admin/offers/actions.ts</files>
|
||||
<behavior>
|
||||
Add to `src/app/admin/offers/actions.ts` (alongside existing exports, do not remove
|
||||
them):
|
||||
|
||||
- Test 1: `saveOfferEditor(macroId, payload)` rejects (throws) when `payload.tiers`
|
||||
contains a `tier_letter` not in `["A","B","C"]` — Zod enum validation, defense-in-depth
|
||||
alongside the DB CHECK constraint from Plan 01.
|
||||
- Test 2: `saveOfferEditor(macroId, payload)` updates `offer_macros` scalar fields
|
||||
(`internal_name`, `description`, `category`, `ticket`, `cliente_ideale`, `risultato`,
|
||||
`tempo`, `pain`, `metodo`) in one `db.update(offer_macros)...where(eq(id, macroId))`.
|
||||
- Test 3: `saveOfferEditor` upserts each tier in `payload.tiers`: if a tier `id` is
|
||||
provided, `db.update(offer_micros).set({ tier_letter, public_price, internal_name,
|
||||
public_name, duration_months })`; if no `id`, `db.insert(offer_micros).values({...,
|
||||
macro_id: macroId})` (supports creating tiers for a macro that has fewer than 3).
|
||||
- Test 4: `saveOfferEditor` replaces each tier's `offer_tier_services` rows via
|
||||
delete-then-reinsert (same pattern as `updateMicroOfferServices`): input tier with
|
||||
`assignedServiceIds: ["svc1","svc2"]` -> after call, `offer_tier_services` has exactly
|
||||
2 rows for that `tier_id`, both pointing at svc1/svc2.
|
||||
- Test 5: `saveOfferEditor` replaces Tipo/Obiettivo tags via delete-then-reinsert against
|
||||
`tags` where `entity_type in ("offer_macros.tipo","offer_macros.obiettivo")` and
|
||||
`entity_id = macroId`.
|
||||
- Test 6: `toggleOfferArchived(macroId, archived: boolean)` sets
|
||||
`offer_macros.is_archived = archived`.
|
||||
- Test 7: `addOfferTag(dimension, macroId, value)` / `removeOfferTag(dimension, macroId,
|
||||
value)` work for `dimension in ("tipo","obiettivo")`, mirroring
|
||||
`addServiceOption`/`removeServiceOption` against `entity_type =
|
||||
"offer_macros." + dimension`. Reject any other `dimension` value.
|
||||
- Test 8: `renameOfferOption(field, oldValue, newValue)` for `field in
|
||||
("categoria","ticket")` updates `offer_macros.category`/`offer_macros.ticket` for all
|
||||
rows matching `oldValue` (mirrors `renameServiceOption`'s single-select branch); for
|
||||
`field in ("tipo","obiettivo")` updates `tags.name` (mirrors the multi-select branch).
|
||||
- Test 9: `createOfferMacro(formData)` creates a new `offer_macros` row from
|
||||
`internal_name` (required) + optional `public_name`/`description`/`category` —
|
||||
needed so Plan 04's "+ Nuova Offerta" button has a target action (UI-SPEC section 1A).
|
||||
If `public_name` is omitted, default it to `internal_name` (existing `offer_macros`
|
||||
schema requires `public_name` NOT NULL).
|
||||
|
||||
Same test-runner decision as Plan 03 Task 1: if no test runner configured, write
|
||||
`scripts/verify-12-03-actions.ts` (typechecks, documents the 9 cases) instead of a
|
||||
dedicated test file — do not introduce a new framework.
|
||||
</behavior>
|
||||
<action>
|
||||
Implement in `src/app/admin/offers/actions.ts`, reusing `requireAdmin()`,
|
||||
`revalidatePath("/admin/offers")`, and the `z` (Zod) import already present:
|
||||
|
||||
1. **`saveOfferEditor(macroId: string, payload: SaveOfferEditorPayload)`** where:
|
||||
```typescript
|
||||
const tierSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
tier_letter: z.enum(["A", "B", "C"]),
|
||||
internal_name: z.string().min(1),
|
||||
public_name: z.string().min(1),
|
||||
duration_months: z.coerce.number().int().min(1),
|
||||
public_price: z.coerce.number().min(0).optional().nullable(),
|
||||
assignedServiceIds: z.array(z.string()),
|
||||
});
|
||||
const saveOfferEditorSchema = z.object({
|
||||
internal_name: z.string().min(1, "Nome interno richiesto"),
|
||||
public_name: z.string().min(1, "Nome pubblico richiesto"),
|
||||
description: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
ticket: z.string().optional(),
|
||||
cliente_ideale: z.string().optional(),
|
||||
risultato: z.string().optional(),
|
||||
tempo: z.string().optional(),
|
||||
pain: z.string().optional(),
|
||||
metodo: z.string().optional(),
|
||||
tiers: z.array(tierSchema).max(3),
|
||||
tipoTags: z.array(z.string()),
|
||||
obiettivoTags: z.array(z.string()),
|
||||
});
|
||||
export type SaveOfferEditorPayload = z.infer<typeof saveOfferEditorSchema>;
|
||||
```
|
||||
Implementation order (sequential `await`s, no transaction wrapper needed — Drizzle
|
||||
+ `postgres` driver here doesn't use an interactive tx helper elsewhere in this
|
||||
codebase, follow existing per-statement pattern):
|
||||
- `safeParse` the payload; throw `parsed.error.issues[0].message` on failure.
|
||||
- `db.update(offer_macros).set({...scalars, public_price fields N/A here...}).where(eq(offer_macros.id, macroId))`
|
||||
— set `internal_name, public_name, description, category, ticket, cliente_ideale,
|
||||
risultato, tempo, pain, metodo` (empty-string -> `null` for optional text fields,
|
||||
same `|| null` convention as `createMacro`).
|
||||
- For each tier in `parsed.data.tiers`:
|
||||
- If `tier.id` exists: `db.update(offer_micros).set({ tier_letter, internal_name,
|
||||
public_name, duration_months, public_price: tier.public_price != null ?
|
||||
String(tier.public_price) : null }).where(eq(offer_micros.id, tier.id))`.
|
||||
- Else: `db.insert(offer_micros).values({ macro_id: macroId, tier_letter,
|
||||
internal_name, public_name, duration_months, public_price: ... })` and capture
|
||||
the new id (insert `.returning({ id: offer_micros.id })`).
|
||||
- Delete-then-reinsert `offer_tier_services` for that tier id (mirror
|
||||
`updateMicroOfferServices`): `db.delete(offer_tier_services).where(eq(tier_id,
|
||||
...))`, then if `assignedServiceIds.length > 0`,
|
||||
`db.insert(offer_tier_services).values(assignedServiceIds.map(service_id => ({
|
||||
tier_id, service_id })))`.
|
||||
- Delete-then-reinsert Tipo/Obiettivo tags: `db.delete(tags).where(and(eq(entity_id,
|
||||
macroId), inArray(entity_type, ["offer_macros.tipo","offer_macros.obiettivo"])))`,
|
||||
then insert rows for each `tipoTags`/`obiettivoTags` entry with the corresponding
|
||||
`entity_type`, using `onConflictDoNothing()` per row (mirror `addServiceOption`).
|
||||
- `revalidatePath("/admin/offers")` and `revalidatePath(\`/admin/offers/${macroId}/edit\`)`.
|
||||
|
||||
2. **`toggleOfferArchived(macroId: string, archived: boolean)`**: `requireAdmin()`,
|
||||
`db.update(offer_macros).set({ is_archived: archived }).where(eq(id, macroId))`,
|
||||
`revalidatePath("/admin/offers")`.
|
||||
|
||||
3. **`addOfferTag(dimension: "tipo" | "obiettivo", macroId: string, value: string)`** /
|
||||
**`removeOfferTag(dimension, macroId, value)`**: mirror
|
||||
`addServiceOption`/`removeServiceOption` exactly, using `entity_type =
|
||||
\`offer_macros.${dimension}\`` and `entity_id = macroId`. Validate `dimension` is one
|
||||
of the two allowed values (throw otherwise).
|
||||
|
||||
4. **`renameOfferOption(field: "categoria" | "ticket" | "tipo" | "obiettivo", oldValue:
|
||||
string, newValue: string)`**: mirror `renameServiceOption`. For `"categoria"`:
|
||||
`db.update(offer_macros).set({ category: next }).where(eq(category, oldValue))`. For
|
||||
`"ticket"`: same on `offer_macros.ticket`. For `"tipo"`/`"obiettivo"`:
|
||||
`db.update(tags).set({ name: next }).where(and(eq(entity_type,
|
||||
\`offer_macros.${field}\`), eq(name, oldValue)))`.
|
||||
|
||||
5. **`createOfferMacro(formData: FormData)`**: Zod-validate `internal_name` (required),
|
||||
optional `public_name` (default = `internal_name` if empty), `description`,
|
||||
`category`. `db.insert(offer_macros).values({...})`, `revalidatePath("/admin/offers")`.
|
||||
This is additive alongside the existing `createMacro` (legacy form-based create on
|
||||
the old page) — `createOfferMacro` is the Plan 04 "+ Nuova Offerta" target with the
|
||||
new fields.
|
||||
|
||||
Import `offer_tier_services`, `tags` from `@/db/schema` (add to existing import list);
|
||||
import `inArray` from `drizzle-orm` if not already imported.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npx tsc --noEmit` passes with zero errors
|
||||
- `grep -c "export async function saveOfferEditor\|export async function toggleOfferArchived\|export async function addOfferTag\|export async function removeOfferTag\|export async function renameOfferOption\|export async function createOfferMacro" src/app/admin/offers/actions.ts` == 6
|
||||
- `grep -c "export async function createMacro\|export async function deleteMacro\|export async function createMicro\|export async function deleteMicro\|export async function updateMicroOfferServices" src/app/admin/offers/actions.ts` == 5 (existing exports untouched)
|
||||
- `grep -c "z.enum(\[\"A\", \"B\", \"C\"\]\|z.enum([\"A\",\"B\",\"C\"]" src/app/admin/offers/actions.ts` >= 1 (tier_letter Zod validation present)
|
||||
- `grep -c "requireAdmin()" src/app/admin/offers/actions.ts` >= 11 (every action, old + new, calls requireAdmin)
|
||||
</acceptance_criteria>
|
||||
<done>actions.ts exports saveOfferEditor, toggleOfferArchived, addOfferTag, removeOfferTag, renameOfferOption, createOfferMacro — all requireAdmin-guarded, Zod-validated, revalidating /admin/offers; existing exports unchanged; file typechecks.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Admin browser -> saveOfferEditor server action | Admin-authenticated input (full editor payload: scalars, tier compositions, tags) crosses into DB writes |
|
||||
| Admin browser -> offer tag CRUD actions | Free-text tag names (Tipo/Obiettivo/Categoria/Ticket) crosses into `tags`/`offer_macros` |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-12-06 | Tampering | `saveOfferEditor` tier_letter / numeric fields | mitigate | Zod schema: `tier_letter` restricted to enum `["A","B","C"]`, `public_price`/`duration_months` coerced + min-bounded; DB CHECK constraint (Plan 01) as second layer |
|
||||
| T-12-07 | Elevation of Privilege | All new server actions (`saveOfferEditor`, `toggleOfferArchived`, tag CRUD, `createOfferMacro`) | mitigate | Every action calls `requireAdmin()` first (session check via Auth.js), identical to all existing `/admin/offers` and `/admin/catalog` actions |
|
||||
| T-12-08 | Tampering | Free-text tag/category/ticket values stored via `onConflictDoNothing`/update | accept | Same trust level as Phase 11 catalog tags (admin-only input, no client-facing exposure); length/content not constrained beyond non-empty, consistent with existing `addServiceOption`/`renameServiceOption` |
|
||||
| T-12-09 | Information Disclosure | `getOfferEditorData`/`getOfferListCards`/`getOfferFieldOptions` expose `offer_tier_services`/pricing data | accept | Functions are imported only by `/admin/offers/*` pages (Plans 04/05), which sit behind Auth.js session middleware; no route in this plan is client-facing |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `npx tsc --noEmit` passes for the full project.
|
||||
2. `getOfferEditorData`, `getOfferListCards`, `getOfferFieldOptions` exported from `src/lib/offer-queries.ts`, existing exports (`getCatalogWithMicros`, `getAllOfferServices`, `getMicroAssignedServiceIds`) untouched.
|
||||
3. `saveOfferEditor`, `toggleOfferArchived`, `addOfferTag`, `removeOfferTag`, `renameOfferOption`, `createOfferMacro` exported from `src/app/admin/offers/actions.ts`, existing exports (`createMacro`, `deleteMacro`, `createMicro`, `deleteMicro`, `updateMicroOfferServices`) untouched.
|
||||
4. Every new server action calls `requireAdmin()` before any DB write.
|
||||
5. `saveOfferEditor` validates `tier_letter` against `["A","B","C"]` via Zod before any write.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Plan 05's editor page can fetch a complete `OfferEditorData` shape and persist all of it via `saveOfferEditor` in one call.
|
||||
- Plan 04's list page can fetch `OfferListCard[]` with category + archive status.
|
||||
- All four tag dimensions (Categoria, Ticket, Tipo, Obiettivo) are readable/writable via `getOfferFieldOptions`/`addOfferTag`/`removeOfferTag`/`renameOfferOption`.
|
||||
- No legacy `offer_micro_services`/`offer_services`/existing-export code paths modified.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-03-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
phase: 12-offer-composition-drag-drop-csv-import
|
||||
plan: 03
|
||||
subsystem: api
|
||||
tags: [drizzle, postgres, zod, server-actions, next.js]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 12-offer-composition-drag-drop-csv-import (Plan 01)
|
||||
provides: Additive offer_macros/offer_micros columns (category, ticket, is_archived, transformation-promise fields, tier_letter, public_price) and offer_tier_services junction table, applied to prod via Plan 02
|
||||
provides:
|
||||
- getOfferEditorData(macroId) — full editor data shape (macro fields, A/B/C tiers with assignedServiceIds + computed servicesTotal, category-filtered availableServices, tipoTags/obiettivoTags)
|
||||
- getOfferListCards() — list-page cards with category + archive status
|
||||
- getOfferFieldOptions() — categoria/ticket/tipo/obiettivo select pools
|
||||
- saveOfferEditor(macroId, payload) — single-call persistence of macro scalars, per-tier upsert + offer_tier_services replace, and Tipo/Obiettivo tag replace
|
||||
- toggleOfferArchived, addOfferTag, removeOfferTag, renameOfferOption, createOfferMacro server actions
|
||||
affects: [12-04-offer-list-page, 12-05-offer-editor-page]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Polymorphic tags table reused for offer_macros Tipo/Obiettivo dimensions via entity_type = 'offer_macros.tipo' | 'offer_macros.obiettivo', mirroring the Phase 11 services/leads D-06 pattern"
|
||||
- "Computed servicesTotal via coalesce(sum(unit_price::numeric), 0) joined through offer_tier_services -> services, mirroring getCatalogWithMicros's cumulMap pattern"
|
||||
- "Delete-then-reinsert for many-to-many replacement (offer_tier_services per tier, Tipo/Obiettivo tags per macro), mirroring updateMicroOfferServices"
|
||||
- "Zod enum tier_letter validation (A/B/C) as defense-in-depth alongside the Plan 01 DB CHECK constraint"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- scripts/verify-12-03-queries.ts
|
||||
- scripts/verify-12-03-actions.ts
|
||||
modified:
|
||||
- src/lib/offer-queries.ts
|
||||
- src/app/admin/offers/actions.ts
|
||||
|
||||
key-decisions:
|
||||
- "No test runner configured (no scripts.test in package.json) — wrote typecheck-only verification scripts documenting the 14 spec'd test cases (5 query + 9 action), per plan's explicit fallback; not executed against prod DB"
|
||||
- "saveOfferEditor uses sequential awaits (no transaction wrapper) per macro update -> tier upserts -> tag replace, matching the existing per-statement pattern in this codebase (no interactive tx helper elsewhere)"
|
||||
|
||||
patterns-established:
|
||||
- "OFFER_TIPO_ENTITY/OFFER_OBIETTIVO_ENTITY constants ('offer_macros.tipo'/'offer_macros.obiettivo') as the canonical entity_type values for offer-level tag dimensions — reuse in Plans 04/05 UI"
|
||||
|
||||
requirements-completed: [OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18]
|
||||
|
||||
# Metrics
|
||||
duration: 12min
|
||||
completed: 2026-06-15
|
||||
---
|
||||
|
||||
# Phase 12 Plan 03: Offer Editor Query Layer & Server Actions Summary
|
||||
|
||||
**Query layer (`getOfferEditorData`, `getOfferListCards`, `getOfferFieldOptions`) and server actions (`saveOfferEditor` + archive/tag/rename/create actions) giving Plans 04/05 a complete, type-safe, admin-only data contract for the offer editor.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 12 min
|
||||
- **Started:** 2026-06-15T08:15:00Z (approx)
|
||||
- **Completed:** 2026-06-15T08:17:07Z
|
||||
- **Tasks:** 2 completed
|
||||
- **Files modified:** 4 (2 source, 2 new verification scripts)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Added `getOfferListCards()` to `src/lib/offer-queries.ts`: returns `{ id, internal_name, description, category, is_archived }` per `offer_macros` row, ordered by `sort_order`, including archived offers (client-side filter per UI-SPEC)
|
||||
- Added `getOfferEditorData(macroId)`: returns `{ macro, tiers, availableServices, tipoTags, obiettivoTags }` — tiers ordered A→B→C via SQL `CASE` expression, each with `assignedServiceIds` and a computed `servicesTotal` (`coalesce(sum(unit_price::numeric), 0)` joined through `offer_tier_services`), available services pre-filtered by `services.category === macro.category` (D-4, best-effort when category unset)
|
||||
- Added `getOfferFieldOptions()`: `{ categoria, ticket, tipo, obiettivo }` distinct-value pools, mirroring `getCatalogFieldOptions`'s null-filter + alphabetical sort pattern
|
||||
- Added `saveOfferEditor(macroId, payload)` to `src/app/admin/offers/actions.ts`: Zod-validates the full editor payload (`tier_letter` restricted to `["A","B","C"]`), updates `offer_macros` scalars, upserts each tier (update if `id` present, insert + `.returning({id})` if not), delete-then-reinserts `offer_tier_services` per tier, and delete-then-reinserts Tipo/Obiettivo `tags`
|
||||
- Added `toggleOfferArchived`, `addOfferTag`/`removeOfferTag` (tipo/obiettivo dimensions), `renameOfferOption` (categoria/ticket/tipo/obiettivo), and `createOfferMacro` (Plan 04 "+ Nuova Offerta" target, defaults `public_name` to `internal_name`)
|
||||
- All new actions call `requireAdmin()` first; existing exports in both files left untouched
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Query layer — getOfferEditorData, getOfferListCards, getOfferFieldOptions** - `0d742f2` (feat)
|
||||
2. **Task 2: Server actions — saveOfferEditor, toggleOfferArchived, offer tag CRUD, createOfferMacro** - `a372c61` (feat)
|
||||
|
||||
**Plan metadata:** (this commit, follows)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `src/lib/offer-queries.ts` - Added `getOfferListCards`, `getOfferEditorData`, `getOfferFieldOptions` + `OfferListCard`/`OfferTierData`/`OfferEditorData`/`OfferFieldOptions` types; imports `offer_tier_services`, `services`, `tags`, `and` from drizzle-orm
|
||||
- `src/app/admin/offers/actions.ts` - Added `saveOfferEditor`, `toggleOfferArchived`, `addOfferTag`, `removeOfferTag`, `renameOfferOption`, `createOfferMacro` + `SaveOfferEditorPayload`/`tierSchema`/`saveOfferEditorSchema` types; imports `offer_tier_services`, `tags`, `and`, `inArray`
|
||||
- `scripts/verify-12-03-queries.ts` - Typecheck-only documentation of the 5 query-layer test cases (Tests 1-5 from Task 1's `<behavior>`)
|
||||
- `scripts/verify-12-03-actions.ts` - Typecheck-only documentation of the 9 server-action test cases (Tests 1-9 from Task 2's `<behavior>`)
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- No test runner configured in this repo (`package.json` has no `scripts.test`), and `.env.local`'s `DATABASE_URL` points at production per project memory — followed the plan's explicit fallback and wrote `scripts/verify-12-03-queries.ts` / `scripts/verify-12-03-actions.ts` as typecheck-only documentation of all 14 spec'd test cases, not executed against the live DB
|
||||
- `saveOfferEditor` uses sequential `await`s (macro update → per-tier upsert/replace → tag replace) with no transaction wrapper, matching the existing per-statement pattern used by `updateMicroOfferServices` and the rest of this codebase
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written. Both tasks implemented the documented function signatures, return shapes, and validation rules verbatim; existing exports in `offer-queries.ts` and `actions.ts` were not modified.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required. This plan is pure source-code (query/action functions); migration 0008 was already live on production (verified per Plan 02), and no data-mutating scripts were run.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 04 (offer list page) can call `getOfferListCards()` and `createOfferMacro` for the "+ Nuova Offerta" button
|
||||
- Plan 05 (offer editor page) can call `getOfferEditorData(macroId)` to populate the full editor (macro fields, tier matrix with live totals, Tipo/Obiettivo tags, category-filtered service catalog) and persist all of it via a single `saveOfferEditor(macroId, payload)` call; archive toggling via `toggleOfferArchived`; tag/option CRUD via `addOfferTag`/`removeOfferTag`/`renameOfferOption`
|
||||
- `npx tsc --noEmit` passes with zero errors across the full project
|
||||
- No legacy `offer_micro_services`/`offer_services`/existing-export code paths were touched
|
||||
|
||||
---
|
||||
|
||||
*Phase: 12-offer-composition-drag-drop-csv-import*
|
||||
*Completed: 2026-06-15*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All created/modified files verified present on disk:
|
||||
|
||||
- FOUND: src/lib/offer-queries.ts
|
||||
- FOUND: src/app/admin/offers/actions.ts
|
||||
- FOUND: scripts/verify-12-03-queries.ts
|
||||
- FOUND: scripts/verify-12-03-actions.ts
|
||||
- FOUND: .planning/phases/12-offer-composition-drag-drop-csv-import/12-03-SUMMARY.md
|
||||
|
||||
All task commits verified present in git log:
|
||||
|
||||
- FOUND: 0d742f2 (Task 1)
|
||||
- FOUND: a372c61 (Task 2)
|
||||
@@ -0,0 +1,284 @@
|
||||
---
|
||||
phase: 12
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: [1, 3]
|
||||
files_modified:
|
||||
- src/app/admin/offers/page.tsx
|
||||
- src/components/admin/offers/OfferListClient.tsx
|
||||
autonomous: true
|
||||
requirements: [OFFER-18]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Admin sees a card grid of all offers, each showing internal name, short description, and category chip"
|
||||
- "Admin can filter the card grid by category (Tutti / Entry Offer / Signature Offer / Retainer Offer, derived dynamically from existing category values)"
|
||||
- "Admin can toggle 'Mostra offerte archiviate' to show/hide archived offers (hidden by default)"
|
||||
- "Admin can create a new offer via '+ Nuova Offerta', which creates an offer_macros row and the new card appears in the grid"
|
||||
- "Each card links to the Plan 05 editor route /admin/offers/[id]/edit"
|
||||
artifacts:
|
||||
- path: "src/app/admin/offers/page.tsx"
|
||||
provides: "Server component: fetches getOfferListCards() + getOfferFieldOptions(), renders OfferListClient"
|
||||
- path: "src/components/admin/offers/OfferListClient.tsx"
|
||||
provides: "Client component: category filter chips, archive toggle, card grid, '+ Nuova Offerta' button calling createOfferMacro"
|
||||
exports: ["OfferListClient"]
|
||||
key_links:
|
||||
- from: "src/components/admin/offers/OfferListClient.tsx"
|
||||
to: "/admin/offers/[id]/edit"
|
||||
via: "next/link Link per card"
|
||||
pattern: "admin/offers/\\$\\{.*\\}/edit"
|
||||
- from: "src/components/admin/offers/OfferListClient.tsx (+ Nuova Offerta)"
|
||||
to: "createOfferMacro server action (Plan 03)"
|
||||
via: "useTransition + router.refresh on success, new card appears in grid"
|
||||
pattern: "createOfferMacro"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Redesign `/admin/offers` (currently a flat macro/micro CRUD list — the legacy page) into the
|
||||
Phase 12 UI-SPEC card-grid list: filterable by category chips, archive toggle, "+ Nuova
|
||||
Offerta" CTA, each card linking to the new per-offer editor route (`/admin/offers/[id]/edit`,
|
||||
built in Plan 05).
|
||||
|
||||
Purpose: Deliver OFFER-18 (category-filterable, archive-aware offer list) per the locked
|
||||
UI-SPEC (`12-UI-SPEC.md` section 1) and D-1 (full editor list+detail, CSV import deferred).
|
||||
Output: `src/app/admin/offers/page.tsx` rewritten as the new list page;
|
||||
`src/components/admin/offers/OfferListClient.tsx` (new) implementing filter/grid/CTA logic.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md
|
||||
@src/app/admin/catalog/CatalogSearch.tsx
|
||||
@src/components/admin/AdminSidebar.tsx
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 03 (src/lib/offer-queries.ts) — use directly, no further exploration needed. -->
|
||||
|
||||
```typescript
|
||||
export type OfferListCard = Pick<OfferMacro, "id" | "internal_name" | "description" | "category" | "is_archived">;
|
||||
export async function getOfferListCards(): Promise<OfferListCard[]>;
|
||||
|
||||
export type OfferFieldOptions = { categoria: string[]; ticket: string[]; tipo: string[]; obiettivo: string[] };
|
||||
export async function getOfferFieldOptions(): Promise<OfferFieldOptions>;
|
||||
```
|
||||
|
||||
<!-- From Plan 03 (src/app/admin/offers/actions.ts) — use directly. -->
|
||||
|
||||
```typescript
|
||||
// Creates a new offer_macros row from FormData { internal_name, public_name?, description?, category? }
|
||||
// public_name defaults to internal_name if omitted. Returns void (revalidates /admin/offers).
|
||||
// NOTE: createOfferMacro does NOT return the new row's id (FormData server actions can't
|
||||
// easily return values to client callers in the <form action={fn}> pattern). For the
|
||||
// "+ Nuova Offerta" flow, Task 2 calls createOfferMacro via startTransition + FormData,
|
||||
// then router.refresh() — the new card appears in the grid; admin clicks it to open the
|
||||
// editor (Plan 05). No need to extend Plan 03's contract.
|
||||
export async function createOfferMacro(formData: FormData): Promise<void>;
|
||||
```
|
||||
|
||||
<!-- From src/app/admin/catalog/CatalogSearch.tsx — reference pattern for a client component
|
||||
wrapping a server-fetched list with client-side filter state (search/category chips). Read
|
||||
this file's structure (useState + useMemo filter, "use client") before writing OfferListClient. -->
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Rewrite /admin/offers page.tsx as the new list page entry point</name>
|
||||
<files>src/app/admin/offers/page.tsx</files>
|
||||
<read_first>
|
||||
- src/app/admin/offers/page.tsx (current — full file, ~239 lines, the legacy macro/micro
|
||||
CRUD page being replaced)
|
||||
- src/app/admin/catalog/page.tsx (pattern: thin server component, `Promise.all` fetch,
|
||||
delegates to a client component for interactivity)
|
||||
- .planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md (section 1:
|
||||
Offer List Page layout, header, filter row, card grid, empty state, copywriting
|
||||
contract table)
|
||||
</read_first>
|
||||
<action>
|
||||
Replace the entire contents of `src/app/admin/offers/page.tsx` with a thin server
|
||||
component matching the `catalog/page.tsx` pattern:
|
||||
|
||||
```typescript
|
||||
import { getOfferListCards, getOfferFieldOptions } from "@/lib/offer-queries";
|
||||
import { OfferListClient } from "@/components/admin/offers/OfferListClient";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function OffersPage() {
|
||||
const [cards, options] = await Promise.all([
|
||||
getOfferListCards(),
|
||||
getOfferFieldOptions(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<OfferListClient cards={cards} categoryOptions={options.categoria} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The legacy macro/micro/offer_services CRUD UI previously on this page (forms for
|
||||
`createMacro`/`createMicro`/`createOfferService`, `ServiceCheckboxList`, etc.) is
|
||||
REMOVED from this route per D-1 (full editor redesign) and the UI-SPEC (section 1 — no
|
||||
legacy CRUD forms in the new list page). The underlying `offer_macros`/`offer_micros`
|
||||
legacy actions (`createMacro`, `deleteMacro`, `createMicro`, `deleteMicro`,
|
||||
`updateMicroOfferServices`) remain exported from `actions.ts` (Plan 03 did not remove
|
||||
them) but are no longer referenced from any page — this is intentional dead-export
|
||||
cleanup deferred to a future phase if needed (do NOT delete the exports now, only
|
||||
routes/usages, to avoid breaking `npx tsc --noEmit` if anything else imports them; grep
|
||||
confirms nothing else does, but leaving the exports is zero-risk and out of this plan's
|
||||
scope to remove).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `src/app/admin/offers/page.tsx` is a server component importing `getOfferListCards`, `getOfferFieldOptions`, and `OfferListClient`
|
||||
- `grep -c "ServiceCheckboxList\|createMacro\|createMicro\|createOfferService" src/app/admin/offers/page.tsx` == 0 (legacy forms removed from this page)
|
||||
- `npx tsc --noEmit` passes (will fail until Task 2 creates `OfferListClient` — run combined verification after both tasks)
|
||||
</acceptance_criteria>
|
||||
<done>page.tsx is a thin server component delegating to OfferListClient with the new data shape.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Build OfferListClient — category filter, archive toggle, card grid, create CTA</name>
|
||||
<files>src/components/admin/offers/OfferListClient.tsx</files>
|
||||
<read_first>
|
||||
- src/app/admin/catalog/CatalogSearch.tsx (client component pattern: "use client",
|
||||
useState filter state, useMemo derived list, search input)
|
||||
- src/components/ui/button.tsx, src/components/ui/badge.tsx, src/components/ui/input.tsx
|
||||
(exported component signatures — use these for CTA button, category chips/badges,
|
||||
"+ Nuova Offerta" form input)
|
||||
- .planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md (section 1:
|
||||
exact layout ASCII diagram, color tokens #1A463C/#DEF168/#dc2626/#71717a/#e5e7eb,
|
||||
card structure, empty state copy, filter chip active/inactive styling, copywriting
|
||||
contract table for all literal strings)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/components/admin/offers/OfferListClient.tsx`:
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useState, useMemo, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { createOfferMacro } from "@/app/admin/offers/actions";
|
||||
import type { OfferListCard } from "@/lib/offer-queries";
|
||||
|
||||
export function OfferListClient({
|
||||
cards,
|
||||
categoryOptions,
|
||||
}: {
|
||||
cards: OfferListCard[];
|
||||
categoryOptions: string[];
|
||||
}) {
|
||||
// ... implementation below
|
||||
}
|
||||
```
|
||||
|
||||
Implementation requirements (per UI-SPEC section 1 + copywriting contract table):
|
||||
|
||||
1. **State**: `activeCategory: string | null` (null = "Tutti"), `showArchived: boolean`
|
||||
(default `false`), `showCreateForm: boolean` + a controlled text input for the new
|
||||
offer's `internal_name`.
|
||||
|
||||
2. **Filter row**:
|
||||
- Chip row: "Tutti" + one chip per `categoryOptions` value. Active chip: border
|
||||
`#1A463C`, text `#1A463C`. Inactive: border `#e5e7eb`, text `#71717a`. 8px gap
|
||||
(`gap-2`).
|
||||
- "Mostra offerte archiviate" checkbox + label (`#71717a` text), toggles
|
||||
`showArchived`.
|
||||
|
||||
3. **Filtered list** (`useMemo`): `cards.filter(c => (activeCategory === null ||
|
||||
c.category === activeCategory) && (showArchived || !c.is_archived))`.
|
||||
|
||||
4. **Card grid**: `grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4`. Each card:
|
||||
- `Link href={`/admin/offers/${card.id}/edit`}` wrapping a `div` with white bg,
|
||||
`border border-[#e5e7eb] rounded-lg p-4 hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)]
|
||||
cursor-pointer transition-shadow`.
|
||||
- `internal_name` as h3 (16px/600).
|
||||
- `description` (if present) as body text, `line-clamp-2`, `#71717a`.
|
||||
- Category badge: small chip showing `category` (if present) — reuse
|
||||
`src/components/ui/badge.tsx` `<Badge>` or a styled `<span>`.
|
||||
- If `is_archived`: red "Archiviata" badge (text `#dc2626`).
|
||||
|
||||
5. **Empty state**: if `filteredCards.length === 0`, show centered block: heading
|
||||
"Nessuna offerta" (h3), body "Inizia creando la tua prima offerta" (`#71717a`), and
|
||||
the "+ Nuova Offerta" CTA (same as header).
|
||||
|
||||
6. **"+ Nuova Offerta" CTA** (top-right of page header, "Offerte" h2 title to its left):
|
||||
- Clicking "+ Nuova Offerta" toggles `showCreateForm`, revealing a small inline
|
||||
`<form>` with one text `<input name="internal_name" required placeholder="Nome
|
||||
offerta...">` and a submit button "Crea".
|
||||
- On submit: `startTransition(async () => { await createOfferMacro(formData);
|
||||
setShowCreateForm(false); router.refresh(); })`. After `router.refresh()`, the new
|
||||
card appears in the grid — do NOT attempt to auto-navigate to the editor in this
|
||||
plan (acceptable per UI-SPEC: admin clicks the new card to open the editor). Button
|
||||
style: bg `#1A463C`, text white, per UI-SPEC.
|
||||
|
||||
7. **Page header**: "Offerte" (h2, 20px/600, `#1a1a1a`) + "+ Nuova Offerta" button,
|
||||
`flex items-center justify-between`.
|
||||
|
||||
All literal copy strings come from the UI-SPEC copywriting contract table verbatim
|
||||
(Italian): "Offerte", "+ Nuova Offerta", "Mostra offerte archiviate", "Tutti",
|
||||
"Nessuna offerta", "Inizia creando la tua prima offerta", "Archiviata", "Crea", "Nome
|
||||
offerta...".
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30 && npx eslint src/app/admin/offers/page.tsx src/components/admin/offers/OfferListClient.tsx 2>&1 | tail -30</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npx tsc --noEmit` passes with zero errors (page.tsx + OfferListClient.tsx + rest of project)
|
||||
- `npx eslint src/app/admin/offers/page.tsx src/components/admin/offers/OfferListClient.tsx` reports zero errors
|
||||
- `grep -c "OfferListCard" src/components/admin/offers/OfferListClient.tsx` >= 1
|
||||
- `grep -c "createOfferMacro" src/components/admin/offers/OfferListClient.tsx` >= 1
|
||||
- `grep -c "admin/offers/\${" src/components/admin/offers/OfferListClient.tsx` >= 1 (card -> editor link)
|
||||
- `grep -c "Mostra offerte archiviate\|Nessuna offerta\|Nuova Offerta" src/components/admin/offers/OfferListClient.tsx` >= 3 (UI-SPEC copy present)
|
||||
</acceptance_criteria>
|
||||
<done>OfferListClient renders the category-filterable, archive-aware card grid with working "+ Nuova Offerta" creation; page.tsx + OfferListClient.tsx typecheck and lint clean.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Admin browser -> createOfferMacro (via "+ Nuova Offerta") | Free-text `internal_name` input crosses into a new `offer_macros` row |
|
||||
| Admin browser -> /admin/offers/[id]/edit links | Card `id` values are server-fetched (not user input) — links are safe by construction |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-12-10 | Tampering | "+ Nuova Offerta" inline form | accept | `createOfferMacro` (Plan 03) already calls `requireAdmin()` + Zod-validates `internal_name` non-empty; this plan only adds the UI trigger, no new validation surface |
|
||||
| T-12-11 | Information Disclosure | Offer list exposes `description`/`category`/`is_archived` for all offers | accept | Route is under `/admin/*`, Auth.js session-gated middleware (existing project-wide constraint); no change to access control in this plan |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `npx tsc --noEmit` passes for the full project.
|
||||
2. `npx eslint` clean for both modified/created files.
|
||||
3. `/admin/offers` renders a card grid sourced from `getOfferListCards()`, with category filter chips from `getOfferFieldOptions().categoria` and an archive toggle (default off).
|
||||
4. "+ Nuova Offerta" creates a new `offer_macros` row via `createOfferMacro` and the new card appears after `router.refresh()`.
|
||||
5. Each card links to `/admin/offers/${id}/edit` (Plan 05's route — may 404 until Plan 05 lands, but the link target is correct).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `/admin/offers` matches the UI-SPEC section 1 layout: header + CTA, category filter chips, archive toggle, card grid, empty state.
|
||||
- OFFER-18 satisfied: list filterable by category, archive toggle hides archived offers by default.
|
||||
- No legacy macro/micro CRUD forms remain on `/admin/offers`.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-04-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
phase: 12-offer-composition-drag-drop-csv-import
|
||||
plan: 04
|
||||
subsystem: ui
|
||||
tags: [next.js, react, tailwind, shadcn]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 12-offer-composition-drag-drop-csv-import (Plan 03)
|
||||
provides: getOfferListCards(), getOfferFieldOptions(), createOfferMacro server action
|
||||
provides:
|
||||
- "/admin/offers list page rewritten as category-filterable, archive-aware offer card grid (UI-SPEC section 1)"
|
||||
- "OfferListClient component: category filter chips, archive toggle, '+ Nuova Offerta' inline create form, card grid linking to /admin/offers/[id]/edit"
|
||||
affects: [12-05-offer-editor-page]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Thin server-component list page (Promise.all fetch -> client component), mirroring src/app/admin/catalog/page.tsx"
|
||||
- "useTransition + router.refresh() for FormData server-action create flow without returning the new row's id"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- src/components/admin/offers/OfferListClient.tsx
|
||||
modified:
|
||||
- src/app/admin/offers/page.tsx
|
||||
|
||||
key-decisions:
|
||||
- "Legacy macro/micro/offer_services CRUD UI removed from /admin/offers entirely per D-1 and UI-SPEC section 1; underlying actions.ts exports (createMacro, createMicro, createOfferService, deleteMacro, deleteMicro, updateMicroOfferServices) left untouched and unreferenced — zero-risk dead exports, confirmed via grep no other usages exist"
|
||||
- "Card grid links to /admin/offers/${id}/edit (Plan 05's route) — link target correct even though Plan 05 may not have landed yet at verification time"
|
||||
|
||||
patterns-established:
|
||||
- "'+ Nuova Offerta' inline create-form pattern: toggle visibility, startTransition(createOfferMacro(formData)), then router.refresh() — admin clicks the new card to open the editor (no auto-navigation, no id returned from the server action)"
|
||||
|
||||
requirements-completed: [OFFER-18]
|
||||
|
||||
# Metrics
|
||||
duration: 10min
|
||||
completed: 2026-06-15
|
||||
---
|
||||
|
||||
# Phase 12 Plan 04: Offer List Page Redesign Summary
|
||||
|
||||
**Rewrote `/admin/offers` from a legacy flat macro/micro/offer_services CRUD page into a category-filterable, archive-aware offer card grid with a "+ Nuova Offerta" inline creation flow, per UI-SPEC section 1.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 10 min
|
||||
- **Started:** 2026-06-15T08:15:00Z (approx)
|
||||
- **Completed:** 2026-06-15T08:25:30Z
|
||||
- **Tasks:** 2 completed
|
||||
- **Files modified:** 2 (1 rewritten, 1 created)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `src/app/admin/offers/page.tsx` rewritten as a thin server component: `Promise.all([getOfferListCards(), getOfferFieldOptions()])` feeding `OfferListClient`, matching the `catalog/page.tsx` pattern (239 lines of legacy macro/micro/offer_services CRUD forms removed)
|
||||
- New `src/components/admin/offers/OfferListClient.tsx`:
|
||||
- Category filter chip row ("Tutti" + dynamic `categoryOptions`), active state `#1A463C` border/text, inactive `#e5e7eb`/`#71717a`
|
||||
- "Mostra offerte archiviate" checkbox toggle (default off), filters via `useMemo` on `activeCategory` + `showArchived`
|
||||
- Responsive card grid (1/2/3 columns) — each card is a `Link` to `/admin/offers/${card.id}/edit`, showing `internal_name`, `description` (line-clamp-2), category `Badge`, and red "Archiviata" text for archived offers
|
||||
- Empty state: "Nessuna offerta" / "Inizia creando la tua prima offerta" + "+ Nuova Offerta" CTA
|
||||
- "+ Nuova Offerta" header CTA toggles an inline form (`Input name="internal_name"` + "Crea" button); on submit calls `createOfferMacro` via `startTransition`, then `router.refresh()` — new card appears in the grid
|
||||
- All literal copy strings verbatim from UI-SPEC copywriting contract table (Italian)
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Rewrite /admin/offers page.tsx as the new list page entry point** - `68dc1b6` (feat)
|
||||
2. **Task 2: Build OfferListClient — category filter, archive toggle, card grid, create CTA** - `7df4b9c` (feat)
|
||||
|
||||
**Plan metadata:** (this commit, follows)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `src/app/admin/offers/page.tsx` - Thin server component: fetches `getOfferListCards()` + `getOfferFieldOptions()`, renders `OfferListClient`
|
||||
- `src/components/admin/offers/OfferListClient.tsx` - Client component: category filter chips, archive toggle, responsive card grid, "+ Nuova Offerta" inline create form using `createOfferMacro`
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Removed all legacy macro/micro/offer_services CRUD UI from `/admin/offers` per D-1 and UI-SPEC section 1; confirmed via `grep -rn` that no other file in `src/app`/`src/components` references `createMacro`, `createMicro`, `createOfferService`, `deleteMacro`, `deleteMicro`, `toggleOfferServiceActive`, `getCatalogWithMicros`, `getAllOfferServices`, or `ServiceCheckboxList` outside the old page — these remain exported from `actions.ts`/`offer-queries.ts` as zero-risk dead code (per plan's explicit instruction not to remove them)
|
||||
- Used shadcn `Button`, `Badge`, and `Input` components for CTA, category badge, and create-form text input respectively, matching the project's existing component library usage in `CatalogSearch.tsx`
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written. Both tasks implemented the documented component structure, state shape, filter logic, and copy strings verbatim.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required. Pure UI change consuming Plan 03's existing query/action exports.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- `/admin/offers` now renders the new card-grid list per UI-SPEC section 1; OFFER-18 satisfied (category filter + archive toggle, default hides archived)
|
||||
- Each card links to `/admin/offers/${id}/edit` — Plan 05 builds this route in a parallel worktree; link target is correct regardless of Plan 05's landing order
|
||||
- `npx tsc --noEmit` passes with zero errors across the full project; `npx eslint` clean for both modified/created files
|
||||
- No legacy macro/micro/offer_services CRUD routes remain; underlying actions/queries left as unreferenced exports for future cleanup (out of this plan's scope)
|
||||
|
||||
---
|
||||
|
||||
*Phase: 12-offer-composition-drag-drop-csv-import*
|
||||
*Completed: 2026-06-15*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All created/modified files verified present on disk:
|
||||
|
||||
- FOUND: src/app/admin/offers/page.tsx
|
||||
- FOUND: src/components/admin/offers/OfferListClient.tsx
|
||||
- FOUND: .planning/phases/12-offer-composition-drag-drop-csv-import/12-04-SUMMARY.md
|
||||
|
||||
All task commits verified present in git log:
|
||||
|
||||
- FOUND: 68dc1b6 (Task 1)
|
||||
- FOUND: 7df4b9c (Task 2)
|
||||
- FOUND: ca37126 (docs: plan summary)
|
||||
@@ -0,0 +1,463 @@
|
||||
---
|
||||
phase: 12
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: [1, 3]
|
||||
files_modified:
|
||||
- src/app/admin/offers/[id]/edit/page.tsx
|
||||
- src/components/admin/offers/OfferEditorClient.tsx
|
||||
autonomous: true
|
||||
requirements: [OFFER-11, OFFER-15, OFFER-16, OFFER-17]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Admin can open /admin/offers/[id]/edit and see the offer's name, category/ticket chips, 4 tag dimensions, services matrix, transformation promise, and Salva/Annulla/Archivia actions"
|
||||
- "Admin can select Categoria and Ticket (single-select), and add/remove Tipo and Obiettivo tags (multi-select, creatable on the fly) — OFFER-15"
|
||||
- "Admin sees a service x tier (A/B/C) checkbox matrix pre-filtered to services matching the offer's category, with live-updating 'Totale Servizi' per tier as checkboxes toggle — OFFER-11"
|
||||
- "Admin can enter an independent manual 'Prezzo Pubblico' per tier, separate from 'Totale Servizi' — OFFER-16"
|
||||
- "Admin can edit 5 transformation-promise fields (Aiuto/A ottenere/In/Senza/Grazie a) — OFFER-17"
|
||||
- "Clicking 'Salva Offerta' persists all changes (macro fields, tier data, tier-service assignments, tags) via saveOfferEditor and redirects to /admin/offers"
|
||||
- "Clicking 'Archivia' toggles is_archived (with confirmation) via toggleOfferArchived"
|
||||
artifacts:
|
||||
- path: "src/app/admin/offers/[id]/edit/page.tsx"
|
||||
provides: "Server component: fetches getOfferEditorData(id) + getOfferFieldOptions(), renders OfferEditorClient or notFound()"
|
||||
- path: "src/components/admin/offers/OfferEditorClient.tsx"
|
||||
provides: "Client component: editable name/category/ticket, 4-dimension tags, services matrix with live totals, transformation promise fields, Salva/Annulla/Archivia actions"
|
||||
exports: ["OfferEditorClient"]
|
||||
key_links:
|
||||
- from: "src/components/admin/offers/OfferEditorClient.tsx (Salva Offerta)"
|
||||
to: "saveOfferEditor server action (Plan 03)"
|
||||
via: "useTransition + router.push('/admin/offers') on success"
|
||||
pattern: "saveOfferEditor"
|
||||
- from: "src/components/admin/offers/OfferEditorClient.tsx (services matrix)"
|
||||
to: "OfferEditorData.tiers[].assignedServiceIds + availableServices"
|
||||
via: "client-side state, checkbox toggles update local tier.assignedServiceIds, Totale Servizi recomputed via useMemo from unit_price sums"
|
||||
pattern: "Totale Servizi|assignedServiceIds"
|
||||
- from: "src/components/admin/offers/OfferEditorClient.tsx (Archivia)"
|
||||
to: "toggleOfferArchived server action (Plan 03)"
|
||||
via: "confirm dialog + useTransition + router.push('/admin/offers')"
|
||||
pattern: "toggleOfferArchived"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the Offer Editor detail page at `/admin/offers/[id]/edit`: a full-page editor covering
|
||||
the offer's name/category/ticket, 4-dimension tags (Categoria/Ticket single-select,
|
||||
Tipo/Obiettivo multi-select creatable on the fly), the service x tier (A/B/C) checkbox
|
||||
matrix pre-filtered by category with live "Totale Servizi" totals, independent manual
|
||||
"Prezzo Pubblico" per tier, the 5-field transformation promise block, and
|
||||
Salva/Annulla/Archivia actions.
|
||||
|
||||
Purpose: Deliver OFFER-11 (3-tier checkbox matrix with live totals), OFFER-15 (4-dimension
|
||||
tags), OFFER-16 (manual public price per tier), and OFFER-17 (structured transformation
|
||||
promise) per the locked UI-SPEC section 2 and CONTEXT.md decisions D-2 through D-5.
|
||||
Output: `src/app/admin/offers/[id]/edit/page.tsx` (new server component) and
|
||||
`src/components/admin/offers/OfferEditorClient.tsx` (new client component) implementing the
|
||||
full editor.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md
|
||||
@src/components/admin/catalog/ServiceTable.tsx
|
||||
@src/components/ui/option-select.tsx
|
||||
@src/components/ui/option-multi-select.tsx
|
||||
@src/components/ui/editable-cell.tsx
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 03 (src/lib/offer-queries.ts) — use directly, no further exploration needed. -->
|
||||
|
||||
```typescript
|
||||
export type OfferTierData = {
|
||||
id: string;
|
||||
tier_letter: string | null; // "A" | "B" | "C" | null
|
||||
internal_name: string;
|
||||
public_name: string;
|
||||
duration_months: number;
|
||||
public_price: string | null; // numeric as string, e.g. "499.00"
|
||||
assignedServiceIds: string[];
|
||||
servicesTotal: string; // pre-computed sum, numeric as string e.g. "350.00"
|
||||
};
|
||||
|
||||
export type OfferEditorData = {
|
||||
macro: OfferMacro; // includes id, internal_name, public_name, description,
|
||||
// category, ticket, is_archived, cliente_ideale,
|
||||
// risultato, tempo, pain, metodo (all from Plan 01 schema)
|
||||
tiers: OfferTierData[]; // ordered A, B, C (0-3 entries)
|
||||
availableServices: Array<{ id: string; name: string; unit_price: string; category: string | null }>;
|
||||
tipoTags: string[]; // current Tipo tag values for this offer
|
||||
obiettivoTags: string[]; // current Obiettivo tag values for this offer
|
||||
};
|
||||
|
||||
export async function getOfferEditorData(macroId: string): Promise<OfferEditorData | null>;
|
||||
|
||||
export type OfferFieldOptions = { categoria: string[]; ticket: string[]; tipo: string[]; obiettivo: string[] };
|
||||
export async function getOfferFieldOptions(): Promise<OfferFieldOptions>;
|
||||
```
|
||||
|
||||
<!-- From Plan 03 (src/app/admin/offers/actions.ts) — use directly. -->
|
||||
|
||||
```typescript
|
||||
// Zod-validated; tiers array max 3, each tier has tier_letter "A"|"B"|"C",
|
||||
// internal_name, public_name, duration_months (int >= 1), public_price (number >= 0 | null),
|
||||
// assignedServiceIds (string[]). id optional per tier (omit for new tier, include to update).
|
||||
export type SaveOfferEditorPayload = {
|
||||
internal_name: string;
|
||||
public_name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
ticket?: string;
|
||||
cliente_ideale?: string;
|
||||
risultato?: string;
|
||||
tempo?: string;
|
||||
pain?: string;
|
||||
metodo?: string;
|
||||
tiers: Array<{
|
||||
id?: string;
|
||||
tier_letter: "A" | "B" | "C";
|
||||
internal_name: string;
|
||||
public_name: string;
|
||||
duration_months: number;
|
||||
public_price?: number | null;
|
||||
assignedServiceIds: string[];
|
||||
}>;
|
||||
tipoTags: string[];
|
||||
obiettivoTags: string[];
|
||||
};
|
||||
|
||||
export async function saveOfferEditor(macroId: string, payload: SaveOfferEditorPayload): Promise<void>;
|
||||
export async function toggleOfferArchived(macroId: string, archived: boolean): Promise<void>;
|
||||
export async function addOfferTag(dimension: "tipo" | "obiettivo", macroId: string, value: string): Promise<void>;
|
||||
export async function removeOfferTag(dimension: "tipo" | "obiettivo", macroId: string, value: string): Promise<void>;
|
||||
export async function renameOfferOption(field: "categoria" | "ticket" | "tipo" | "obiettivo", oldValue: string, newValue: string): Promise<void>;
|
||||
```
|
||||
|
||||
<!-- From src/components/ui/option-select.tsx and option-multi-select.tsx — use directly for
|
||||
Categoria/Ticket (single-select) and Tipo/Obiettivo (multi-select) dimension rows. -->
|
||||
|
||||
```typescript
|
||||
export interface OptionSelectProps {
|
||||
value: string | null;
|
||||
options: string[];
|
||||
onChange: (value: string | null) => void;
|
||||
onRename?: (oldValue: string, newValue: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
export function OptionSelect(props: OptionSelectProps): JSX.Element;
|
||||
|
||||
export interface OptionMultiSelectProps {
|
||||
values: string[];
|
||||
options: string[];
|
||||
onAdd: (value: string) => void;
|
||||
onRemove: (value: string) => void;
|
||||
onRename?: (oldValue: string, newValue: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
export function OptionMultiSelect(props: OptionMultiSelectProps): JSX.Element;
|
||||
```
|
||||
|
||||
<!-- From src/components/ui/editable-cell.tsx — use directly for Offerta Name and
|
||||
transformation-promise fields. -->
|
||||
|
||||
```typescript
|
||||
export interface EditableCellProps {
|
||||
value: string;
|
||||
type?: "text" | "number";
|
||||
onSave: (value: string) => void | Promise<void>;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
formatDisplay?: (value: string) => string;
|
||||
}
|
||||
export function EditableCell(props: EditableCellProps): JSX.Element;
|
||||
```
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create editor route + page.tsx server component</name>
|
||||
<files>src/app/admin/offers/[id]/edit/page.tsx</files>
|
||||
<read_first>
|
||||
- src/app/admin/offers/page.tsx (current, post-Plan-04 — server component fetch pattern)
|
||||
- src/lib/offer-queries.ts (Plan 03 — getOfferEditorData, getOfferFieldOptions signatures, confirm null-handling)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/app/admin/offers/[id]/edit/page.tsx` as a server component (Next.js 16 App
|
||||
Router, async `params`):
|
||||
|
||||
```typescript
|
||||
import { notFound } from "next/navigation";
|
||||
import { getOfferEditorData, getOfferFieldOptions } from "@/lib/offer-queries";
|
||||
import { OfferEditorClient } from "@/components/admin/offers/OfferEditorClient";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function OfferEditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const [data, options] = await Promise.all([
|
||||
getOfferEditorData(id),
|
||||
getOfferFieldOptions(),
|
||||
]);
|
||||
|
||||
if (!data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <OfferEditorClient data={data} fieldOptions={options} />;
|
||||
}
|
||||
```
|
||||
|
||||
Use the Next.js 16 async `params` pattern (consistent with other dynamic routes in this
|
||||
project — confirm by checking an existing `[id]` route if one exists, e.g. under
|
||||
`/admin/leads/[id]` from Phase 14, otherwise this signature is correct for Next 16 App
|
||||
Router).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && grep -c "getOfferEditorData\|getOfferFieldOptions\|OfferEditorClient\|notFound" src/app/admin/offers/\[id\]/edit/page.tsx</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File exists at `src/app/admin/offers/[id]/edit/page.tsx`
|
||||
- Server component (no `"use client"` directive)
|
||||
- Calls `getOfferEditorData(id)` and `getOfferFieldOptions()` via `Promise.all`
|
||||
- Calls `notFound()` when `data === null`
|
||||
- Renders `<OfferEditorClient data={data} fieldOptions={options} />`
|
||||
</acceptance_criteria>
|
||||
<done>page.tsx exists, fetches editor data + field options, handles not-found, delegates to OfferEditorClient.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 2: Build OfferEditorClient — tags, services matrix with live totals, promise block, actions</name>
|
||||
<files>src/components/admin/offers/OfferEditorClient.tsx</files>
|
||||
<read_first>
|
||||
- src/components/admin/catalog/ServiceTable.tsx (EditableCell/OptionSelect/OptionMultiSelect
|
||||
usage patterns, table layout conventions)
|
||||
- .planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md section 2 (full
|
||||
editor layout, color tokens, copywriting contract table, visual states)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/components/admin/offers/OfferEditorClient.tsx`:
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useState, useMemo, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
saveOfferEditor,
|
||||
toggleOfferArchived,
|
||||
addOfferTag,
|
||||
removeOfferTag,
|
||||
renameOfferOption,
|
||||
type SaveOfferEditorPayload,
|
||||
} from "@/app/admin/offers/actions";
|
||||
import { OptionSelect } from "@/components/ui/option-select";
|
||||
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
|
||||
import { EditableCell } from "@/components/ui/editable-cell";
|
||||
import type { OfferEditorData, OfferFieldOptions } from "@/lib/offer-queries";
|
||||
|
||||
export function OfferEditorClient({
|
||||
data,
|
||||
fieldOptions,
|
||||
}: {
|
||||
data: OfferEditorData;
|
||||
fieldOptions: OfferFieldOptions;
|
||||
}) {
|
||||
// ... implementation below
|
||||
}
|
||||
```
|
||||
|
||||
Implementation requirements (per UI-SPEC section 2 + copywriting contract table — all
|
||||
literal strings verbatim Italian):
|
||||
|
||||
1. **Local editable state** (all derived from `data` on mount, no re-fetch on change):
|
||||
- `macro` fields: `internal_name`, `public_name`, `description`, `category`,
|
||||
`ticket`, `cliente_ideale`, `risultato`, `tempo`, `pain`, `metodo` — each as
|
||||
individual `useState<string>` or one combined `useState<typeof initialMacro>`
|
||||
object updated via spread.
|
||||
- `tiers: OfferTierData[]` state, seeded from `data.tiers`. If `data.tiers.length <
|
||||
3`, pad to exactly 3 tiers (A, B, C) with empty placeholders: `{ id: "", tier_letter:
|
||||
"A"|"B"|"C", internal_name: "", public_name: "", duration_months: 1, public_price:
|
||||
null, assignedServiceIds: [], servicesTotal: "0" }` for any missing letter, so the
|
||||
matrix always renders 3 columns.
|
||||
- `tipoTags: string[]`, `obiettivoTags: string[]` state, seeded from
|
||||
`data.tipoTags`/`data.obiettivoTags`.
|
||||
- `categoriaOptions`/`ticketOptions`/`tipoOptions`/`obiettivoOptions` from
|
||||
`fieldOptions`, kept in local state so `renameOfferOption` can update them
|
||||
optimistically.
|
||||
|
||||
2. **Header & navigation**:
|
||||
- "← Indietro" link (text, `#71717a`) -> `Link href="/admin/offers"`.
|
||||
- "Modifica Offerta" as h2 (20px/600).
|
||||
- Offer name as `EditableCell` (text, required) bound to `macro.internal_name`, styled
|
||||
as h3 (16px/600).
|
||||
|
||||
3. **Tags block** ("Tag" h3 section label, 16px between rows):
|
||||
- **Categoria** row: label "Categoria" (12px/400, `#71717a`) + `<OptionSelect
|
||||
value={macro.category} options={categoriaOptions} onChange={...} onRename={(old,
|
||||
next) => { renameOfferOption("categoria", old, next); /* update local options +
|
||||
macro.category if it matched old */ }} placeholder="Seleziona categoria..." />`.
|
||||
When `macro.category` changes, the services matrix (step 5) must re-filter
|
||||
`availableServices` by the new category (D-4) — implement via `useMemo` keyed on
|
||||
`macro.category`.
|
||||
- **Ticket** row: same pattern with `macro.ticket` / `ticketOptions` /
|
||||
`renameOfferOption("ticket", ...)`, placeholder "Seleziona ticket...".
|
||||
- **Tipo** row: label "Tipo" + `<OptionMultiSelect values={tipoTags}
|
||||
options={tipoOptions} onAdd={(v) => { setTipoTags([...tipoTags, v]); if
|
||||
(!tipoOptions.includes(v)) setTipoOptions([...tipoOptions, v]); }}
|
||||
onRemove={(v) => setTipoTags(tipoTags.filter(t => t !== v))}
|
||||
onRename={(old, next) => renameOfferOption("tipo", old, next)} placeholder="+ Crea
|
||||
Tipo" />`. Tag additions/removals are tracked in local state only and persisted on
|
||||
"Salva Offerta" via `tipoTags` in the payload — do NOT call `addOfferTag`/
|
||||
`removeOfferTag` on every click (avoids partial-save inconsistency); these two
|
||||
actions exist for potential future inline-only flows but this editor batches via
|
||||
`saveOfferEditor`.
|
||||
- **Obiettivo** row: same pattern as Tipo, using `obiettivoTags`/`obiettivoOptions`,
|
||||
placeholder "+ Crea Obiettivo".
|
||||
|
||||
4. **Services matrix** ("Servizi Inclusi" h3 section label):
|
||||
- `filteredServices = useMemo(() => macro.category ? data.availableServices.filter(s
|
||||
=> s.category === macro.category) : data.availableServices, [macro.category,
|
||||
data.availableServices])`.
|
||||
- Table: header row "Servizio" (left) | "Prezzo" (right-aligned) | "A" | "B" | "C"
|
||||
(centered), header bg `#f9f9f9`, border-bottom `#e5e7eb`.
|
||||
- One row per `filteredServices` item: service `name` (left), `unit_price` formatted
|
||||
as "€X,XX" (right-aligned, tabular-nums), then one `<input type="checkbox">` per
|
||||
tier (A/B/C), checked if `tiers[tierIdx].assignedServiceIds.includes(service.id)`.
|
||||
On change: toggle the service id in/out of that tier's `assignedServiceIds` array
|
||||
(immutable update via `setTiers`). Checkbox accent color `#1A463C` (use
|
||||
`accent-[#1A463C]` Tailwind class or inline style `accentColor: "#1A463C"`). Row
|
||||
height ~40px, hover bg `#f9f9f9`, border-bottom `#e5e7eb`.
|
||||
- If `filteredServices.length === 0`: render a single placeholder row spanning all
|
||||
columns with text "Nessun servizio disponibile per questa categoria" (`#71717a`),
|
||||
table headers still visible.
|
||||
- **Totale Servizi row** (sticky/bold, bottom of table, computed via `useMemo`):
|
||||
label "Totale Servizi" (bold 14px/600, `#1a1a1a`), then per tier:
|
||||
`tiers[i].assignedServiceIds.reduce((sum, id) => sum +
|
||||
Number(filteredServices.find(s => s.id === id)?.unit_price ?? 0), 0)` formatted as
|
||||
"€X.XXX,XX" (Italian locale, e.g. `toLocaleString("it-IT", { minimumFractionDigits:
|
||||
2, maximumFractionDigits: 2 })`). Recomputes instantly on every checkbox toggle (no
|
||||
network call) — this is the OFFER-11 live-total requirement.
|
||||
- **Prezzo Pubblico row** (bold label, bottom of table, below Totale Servizi): label
|
||||
"Prezzo Pubblico" (bold 14px/600), then per tier an `<input type="number" min="0"
|
||||
step="0.01">` bound to `tiers[i].public_price` (string | null -> controlled value
|
||||
`tiers[i].public_price ?? ""`), placeholder "€0,00". On change, update
|
||||
`tiers[i].public_price` in state (store as string; convert to number on save).
|
||||
Independent of Totale Servizi (D-5) — no auto-sync between the two rows.
|
||||
|
||||
5. **Transformation Promise block** ("Promessa di Trasformazione" h3 section label, 16px
|
||||
gap between fields): 5 rows, each `label (12px/400, #71717a) + EditableCell(text,
|
||||
optional)`:
|
||||
- "Aiuto" -> `macro.cliente_ideale`, placeholder "Aggiungi cliente ideale"
|
||||
- "A ottenere" -> `macro.risultato`, placeholder "Aggiungi risultato"
|
||||
- "In" -> `macro.tempo`, placeholder "Aggiungi durata (es. 3 mesi)"
|
||||
- "Senza" -> `macro.pain`, placeholder "Aggiungi pain point"
|
||||
- "Grazie a" -> `macro.metodo`, placeholder "Aggiungi metodo"
|
||||
All optional (no `required` prop), `onSave` updates the corresponding `macro.*` state
|
||||
field.
|
||||
|
||||
6. **Action buttons** (bottom, 8px gap):
|
||||
- **"Salva Offerta"** (primary, bg `#1A463C`, text white, 14px/600, padding `16px
|
||||
24px`): disabled if NO tier has `assignedServiceIds.length > 0` (tooltip "Seleziona
|
||||
almeno un servizio" via `title` attribute when disabled). On click:
|
||||
`startTransition(async () => { const payload: SaveOfferEditorPayload = { ...macro
|
||||
fields, tiers: tiers.map(t => ({ id: t.id || undefined, tier_letter: t.tier_letter,
|
||||
internal_name: t.internal_name || t.tier_letter (e.g. "Tier A"), public_name:
|
||||
t.public_name || t.tier_letter, duration_months: t.duration_months || 1,
|
||||
public_price: t.public_price ? Number(t.public_price) : null, assignedServiceIds:
|
||||
t.assignedServiceIds })), tipoTags, obiettivoTags }; try { await
|
||||
saveOfferEditor(data.macro.id, payload); router.push("/admin/offers"); } catch (e) {
|
||||
/* show inline error text "Errore nel salvataggio. Verifica i campi." */ } })`.
|
||||
- **"Annulla"** (secondary, border `#e5e7eb`, text `#1a1a1a`): `Link
|
||||
href="/admin/offers"` — no dirty-check confirmation in this iteration (acceptable
|
||||
simplification per UI-SPEC note "Auto-save optional (planner decides)" — explicit
|
||||
Salva is the persistence model, Annulla is a plain navigation link).
|
||||
- **"Archivia"** (destructive, text `#dc2626`, no fill), visible only if
|
||||
`!macro_initial.is_archived` (track initial archived state separately from any
|
||||
local toggle): on click, `window.confirm("Sei sicuro? L'offerta non sarà visibile
|
||||
nella lista.")`, if confirmed `startTransition(async () => { await
|
||||
toggleOfferArchived(data.macro.id, true); router.push("/admin/offers"); })`.
|
||||
|
||||
All literal copy strings (Italian) per the UI-SPEC copywriting contract table: "←
|
||||
Indietro", "Modifica Offerta", "Tag", "Categoria", "Ticket", "Tipo", "Obiettivo", "+ Crea
|
||||
Tipo", "+ Crea Obiettivo", "Servizi Inclusi", "Servizio", "Prezzo", "A", "B", "C",
|
||||
"Totale Servizi", "Prezzo Pubblico", "Promessa di Trasformazione", "Aiuto", "A
|
||||
ottenere", "In", "Senza", "Grazie a", "Salva Offerta", "Annulla", "Archivia", "Sei
|
||||
sicuro? L'offerta non sarà visibile nella lista.", "Nessun servizio disponibile per
|
||||
questa categoria", "Errore nel salvataggio. Verifica i campi.", "Seleziona almeno un
|
||||
servizio".
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30 && npx eslint src/app/admin/offers/\[id\]/edit/page.tsx src/components/admin/offers/OfferEditorClient.tsx 2>&1 | tail -30</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npx tsc --noEmit` passes with zero errors for the full project
|
||||
- `npx eslint src/app/admin/offers/[id]/edit/page.tsx src/components/admin/offers/OfferEditorClient.tsx` reports zero errors
|
||||
- `grep -c "saveOfferEditor\|toggleOfferArchived" src/components/admin/offers/OfferEditorClient.tsx` >= 2
|
||||
- `grep -c "OptionSelect\|OptionMultiSelect" src/components/admin/offers/OfferEditorClient.tsx` >= 2 (Categoria/Ticket via OptionSelect, Tipo/Obiettivo via OptionMultiSelect)
|
||||
- `grep -c "Totale Servizi\|Prezzo Pubblico" src/components/admin/offers/OfferEditorClient.tsx` >= 2
|
||||
- `grep -c "Promessa di Trasformazione\|cliente_ideale\|risultato\|tempo\|pain\|metodo" src/components/admin/offers/OfferEditorClient.tsx` >= 6
|
||||
- `grep -c "type=\"checkbox\"" src/components/admin/offers/OfferEditorClient.tsx` >= 1
|
||||
- `grep -c "Nessun servizio disponibile per questa categoria" src/components/admin/offers/OfferEditorClient.tsx` == 1
|
||||
</acceptance_criteria>
|
||||
<done>OfferEditorClient renders the full editor per UI-SPEC section 2: tags (4 dimensions), category-filtered services matrix with live Totale Servizi + independent Prezzo Pubblico per tier, transformation promise block, and working Salva/Annulla/Archivia actions; project typechecks and lints clean.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Admin browser -> saveOfferEditor | Free-text macro fields, tier data, service-id arrays, and tag arrays cross into `offer_macros`/`offer_micros`/`offer_tier_services`/`tags` rows |
|
||||
| Admin browser -> toggleOfferArchived | Boolean flag crosses into `offer_macros.is_archived` |
|
||||
| URL param `[id]` -> getOfferEditorData | Route param crosses into a DB lookup; returns `null` (404) for unknown/foreign ids |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-12-12 | Tampering | "Salva Offerta" payload (tier_letter, public_price, assignedServiceIds, tag arrays) | mitigate | `saveOfferEditor` (Plan 03) Zod-validates `tier_letter` against `z.enum(["A","B","C"])`, `public_price` against `z.coerce.number().min(0)`, and the CHECK constraint on `offer_micros.tier_letter` (Plan 01) provides DB-level defense in depth |
|
||||
| T-12-13 | Tampering | `assignedServiceIds` referencing services outside the offer's category or inactive services | accept | `availableServices` is server-fetched and category-filtered (D-4); a malicious client could submit arbitrary service ids in the payload, but `offer_tier_services` has FK `ON DELETE CASCADE` to `services.id` (Plan 01) — invalid ids fail the FK constraint, no data corruption possible; low-value target (admin-only route) |
|
||||
| T-12-14 | Elevation of Privilege | All editor mutations (`saveOfferEditor`, `toggleOfferArchived`, `renameOfferOption`) | mitigate | Plan 03 wraps every action in `requireAdmin()`; this plan adds no new server-side surface, only UI calling the already-gated actions |
|
||||
| T-12-15 | Information Disclosure | `/admin/offers/[id]/edit` exposes full offer internals (internal_name, all tier data, transformation promise) | accept | Route is under `/admin/*`, Auth.js session-gated middleware (existing project-wide constraint); consistent with Plan 04's list page disposition (T-12-11) |
|
||||
| T-12-16 | Denial of Service | Unbounded `[id]` route param triggers a DB query for every request | accept | `getOfferEditorData` returns `null` -> `notFound()` for any non-matching id; standard Next.js 404 handling, no amplification risk on an admin-only route |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `npx tsc --noEmit` passes for the full project.
|
||||
2. `npx eslint` clean for both modified/created files.
|
||||
3. `/admin/offers/[id]/edit` renders the offer name, Categoria/Ticket single-select chips, Tipo/Obiettivo multi-select chips (with "+ Crea Tipo"/"+ Crea Obiettivo" inline creation), and the services matrix pre-filtered by `macro.category`.
|
||||
4. Toggling a service checkbox in any tier column instantly updates that tier's "Totale Servizi" value (client-side, no network call).
|
||||
5. Entering a value in "Prezzo Pubblico" for a tier does not affect "Totale Servizi" for that tier (independent fields, D-5).
|
||||
6. All 5 transformation-promise fields (Aiuto/A ottenere/In/Senza/Grazie a) are editable via EditableCell and map to `cliente_ideale`/`risultato`/`tempo`/`pain`/`metodo`.
|
||||
7. "Salva Offerta" is disabled (with tooltip) when no tier has any assigned service; when enabled, clicking it calls `saveOfferEditor` and navigates back to `/admin/offers`.
|
||||
8. "Archivia" (visible only for non-archived offers) prompts for confirmation, then calls `toggleOfferArchived(id, true)` and navigates back to `/admin/offers`.
|
||||
9. "Annulla" navigates back to `/admin/offers` without persisting changes.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `/admin/offers/[id]/edit` matches the UI-SPEC section 2 layout: header/back-link, name + tag dimensions, category-filtered services matrix with live totals and independent public price, transformation promise block, and Salva/Annulla/Archivia actions.
|
||||
- OFFER-11 satisfied: 3-tier (A/B/C) checkbox matrix with live "Totale Servizi" recalculation.
|
||||
- OFFER-15 satisfied: Categoria/Ticket single-select, Tipo/Obiettivo multi-select with on-the-fly creation, all backed by `getOfferFieldOptions`/`renameOfferOption`/`saveOfferEditor`.
|
||||
- OFFER-16 satisfied: "Prezzo Pubblico" per tier is a manual numeric input independent of "Totale Servizi".
|
||||
- OFFER-17 satisfied: all 5 transformation-promise fields editable and persisted via `saveOfferEditor`.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-05-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
phase: 12-offer-composition-drag-drop-csv-import
|
||||
plan: 05
|
||||
subsystem: ui
|
||||
tags: [next.js, react, tailwind, server-actions, drizzle]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 12-offer-composition-drag-drop-csv-import (Plan 03)
|
||||
provides: getOfferEditorData/getOfferFieldOptions query layer + saveOfferEditor/toggleOfferArchived/renameOfferOption/addOfferTag/removeOfferTag server actions
|
||||
provides:
|
||||
- "/admin/offers/[id]/edit route: full offer editor page (server component + client component)"
|
||||
- OfferEditorClient — editable name, Categoria/Ticket/Tipo/Obiettivo tags, services x tier (A/B/C) checkbox matrix with live totals, independent Prezzo Pubblico, Promessa di Trasformazione block, Salva/Annulla/Archivia actions
|
||||
affects: [12-04-offer-list-page]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Tier padding to exactly 3 (A/B/C) via padTiers() so the matrix always renders 3 columns even for offers with <3 persisted tiers"
|
||||
- "Client-side useMemo for category-filtered services + live per-tier Totale Servizi (OFFER-11), zero network calls on checkbox toggle"
|
||||
- "Batched save: all macro/tier/tag edits held in local state, persisted in one saveOfferEditor() call on 'Salva Offerta' (no per-keystroke server calls for tags)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- src/app/admin/offers/[id]/edit/page.tsx
|
||||
- src/components/admin/offers/OfferEditorClient.tsx
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "EditableCell.onSave is synchronous (value: string) => void in this codebase (not Promise-returning as the plan's interface sketch suggested) — all macro-field onSave handlers call setMacro synchronously, consistent with the actual component signature"
|
||||
- "renameOfferOption calls (Categoria/Ticket/Tipo/Obiettivo rename) are fired via startTransition with optimistic local-state updates to options/tags, matching ServiceTable's existing rename pattern from Phase 11"
|
||||
|
||||
patterns-established: []
|
||||
|
||||
requirements-completed: [OFFER-11, OFFER-15, OFFER-16, OFFER-17]
|
||||
|
||||
# Metrics
|
||||
duration: 18min
|
||||
completed: 2026-06-15
|
||||
---
|
||||
|
||||
# Phase 12 Plan 05: Offer Editor Detail Page Summary
|
||||
|
||||
**Full-page Offer Editor at `/admin/offers/[id]/edit`: 4-dimension tags, category-filtered A/B/C services checkbox matrix with live "Totale Servizi", independent "Prezzo Pubblico" per tier, 5-field transformation promise, and Salva/Annulla/Archivia actions.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 18 min
|
||||
- **Started:** 2026-06-15T08:07:00Z (approx)
|
||||
- **Completed:** 2026-06-15T08:25:22Z
|
||||
- **Tasks:** 2 completed
|
||||
- **Files modified:** 2 (both new)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Created `src/app/admin/offers/[id]/edit/page.tsx`: async server component using Next.js 16's `Promise<{ id: string }>` params pattern, fetches `getOfferEditorData(id)` + `getOfferFieldOptions()` via `Promise.all`, calls `notFound()` for unknown ids, delegates to `OfferEditorClient`
|
||||
- Created `src/components/admin/offers/OfferEditorClient.tsx`: client component covering the full UI-SPEC section 2 contract:
|
||||
- Header with "← Indietro" link, "Modifica Offerta" heading, editable offer name via `EditableCell`
|
||||
- "Tag" section: Categoria/Ticket via `OptionSelect` (single-select, with rename), Tipo/Obiettivo via `OptionMultiSelect` (multi-select, creatable on the fly via "+ Crea Tipo"/"+ Crea Obiettivo")
|
||||
- "Servizi Inclusi" matrix: services filtered by `macro.category` (live-recomputed via `useMemo` when Categoria changes), one checkbox column per tier (A/B/C), "Totale Servizi" row recalculated instantly client-side on every toggle (OFFER-11), independent "Prezzo Pubblico" numeric input per tier (OFFER-16, D-5)
|
||||
- "Promessa di Trasformazione" block: 5 `EditableCell` fields (Aiuto/A ottenere/In/Senza/Grazie a) mapped to `cliente_ideale`/`risultato`/`tempo`/`pain`/`metodo` (OFFER-17)
|
||||
- "Salva Offerta" (disabled with tooltip unless at least one tier has an assigned service) → `saveOfferEditor` → redirect to `/admin/offers`; "Annulla" → plain link back; "Archivia" (visible only when not already archived) → confirm dialog → `toggleOfferArchived(id, true)` → redirect
|
||||
- All literal Italian copy strings from the UI-SPEC copywriting contract table implemented verbatim
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Create editor route + page.tsx server component** - `8e0e4b9` (feat)
|
||||
2. **Task 2: Build OfferEditorClient — tags, services matrix with live totals, promise block, actions** - `8c5c918` (feat)
|
||||
|
||||
**Plan metadata:** (this commit, follows)
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `src/app/admin/offers/[id]/edit/page.tsx` - New server component: fetches editor data + field options, 404s on missing offer, renders `OfferEditorClient`
|
||||
- `src/components/admin/offers/OfferEditorClient.tsx` - New client component: the entire offer editor UI and its Salva/Annulla/Archivia/rename interactions
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- `EditableCell.onSave` in this codebase is `(value: string) => void` (synchronous), not `(value: string) => void | Promise<void>` as the plan's interface sketch suggested — implemented all macro-field handlers as synchronous `setMacro` updates, matching the real signature (verified via `npx tsc --noEmit`)
|
||||
- Tier padding: `padTiers()` always produces exactly 3 entries (A, B, C), filling missing tiers with empty placeholders (`id: ""`, `assignedServiceIds: []`, `servicesTotal: "0"`) so the matrix always renders 3 columns regardless of how many tiers exist in the DB
|
||||
- Tag rename (Categoria/Ticket/Tipo/Obiettivo) follows the existing `ServiceTable`/`renameServiceOption` optimistic-update pattern: local state updates immediately, `renameOfferOption` server action fires via `startTransition`
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written. The only adjustment was using the actual (synchronous) `EditableCell.onSave` signature instead of the plan's sketch signature, which is a same-behavior implementation detail (Rule 1 — code correctness), not a scope change.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required. This plan is pure UI (server + client components) consuming the already-deployed Plan 03 query layer and server actions against the live production schema (migration 0008).
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- `/admin/offers/[id]/edit` is fully functional and type-checks/lints clean across the whole project (`npx tsc --noEmit` and `npx eslint` both pass with zero errors)
|
||||
- Plan 04's list page (`/admin/offers` + `OfferListClient.tsx`, built in parallel in a separate worktree) can link directly to `/admin/offers/[id]/edit` — no shared files were touched, no merge conflicts expected
|
||||
- All four target requirements (OFFER-11, OFFER-15, OFFER-16, OFFER-17) are implemented end-to-end: UI -> `saveOfferEditor`/`toggleOfferArchived`/`renameOfferOption` (Plan 03) -> `offer_macros`/`offer_micros`/`offer_tier_services`/`tags` (Plan 01/02, live on prod)
|
||||
|
||||
---
|
||||
|
||||
*Phase: 12-offer-composition-drag-drop-csv-import*
|
||||
*Completed: 2026-06-15*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All created files verified present on disk:
|
||||
|
||||
- FOUND: src/app/admin/offers/[id]/edit/page.tsx
|
||||
- FOUND: src/components/admin/offers/OfferEditorClient.tsx
|
||||
- FOUND: .planning/phases/12-offer-composition-drag-drop-csv-import/12-05-SUMMARY.md
|
||||
|
||||
All task commits verified present in git log:
|
||||
|
||||
- FOUND: 8e0e4b9 (Task 1)
|
||||
- FOUND: 8c5c918 (Task 2)
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
phase: 12
|
||||
phase_name: offer-editor-tier-tag-prezzo-pubblico
|
||||
source: user mockups + decisions (2026-06-14)
|
||||
status: decisions-locked
|
||||
---
|
||||
|
||||
# Phase 12 — Context (User Decisions)
|
||||
|
||||
> Catturato da 2 mockup utente + risposte dirette il 2026-06-14. Questo documento è
|
||||
> **autorevole** per il planner: dove confligge con la roadmap originale ("drag&drop + CSV"),
|
||||
> vince questo. La Phase 12 è stata **ri-scoped** da "Offer Composition Drag&Drop & CSV Import"
|
||||
> a **"Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico"**.
|
||||
|
||||
## Decisioni bloccate
|
||||
|
||||
| # | Decisione | Valore |
|
||||
|---|-----------|--------|
|
||||
| D-1 | Scope | Editor offerte completo (lista + dettaglio). Import CSV/Notion (ex OFFER-12) **rimandato** a fase futura. |
|
||||
| D-2 | UX composizione tier | **Matrice di checkbox** servizio × tier (A/B/C), NON drag&drop puro. Totale servizi live per colonna/tier. `@dnd-kit` usato solo (eventualmente) per riordinare le righe. |
|
||||
| D-3 | Fonte servizi | Catalogo unificato `services` (Phase 11), **non** il legacy `offer_services`. |
|
||||
| D-4 | Filtro servizi per categoria | Il servizio porta già, a livello di catalogo, la designazione di categoria offerta (entry / signature / retainer). L'editor mostra/pre-filtra nella matrice i servizi pertinenti alla categoria dell'offerta in modifica. |
|
||||
| D-5 | Prezzo | Per ogni tier: **Totale servizi** (auto-somma dei servizi spuntati) + **Prezzo Pubblico** (manuale, indipendente). Coerente con la decisione PROJECT.md "prezzi pacchetti per-preventivo, non da catalogo". |
|
||||
| D-6 | Schema | **Solo additivo** (Data Safety LOCKED). Riusare le tabelle offerte esistenti, non ricostruire. |
|
||||
|
||||
## Modello dati (intento — il planner produce la migration esatta)
|
||||
|
||||
Mappatura mockup → schema esistente (`src/db/schema.ts`):
|
||||
|
||||
- **Offerta** → `offer_macros` (esiste: `internal_name`, `public_name`, `transformation_promise`, `sort_order`).
|
||||
Aggiunte additive previste: descrizione breve, flag `is_archived`, campi strutturati della
|
||||
promessa di trasformazione (Cliente Ideale / Risultato / tempo / Pain / Metodo).
|
||||
- **Tier A/B/C** → `offer_micros` (esiste: `macro_id`, `cumulative_price`, `duration_months`, ...).
|
||||
Aggiunte additive previste: lettera tier (`A`|`B`|`C`) e `public_price` (prezzo pubblico manuale).
|
||||
Un'offerta = una macro con (fino a) 3 micro-tier.
|
||||
- **Matrice servizi × tier** → junction tier ↔ `services`. La junction legacy `offer_micro_services`
|
||||
punta a `offer_services`; **non modificarla**. Approccio additivo consigliato: **nuova tabella di
|
||||
junction** (es. `offer_tier_services`: tier_id → `offer_micros.id`, service_id → `services.id`) così
|
||||
da non toccare dati legacy e agganciare il catalogo unificato. Il planner decide il nome/struttura
|
||||
esatti; deve restare additivo.
|
||||
- **Tag multi-dimensione** (categoria / ticket / tipo / obiettivo) → riusare la tabella polimorfica
|
||||
`tags` (`entity_type`, `entity_id`, `name`) di Phase 11 con `entity_type = "offer_macros"` (o simile).
|
||||
Le 4 dimensioni vanno distinte: il planner valuta se serve un campo `dimension` additivo sulla
|
||||
tabella `tags`, oppure colonne single-select dedicate su `offer_macros` per categoria/ticket (singole)
|
||||
+ tag multipli per tipo/obiettivo. Da chiarire in research; resta additivo.
|
||||
- **Designazione categoria del servizio** (entry/signature/retainer, D-4): verificare se va su
|
||||
`services.category` (single-select già esistente), su un tag, o su una nuova colonna additiva
|
||||
dedicata. NON sovrascrivere la semantica attuale di `services.category`/`services.fase` se già usata
|
||||
diversamente in `/admin/catalog` — il planner verifica l'uso corrente prima di scegliere.
|
||||
|
||||
## UI (riferimento mockup)
|
||||
|
||||
**Lista offerte** (`/admin/offers` ridisegnata): card filtrabili per categoria (Entry / Signature /
|
||||
Retainer Offer come chip colorati), ogni card = nome custom + breve descrizione + chip categoria;
|
||||
toggle "mostra offerte archiviate"; bottone "+ Nuova Offerta".
|
||||
|
||||
**Editor offerta**: nome custom editabile in alto; chip Categoria + Ticket; chip Tipo (Audit /
|
||||
Done For You) e Obiettivo (Primo Acquisto / Up Scala Valore / Lead Generation); tabella "servizi
|
||||
inclusi" con colonne A / B / C e checkbox per cella, prezzo del servizio a sinistra; riga "Totale dei
|
||||
servizi" (auto) e "Prezzo Pubblico" (manuale) per ogni tier; blocco "Promessa di Trasformazione"
|
||||
strutturato (Aiuto Cliente Ideale / A ottenere Risultato / in tempo / Senza Pain / Grazie a Metodo);
|
||||
bottone "Salva Offerta".
|
||||
|
||||
Stile: continuità con Phase 11 (vista database Notion-like, palette brand #1A463C / #DEF168, EditableCell,
|
||||
OptionMultiSelect/tag, server actions `requireAdmin`-guarded). Vedi `12-UI-SPEC.md` (da rigenerare su
|
||||
questo scope).
|
||||
|
||||
## Vincoli (da CLAUDE.md / PROJECT.md)
|
||||
|
||||
- **Data Safety LOCKED**: migration solo additive su `clients`/`projects`/`payments`/`phases`; nessun
|
||||
drop/truncate. Anche le tabelle offerte vanno estese, non ricostruite.
|
||||
- **Migration manuali**: `drizzle-kit generate` è non funzionante (snapshot fuori sync da migration 0001) —
|
||||
scrivere SQL a mano seguendo la convenzione 0003-0007. Applicare a **prod** via SSH+docker exec
|
||||
**PRIMA** di pushare codice che dipende dal nuovo schema (DB prod firewalled, raggiungibile solo via SSH).
|
||||
- `quote_items` mai esposti via client API. L'editor offerte è admin-only (`/admin/*`, sessione Auth.js).
|
||||
|
||||
## Fuori scope (questa fase)
|
||||
|
||||
- Import CSV / import da Notion (OFFER-12, rimandato).
|
||||
- Sezioni analitiche Notion (psicologia/rating/performance — OFFER-14, v2).
|
||||
- Collegamento offerta→preventivo/pagamento (Proposal AI, Phase 16/17).
|
||||
- Pagina pubblica dell'offerta (Phase 17).
|
||||
|
||||
## Domande risolte (erano aperte nel primo RESEARCH)
|
||||
|
||||
1. Placement composer → **`/admin/offers` ridisegnata** (lista + editor). ✓
|
||||
2. Persistenza offerta → **macro = offerta, micro = tier A/B/C** (riuso additivo). ✓
|
||||
3. Tag audit CSV → **N/A** (import rimandato). ✓
|
||||
4. Duplicati / parsing CSV → **N/A** (import rimandato). ✓
|
||||
5. Prezzi servizi editabili → **no inline qui**: il tier ha totale auto + prezzo pubblico manuale; i
|
||||
prezzi unitari restano gestiti nel catalogo `services` (Phase 11). ✓
|
||||
@@ -0,0 +1,819 @@
|
||||
# Phase 12: Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico - Research
|
||||
|
||||
**Researched:** 2026-06-14
|
||||
**Domain:** Offer composition UI + additive schema (tier designations, tag dimensions, public pricing)
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 12 is a **complete UI + schema redesign** of offer composition, re-scoped from "drag-drop composer + CSV import" to a **tier-matrix offer editor** (A/B/C tiers with checkbox matrix, live totals, tag dimensions, structured transformation promise, public pricing per tier). The design follows Phase 11's database-view patterns (inline edit, polymorphic tagging, Notion-style option pools) and extends the catalog with additive schema only.
|
||||
|
||||
**Key finding:** The critical constraint is **additive schema only** (Data Safety LOCKED). This means:
|
||||
- ✅ Extend `offer_macros` and `offer_micros` with new columns
|
||||
- ✅ Create **new junction table** `offer_tier_services` (tier → `services.id`, not `offer_services.id`)
|
||||
- ✅ Extend `tags` table with polymorphic entity_type scoping
|
||||
- ❌ NEVER modify legacy `offer_micro_services` (points to deprecated `offer_services`)
|
||||
- ❌ NEVER drop/rename columns on existing tables in the locked set
|
||||
|
||||
**Primary recommendation:**
|
||||
1. Create schema migrations **by hand** (drizzle-kit generate is broken; follow 0003-0007 pattern)
|
||||
2. Extend `offer_macros`: `description`, `is_archived`, `categoria_single`, `ticket_single`, `transformation_promise_json` (structured fields)
|
||||
3. Extend `offer_micros`: `tier_letter` (A|B|C), `public_price`
|
||||
4. Add **new junction table** `offer_tier_services`: `(tier_id, service_id)` as PK
|
||||
5. Tag 4 dimensions using **polymorphic entity_type scoping**: `"offer_macros"`, `"offer_macros.ticket"`, `"offer_macros.tipo"`, `"offer_macros.obiettivo"`
|
||||
6. Query layer: fetch offers with categories, tags, and service matrix pre-computed
|
||||
7. Editor UI: use Phase 11 patterns (`EditableCell`, `OptionMultiSelect`, `requireAdmin` server actions, inline validation via Zod)
|
||||
|
||||
---
|
||||
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
- **D-1 (Scope):** Editor offerte completo (lista + dettaglio). Import CSV/Notion (ex OFFER-12) **rimandato** a fase futura.
|
||||
- **D-2 (UX composizione tier):** **Matrice di checkbox** servizio × tier (A/B/C), NON drag&drop puro. Totale servizi live per colonna/tier. `@dnd-kit` usato solo (eventualmente) per riordinare le righe.
|
||||
- **D-3 (Fonte servizi):** Catalogo unificato `services` (Phase 11), **non** il legacy `offer_services`.
|
||||
- **D-4 (Filtro servizi per categoria):** Il servizio porta già, a livello di catalogo, la designazione di categoria offerta (entry / signature / retainer). L'editor mostra/pre-filtra nella matrice i servizi pertinenti alla categoria dell'offerta in modifica.
|
||||
- **D-5 (Prezzo):** Per ogni tier: **Totale servizi** (auto-somma dei servizi spuntati) + **Prezzo Pubblico** (manuale, indipendente). Coerente con la decisione PROJECT.md "prezzi pacchetti per-preventivo, non da catalogo".
|
||||
- **D-6 (Schema):** **Solo additivo** (Data Safety LOCKED). Riusare le tabelle offerte esistenti, non ricostruire.
|
||||
|
||||
### Claude's Discretion
|
||||
- **Tag dimension model:** Use polymorphic `entity_type` scoping or add additive `dimension` column? Recommend: polymorphic entity_type (`"offer_macros.categoria"`, `"offer_macros.ticket"`, etc.) to avoid schema change and reuse Phase 11 pattern.
|
||||
- **Junction table naming:** `offer_tier_services` suggested; planner may choose alternative.
|
||||
- **Tier letter persistence:** Store in `offer_micros.tier_letter` or compute from row position? Recommend: store explicitly for clarity.
|
||||
- **Public price per tier:** Manual input or formula? Locked as manual (D-5).
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- **Import CSV / import da Notion** (OFFER-12, rimandato a fase futura su decisione utente 2026-06-14).
|
||||
- **Sezioni analitiche Notion** (psicologia/rating/performance — OFFER-14, v2).
|
||||
- **Drag&drop composer** (originally in Phase 12 scope per UI-SPEC 12-UI-SPEC.md, now replaced by checkbox matrix).
|
||||
- **Collegamento offerta→preventivo/pagamento** (Proposal AI, Phase 16/17).
|
||||
|
||||
---
|
||||
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| OFFER-11 | L'admin compone un'offerta a 3 tier (A/B/C) assegnando i servizi del catalogo unificato `services` tramite matrice di checkbox; il totale servizi di ogni tier si aggiorna live | Schema: `offer_micros.tier_letter`, new junction `offer_tier_services`; UI: checkbox matrix with live total calculation (client-side sum per tier) |
|
||||
| OFFER-15 | Ogni offerta ha tag su 4 dimensioni (categoria, ticket, tipo, obiettivo) creabili al volo, riusando il sistema tag di Phase 11 | Tag pattern: polymorphic `tags` table with scoped `entity_type` (e.g. `"offer_macros.categoria"`, `"offer_macros.ticket"`) — reuse Phase 11 pattern; addServiceOption/removeServiceOption actions |
|
||||
| OFFER-16 | Per ogni tier l'admin imposta un "prezzo pubblico" manuale, distinto e indipendente dal totale dei servizi | Schema: `offer_micros.public_price` (numeric); UI: EditableCell input per tier row |
|
||||
| OFFER-17 | Ogni offerta ha una promessa di trasformazione strutturata (Cliente Ideale / Risultato / tempo / Pain / Metodo) | Schema: `offer_macros.transformation_promise_json` (JSONB); structured form in editor with 5 text inputs |
|
||||
| OFFER-18 | La lista offerte è filtrabile per categoria e supporta l'archiviazione (mostra/nascondi archiviate) | Schema: `offer_macros.is_archived` (boolean); UI: category chip filter + "Mostra archiviate" toggle |
|
||||
|
||||
---
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Offer list rendering (cards, filters) | Frontend Server (SSR) | — | Server-side query fetch (offer list + tags + counts), hydrate React component for filter UI |
|
||||
| Offer editor (form + matrix) | Browser / Frontend | Frontend Server | Browser state (tier matrix checkboxes, live total); form submission to server action |
|
||||
| Live tier total calculation | Browser / Frontend | — | Deterministic sum of selected service prices per tier; no server round-trip per toggle |
|
||||
| Tag management (add/remove/rename) | API / Backend | Frontend | Server actions (`addOfferTag`, `removeOfferTag`, `renameOfferTag` — requireAdmin-guarded) |
|
||||
| Service filtering by offer category | Frontend Server (SSR) | — | Query layer: fetch services filtered by category, return in hydrated props |
|
||||
| Transformation promise validation | API / Backend | — | Zod schema validation on offer save |
|
||||
| Schema migrations | Database / Infrastructure | — | Hand-written SQL (drizzle-kit broken); applies to prod via SSH+docker exec before code push |
|
||||
|
||||
---
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (Locked from CLAUDE.md)
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| Next.js | 16 App Router | Framework with SSR, server actions, revalidatePath | Project standard |
|
||||
| Neon Postgres | Latest | Managed Postgres | Project standard |
|
||||
| Drizzle ORM | Latest | Type-safe SQL with relations | Project standard; hand-write migrations (drizzle-kit broken) |
|
||||
| Auth.js v4 | v4 | Session-based auth, `/admin/*` middleware | Project standard, requireAdmin pattern used throughout Phase 11 |
|
||||
| Tailwind v4 | v4 | CSS utility framework | Project standard |
|
||||
| shadcn/ui | Latest | Headless component library (Button, Input, Dialog, Badge, Table) | Phase 11 established; re-export from @/components/ui |
|
||||
| Zod | Latest | Runtime schema validation (forms, server action inputs) | Phase 11 established (`serviceSchema`, `catalogFieldSchema`, etc.) |
|
||||
|
||||
### Supporting (Reuse Phase 11 patterns)
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| react-hook-form | Latest | Uncontrolled form state (optional; Phase 11 uses mostly FormData) | Only if structured form (5-field transformation promise) requires form state |
|
||||
| Lucide React | Latest | Icon library | Drag handle, trash icon, filter chips (Phase 11 pattern) |
|
||||
|
||||
### NOT Used (Deferred)
|
||||
| Instead of | Could Use | Why Not Used |
|
||||
|-----------|-----------|--------------|
|
||||
| `@dnd-kit/core` + `@dnd-kit/sortable` | Native drag-and-drop API | Originally in scope (D-2 mentions "@dnd-kit usato solo eventualmente per riordinare le righe"); checkbox matrix is simpler and preferred UX (D-2 locked). Row reorder optional; defer to Phase 12 plan. |
|
||||
| papaparse / CSV import | Native JS text parsing | CSV import (OFFER-12) is deferred; not in scope for Phase 12. |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ /admin/offers │
|
||||
│ (OffersList + OfferEditor) │
|
||||
└────────────┬──────────────────────────────────┬───────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────────┐ ┌──────────────────────┐
|
||||
│ OfferListQuery │ │ OfferEditorQuery │
|
||||
│ (SSR page.tsx) │ │ (SSR page.tsx) │
|
||||
│ │ │ │
|
||||
│ • getAllOffers() │ │ • getOfferDetail() │
|
||||
│ • filter by cat │ │ • getCategoriesOpts │
|
||||
│ • getTagOptions() │ │ • getServicesForOffer│
|
||||
│ │ │ • getServicesByCateg │
|
||||
└────────┬───────────┘ └──────────┬───────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────────┐ ┌──────────────────────┐
|
||||
│ OfferListUI │ │ OfferEditorUI │
|
||||
│ (React Client) │ │ (React Client) │
|
||||
│ │ │ │
|
||||
│ • Chip filter cat │ │ • Offer header form │
|
||||
│ • Archive toggle │ │ • 4-dim tag inputs │
|
||||
│ • Card list │ │ • Tier matrix │
|
||||
│ • "+New" button │ │ • Live totals/tier │
|
||||
│ • Edit/delete │ │ • Transform promise │
|
||||
└────────┬───────────┘ │ • [Salva]/[Annulla] │
|
||||
│ └──────────┬───────────┘
|
||||
│ │
|
||||
└───────────┬───────────────────┘
|
||||
│
|
||||
┌──────────▼──────────┐
|
||||
│ Server Actions │
|
||||
│ (actions.ts) │
|
||||
│ │
|
||||
│ • createOffer() │
|
||||
│ • updateOffer() │
|
||||
│ • deleteOffer() │
|
||||
│ • updateOfferTiers()│
|
||||
│ • addOfferTag() │
|
||||
│ • removeOfferTag() │
|
||||
│ • renameOfferTag() │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
┌──────────▼────────────────────┐
|
||||
│ Database Layer (Drizzle) │
|
||||
│ │
|
||||
│ Tables: │
|
||||
│ • offer_macros (+) │
|
||||
│ • offer_micros (+) │
|
||||
│ • offer_tier_services (NEW) │
|
||||
│ • services (Phase 11) │
|
||||
│ • tags (polymorphic scoped) │
|
||||
└───────────────────────────────┘
|
||||
```
|
||||
|
||||
**Data flow:**
|
||||
1. **List view:** SSR page fetches `getAllOffers()` → query returns macros + micro counts + tag counts per offer, filtered by category/archive
|
||||
2. **Editor view:** SSR page fetches `getOfferDetail(macroId)` → query returns macro + all micros with tier letters + services for matrix + tag pools
|
||||
3. **Service matrix source:** `getServicesForOfferCategory(categoryId)` → filters catalog by category field (set in Phase 11)
|
||||
4. **Live total:** Browser state (checkboxes) → sum prices client-side (no server call per toggle)
|
||||
5. **Save:** Form submission → server action validates (Zod) → updates offer_macros + offer_micros + offer_tier_services + tags → revalidatePath
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/admin/offers/
|
||||
│ ├── page.tsx # OffersList (SSR, list + filters)
|
||||
│ ├── [macroId]/
|
||||
│ │ └── page.tsx # OfferEditor (SSR, detail + matrix editor)
|
||||
│ ├── actions.ts # Server actions (CRUD, tags)
|
||||
│ └── layout.tsx # Shared layout
|
||||
├── lib/
|
||||
│ └── offer-queries.ts # Query layer (new/extended)
|
||||
├── components/admin/offers/
|
||||
│ ├── OffersList.tsx # List component (filters, cards)
|
||||
│ ├── OfferEditor.tsx # Editor component (form + matrix)
|
||||
│ ├── OfferMatrix.tsx # Tier×Service checkbox matrix
|
||||
│ ├── TransformationPromiseForm.tsx # 5-field structured form
|
||||
│ ├── OfferTagInput.tsx # Multi-select tag input
|
||||
│ └── OfferCategoryFilter.tsx # Category chip filter
|
||||
└── db/
|
||||
└── schema.ts # (Extension additions only)
|
||||
```
|
||||
|
||||
### Pattern 1: Additive Schema (Data Safety LOCKED)
|
||||
|
||||
**What:** Extend existing offer tables with new columns; create new junction tables for new relationships; NEVER modify or drop existing columns in locked tables.
|
||||
|
||||
**When to use:** Every schema change in Phase 12. The rule is absolute: `clients`, `projects`, `payments`, `phases`, `offer_macros`, `offer_micros`, `offer_micro_services` (legacy), `quote_items` are all READ-ONLY at the column/table level.
|
||||
|
||||
**Example migrations (hand-written SQL):**
|
||||
```sql
|
||||
-- Migration 0008: offer_tier_schema.sql (following 0003-0007 convention)
|
||||
|
||||
-- Extend offer_macros with new columns
|
||||
ALTER TABLE offer_macros ADD COLUMN description TEXT;
|
||||
ALTER TABLE offer_macros ADD COLUMN is_archived BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE offer_macros ADD COLUMN transformation_promise_json JSONB;
|
||||
|
||||
-- Extend offer_micros with tier designations and public pricing
|
||||
ALTER TABLE offer_micros ADD COLUMN tier_letter TEXT CHECK (tier_letter IN ('A', 'B', 'C'));
|
||||
ALTER TABLE offer_micros ADD COLUMN public_price NUMERIC(10, 2);
|
||||
|
||||
-- NEW junction table: tier ↔ services (replaces legacy offer_micro_services reference)
|
||||
CREATE TABLE offer_tier_services (
|
||||
tier_id TEXT NOT NULL REFERENCES offer_micros(id) ON DELETE CASCADE,
|
||||
service_id TEXT NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tier_id, service_id),
|
||||
INDEX offer_tier_services_tier_idx (tier_id)
|
||||
);
|
||||
|
||||
-- Tags for offers are scoped via entity_type (no schema change needed)
|
||||
-- entity_type values: "offer_macros", "offer_macros.ticket", "offer_macros.tipo", "offer_macros.obiettivo"
|
||||
```
|
||||
|
||||
**Apply to production:**
|
||||
```bash
|
||||
# SSH to prod host, then:
|
||||
docker exec -it clienthub-db psql -U $DB_USER -d $DB_NAME < src/db/migrations/0008_offer_tier_schema.sql
|
||||
# BEFORE pushing code that references offer_tier_services
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pattern 2: Polymorphic Tags (Phase 11 Extended)
|
||||
|
||||
**What:** The `tags` table uses `entity_type` + `entity_id` to scope tags across different entities. Phase 11 scoped tags as `"services"` and `"services.pacchetto"`. Phase 12 extends this to offers with 4 dimensions.
|
||||
|
||||
**Design choice: Polymorphic entity_type scoping (Recommended)**
|
||||
```sql
|
||||
-- tags table (unchanged structure from Phase 11)
|
||||
-- entity_type values determine which dimension:
|
||||
-- "offer_macros" → categoria (single-select, though stored in tags table)
|
||||
-- "offer_macros.ticket" → ticket (single-select, though stored in tags table)
|
||||
-- "offer_macros.tipo" → tipo (multi-select)
|
||||
-- "offer_macros.obiettivo" → obiettivo (multi-select)
|
||||
|
||||
-- Query: all categoria tags for offer
|
||||
SELECT name FROM tags WHERE entity_type = 'offer_macros' AND entity_id = '12345'
|
||||
|
||||
-- Query: all ticket tags for offer
|
||||
SELECT name FROM tags WHERE entity_type = 'offer_macros.ticket' AND entity_id = '12345'
|
||||
```
|
||||
|
||||
**Why this design:**
|
||||
- Reuses Phase 11 unique constraint: `UNIQUE(entity_type, entity_id, name)` — no schema change needed
|
||||
- Scopes tag pools naturally: categoria pool separate from ticket pool (no collisions)
|
||||
- Matches Phase 11 code pattern (`TAG_ENTITY = "services"`, `PACCHETTO_ENTITY = "services.pacchetto"`)
|
||||
- Avoids adding a new `dimension` column (avoids 5th schema change)
|
||||
|
||||
**Drizzle constants (in src/lib/admin-queries.ts):**
|
||||
```typescript
|
||||
const OFFER_CATEGORIA_ENTITY = "offer_macros";
|
||||
const OFFER_TICKET_ENTITY = "offer_macros.ticket";
|
||||
const OFFER_TIPO_ENTITY = "offer_macros.tipo";
|
||||
const OFFER_OBIETTIVO_ENTITY = "offer_macros.obiettivo";
|
||||
```
|
||||
|
||||
**Server action pattern (reuse from Phase 11):**
|
||||
```typescript
|
||||
export async function addOfferTag(
|
||||
macroId: string,
|
||||
dimension: "categoria" | "ticket" | "tipo" | "obiettivo",
|
||||
tagName: string
|
||||
) {
|
||||
await requireAdmin();
|
||||
const entity_type = {
|
||||
categoria: OFFER_CATEGORIA_ENTITY,
|
||||
ticket: OFFER_TICKET_ENTITY,
|
||||
tipo: OFFER_TIPO_ENTITY,
|
||||
obiettivo: OFFER_OBIETTIVO_ENTITY,
|
||||
}[dimension];
|
||||
|
||||
await db.insert(tags).values({
|
||||
entity_type,
|
||||
entity_id: macroId,
|
||||
name: tagName.trim(),
|
||||
}).onConflictDoNothing();
|
||||
|
||||
revalidatePath("/admin/offers");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pattern 3: Tier Matrix Composition (Checkbox Pattern)
|
||||
|
||||
**What:** Instead of drag-and-drop, use a simple checkbox matrix: rows are services, columns are tiers (A/B/C), cells are checkboxes.
|
||||
|
||||
**When to use:** Building the OfferMatrix UI component.
|
||||
|
||||
**Example UX:**
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Servizi inclusi nella tua offerta │
|
||||
├─────────────────────────────┬──────┬──────┬──────┬──────────┤
|
||||
│ Servizio │ Tier │ Tier │ Tier │ Prezzo │
|
||||
│ │ A │ B │ C │ unitario │
|
||||
├─────────────────────────────┼──────┼──────┼──────┼──────────┤
|
||||
│ Personal Branding Audit │ [x] │ [ ] │ [ ] │ €500 │
|
||||
│ Brand Strategy Workshop │ [x] │ [x] │ [ ] │ €300 │
|
||||
│ Logo Design │ [ ] │ [x] │ [x] │ €800 │
|
||||
├─────────────────────────────┼──────┼──────┼──────┼──────────┤
|
||||
│ Totale servizi (auto) │ €800 │ €1.1K│ €800 │ │
|
||||
│ Prezzo pubblico (manuale) │ [ €900 ] │ [ €1.2K ] │ [ €900 ] │
|
||||
└─────────────────────────────┴──────┴──────┴──────┴──────────┘
|
||||
```
|
||||
|
||||
**React component pattern:**
|
||||
```typescript
|
||||
// OfferMatrix.tsx
|
||||
type OfferMatrixProps = {
|
||||
services: Service[]; // filtered by offer category
|
||||
tiers: Array<OfferMicro & { tier_letter: 'A'|'B'|'C' }>;
|
||||
initialAssignments: Map<string, string[]>; // Map<tier_id, service_ids>
|
||||
onChange: (assignments: Map<string, string[]>) => void;
|
||||
};
|
||||
|
||||
export function OfferMatrix({ services, tiers, initialAssignments, onChange }: OfferMatrixProps) {
|
||||
const [assignments, setAssignments] = useState<Map<string, Set<string>>>(
|
||||
new Map(Array.from(initialAssignments.entries()).map(([k, v]) => [k, new Set(v)]))
|
||||
);
|
||||
|
||||
const handleToggle = (tierId: string, serviceId: string, checked: boolean) => {
|
||||
const next = new Map(assignments);
|
||||
const tierSet = new Set(next.get(tierId) ?? []);
|
||||
if (checked) tierSet.add(serviceId);
|
||||
else tierSet.delete(serviceId);
|
||||
next.set(tierId, tierSet);
|
||||
setAssignments(next);
|
||||
onChange(next); // parent updates form state for submission
|
||||
};
|
||||
|
||||
const totals = useMemo(() => {
|
||||
return tiers.map(tier => ({
|
||||
tier_id: tier.id,
|
||||
total: Array.from(assignments.get(tier.id) ?? [])
|
||||
.map(sid => parseFloat(services.find(s => s.id === sid)?.unit_price ?? "0"))
|
||||
.reduce((a, b) => a + b, 0),
|
||||
}));
|
||||
}, [assignments, services, tiers]);
|
||||
|
||||
return (
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
||||
<th className="text-left px-4 py-2 font-semibold">Servizio</th>
|
||||
<th className="text-center px-4 py-2 font-semibold">Prezzo</th>
|
||||
{tiers.map(tier => (
|
||||
<th key={tier.id} className="text-center px-4 py-2 font-semibold">
|
||||
Tier {tier.tier_letter}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{services.map(service => (
|
||||
<tr key={service.id} className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9]">
|
||||
<td className="px-4 py-3 font-medium">{service.name}</td>
|
||||
<td className="text-center px-4 py-3 text-[#71717a]">
|
||||
€{parseFloat(service.unit_price).toFixed(2)}
|
||||
</td>
|
||||
{tiers.map(tier => (
|
||||
<td key={tier.id} className="text-center px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(assignments.get(tier.id) ?? new Set()).has(service.id)}
|
||||
onChange={e => handleToggle(tier.id, service.id, e.target.checked)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Data flow on save:**
|
||||
1. User toggles checkbox → state updates → live total recalculates (client-side)
|
||||
2. User clicks "Salva" → form submission with Map<tier_id, Set<service_id>>
|
||||
3. Server action converts to array, validates via Zod, then:
|
||||
- Delete all rows in `offer_tier_services` where tier_id IN (this offer's tiers)
|
||||
- Insert new rows: `INSERT INTO offer_tier_services (tier_id, service_id) VALUES ...`
|
||||
4. Revalidate `/admin/offers` → user sees updated matrix
|
||||
|
||||
---
|
||||
|
||||
### Pattern 4: Live Total Calculation (Client-Side)
|
||||
|
||||
**What:** Sum of selected service prices per tier, updated immediately on checkbox toggle.
|
||||
|
||||
**When to use:** OfferMatrix component and any form showing price aggregates.
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
const computeTierTotal = (selectedServiceIds: string[], services: Service[]): number => {
|
||||
return services
|
||||
.filter(s => selectedServiceIds.includes(s.id))
|
||||
.reduce((sum, s) => sum + parseFloat(s.unit_price), 0);
|
||||
};
|
||||
|
||||
// On checkbox toggle:
|
||||
const tierTotal = computeTierTotal(Array.from(checkedIds), services);
|
||||
setTotals(prev => ({ ...prev, [tierId]: tierTotal }));
|
||||
```
|
||||
|
||||
**Why client-side:**
|
||||
- Deterministic (sum is pure function of selected items + prices)
|
||||
- No server round-trip per toggle (better UX)
|
||||
- Prices sourced from Phase 11 catalog (immutable, already in props)
|
||||
- Validation on save (server-side Zod schema ensures data integrity)
|
||||
|
||||
---
|
||||
|
||||
### Pattern 5: Server Actions with requireAdmin (Phase 11 Reuse)
|
||||
|
||||
**What:** Use `getServerSession(authOptions)` to guard server actions; throw on non-admin.
|
||||
|
||||
**When to use:** All mutation operations.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// src/app/admin/offers/actions.ts
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("Non autorizzato");
|
||||
}
|
||||
|
||||
const updateOfferTiersSchema = z.object({
|
||||
macro_id: z.string().min(1),
|
||||
tiers: z.array(
|
||||
z.object({
|
||||
micro_id: z.string().min(1),
|
||||
tier_letter: z.enum(["A", "B", "C"]),
|
||||
public_price: z.coerce.number().min(0),
|
||||
service_ids: z.array(z.string()),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export async function updateOfferTiers(formData: FormData) {
|
||||
await requireAdmin();
|
||||
|
||||
const parsed = updateOfferTiersSchema.safeParse({
|
||||
macro_id: formData.get("macro_id"),
|
||||
tiers: JSON.parse(formData.get("tiers_json") as string),
|
||||
});
|
||||
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||
|
||||
const { macro_id, tiers } = parsed.data;
|
||||
|
||||
// Update each tier
|
||||
for (const tier of tiers) {
|
||||
await db.update(offer_micros)
|
||||
.set({
|
||||
tier_letter: tier.tier_letter,
|
||||
public_price: tier.public_price.toFixed(2),
|
||||
})
|
||||
.where(eq(offer_micros.id, tier.micro_id));
|
||||
|
||||
// Delete + re-insert services for this tier
|
||||
await db.delete(offer_tier_services)
|
||||
.where(eq(offer_tier_services.tier_id, tier.micro_id));
|
||||
|
||||
if (tier.service_ids.length > 0) {
|
||||
await db.insert(offer_tier_services).values(
|
||||
tier.service_ids.map(sid => ({ tier_id: tier.micro_id, service_id: sid }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/admin/offers");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **❌ Exposing offer details via client API:** Only `/admin/offers` (admin-only routes with session check). Never `/api/offers` (public endpoint). Offer composition is locked behind Auth.js.
|
||||
- **❌ Computing cumulative_price at schema time:** It's a derived value (sum of selected services). Compute at **query time** (SQL SUM), not stored.
|
||||
- **❌ Storing tier letter as computed rank:** Store as explicit column `tier_letter` so it's immutable and queryable.
|
||||
- **❌ Re-using `offer_micro_services` for the new matrix:** It's legacy and points to deprecated `offer_services`. Always use the **new `offer_tier_services`** junction to `services.id`.
|
||||
- **❌ Bulk-updating tier prices inline:** Each tier edit is a separate save. Avoid multi-tier batch updates unless required by spec.
|
||||
|
||||
---
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|------------|-------------|-----|
|
||||
| Multi-select tag input | Custom autocomplete with tag picker | `shadcn/ui` Badge + Input with onKeyDown, pattern from Phase 11 `OptionMultiSelect` | Phase 11 already solved this; reuse component + logic |
|
||||
| Tier matrix checkbox logic | Custom matrix renderer | React state (useState) + table component | Simple enough; native HTML + React state is cleaner than a library |
|
||||
| Live total calculation | Custom sum function | `reduce()` over selected services | Standard JS; no library needed for deterministic arithmetic |
|
||||
| Tag management (add/remove/rename) | Custom CRUD logic | Mirror Phase 11 server actions: `addOfferTag`, `removeOfferTag`, `renameOfferTag` | Tag lifecycle is identical across entities; DRY principle |
|
||||
| Offer schema validation | Regex or ad-hoc checks | Zod schema (`offerMacroSchema`, `offerMicroSchema`) | Type-safe, composable, reuses project standard |
|
||||
| JSON transformation promise | String concatenation | JSONB column with typed read/write (Zod parse on read) | Structured data → queryable later; no parsing fragility |
|
||||
|
||||
**Key insight:** Phase 11 established production-grade patterns for inline edit, tagging, quick-add. Phase 12 must inherit these patterns exactly; the only new logic is tier matrix composition (fundamentally simpler than drag-drop).
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Example 1: Query — Offer Detail with Tags & Services
|
||||
|
||||
```typescript
|
||||
// src/lib/offer-queries.ts (new function)
|
||||
|
||||
export type OfferDetailWithServices = {
|
||||
macro: OfferMacro & {
|
||||
tags: {
|
||||
categoria: string[];
|
||||
ticket: string[];
|
||||
tipo: string[];
|
||||
obiettivo: string[];
|
||||
}
|
||||
};
|
||||
micros: Array<OfferMicro & {
|
||||
services: Array<{ id: string; name: string; unit_price: string }>;
|
||||
cumulative_price: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function getOfferDetail(macroId: string): Promise<OfferDetailWithServices | null> {
|
||||
const macro = await db.query.offer_macros.findFirst({ where: eq(offer_macros.id, macroId) });
|
||||
if (!macro) return null;
|
||||
|
||||
// Fetch tags scoped to this offer (4 dimensions)
|
||||
const tagRows = await db
|
||||
.select({ entity_type: tags.entity_type, name: tags.name })
|
||||
.from(tags)
|
||||
.where(
|
||||
and(
|
||||
eq(tags.entity_id, macroId),
|
||||
inArray(tags.entity_type, [
|
||||
"offer_macros",
|
||||
"offer_macros.ticket",
|
||||
"offer_macros.tipo",
|
||||
"offer_macros.obiettivo",
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
const tagsMap = {
|
||||
categoria: tagRows.filter(t => t.entity_type === "offer_macros").map(t => t.name),
|
||||
ticket: tagRows.filter(t => t.entity_type === "offer_macros.ticket").map(t => t.name),
|
||||
tipo: tagRows.filter(t => t.entity_type === "offer_macros.tipo").map(t => t.name),
|
||||
obiettivo: tagRows.filter(t => t.entity_type === "offer_macros.obiettivo").map(t => t.name),
|
||||
};
|
||||
|
||||
// Fetch micros + services via NEW junction
|
||||
const micros = await db.select().from(offer_micros).where(eq(offer_micros.macro_id, macroId));
|
||||
const microIds = micros.map(m => m.id);
|
||||
|
||||
const servicesForMicros = await db
|
||||
.select({
|
||||
micro_id: offer_tier_services.tier_id,
|
||||
service_id: offer_tier_services.service_id,
|
||||
name: services.name,
|
||||
unit_price: services.unit_price,
|
||||
})
|
||||
.from(offer_tier_services)
|
||||
.innerJoin(services, eq(offer_tier_services.service_id, services.id))
|
||||
.where(inArray(offer_tier_services.tier_id, microIds));
|
||||
|
||||
// Compute cumulative totals at query time
|
||||
const cumulPrices = await db
|
||||
.select({
|
||||
micro_id: offer_tier_services.tier_id,
|
||||
cumulative: sql<string>`COALESCE(SUM(${services.unit_price}::numeric), 0)`,
|
||||
})
|
||||
.from(offer_tier_services)
|
||||
.innerJoin(services, eq(offer_tier_services.service_id, services.id))
|
||||
.where(inArray(offer_tier_services.tier_id, microIds))
|
||||
.groupBy(offer_tier_services.tier_id);
|
||||
|
||||
const cumulMap = new Map(cumulPrices.map(r => [r.micro_id, r.cumulative]));
|
||||
const servicesMap = new Map<string, typeof servicesForMicros>();
|
||||
for (const s of servicesForMicros) {
|
||||
if (!servicesMap.has(s.micro_id)) servicesMap.set(s.micro_id, []);
|
||||
servicesMap.get(s.micro_id)!.push(s);
|
||||
}
|
||||
|
||||
return {
|
||||
macro: { ...macro, tags: tagsMap },
|
||||
micros: micros.map(m => ({
|
||||
...m,
|
||||
services: (servicesMap.get(m.id) ?? []).map(s => ({
|
||||
id: s.service_id,
|
||||
name: s.name,
|
||||
unit_price: s.unit_price,
|
||||
})),
|
||||
cumulative_price: cumulMap.get(m.id) ?? "0",
|
||||
})),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Source:** [VERIFIED: Pattern from Phase 11 getAllServices + getCatalogWithMicros in offer-queries.ts lines 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<OfferMicro & { tier_letter: 'A'|'B'|'C' }>;
|
||||
initialAssignments: Map<string, string[]>;
|
||||
onChange: (assignments: Map<string, string[]>) => void;
|
||||
};
|
||||
|
||||
export function OfferMatrix({ services, tiers, initialAssignments, onChange }: Props) {
|
||||
const [assignments, setAssignments] = useState<Map<string, Set<string>>>(
|
||||
new Map(Array.from(initialAssignments.entries()).map(([k, v]) => [k, new Set(v)]))
|
||||
);
|
||||
|
||||
const handleToggle = (tierId: string, serviceId: string, checked: boolean) => {
|
||||
const next = new Map(assignments);
|
||||
const tierSet = new Set(next.get(tierId) ?? []);
|
||||
if (checked) tierSet.add(serviceId);
|
||||
else tierSet.delete(serviceId);
|
||||
next.set(tierId, tierSet);
|
||||
setAssignments(next);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const totals = useMemo(() => {
|
||||
return tiers.map(tier => ({
|
||||
tier_id: tier.id,
|
||||
total: Array.from(assignments.get(tier.id) ?? [])
|
||||
.map(sid => parseFloat(services.find(s => s.id === sid)?.unit_price ?? "0"))
|
||||
.reduce((a, b) => a + b, 0),
|
||||
}));
|
||||
}, [assignments, services, tiers]);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
||||
<th className="text-left px-4 py-2 font-semibold">Servizio</th>
|
||||
<th className="text-center px-4 py-2 font-semibold">Prezzo</th>
|
||||
{tiers.map(tier => (
|
||||
<th key={tier.id} className="text-center px-4 py-2 font-semibold">
|
||||
Tier {tier.tier_letter}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{services.map(service => (
|
||||
<tr key={service.id} className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9]">
|
||||
<td className="px-4 py-3 font-medium">{service.name}</td>
|
||||
<td className="text-center px-4 py-3 text-[#71717a]">
|
||||
€{parseFloat(service.unit_price).toFixed(2)}
|
||||
</td>
|
||||
{tiers.map(tier => (
|
||||
<td key={tier.id} className="text-center px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(assignments.get(tier.id) ?? new Set()).has(service.id)}
|
||||
onChange={e => handleToggle(tier.id, service.id, e.target.checked)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="mt-4 flex gap-8 font-semibold">
|
||||
{totals.map(({ tier_id, total }) => (
|
||||
<div key={tier_id}>
|
||||
<span className="text-[#71717a]">Tier {tiers.find(t => t.id === tier_id)?.tier_letter}:</span>
|
||||
<span className="ml-2">€{total.toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Source:** [VERIFIED: React hooks pattern + browser native checkbox]
|
||||
|
||||
---
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| `offer_micro_services` → `offer_services` | `offer_tier_services` → `services` (unified) | Phase 11 / Phase 12 | Single service catalog reduces duplication |
|
||||
| Drag-and-drop composer (UI-SPEC.md, original scope) | Checkbox matrix (CONTEXT.md D-2, re-scoped 2026-06-14) | 2026-06-14 user decision | Simpler UX, faster implementation, better accessibility |
|
||||
| Offer tiers as implicit rows | Explicit tier letters A/B/C in schema | Phase 12 (D-2) | Tier designations immutable, queryable, unambiguous |
|
||||
| Manual text transformation promise | Structured JSON fields (5-field JSONB) | Phase 12 (OFFER-17) | Enables future analytics, templating, per-field editing |
|
||||
| Shared `tags` table with single pool | Polymorphic entity_type scoping | Phase 11 / Phase 12 | Multiple tag dimensions stay separate; no collision risk |
|
||||
|
||||
---
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | Services already filtered by category field in Phase 11; offer editor can pre-filter matrix by `services.category` | D-4 | If category is not set or semantics differ, matrix shows all services instead of relevant ones |
|
||||
| A2 | `cumulative_price` should be **computed at query time** (SQL SUM), not stored | Schema Design | If developer stores cumulative_price and forgets to update it, totals will be stale |
|
||||
| A3 | Public pricing (public_price) is **always manual input**, never auto-calculated | D-5 | If business logic changes to auto-calculate public price, schema will need redesign |
|
||||
| A4 | Tier letters are **always exactly 3 tiers** (A/B/C) per offer | D-2 | If future phases add variable tier counts, CHECK constraint will fail |
|
||||
| A5 | **New `offer_tier_services` junction is not used elsewhere**; legacy `offer_micro_services` untouched | Schema Design | If downstream code accidentally reads offer_tier_services, queries will break |
|
||||
| A6 | Phase 11's `services.category` and `services.fase` are not overloaded with other semantics | D-4 | If category/fase used differently elsewhere, offer editor filtering will be wrong |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Services all have category set? (Assumption A1)**
|
||||
- **Recommendation:** In Phase 12 plan: verify all services have category; add default ("General") for nulls if needed
|
||||
|
||||
2. **Transformation promise: JSON or 5 columns? (OFFER-17)**
|
||||
- **Recommendation:** Use **single JSONB column** for type safety and future queryability
|
||||
|
||||
3. **Tag dimensions: polymorphic entity_type or dimension column? (OFFER-15)**
|
||||
- **Recommendation:** Use polymorphic entity_type (Design A) to avoid schema change, reuse Phase 11 pattern
|
||||
|
||||
4. **Row reordering via @dnd-kit? (D-2 optional mention)**
|
||||
- **Recommendation:** Not in initial scope. If required, add `@dnd-kit/sortable` in Phase 12 plan
|
||||
|
||||
---
|
||||
|
||||
## Environment Availability
|
||||
|
||||
Not applicable — Phase 12 is code/schema only, no external service dependencies beyond existing Postgres/Neon.
|
||||
|
||||
---
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | Yes | Auth.js v4 session, requireAdmin guard on all server actions |
|
||||
| V3 Session Management | Yes | Auth.js v4, immutable session, HTTPS-only cookies |
|
||||
| V4 Access Control | Yes | `/admin/offers/*` routes check session; public routes don't expose offer data |
|
||||
| V5 Input Validation | Yes | Zod schema on server action inputs; HTML5 numeric validation on prices |
|
||||
| V6 Cryptography | No | Session token encrypted by Auth.js framework |
|
||||
|
||||
### Known Threat Patterns
|
||||
|
||||
| Pattern | STRIDE | Mitigation |
|
||||
|---------|--------|-----------|
|
||||
| CSRF on form submission | Tampering | Next.js server actions auto-include SameSite=Strict cookie |
|
||||
| SQL injection via ORM | Tampering | Drizzle ORM uses parameterized queries; no string interpolation |
|
||||
| Unvalidated price input | Tampering | Zod validates numeric range; coerced to number, min 0 |
|
||||
| Privilege escalation | Elevation | requireAdmin() checks session; throws if missing |
|
||||
| Data exposure via API | Information Disclosure | No public API; only `/admin/offers*` (private) access offer data |
|
||||
| Race condition on service assignment | Tampering | PK constraint on junction prevents duplicates; last-write-wins acceptable for admin-only |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- **12-CONTEXT.md** — User decisions locked (D-1 through D-6)
|
||||
- **ROADMAP.md Phase 12** — Requirements OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18; re-scoped 2026-06-14
|
||||
- **REQUIREMENTS.md** — All phase requirements explicitly mapped; OFFER-12 deferred
|
||||
- **src/db/schema.ts** — Current `offer_macros` (lines 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.*
|
||||
@@ -0,0 +1,413 @@
|
||||
---
|
||||
phase: 12-offer-composition-drag-drop-csv-import
|
||||
reviewed: 2026-06-15T10:36:00Z
|
||||
depth: standard
|
||||
files_reviewed: 12
|
||||
files_reviewed_list:
|
||||
- scripts/push-12-offer-tier-schema.ts
|
||||
- scripts/verify-12-03-actions.ts
|
||||
- scripts/verify-12-03-queries.ts
|
||||
- src/app/admin/offers/[id]/edit/page.tsx
|
||||
- src/app/admin/offers/actions.ts
|
||||
- src/app/admin/offers/page.tsx
|
||||
- src/components/admin/offers/OfferEditorClient.tsx
|
||||
- src/components/admin/offers/OfferListClient.tsx
|
||||
- src/db/migrations/0008_offer_tier_schema.sql
|
||||
- src/db/migrations/meta/_journal.json
|
||||
- src/db/schema.ts
|
||||
- src/lib/offer-queries.ts
|
||||
findings:
|
||||
critical: 0
|
||||
warning: 6
|
||||
info: 4
|
||||
total: 10
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 12: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-06-15T10:36:00Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 12
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
This batch implements the Phase 12 Offer Editor: an additive DB migration (tier
|
||||
designation, public pricing, archive flag, category/ticket dimensions, tipo/obiettivo
|
||||
tags), a query layer (`offer-queries.ts`), server actions
|
||||
(`saveOfferEditor`/`toggleOfferArchived`/`addOfferTag`/`removeOfferTag`/
|
||||
`renameOfferOption`/`createOfferMacro`), and the editor/list client components.
|
||||
|
||||
The migration and push script are correctly additive/idempotent and respect the
|
||||
Data Safety constraints in CLAUDE.md (no drops/truncates, `ADD COLUMN IF NOT
|
||||
EXISTS`, guarded `DO $$` block for the CHECK constraint). `requireAdmin()` is
|
||||
consistently called at the top of every new server action.
|
||||
|
||||
No critical/security issues were found. The main concerns are: (1) the tier
|
||||
upsert in `saveOfferEditor` doesn't enforce uniqueness of `tier_letter` within a
|
||||
single macro, which can desync the UI's `padTiers()` assumption and silently
|
||||
orphan rows; (2) `renameOfferOption("tipo"/"obiettivo", ...)` can throw an
|
||||
uncaught unique-constraint violation when the target name already exists,
|
||||
surfacing as a generic 500 with no user-facing message; (3) the "unarchive" path
|
||||
(`toggleOfferArchived(macroId, false)`) is implemented server-side and covered by
|
||||
the verify script but has no reachable UI entry point, making it effectively dead
|
||||
code from the user's perspective; and (4) several smaller robustness/UX gaps
|
||||
around empty tiers being persisted with placeholder names, and total mismatches
|
||||
when an assigned service falls outside the category filter.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: `saveOfferEditor` does not enforce unique `tier_letter` per macro
|
||||
|
||||
**File:** `src/app/admin/offers/actions.ts:152-238`
|
||||
**Issue:** `tierSchema` validates each tier's `tier_letter` is one of `"A"|"B"|"C"`
|
||||
and `saveOfferEditorSchema.tiers` is capped at `.max(3)`, but nothing prevents two
|
||||
tiers in the same payload from sharing the same `tier_letter` (e.g., two tiers
|
||||
both `"A"`). The loop at lines 200-238 will happily update/insert both rows
|
||||
independently — both ending up in `offer_micros` with `macro_id = macroId` and
|
||||
`tier_letter = "A"`.
|
||||
|
||||
On the next load, `getOfferEditorData` (`src/lib/offer-queries.ts:163-167`) orders
|
||||
by `tierOrder` (A→B→C, ties broken arbitrarily/by insertion), and
|
||||
`OfferEditorClient.padTiers()` (`src/components/admin/offers/OfferEditorClient.tsx:33-37`)
|
||||
uses `tiers.find((t) => t.tier_letter === letter)` — which returns only the FIRST
|
||||
matching tier. The second "A" tier becomes invisible in the UI but still exists in
|
||||
the DB (with its own `offer_tier_services` rows), and a subsequent save will
|
||||
silently drop it from the `tiers` array sent to `saveOfferEditor` (since
|
||||
`OfferEditorClient` state only ever holds 3 padded tiers) — leaving an orphaned
|
||||
`offer_micros` row that's never cleaned up.
|
||||
|
||||
**Fix:** Either enforce uniqueness in `saveOfferEditorSchema` via a `.refine()`
|
||||
check, or — more robustly — have `saveOfferEditor` delete any existing tiers for
|
||||
`macroId` whose `tier_letter` is NOT present in the incoming payload (so stale/
|
||||
duplicate rows get cleaned up):
|
||||
|
||||
```ts
|
||||
const saveOfferEditorSchema = z.object({
|
||||
// ...
|
||||
tiers: z.array(tierSchema).max(3).refine(
|
||||
(tiers) => {
|
||||
const letters = tiers.map((t) => t.tier_letter);
|
||||
return new Set(letters).size === letters.length;
|
||||
},
|
||||
{ message: "Ogni tier deve avere una lettera univoca (A/B/C)" }
|
||||
),
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### WR-02: `renameOfferOption("tipo"/"obiettivo", ...)` can throw on unique constraint violation
|
||||
|
||||
**File:** `src/app/admin/offers/actions.ts:337-341`
|
||||
**Issue:** For `field === "tipo" | "obiettivo"`, the action runs:
|
||||
|
||||
```ts
|
||||
await db
|
||||
.update(tags)
|
||||
.set({ name: next })
|
||||
.where(and(eq(tags.entity_type, OFFER_TAG_ENTITY[field]), eq(tags.name, oldValue)));
|
||||
```
|
||||
|
||||
This is a global rename across ALL macros that have a tag named `oldValue` for
|
||||
that `entity_type`. The `tags` table has a unique index
|
||||
`tags_entity_name_unique` on `(entity_type, entity_id, name)`
|
||||
(`src/db/schema.ts:133-137`). If any macro already has a tag named `next` for the
|
||||
same `entity_type` (e.g., renaming "Audit" → "Coaching" but macro X already has a
|
||||
"Coaching" tag), the UPDATE on macro X's row will violate the unique index and
|
||||
throw a raw Postgres error. This error is not caught here, and propagates up to
|
||||
`OfferEditorClient.handleRenameTipo`/`handleRenameObiettivo`
|
||||
(`src/components/admin/offers/OfferEditorClient.tsx:210-232`), which `catch` it
|
||||
generically and show "Errore nel salvataggio. Verifica i campi." — but by that
|
||||
point the optimistic client-side state update (`setTipoOptions`, `setTipoTags`)
|
||||
has ALREADY applied for ALL macros' local view, while the DB only partially
|
||||
applied the rename (some rows renamed, the colliding row left as `oldValue` or
|
||||
the whole statement rolled back depending on transaction semantics — Drizzle here
|
||||
issues a single statement so it's atomic per-statement, but the local UI state for
|
||||
THIS macro is now inconsistent with the DB: `tipoTags` shows `next` even though
|
||||
the DB row may not have been updated due to the throw on a *different* row update
|
||||
within the same UPDATE statement — actually since it's one UPDATE affecting
|
||||
multiple rows, a constraint violation on any row aborts the whole statement,
|
||||
meaning NO rows are renamed in the DB, but the UI already shows the renamed tag).
|
||||
|
||||
**Fix:** Wrap in `onConflictDoNothing`-style handling isn't directly possible for
|
||||
UPDATE; instead, pre-check or catch the specific error and surface a meaningful
|
||||
message:
|
||||
|
||||
```ts
|
||||
} else if (field === "tipo" || field === "obiettivo") {
|
||||
try {
|
||||
await db
|
||||
.update(tags)
|
||||
.set({ name: next })
|
||||
.where(and(eq(tags.entity_type, OFFER_TAG_ENTITY[field]), eq(tags.name, oldValue)));
|
||||
} catch (err) {
|
||||
throw new Error(`Esiste già un tag "${next}" per questo campo`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### WR-03: `toggleOfferArchived(macroId, false)` has no reachable UI entry point
|
||||
|
||||
**File:** `src/components/admin/offers/OfferEditorClient.tsx:174-184, 487-496`
|
||||
**Issue:** `handleArchive` only ever calls `toggleOfferArchived(data.macro.id,
|
||||
true)` (line 178), and the "Archivia" button is only rendered when
|
||||
`!initialArchived` (line 487). There is no button/action that calls
|
||||
`toggleOfferArchived(macroId, false)` to un-archive an offer. Once an offer is
|
||||
archived, the only way back is presumably a direct DB edit — `OfferListClient`'s
|
||||
"Mostra offerte archiviate" toggle (line 116-124 in `OfferListClient.tsx`) lets
|
||||
the admin SEE archived offers and click through to `/admin/offers/[id]/edit`, but
|
||||
the edit page gives them no way to restore it. `toggleOfferArchived(macroId,
|
||||
false)` is exercised in `scripts/verify-12-03-actions.ts` (Test 6) but is
|
||||
effectively dead code from a product standpoint.
|
||||
|
||||
**Fix:** Add a "Ripristina" (restore) button shown when `initialArchived` is
|
||||
true, calling `toggleOfferArchived(data.macro.id, false)`:
|
||||
|
||||
```tsx
|
||||
{initialArchived && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startTransition(async () => {
|
||||
try {
|
||||
await toggleOfferArchived(data.macro.id, false);
|
||||
router.push("/admin/offers");
|
||||
} catch {
|
||||
setSaveError("Errore nel salvataggio. Verifica i campi.");
|
||||
}
|
||||
})}
|
||||
disabled={isPending}
|
||||
className="text-[#1A463C] text-sm font-semibold px-6 py-4 rounded hover:bg-[#f0f7f4] transition-colors duration-150 disabled:opacity-50"
|
||||
>
|
||||
Ripristina
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
### WR-04: Empty/unused tier slots are persisted as real `offer_micros` rows with placeholder names
|
||||
|
||||
**File:** `src/components/admin/offers/OfferEditorClient.tsx:138-172`, `src/app/admin/offers/actions.ts:200-238`
|
||||
**Issue:** `padTiers()` always produces exactly 3 `OfferTierData` entries (A/B/C),
|
||||
filling missing ones with `emptyTier(letter)` (`id: ""`, empty names,
|
||||
`assignedServiceIds: []`). `handleSave` (lines 151-159) maps ALL 3 tiers
|
||||
unconditionally into the payload, defaulting `internal_name` to `` `Tier
|
||||
${tier.tier_letter}` `` and `public_name` to `t.tier_letter || "Tier"` when
|
||||
empty. `saveOfferEditor`'s tier loop (lines 200-238) then inserts a NEW
|
||||
`offer_micros` row for every tier without an `id` — including these placeholder
|
||||
"empty" tiers — as long as `canSave` is true (i.e., AT LEAST ONE of the 3 tiers
|
||||
has assigned services).
|
||||
|
||||
Concretely: if the admin only fills in Tier A with services and leaves B/C empty,
|
||||
saving creates 3 `offer_micros` rows total — 2 of which are "ghost" tiers named
|
||||
"Tier B"/"Tier C" with `duration_months: 1`, `public_price: null`, and zero
|
||||
assigned services — visible in any other view that lists `offer_micros` for this
|
||||
macro (e.g., `getCatalogWithMicros`, used elsewhere in the admin).
|
||||
|
||||
**Fix:** Skip tiers that are both unsaved (`!t.id`) AND empty
|
||||
(`assignedServiceIds.length === 0` and no `internal_name`/`public_name` entered)
|
||||
when building the payload:
|
||||
|
||||
```ts
|
||||
tiers: tiers
|
||||
.filter((t) => t.id || t.assignedServiceIds.length > 0 || t.internal_name || t.public_name)
|
||||
.map((t) => ({ ... })),
|
||||
```
|
||||
|
||||
### WR-05: `tierTotals` silently treats services outside the category filter as €0, diverging from server-computed `servicesTotal`
|
||||
|
||||
**File:** `src/components/admin/offers/OfferEditorClient.tsx:96-109`
|
||||
**Issue:** `filteredServices` (lines 96-100) is `data.availableServices` filtered
|
||||
by `macro.category` (client-side, reactive to the in-progress edit of
|
||||
`macro.category`). `tierTotals` (lines 102-109) computes each tier's total by
|
||||
looking up `tier.assignedServiceIds` against `filteredServices` only, defaulting
|
||||
to `0` via `Number(service?.unit_price ?? 0)` for any assigned service ID not
|
||||
found in `filteredServices`.
|
||||
|
||||
If a tier has services assigned that belong to a DIFFERENT category than the
|
||||
macro's current `category` (e.g., the macro's category was just changed in this
|
||||
editing session, or a service's category changed since assignment), those
|
||||
services drop out of `filteredServices` and their price silently becomes €0 in
|
||||
the "Totale Servizi" row — even though `data.tiers[i].servicesTotal` (computed
|
||||
server-side in `getOfferEditorData`, `src/lib/offer-queries.ts:183-193`, which
|
||||
does NOT filter by category) would include them. This produces a displayed total
|
||||
that's lower than what's stored/will be recomputed after save, with no
|
||||
indication to the admin why.
|
||||
|
||||
**Fix:** Compute `tierTotals` against `data.availableServices` (unfiltered) or a
|
||||
combined lookup map that includes all services ever referenced by
|
||||
`assignedServiceIds`, not just the category-filtered subset:
|
||||
|
||||
```ts
|
||||
const serviceById = useMemo(
|
||||
() => new Map(data.availableServices.map((s) => [s.id, s])),
|
||||
[data.availableServices]
|
||||
);
|
||||
const tierTotals = useMemo(() => {
|
||||
return tiers.map((tier) =>
|
||||
tier.assignedServiceIds.reduce((sum, id) => {
|
||||
const service = serviceById.get(id);
|
||||
return sum + Number(service?.unit_price ?? 0);
|
||||
}, 0)
|
||||
);
|
||||
}, [tiers, serviceById]);
|
||||
```
|
||||
(Note: this still won't be 100% accurate if a service belongs to a category
|
||||
outside `data.availableServices` entirely — but that's the same best-effort
|
||||
boundary the server-side query already has.)
|
||||
|
||||
### WR-06: `createOfferMacro` allows whitespace-only `internal_name`
|
||||
|
||||
**File:** `src/app/admin/offers/actions.ts:351-376`
|
||||
**Issue:** `createOfferMacroSchema.internal_name` is `z.string().min(1, "Nome
|
||||
interno richiesto")`. Zod's `.min(1)` checks string LENGTH, not trimmed content
|
||||
— a value of `" "` (single space) passes validation. Line 370 then does
|
||||
`parsed.data.public_name?.trim() || parsed.data.internal_name` — if
|
||||
`public_name` is empty/whitespace, `public_name` ends up being the untrimmed
|
||||
`" "` as well. The result is a new `offer_macros` row with
|
||||
`internal_name = " "` and `public_name = " "`, which will render as a blank card
|
||||
in `OfferListClient` (line 142: `<h3>{card.internal_name}</h3>` shows nothing
|
||||
visible) with no way to identify or rename it cleanly from the list view (the
|
||||
only `EditableCell` for `internal_name` is on the edit page, line 246-251, which
|
||||
itself has `required` but the same `tempValue.trim().length === 0` check in
|
||||
`EditableCell.commit` — so once created, it actually CAN be fixed, but the
|
||||
initial blank card is confusing).
|
||||
|
||||
**Fix:** Trim and re-validate in the Zod schema:
|
||||
|
||||
```ts
|
||||
const createOfferMacroSchema = z.object({
|
||||
internal_name: z.string().trim().min(1, "Nome interno richiesto"),
|
||||
public_name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
});
|
||||
```
|
||||
and at line 370, trim `internal_name` too:
|
||||
```ts
|
||||
public_name: parsed.data.public_name?.trim() || parsed.data.internal_name.trim(),
|
||||
```
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: `OfferListClient.handleCreateSubmit` always calls `router.refresh()` even when `createOfferMacro` throws
|
||||
|
||||
**File:** `src/components/admin/offers/OfferListClient.tsx:34-41`
|
||||
**Issue:**
|
||||
|
||||
```ts
|
||||
function handleCreateSubmit(formData: FormData) {
|
||||
startTransition(async () => {
|
||||
await createOfferMacro(formData);
|
||||
setShowCreateForm(false);
|
||||
setNewOfferName("");
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
`createOfferMacro` can throw (e.g., Zod validation failure if `internal_name` is
|
||||
empty — though the `required` attribute on the `<Input>` at line 59 mitigates
|
||||
this for normal browser usage). If it throws, the `await` rejects, and since
|
||||
there's no `try/catch`, the rejection becomes an unhandled promise rejection
|
||||
inside `startTransition`'s async callback — `setShowCreateForm(false)`,
|
||||
`setNewOfferName("")`, and `router.refresh()` never run, but there's also no
|
||||
`saveError`-style state to inform the user anything went wrong. The form stays
|
||||
open with stale state and no feedback.
|
||||
|
||||
**Fix:** Wrap in try/catch and surface an error state, mirroring the pattern used
|
||||
in `OfferEditorClient`:
|
||||
|
||||
```ts
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
function handleCreateSubmit(formData: FormData) {
|
||||
setCreateError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await createOfferMacro(formData);
|
||||
setShowCreateForm(false);
|
||||
setNewOfferName("");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setCreateError("Errore nella creazione dell'offerta.");
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### IN-02: `requireAdmin()` discards the session without using it for ownership/role checks
|
||||
|
||||
**File:** `src/app/admin/offers/actions.ts:18-21`
|
||||
**Issue:**
|
||||
|
||||
```ts
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("Non autorizzato");
|
||||
}
|
||||
```
|
||||
|
||||
This is consistent with the existing pattern in this file (pre-existing, not
|
||||
introduced by this phase), so it's not a regression — but worth flagging as a
|
||||
pre-existing gap: any authenticated session is treated as "admin" with no role
|
||||
check. Since this is a single-admin app per CLAUDE.md's architecture, this is
|
||||
likely fine, but the function name `requireAdmin` implies a role check that
|
||||
doesn't exist. Purely informational — no change required unless multi-user admin
|
||||
roles are planned.
|
||||
|
||||
**Fix:** None required for this phase; consider renaming to `requireSession` for
|
||||
clarity in a future cleanup, or add a role check if multi-admin support is ever
|
||||
introduced.
|
||||
|
||||
### IN-03: `formatUnitPrice` and `formatEuro` duplicate locale-formatting logic
|
||||
|
||||
**File:** `src/components/admin/offers/OfferEditorClient.tsx:39-46`
|
||||
**Issue:**
|
||||
|
||||
```ts
|
||||
function formatEuro(value: number): string {
|
||||
return `€${value.toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function formatUnitPrice(raw: string): string {
|
||||
const num = parseFloat(raw);
|
||||
return `€${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
```
|
||||
|
||||
Both functions produce identical output format; `formatUnitPrice` is just
|
||||
`formatEuro(parseFloat(raw) || 0)`. Minor duplication — not a bug, but could be
|
||||
collapsed into one helper that accepts `string | number`.
|
||||
|
||||
**Fix (optional):**
|
||||
|
||||
```ts
|
||||
function formatEuro(value: number | string): string {
|
||||
const num = typeof value === "string" ? parseFloat(value) : value;
|
||||
return `€${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
```
|
||||
|
||||
### IN-04: `push-12-offer-tier-schema.ts` and `0008_offer_tier_schema.sql` can drift independently
|
||||
|
||||
**File:** `scripts/push-12-offer-tier-schema.ts:1-143`, `src/db/migrations/0008_offer_tier_schema.sql:1-45`
|
||||
**Issue:** The push script (`scripts/push-12-offer-tier-schema.ts`) is a
|
||||
hand-written duplicate of the SQL migration file's statements (per the comment at
|
||||
the top of `0008_offer_tier_schema.sql`, this is because `drizzle-kit generate` is
|
||||
broken per project memory). Both files express the same schema change in two
|
||||
different forms (column lists in a TS array vs. raw `ALTER TABLE` statements).
|
||||
This is a deliberate, documented workaround, but creates a maintenance hazard: if
|
||||
either file is edited in isolation in a future phase, the two can silently
|
||||
diverge (e.g., a column added to the `.sql` migration but not to the push
|
||||
script's `offerMacrosColumns`/`offerMicrosColumns` arrays, or vice versa).
|
||||
|
||||
**Fix (optional):** Add a comment cross-reference in both files pointing at each
|
||||
other ("keep in sync with X"), or — longer-term — generate the push script's
|
||||
column lists programmatically by parsing the `.sql` file, to guarantee they can't
|
||||
diverge. Not blocking for this phase given the documented one-off nature.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-06-15T10:36:00Z_
|
||||
_Reviewer: Claude (gsd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,578 @@
|
||||
---
|
||||
phase: 12
|
||||
slug: offer-editor-tier-tag-prezzo-pubblico
|
||||
status: draft
|
||||
shadcn_initialized: true
|
||||
preset: default
|
||||
created: 2026-06-14
|
||||
---
|
||||
|
||||
# Phase 12 — UI Design Contract
|
||||
## Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico
|
||||
|
||||
> Visual and interaction contract for the Offer Editor: list page + detail editor with 3-tier checkbox matrix, multi-dimensional tags, and structured transformation promise. Generated by gsd-ui-researcher, verified by gsd-ui-checker.
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Tool | shadcn/ui |
|
||||
| Preset | default (existing, Phase 11 forward) |
|
||||
| Component library | radix |
|
||||
| Icon library | Lucide React |
|
||||
| Font | Geist Sans |
|
||||
| Design direction | ClickUp/Pipedrive minimal flat style (per DESIGN-SYSTEM.md) |
|
||||
|
||||
**Notes:** Phase 12 builds on Phase 11's established database-view UX (inline edit, tag multi-select, EditableCell). All colors, spacing, typography inherited from Phase 11 and project globals. No new design system tokens introduced.
|
||||
|
||||
---
|
||||
|
||||
## Spacing Scale
|
||||
|
||||
Multiples of 4px per project standard:
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| xs | 4px | Icon gaps, inline padding |
|
||||
| sm | 8px | Compact element spacing, tag chip gaps |
|
||||
| md | 16px | Default cell/control spacing, table row padding |
|
||||
| lg | 24px | Section padding, card margin, tag dimension spacing |
|
||||
| xl | 32px | Layout gaps between sections |
|
||||
| 2xl | 48px | Major section breaks |
|
||||
| 3xl | 64px | Page-level spacing |
|
||||
|
||||
Exceptions: None
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
Phase 12 reuses Phase 11 typography (no new sizes introduced). Two weights only (400 regular, 600 bold):
|
||||
|
||||
| Role | Size | Weight | Line Height |
|
||||
|------|------|--------|-------------|
|
||||
| Body | 14px | 400 | 1.5 |
|
||||
| Label | 12px | 400 | 1.5 |
|
||||
| Heading (h3) | 16px | 600 | 1.2 |
|
||||
| Heading (h2) | 20px | 600 | 1.2 |
|
||||
|
||||
---
|
||||
|
||||
## Color
|
||||
|
||||
Phase 12 uses Phase 11 color contract (invariant brand tokens):
|
||||
|
||||
| Role | Value | Usage |
|
||||
|------|-------|-------|
|
||||
| Dominant (60%) | #ffffff | Page/card backgrounds, editor surfaces |
|
||||
| Secondary (30%) | #f9f9f9 | Muted backgrounds, table headers, category filter chips (inactive) |
|
||||
| Accent (10%) | #1A463C | Primary CTAs ("Salva Offerta"), focus ring, active/selected state, active filter chips |
|
||||
| Accent secondary | #DEF168 | Status indicators (archived badge), active offer state |
|
||||
| Destructive | #dc2626 | Archive/delete actions only |
|
||||
| Muted text | #71717a | Placeholders, metadata, disabled states, inactive labels |
|
||||
| Border | #e5e7eb | Cell/block dividers, table borders |
|
||||
|
||||
**Accent reserved for:**
|
||||
- "Salva Offerta" primary CTA button
|
||||
- Focus ring on EditableCell, TagMultiSelect, and input fields
|
||||
- Active state indicator (category filter chip, active tags)
|
||||
- Selection highlight in checkbox matrix
|
||||
|
||||
---
|
||||
|
||||
## Components & Interaction Patterns
|
||||
|
||||
All patterns inherited from Phase 11 (EditableCell, TagMultiSelect, database-view table) and extended for offer composition and tier management.
|
||||
|
||||
### 1. Offer List Page (`/admin/offers`)
|
||||
|
||||
**Purpose:** Display all offers (active + optionally archived) in a filterable card grid. Admin can create new offers, filter by category, and toggle archive view.
|
||||
|
||||
**Layout Structure:**
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Offerte [+ Nuova Offerta] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Filter row: │
|
||||
│ [Tutti] [Entry Offer] [Signature Offer] [Retainer Offer] │
|
||||
│ Mostra offerte archiviate: [toggle off] │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Offerta 1 │ │ Offerta 2 │ │ Offerta 3 │ │
|
||||
│ │ Breve desc │ │ Breve desc │ │ Breve desc │ │
|
||||
│ │ [Signature] │ │ [Entry] │ │ [Retainer] │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
│ [card 4] [card 5] [card 6] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### A. Page Header
|
||||
- **Title:** "Offerte" (h2, 20px/600, black)
|
||||
- **Primary CTA:** "+ Nuova Offerta" button (bg #1A463C, text white, right-aligned)
|
||||
|
||||
#### B. Filter Row
|
||||
- **Category filter chips:** Buttons for "Tutti", "Entry Offer", "Signature Offer", "Retainer Offer"
|
||||
- Inactive: border #e5e7eb, text #71717a, bg transparent
|
||||
- Active: border #1A463C, text #1A463C, bg transparent (or solid #1A463C text white — planner decides)
|
||||
- Spacing between chips: 8px (sm)
|
||||
- **Archive toggle:** "Mostra offerte archiviate" text label + checkbox or toggle switch
|
||||
- Position: right side of filter row OR below filter chips on tablet/mobile
|
||||
- Label color: #71717a
|
||||
- Toggle: standard checkbox or `<Switch>` component from shadcn
|
||||
|
||||
#### C. Offer Card Grid
|
||||
- **Grid layout:** 3 columns on desktop (≥1024px), 2 on tablet (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 `<input type="checkbox">`
|
||||
- Color: #1A463C when checked
|
||||
- Interaction: Click to toggle; live total updates immediately
|
||||
- Row height: ~40px
|
||||
- Border-bottom: #e5e7eb
|
||||
- Hover: subtle background #f9f9f9 on the entire row
|
||||
- **Services filtered by category:** Only show services whose `services.category` matches the offer's selected category (Categoria tag)
|
||||
- **Row order:** Configurable (drag-to-reorder via @dnd-kit optional; planner decides)
|
||||
|
||||
**Totals rows** (sticky to bottom of table):
|
||||
1. **Totale Servizi** (auto-calculated):
|
||||
- Row label: "Totale Servizi" (bold, 14px/600)
|
||||
- Per tier: SUM of unit_price for all services with checkbox ☑ in that tier
|
||||
- Format: "€X,XXX.XX"
|
||||
- Updates **live** as checkboxes are toggled (no page reload)
|
||||
- Color: bold text, #1a1a1a
|
||||
|
||||
2. **Prezzo Pubblico** (manual input):
|
||||
- Row label: "Prezzo Pubblico" (bold, 14px/600)
|
||||
- Per tier: EditableCell (number type)
|
||||
- Placeholder: "€0,00" or empty
|
||||
- Format: "€X,XXX.XX"
|
||||
- Interaction: Click to edit, blur/Enter to save, Esc to cancel
|
||||
- Independent of "Totale Servizi" — user can set any value
|
||||
- Validation: numeric, ≥ 0, max 2 decimals
|
||||
|
||||
**Empty state (no services matching category):**
|
||||
- Placeholder text: "Nessun servizio disponibile per questa categoria" (#71717a)
|
||||
- Table still visible but with placeholder row
|
||||
|
||||
---
|
||||
|
||||
#### E. Transformation Promise Block
|
||||
|
||||
**Section label:** "Promessa di Trasformazione" (h3)
|
||||
|
||||
**Structure:** 5 editable fields, each a key-value pair:
|
||||
```
|
||||
Aiuto: [Client Ideal — input text, 60 chars]
|
||||
A ottenere: [Result — input text, 60 chars]
|
||||
In: [Time — input text, 30 chars, e.g. "3 mesi"]
|
||||
Senza: [Pain — input text, 60 chars]
|
||||
Grazie a: [Method — input text, 60 chars]
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
- Each field is an EditableCell (text type, optional)
|
||||
- Field label (dim): 12px/400, color #71717a
|
||||
- Input placeholder: "Aggiungi [field name]" (muted)
|
||||
- No asterisk (all optional)
|
||||
- Layout: horizontal label-value pairs, or stacked label-over-input depending on space
|
||||
- On desktop: consider 2-column or full-width labels
|
||||
- Tablet/mobile: stack vertically
|
||||
- Spacing between fields: 16px (md)
|
||||
- Background: optional subtle bg #f9f9f9, or transparent (planner decides)
|
||||
- Border: none, or bottom-border only on each field
|
||||
- Focus ring: #1A463C when in edit mode (EditableCell pattern)
|
||||
|
||||
---
|
||||
|
||||
#### F. Action Buttons
|
||||
|
||||
**Bottom of editor:**
|
||||
- **[Salva Offerta]** (primary CTA)
|
||||
- Button: bg #1A463C, text white, 14px/600, padding 16px 24px
|
||||
- Position: left-aligned
|
||||
- On click: Submit form, validate all required fields (name + category + at least 1 service selected in 1 tier), save to DB, redirect to `/admin/offers`, show toast "Offerta salvata"
|
||||
- Disabled state: if no tiers have services selected, button disabled with tooltip "Seleziona almeno un servizio"
|
||||
|
||||
- **[Annulla]** (secondary CTA)
|
||||
- Button: border #e5e7eb, text #1a1a1a, no fill
|
||||
- On click: Discard unsaved changes, redirect to `/admin/offers`
|
||||
- Optional confirmation: if form is dirty, "Scartare le modifiche?" dialog
|
||||
|
||||
- **[Archivia]** (destructive action, visible only on edit, not on create)
|
||||
- Button: text #dc2626, no fill
|
||||
- On click: Toggle offer `is_archived` flag
|
||||
- Confirmation required: "Sei sicuro? L'offerta non sarà visibile nella lista." [Annulla] [Archivia]
|
||||
- After confirm: Save, show toast "Offerta archiviata", redirect to `/admin/offers`
|
||||
|
||||
**Spacing between buttons:** 8px (sm)
|
||||
|
||||
---
|
||||
|
||||
### 3. Tag Creation Inline ("Crea Tipo", "Crea Obiettivo")
|
||||
|
||||
**Interaction:** When user clicks "+ Crea [Dimension]", an input field appears inline (or in a small popover).
|
||||
|
||||
**Structure:**
|
||||
- Text input: placeholder "Nome [Dimension]..."
|
||||
- On Enter or click checkmark: Create tag (server action `addTagToOffer`), add to active tags list
|
||||
- On Esc or click X: Cancel, close input
|
||||
- On success: Input clears, new tag appears in the chip list with deterministic color
|
||||
|
||||
**Validation:**
|
||||
- Required: tag name 1-30 chars
|
||||
- Trimmed whitespace
|
||||
- Case-insensitive duplicate check per dimension (warn: "Questo tag esiste già")
|
||||
|
||||
---
|
||||
|
||||
## Copywriting Contract
|
||||
|
||||
| Element | Copy | Context |
|
||||
|---------|------|---------|
|
||||
| **Offer list heading** | "Offerte" | Page title |
|
||||
| **Create offer CTA** | "+ Nuova Offerta" | Primary action on list |
|
||||
| **Back link** | "← Indietro" | Breadcrumb/nav on detail |
|
||||
| **Archive toggle label** | "Mostra offerte archiviate" | Filter checkbox |
|
||||
| **Edit page heading** | "Modifica Offerta" | Detail page title (or "Crea Offerta" if new) |
|
||||
| **Tags section label** | "Tag" | Section heading |
|
||||
| **Category dimension** | "Categoria" | Single-select label |
|
||||
| **Ticket dimension** | "Ticket" | Single-select label |
|
||||
| **Type dimension** | "Tipo" | Multi-select label |
|
||||
| **Objective dimension** | "Obiettivo" | Multi-select label |
|
||||
| **Create type link** | "+ Crea Tipo" | Inline tag creation |
|
||||
| **Create objective link** | "+ Crea Obiettivo" | Inline tag creation |
|
||||
| **Services section label** | "Servizi Inclusi" | Table heading |
|
||||
| **Service column header** | "Servizio" | Table column |
|
||||
| **Price column header** | "Prezzo" | Table column |
|
||||
| **Tier headers** | "A", "B", "C" | Table columns (single letters) |
|
||||
| **Services total label** | "Totale Servizi" | Calculated row |
|
||||
| **Public price label** | "Prezzo Pubblico" | Manual input row |
|
||||
| **Transformation block label** | "Promessa di Trasformazione" | Section heading |
|
||||
| **Ideal client label** | "Aiuto" | Field label |
|
||||
| **Result label** | "A ottenere" | Field label |
|
||||
| **Duration label** | "In" | Field label (e.g., "3 mesi") |
|
||||
| **Pain label** | "Senza" | Field label |
|
||||
| **Method label** | "Grazie a" | Field label |
|
||||
| **Save CTA** | "Salva Offerta" | Primary action (verb + noun) |
|
||||
| **Cancel CTA** | "Annulla" | Secondary action |
|
||||
| **Archive CTA** | "Archivia" | Destructive action (visible on edit only) |
|
||||
| **Archive confirmation** | "Sei sicuro? L'offerta non sarà visibile nella lista." | Destructive confirmation copy + context |
|
||||
| **Empty services message** | "Nessun servizio disponibile per questa categoria" | Conditional placeholder |
|
||||
| **Empty offers message** | "Nessuna offerta" | List page empty state heading |
|
||||
| **Empty offers hint** | "Inizia creando la tua prima offerta" | List page empty state body |
|
||||
| **Save success toast** | "Offerta salvata" | Success feedback |
|
||||
| **Archive success toast** | "Offerta archiviata" | Success feedback |
|
||||
| **Save error toast** | "Errore nel salvataggio. Verifica i campi." | Error feedback |
|
||||
| **Tag name placeholder** | "Nome tag..." | Create tag input |
|
||||
| **Duplicate tag warning** | "Questo tag esiste già" | Validation error |
|
||||
| **Disabled save tooltip** | "Seleziona almeno un servizio" | Constraint message |
|
||||
|
||||
---
|
||||
|
||||
## Interaction & State Management
|
||||
|
||||
### Checkbox Matrix Behavior
|
||||
- **On checkbox toggle:** Live recalculation of "Totale Servizi" for that tier (no network call)
|
||||
- **Debounce:** None — updates instant as user clicks
|
||||
- **Persistence:** On "Salva Offerta" click, POST all tier compositions to backend
|
||||
- **Validation on save:** At least 1 tier must have ≥1 service selected (backend confirms)
|
||||
|
||||
### Tag Dimensions
|
||||
- **Categoria & Ticket:** Single-select (user can only have one value per offer)
|
||||
- UI: Radio-button-like chips or dropdown — planner decides
|
||||
- Switching to a different service category auto-filters the services matrix
|
||||
- **Tipo & Obiettivo:** Multi-select (user can have 0..N values per offer)
|
||||
- UI: Checkbox-like chips (inherited TagMultiSelect pattern)
|
||||
- Tag creation inline: "+ Crea Tipo" / "+ Crea Obiettivo"
|
||||
- Duplicate check: case-insensitive per dimension (warn and skip)
|
||||
- Color: deterministic based on tag name hash (6-8 color palette, per Phase 11)
|
||||
|
||||
### Archive Workflow
|
||||
- Archive is a toggle, not permanent deletion
|
||||
- Archived offers are hidden from list by default (togglable via "Mostra offerte archiviate")
|
||||
- On edit page, "Archivia" button only visible if offer is not already archived (or toggle text changes to "Ripristina")
|
||||
- Confirmation required for destructive state change
|
||||
|
||||
### Edit State Management
|
||||
- Form is dirty if any field changed since load
|
||||
- On cancel with unsaved changes: confirmation dialog "Scartare le modifiche?"
|
||||
- On back button navigation: same check (browser may warn too)
|
||||
- Auto-save optional (planner decides) — recommend explicit "Salva" for clarity
|
||||
|
||||
---
|
||||
|
||||
## Visual States
|
||||
|
||||
### Offer Card States
|
||||
| State | Visual |
|
||||
|-------|--------|
|
||||
| Default | White card, border #e5e7eb, normal shadow |
|
||||
| Hover | `cursor-pointer`, shadow deepens (0 4px 12px rgba(0,0,0,0.08)) |
|
||||
| Archived | Gray text, optional red "Archiviata" badge top-right |
|
||||
|
||||
### Checkbox States
|
||||
| State | Visual |
|
||||
|-------|--------|
|
||||
| Unchecked | Empty square, border #e5e7eb |
|
||||
| Checked | Filled square, bg #1A463C, white checkmark |
|
||||
| Hover | Cursor pointer, subtle border darken |
|
||||
| Disabled | Gray box, opacity-50, no cursor |
|
||||
|
||||
### Editable Field States (EditableCell)
|
||||
| State | Visual |
|
||||
|-------|--------|
|
||||
| Display mode | Normal text, `cursor-pointer`, hover bg #f0f0f0 |
|
||||
| Edit mode | Input with ring-1 ring-#1A463C on focus |
|
||||
| Error | Ring-1 ring-#dc2626, error message below |
|
||||
| Disabled | Gray text, opacity-50, no cursor |
|
||||
|
||||
### Tag States (TagMultiSelect)
|
||||
| State | Visual |
|
||||
|-------|--------|
|
||||
| Display | Colored chip (6-8 colors), text inside, X icon to remove |
|
||||
| Hover | Slight opacity change on X icon |
|
||||
| Add mode | Input field + "+ Aggiungi" button (or Enter to confirm) |
|
||||
| Create new | Temporary placeholder, then colored chip with new tag name |
|
||||
|
||||
### Category Filter Chip States (List Page)
|
||||
| State | Visual |
|
||||
|-------|--------|
|
||||
| Inactive | Border #e5e7eb, text #71717a, bg transparent, `cursor-pointer` |
|
||||
| Active | Border #1A463C, text #1A463C (or bg #1A463C text white — planner decides) |
|
||||
| Hover | Border darken slightly |
|
||||
|
||||
---
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
**Desktop (≥1024px):**
|
||||
- Offer list: 3-column card grid
|
||||
- Filter chips: horizontal row
|
||||
- Services matrix: full-width table, horizontal scroll if needed
|
||||
- Editor sections: full-width, stacked vertically
|
||||
|
||||
**Tablet (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*
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
phase: 12
|
||||
verified: 2026-06-18T17:19:00+02:00
|
||||
requirements:
|
||||
OFFER-11: PASS
|
||||
OFFER-15: PASS
|
||||
OFFER-16: PASS
|
||||
OFFER-17: PASS
|
||||
OFFER-18: PASS
|
||||
overall: PASS
|
||||
---
|
||||
|
||||
# Phase 12: Offer Editor — Verification Report
|
||||
|
||||
**Phase Goal:** Build a full offer editor allowing admin to compose offers with 3 tiers (A/B/C), assign services via checkbox matrix, set per-tier public price, add 4-dimension tags (tipo/obiettivo creatable, categoria/ticket single-select), write a structured transformation promise (5 fields), and manage an offer list with category filter and archive toggle.
|
||||
|
||||
**Verified:** 2026-06-18T17:19:00+02:00
|
||||
**Status:** PASS
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
---
|
||||
|
||||
## OFFER-11: Tier A/B/C checkbox matrix with live totals
|
||||
|
||||
**Verdict: PASS**
|
||||
|
||||
**Schema** (`src/db/schema.ts`): `offer_micros.tier_letter` (text, values A/B/C enforced by DB CHECK constraint in migration 0008) and `offer_tier_services` junction table (tier_id → offer_micros.id, service_id → services.id, composite PK) are both present and properly defined. Relations are wired: `offerMicrosRelations` includes `tierServices: many(offer_tier_services)`.
|
||||
|
||||
**Migration** (`src/db/migrations/0008_offer_tier_schema.sql`): Adds `tier_letter` and `public_price` columns to `offer_micros`, adds a guarded CHECK constraint (`tier_letter IN ('A','B','C')`), and creates the `offer_tier_services` table — all with `IF NOT EXISTS` guards, fully additive.
|
||||
|
||||
**Query layer** (`src/lib/offer-queries.ts`, `getOfferEditorData`): Fetches tiers ordered A→B→C via a CASE expression, then fetches `offer_tier_services` rows for all tier IDs in one query, and computes `servicesTotal` per tier with a `SUM(unit_price)` GROUP BY. Returns `assignedServiceIds[]` and `servicesTotal` per tier.
|
||||
|
||||
**Editor UI** (`src/components/admin/offers/OfferEditorClient.tsx`):
|
||||
- `padTiers()` ensures all three tier slots (A, B, C) are always present in state, creating empty tier objects for any letter not yet persisted.
|
||||
- `toggleService(tierIdx, serviceId)` updates `assignedServiceIds` in React state immutably.
|
||||
- `tierTotals` is a `useMemo` that re-derives a per-tier sum from `assignedServiceIds` and `filteredServices.unit_price` on every render — this is the live total.
|
||||
- The tfoot row "Totale Servizi" renders `tierTotals[idx]` for each column (lines 371–378).
|
||||
- Checkboxes at lines 353–360 bind `checked={tier.assignedServiceIds.includes(service.id)}` and `onChange={() => toggleService(tierIdx, service.id)}` — the matrix is fully wired.
|
||||
|
||||
**Persistence** (`src/app/admin/offers/actions.ts`, `saveOfferEditor`): Zod schema enforces `tier_letter: z.enum(["A","B","C"])`. Each tier is upserted (update if `id` present, insert otherwise), then `offer_tier_services` is delete-then-reinserted — correct upsert-replace pattern.
|
||||
|
||||
---
|
||||
|
||||
## OFFER-15: 4-dimension tags (categoria, ticket, tipo, obiettivo)
|
||||
|
||||
**Verdict: PASS**
|
||||
|
||||
**Schema:** `offer_macros.category` (text, single-select) and `offer_macros.ticket` (text, single-select) added in migration 0008. Tipo/Obiettivo reuse the Phase 11 polymorphic `tags` table with entity_type scoped to `"offer_macros.tipo"` and `"offer_macros.obiettivo"` — separate pools from `"services"` and `"leads"` per the D-06 pattern documented in schema comments.
|
||||
|
||||
**Query layer** (`getOfferEditorData`): Fetches tipo/obiettivo tag rows filtered by `inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY])` and splits them into `tipoTags[]` and `obiettivoTags[]`. `getOfferFieldOptions` returns `categoria`, `ticket`, `tipo`, `obiettivo` option pools via `selectDistinct`.
|
||||
|
||||
**Editor UI** (`OfferEditorClient.tsx`):
|
||||
- `OptionSelect` components render categoria (line 262) and ticket (line 272) as single-select Notion-style dropdowns with rename support.
|
||||
- `OptionMultiSelect` components render tipo (line 284) and obiettivo (line 299) as multi-select with `onAdd` (creatable — if value not in options, adds to local options state), `onRemove`, and `onRename` callbacks.
|
||||
- Options are creatable on-the-fly: `onAdd` at line 288 calls `setTipoOptions((prev) => [...prev, v])` when the value is new.
|
||||
|
||||
**Persistence** (`saveOfferEditor`): Tipo/Obiettivo tags are delete-then-reinserted (lines 242–263) with `onConflictDoNothing()`. Categoria/ticket are saved as scalar columns on `offer_macros`. Standalone `addOfferTag`/`removeOfferTag` actions exist for granular tag mutations.
|
||||
|
||||
**Rename propagation** (`renameOfferOption`): Updates `offer_macros.category` / `offer_macros.ticket` in bulk for single-select dimensions, and `tags.name` for tipo/obiettivo — correct cross-row rename.
|
||||
|
||||
---
|
||||
|
||||
## OFFER-16: Per-tier manual public price
|
||||
|
||||
**Verdict: PASS**
|
||||
|
||||
**Schema** (`offer_micros.public_price`): `numeric(10,2)`, nullable, added in migration 0008. Distinct from the `servicesTotal` which is computed at query time and never stored.
|
||||
|
||||
**Query layer** (`getOfferEditorData`): Returns `public_price: tier.public_price` (raw DB value, nullable string) per tier in `OfferTierData`.
|
||||
|
||||
**Editor UI** (`OfferEditorClient.tsx` lines 381–397): The tfoot row "Prezzo Pubblico" renders one `<input type="number">` per tier column, bound to `tier.public_price` and calling `updateTierPublicPrice(tierIdx, e.target.value)`. This is independent of the "Totale Servizi" row above it — two separate tfoot rows, two separate state values.
|
||||
|
||||
**Persistence** (`saveOfferEditor`): `tier.public_price` is converted with `tier.public_price != null ? String(tier.public_price) : null` and written to `offer_micros.public_price` on upsert. Zod schema: `public_price: z.coerce.number().min(0).optional().nullable()`.
|
||||
|
||||
---
|
||||
|
||||
## OFFER-17: Structured transformation promise (5 fields)
|
||||
|
||||
**Verdict: PASS**
|
||||
|
||||
**Schema** (`offer_macros`): Five columns added in migration 0008 — `cliente_ideale`, `risultato`, `tempo`, `pain`, `metodo` — all text, nullable. These are distinct from the pre-existing free-text `transformation_promise` column (retained untouched).
|
||||
|
||||
**Query layer** (`getOfferEditorData`): `db.select().from(offer_macros)` returns all columns including the five new ones. The `OfferMacro` type (inferred from schema) includes them.
|
||||
|
||||
**Editor UI** (`OfferEditorClient.tsx` lines 404–467): "Promessa di Trasformazione" section renders five `EditableCell` fields:
|
||||
- "Aiuto" → `cliente_ideale`
|
||||
- "A ottenere" → `risultato`
|
||||
- "In" → `tempo`
|
||||
- "Senza" → `pain`
|
||||
- "Grazie a" → `metodo`
|
||||
|
||||
Each calls `updateMacro(key, v)` on save, updating the `macro` state object. All five are initialized from `data.macro.*` at component mount.
|
||||
|
||||
**Persistence** (`saveOfferEditor`): All five fields are explicitly mapped in the `db.update(offer_macros).set({...})` call (lines 186–196), with `|| null` coercion for empty strings.
|
||||
|
||||
---
|
||||
|
||||
## OFFER-18: Offer list filterable by category, archive toggle
|
||||
|
||||
**Verdict: PASS**
|
||||
|
||||
**Schema** (`offer_macros.is_archived`): `boolean NOT NULL DEFAULT false`, added in migration 0008.
|
||||
|
||||
**Query layer** (`getOfferListCards`): Returns all offers including archived ones (archived filtering is client-side per spec). Returns `id`, `internal_name`, `description`, `category`, `is_archived`.
|
||||
|
||||
**List page** (`src/app/admin/offers/page.tsx`): Calls `getOfferListCards()` and `getOfferFieldOptions()` in parallel, passes `cards` and `options.categoria` to `OfferListClient`.
|
||||
|
||||
**List component** (`OfferListClient.tsx`):
|
||||
- `activeCategory` state (null = all) drives filter chip selection; "Tutti" chip and one chip per category from `categoryOptions`.
|
||||
- `showArchived` state (default false) drives the "Mostra offerte archiviate" checkbox.
|
||||
- `filteredCards` useMemo at lines 26–32 applies both filters simultaneously: `(activeCategory === null || c.category === activeCategory) && (showArchived || !c.is_archived)`.
|
||||
- Archived offers render with a red "Archiviata" label on the card.
|
||||
- `toggleOfferArchived(macroId, true)` server action archives an offer from the editor (OFFER-18 archive action). Restore path: the action accepts `archived: boolean` so it can also unarchive, though no unarchive button exists in the editor UI — unarchiving requires the list to show the card via the toggle and navigate to edit.
|
||||
|
||||
**Archive action** (`actions.ts` line 267–271): `toggleOfferArchived` updates `offer_macros.is_archived` and calls `revalidatePath("/admin/offers")`.
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns scan
|
||||
|
||||
No placeholder returns (`return null`, `return {}`, `return []` as stubs) found in the key files. The `canSave` guard (`tiers.some((t) => t.assignedServiceIds.length > 0)`) prevents saving a hollow offer but is not a stub — it is a real UX validation. The `emptyTier()` function returns zero-state structs used to pad the three-slot display; they are overwritten by real data fetched from the DB.
|
||||
|
||||
The `servicesTotal: "0"` in `emptyTier` is an initial display value for unpersisted tiers — not a stub, as the live `tierTotals` useMemo recomputes from `assignedServiceIds` state on every checkbox change.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
1. **Unarchive flow is implicit.** There is no "Unarchive" button in the editor. The only way to restore an archived offer is: enable "Mostra offerte archiviate" on the list page, click into the offer, and call `toggleOfferArchived(id, false)` — which exists in `actions.ts` but has no UI trigger for `archived=false` in `OfferEditorClient.tsx`. This is a minor UX gap but does not block any of OFFER-11 through OFFER-18 as specified. The archiving requirement (OFFER-18) asks for "archive toggle" — the toggle on the list is the show/hide toggle; archiving itself works via the editor button. Unarchiving is not called out in the requirements.
|
||||
|
||||
2. **`canSave` gate.** The "Salva Offerta" button is disabled unless at least one tier has a service assigned. This means an offer with only transformation promise fields filled and no services cannot be saved. This aligns with OFFER-11's framing of service assignment as the core editor operation.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-06-18T17:19:00+02:00_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
@@ -0,0 +1,142 @@
|
||||
// PRODUCTION DEPLOY NOTE: This migration (0008_offer_tier_schema.sql) is additive-only
|
||||
// (ADD COLUMN IF NOT EXISTS, a guarded DO-block CHECK constraint, CREATE TABLE IF NOT
|
||||
// EXISTS, and CREATE INDEX IF NOT EXISTS — no drops/truncates). Per CLAUDE.md Data
|
||||
// Safety, this script MUST be run against PRODUCTION via SSH+docker exec BEFORE Phase
|
||||
// 12 Wave 3 code (query layer reading offer_tier_services/tier_letter/public_price/
|
||||
// category/ticket) is exercised against production data. This is the BLOCKING step
|
||||
// for Plan 02. Idempotent — safe to re-run.
|
||||
// Run: npx tsx scripts/push-12-offer-tier-schema.ts (with prod DATABASE_URL)
|
||||
import postgres from "postgres";
|
||||
|
||||
async function push() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
console.error("DATABASE_URL environment variable is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = postgres(databaseUrl);
|
||||
|
||||
try {
|
||||
console.log("Pushing offer tier schema migration (0008)...");
|
||||
|
||||
// offer_macros: archive flag + short description + category/ticket dimensions +
|
||||
// structured transformation promise
|
||||
const offerMacrosColumns: Array<[string, string]> = [
|
||||
["description", "text"],
|
||||
["category", "text"],
|
||||
["ticket", "text"],
|
||||
["is_archived", "boolean NOT NULL DEFAULT false"],
|
||||
["cliente_ideale", "text"],
|
||||
["risultato", "text"],
|
||||
["tempo", "text"],
|
||||
["pain", "text"],
|
||||
["metodo", "text"],
|
||||
];
|
||||
|
||||
for (const [column, type] of offerMacrosColumns) {
|
||||
try {
|
||||
await client.unsafe(
|
||||
`ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS ${column} ${type}`
|
||||
);
|
||||
console.log(` ✓ offer_macros.${column} ready`);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes("already exists")) {
|
||||
console.log(` ✓ offer_macros.${column} already exists (skipped)`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// offer_micros: tier designation (A/B/C) + manual public price
|
||||
const offerMicrosColumns: Array<[string, string]> = [
|
||||
["tier_letter", "text"],
|
||||
["public_price", "numeric(10, 2)"],
|
||||
];
|
||||
|
||||
for (const [column, type] of offerMicrosColumns) {
|
||||
try {
|
||||
await client.unsafe(
|
||||
`ALTER TABLE offer_micros ADD COLUMN IF NOT EXISTS ${column} ${type}`
|
||||
);
|
||||
console.log(` ✓ offer_micros.${column} ready`);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes("already exists")) {
|
||||
console.log(` ✓ offer_micros.${column} already exists (skipped)`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CHECK constraint for tier_letter, guarded so it's safe to re-run.
|
||||
try {
|
||||
await client.unsafe(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'offer_micros_tier_letter_check'
|
||||
) THEN
|
||||
ALTER TABLE offer_micros
|
||||
ADD CONSTRAINT offer_micros_tier_letter_check
|
||||
CHECK (tier_letter IS NULL OR tier_letter IN ('A', 'B', 'C'));
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
console.log(" ✓ offer_micros_tier_letter_check constraint ready");
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes("already exists")) {
|
||||
console.log(" ✓ offer_micros_tier_letter_check already exists (skipped)");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// New junction table: tier (offer_micros) <-> unified services catalog.
|
||||
try {
|
||||
await client.unsafe(`
|
||||
CREATE TABLE IF NOT EXISTS offer_tier_services (
|
||||
tier_id text NOT NULL REFERENCES offer_micros(id) ON DELETE CASCADE,
|
||||
service_id text NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY (tier_id, service_id)
|
||||
)
|
||||
`);
|
||||
console.log(" ✓ offer_tier_services table ready");
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes("already exists")) {
|
||||
console.log(" ✓ offer_tier_services table already exists (skipped)");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await client.unsafe(`
|
||||
CREATE INDEX IF NOT EXISTS offer_tier_services_tier_idx ON offer_tier_services USING btree (tier_id)
|
||||
`);
|
||||
console.log(" ✓ offer_tier_services_tier_idx index ready");
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes("already exists")) {
|
||||
console.log(" ✓ offer_tier_services_tier_idx already exists (skipped)");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✓ Migration 0008 (offer tier schema) applied successfully");
|
||||
process.exit(0);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
console.error("Error pushing migration:", err.message);
|
||||
} else {
|
||||
console.error("Unknown error:", err);
|
||||
}
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
push();
|
||||
@@ -0,0 +1,242 @@
|
||||
// Phase 12 Plan 03 — Task 2 verification script for actions.ts additions.
|
||||
//
|
||||
// This project has no test runner configured (no `scripts.test` in package.json),
|
||||
// and `.env.local`'s DATABASE_URL points at PRODUCTION (per project memory) — so
|
||||
// this script does NOT execute any DB calls or server actions (server actions
|
||||
// also require an Auth.js session, unavailable outside a request context). It
|
||||
// typechecks against the real exports/types from
|
||||
// src/app/admin/offers/actions.ts and documents the 9 expected behaviors from
|
||||
// the plan's <behavior> block, for manual run against a dev DB later if desired.
|
||||
//
|
||||
// Run (manual, dev DB only, inside a request context with admin session):
|
||||
// npx tsx scripts/verify-12-03-actions.ts
|
||||
|
||||
import type { SaveOfferEditorPayload } from "@/app/admin/offers/actions";
|
||||
import {
|
||||
saveOfferEditor,
|
||||
toggleOfferArchived,
|
||||
addOfferTag,
|
||||
removeOfferTag,
|
||||
renameOfferOption,
|
||||
createOfferMacro,
|
||||
} from "@/app/admin/offers/actions";
|
||||
|
||||
// ── Test 1: tier_letter Zod validation ───────────────────────────────────────
|
||||
// saveOfferEditor(macroId, payload) rejects (throws) when payload.tiers contains
|
||||
// a tier_letter not in ["A","B","C"] — Zod enum validation, defense-in-depth
|
||||
// alongside the DB CHECK constraint from Plan 01.
|
||||
async function test1_invalidTierLetter(macroId: string) {
|
||||
const badPayload = {
|
||||
internal_name: "Test",
|
||||
public_name: "Test",
|
||||
tiers: [
|
||||
{
|
||||
tier_letter: "D", // invalid — not in ["A","B","C"]
|
||||
internal_name: "Tier X",
|
||||
public_name: "Tier X",
|
||||
duration_months: 3,
|
||||
assignedServiceIds: [],
|
||||
},
|
||||
],
|
||||
tipoTags: [],
|
||||
obiettivoTags: [],
|
||||
} as unknown as SaveOfferEditorPayload;
|
||||
// Expected: saveOfferEditor(macroId, badPayload) throws (Zod enum mismatch
|
||||
// surfaces as parsed.error.issues[0].message before any DB write).
|
||||
return () => saveOfferEditor(macroId, badPayload);
|
||||
}
|
||||
|
||||
// ── Test 2: macro scalar field update ────────────────────────────────────────
|
||||
// saveOfferEditor updates offer_macros scalars (internal_name, description,
|
||||
// category, ticket, cliente_ideale, risultato, tempo, pain, metodo) in one
|
||||
// db.update(offer_macros)...where(eq(id, macroId)).
|
||||
async function test2_macroScalars(macroId: string) {
|
||||
const payload: SaveOfferEditorPayload = {
|
||||
internal_name: "Offerta Test",
|
||||
public_name: "Offerta Pubblica",
|
||||
description: "Una descrizione",
|
||||
category: "Signature Offer",
|
||||
ticket: "Mid Ticket",
|
||||
cliente_ideale: "Coach",
|
||||
risultato: "Lead qualificati",
|
||||
tempo: "90 giorni",
|
||||
pain: "Mancanza di processo",
|
||||
metodo: "Sistema X",
|
||||
tiers: [],
|
||||
tipoTags: [],
|
||||
obiettivoTags: [],
|
||||
};
|
||||
// Expected: after call, offer_macros row for macroId has all 9 scalar
|
||||
// fields set to the payload values.
|
||||
return () => saveOfferEditor(macroId, payload);
|
||||
}
|
||||
|
||||
// ── Test 3: tier upsert (update existing id, insert when no id) ──────────────
|
||||
// If tier.id is provided -> db.update(offer_micros).set({...}); if no id ->
|
||||
// db.insert(offer_micros).values({..., macro_id: macroId}).
|
||||
async function test3_tierUpsert(macroId: string, existingTierId: string) {
|
||||
const payload: SaveOfferEditorPayload = {
|
||||
internal_name: "Offerta Test",
|
||||
public_name: "Offerta Pubblica",
|
||||
tiers: [
|
||||
{
|
||||
id: existingTierId, // -> update path
|
||||
tier_letter: "A",
|
||||
internal_name: "Tier A",
|
||||
public_name: "Tier A Pubblico",
|
||||
duration_months: 3,
|
||||
public_price: 1500,
|
||||
assignedServiceIds: [],
|
||||
},
|
||||
{
|
||||
// no id -> insert path (supports macros with < 3 tiers)
|
||||
tier_letter: "B",
|
||||
internal_name: "Tier B",
|
||||
public_name: "Tier B Pubblico",
|
||||
duration_months: 6,
|
||||
public_price: 2500,
|
||||
assignedServiceIds: [],
|
||||
},
|
||||
],
|
||||
tipoTags: [],
|
||||
obiettivoTags: [],
|
||||
};
|
||||
// Expected: existingTierId row updated in place; a new offer_micros row
|
||||
// created for tier B with macro_id = macroId.
|
||||
return () => saveOfferEditor(macroId, payload);
|
||||
}
|
||||
|
||||
// ── Test 4: offer_tier_services delete-then-reinsert ─────────────────────────
|
||||
// A tier with assignedServiceIds: ["svc1","svc2"] -> after call,
|
||||
// offer_tier_services has exactly 2 rows for that tier_id, both svc1/svc2.
|
||||
async function test4_tierServicesReplace(macroId: string, tierId: string) {
|
||||
const payload: SaveOfferEditorPayload = {
|
||||
internal_name: "Offerta Test",
|
||||
public_name: "Offerta Pubblica",
|
||||
tiers: [
|
||||
{
|
||||
id: tierId,
|
||||
tier_letter: "A",
|
||||
internal_name: "Tier A",
|
||||
public_name: "Tier A Pubblico",
|
||||
duration_months: 3,
|
||||
assignedServiceIds: ["svc1", "svc2"],
|
||||
},
|
||||
],
|
||||
tipoTags: [],
|
||||
obiettivoTags: [],
|
||||
};
|
||||
// Expected: offer_tier_services has exactly 2 rows where tier_id === tierId,
|
||||
// service_id IN ("svc1", "svc2").
|
||||
return () => saveOfferEditor(macroId, payload);
|
||||
}
|
||||
|
||||
// ── Test 5: Tipo/Obiettivo tags delete-then-reinsert ─────────────────────────
|
||||
// saveOfferEditor replaces tags where entity_type IN
|
||||
// ("offer_macros.tipo","offer_macros.obiettivo") and entity_id = macroId.
|
||||
async function test5_tagsReplace(macroId: string) {
|
||||
const payload: SaveOfferEditorPayload = {
|
||||
internal_name: "Offerta Test",
|
||||
public_name: "Offerta Pubblica",
|
||||
tiers: [],
|
||||
tipoTags: ["Audit", "Coaching"],
|
||||
obiettivoTags: ["Lead Generation"],
|
||||
};
|
||||
// Expected: tags table has 2 rows entity_type="offer_macros.tipo" (Audit,
|
||||
// Coaching) and 1 row entity_type="offer_macros.obiettivo" (Lead
|
||||
// Generation), all entity_id = macroId; any prior rows for this macroId +
|
||||
// these two entity_types are removed first.
|
||||
return () => saveOfferEditor(macroId, payload);
|
||||
}
|
||||
|
||||
// ── Test 6: toggleOfferArchived ──────────────────────────────────────────────
|
||||
// toggleOfferArchived(macroId, archived: boolean) sets offer_macros.is_archived = archived.
|
||||
async function test6_toggleArchived(macroId: string) {
|
||||
// Expected: offer_macros.is_archived === true after toggleOfferArchived(macroId, true);
|
||||
// === false after toggleOfferArchived(macroId, false).
|
||||
return [
|
||||
() => toggleOfferArchived(macroId, true),
|
||||
() => toggleOfferArchived(macroId, false),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Test 7: addOfferTag / removeOfferTag ─────────────────────────────────────
|
||||
// addOfferTag(dimension, macroId, value) / removeOfferTag(dimension, macroId,
|
||||
// value) work for dimension in ("tipo","obiettivo"). Reject any other
|
||||
// dimension value.
|
||||
async function test7_tagCrud(macroId: string) {
|
||||
// Expected: addOfferTag("tipo", macroId, "Audit") inserts a tags row
|
||||
// (entity_type="offer_macros.tipo", entity_id=macroId, name="Audit");
|
||||
// removeOfferTag("tipo", macroId, "Audit") deletes it.
|
||||
// addOfferTag("invalid" as any, macroId, "x") throws.
|
||||
return {
|
||||
addTipo: () => addOfferTag("tipo", macroId, "Audit"),
|
||||
removeTipo: () => removeOfferTag("tipo", macroId, "Audit"),
|
||||
addObiettivo: () => addOfferTag("obiettivo", macroId, "Lead Generation"),
|
||||
removeObiettivo: () => removeOfferTag("obiettivo", macroId, "Lead Generation"),
|
||||
// @ts-expect-error -- intentional invalid dimension to verify the throw path
|
||||
invalid: () => addOfferTag("invalid", macroId, "x"),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Test 8: renameOfferOption ────────────────────────────────────────────────
|
||||
// renameOfferOption(field, oldValue, newValue) for field in ("categoria",
|
||||
// "ticket") updates offer_macros.category/ticket for all matching rows; for
|
||||
// field in ("tipo","obiettivo") updates tags.name.
|
||||
async function test8_renameOption() {
|
||||
// Expected:
|
||||
// renameOfferOption("categoria", "Entry Offer", "Offerta Base") updates
|
||||
// all offer_macros rows where category = "Entry Offer" -> "Offerta Base"
|
||||
// renameOfferOption("ticket", "Low Ticket", "Ticket Basso") updates
|
||||
// all offer_macros rows where ticket = "Low Ticket" -> "Ticket Basso"
|
||||
// renameOfferOption("tipo", "Audit", "Diagnosi") updates all tags rows
|
||||
// where entity_type="offer_macros.tipo" and name="Audit" -> "Diagnosi"
|
||||
// renameOfferOption("obiettivo", "Lead Generation", "Acquisizione Lead")
|
||||
// updates tags rows where entity_type="offer_macros.obiettivo"
|
||||
return {
|
||||
renameCategoria: () => renameOfferOption("categoria", "Entry Offer", "Offerta Base"),
|
||||
renameTicket: () => renameOfferOption("ticket", "Low Ticket", "Ticket Basso"),
|
||||
renameTipo: () => renameOfferOption("tipo", "Audit", "Diagnosi"),
|
||||
renameObiettivo: () => renameOfferOption("obiettivo", "Lead Generation", "Acquisizione Lead"),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Test 9: createOfferMacro ─────────────────────────────────────────────────
|
||||
// createOfferMacro(formData) creates a new offer_macros row from internal_name
|
||||
// (required) + optional public_name/description/category. If public_name is
|
||||
// omitted, defaults to internal_name.
|
||||
async function test9_createOfferMacro() {
|
||||
const fd1 = new FormData();
|
||||
fd1.append("internal_name", "Nuova Offerta Interna");
|
||||
fd1.append("description", "Descrizione breve");
|
||||
fd1.append("category", "Entry Offer");
|
||||
// Expected: new offer_macros row with internal_name="Nuova Offerta Interna",
|
||||
// public_name="Nuova Offerta Interna" (defaulted), description set,
|
||||
// category="Entry Offer".
|
||||
|
||||
const fd2 = new FormData();
|
||||
fd2.append("internal_name", "Altra Offerta");
|
||||
fd2.append("public_name", "Nome Pubblico Esplicito");
|
||||
// Expected: new offer_macros row with public_name="Nome Pubblico Esplicito"
|
||||
// (not defaulted, since explicitly provided).
|
||||
|
||||
return {
|
||||
withDefaultPublicName: () => createOfferMacro(fd1),
|
||||
withExplicitPublicName: () => createOfferMacro(fd2),
|
||||
};
|
||||
}
|
||||
|
||||
// Not executed automatically (production DB + no request context for
|
||||
// requireAdmin) — typecheck-only verification.
|
||||
|
||||
export {
|
||||
test1_invalidTierLetter,
|
||||
test2_macroScalars,
|
||||
test3_tierUpsert,
|
||||
test4_tierServicesReplace,
|
||||
test5_tagsReplace,
|
||||
test6_toggleArchived,
|
||||
test7_tagCrud,
|
||||
test8_renameOption,
|
||||
test9_createOfferMacro,
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
// Phase 12 Plan 03 — Task 1 verification script for offer-queries.ts additions.
|
||||
//
|
||||
// This project has no test runner configured (no `scripts.test` in package.json),
|
||||
// and `.env.local`'s DATABASE_URL points at PRODUCTION (per project memory) — so
|
||||
// this script does NOT execute any DB calls. It typechecks against the real
|
||||
// exports/types from src/lib/offer-queries.ts and documents the 5 expected
|
||||
// behaviors from the plan's <behavior> block, for manual run against a dev DB
|
||||
// later if desired.
|
||||
//
|
||||
// Run (manual, dev DB only): npx tsx scripts/verify-12-03-queries.ts
|
||||
|
||||
import {
|
||||
getOfferEditorData,
|
||||
getOfferListCards,
|
||||
getOfferFieldOptions,
|
||||
type OfferEditorData,
|
||||
type OfferListCard,
|
||||
type OfferFieldOptions,
|
||||
type OfferTierData,
|
||||
} from "@/lib/offer-queries";
|
||||
|
||||
// ── Test 1: getOfferListCards() ──────────────────────────────────────────────
|
||||
// Input: 2 offer_macros rows (1 with is_archived=true, 1 with is_archived=false).
|
||||
// Expected: array of length 2, both present, ordered by sort_order, each row
|
||||
// shaped { id, internal_name, description, category, is_archived }. Archived
|
||||
// rows ARE returned — filtering is client-side (UI-SPEC "Mostra offerte archiviate").
|
||||
async function test1_getOfferListCards() {
|
||||
const cards: OfferListCard[] = await getOfferListCards();
|
||||
// Expected assertions (manual):
|
||||
// cards.length === 2
|
||||
// cards.some(c => c.is_archived === true)
|
||||
// cards.some(c => c.is_archived === false)
|
||||
return cards;
|
||||
}
|
||||
|
||||
// ── Test 2: getOfferEditorData(macroId) — macro with 0 tiers ─────────────────
|
||||
// Input: macro with category = "Signature Offer", 0 offer_micros rows, 3
|
||||
// services with category="Signature Offer" + 2 with a different category.
|
||||
// Expected: { macro: {...}, tiers: [], availableServices: [...3 matching...],
|
||||
// tipoTags: [], obiettivoTags: [] }
|
||||
async function test2_emptyTiers(macroId: string) {
|
||||
const data: OfferEditorData | null = await getOfferEditorData(macroId);
|
||||
// Expected assertions (manual):
|
||||
// data !== null
|
||||
// data.tiers.length === 0
|
||||
// data.availableServices.length === 3 (only services.category === macro.category)
|
||||
// data.tipoTags.length === 0 && data.obiettivoTags.length === 0
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Test 3: getOfferEditorData(macroId) — 3 tiers, tier A has 2 services ─────
|
||||
// Input: macro with 3 offer_micros rows (tier_letter A/B/C) and 2
|
||||
// offer_tier_services rows assigned to tier A.
|
||||
// Expected: tiers sorted A->B->C; each tier has
|
||||
// { id, tier_letter, public_price, assignedServiceIds, servicesTotal };
|
||||
// tier A's servicesTotal === sum(unit_price) of its 2 assigned services;
|
||||
// tiers B/C have assignedServiceIds === [] and servicesTotal === "0".
|
||||
async function test3_tiersWithServices(macroId: string) {
|
||||
const data: OfferEditorData | null = await getOfferEditorData(macroId);
|
||||
// Expected assertions (manual):
|
||||
// data.tiers.map(t => t.tier_letter) === ["A", "B", "C"]
|
||||
// data.tiers[0].assignedServiceIds.length === 2
|
||||
// Number(data.tiers[0].servicesTotal) === sum of the 2 services' unit_price
|
||||
// data.tiers[1].assignedServiceIds === [] && data.tiers[1].servicesTotal === "0"
|
||||
// data.tiers[2].assignedServiceIds === [] && data.tiers[2].servicesTotal === "0"
|
||||
const tierA: OfferTierData | undefined = data?.tiers[0];
|
||||
return { data, tierA };
|
||||
}
|
||||
|
||||
// ── Test 4: getOfferEditorData(macroId) — tipoTags/obiettivoTags ─────────────
|
||||
// Input: 2 "tipo" tags + 1 "obiettivo" tag exist for the macro (entity_type =
|
||||
// "offer_macros.tipo" / "offer_macros.obiettivo", entity_id = macroId).
|
||||
// Expected: tipoTags.length === 2, obiettivoTags.length === 1.
|
||||
async function test4_tags(macroId: string) {
|
||||
const data: OfferEditorData | null = await getOfferEditorData(macroId);
|
||||
// Expected assertions (manual):
|
||||
// data?.tipoTags.length === 2
|
||||
// data?.obiettivoTags.length === 1
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Test 5: getOfferFieldOptions() ───────────────────────────────────────────
|
||||
// Input: 2 macros with category "Entry Offer"/"Signature Offer", 1 "tipo" tag
|
||||
// "Audit", 1 "obiettivo" tag "Lead Generation".
|
||||
// Expected: categoria contains both values; tipo === ["Audit"];
|
||||
// obiettivo === ["Lead Generation"].
|
||||
async function test5_fieldOptions() {
|
||||
const options: OfferFieldOptions = await getOfferFieldOptions();
|
||||
// Expected assertions (manual):
|
||||
// options.categoria includes "Entry Offer" and "Signature Offer"
|
||||
// options.tipo includes "Audit"
|
||||
// options.obiettivo includes "Lead Generation"
|
||||
return options;
|
||||
}
|
||||
|
||||
// Not executed automatically (production DB) — typecheck-only verification.
|
||||
// To run manually against a dev DB: uncomment the call below.
|
||||
// void (async () => {
|
||||
// await test1_getOfferListCards();
|
||||
// await test2_emptyTiers("macro-id");
|
||||
// await test3_tiersWithServices("macro-id");
|
||||
// await test4_tags("macro-id");
|
||||
// await test5_fieldOptions();
|
||||
// })();
|
||||
|
||||
export {
|
||||
test1_getOfferListCards,
|
||||
test2_emptyTiers,
|
||||
test3_tiersWithServices,
|
||||
test4_tags,
|
||||
test5_fieldOptions,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getOfferEditorData, getOfferFieldOptions } from "@/lib/offer-queries";
|
||||
import { OfferEditorClient } from "@/components/admin/offers/OfferEditorClient";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function OfferEditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const [data, options] = await Promise.all([
|
||||
getOfferEditorData(id),
|
||||
getOfferFieldOptions(),
|
||||
]);
|
||||
|
||||
if (!data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <OfferEditorClient data={data} fieldOptions={options} />;
|
||||
}
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
offer_micros,
|
||||
offer_services,
|
||||
offer_micro_services,
|
||||
offer_tier_services,
|
||||
tags,
|
||||
} from "@/db/schema";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
@@ -130,3 +132,245 @@ export async function updateMicroOfferServices(microId: string, serviceIds: stri
|
||||
}
|
||||
revalidatePath("/admin/offers");
|
||||
}
|
||||
|
||||
// ── Phase 12: Offer Editor server actions ───────────────────────────────────
|
||||
// entity_type values for the polymorphic `tags` table, scoped to offer_macros'
|
||||
// Tipo/Obiettivo dimensions (D-06 pattern — separate pools from "services"/"leads").
|
||||
const OFFER_TIPO_ENTITY = "offer_macros.tipo";
|
||||
const OFFER_OBIETTIVO_ENTITY = "offer_macros.obiettivo";
|
||||
|
||||
const tierSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
tier_letter: z.enum(["A", "B", "C"]),
|
||||
internal_name: z.string().min(1),
|
||||
public_name: z.string().min(1),
|
||||
duration_months: z.coerce.number().int().min(1),
|
||||
public_price: z.coerce.number().min(0).optional().nullable(),
|
||||
assignedServiceIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
const saveOfferEditorSchema = z.object({
|
||||
internal_name: z.string().min(1, "Nome interno richiesto"),
|
||||
public_name: z.string().min(1, "Nome pubblico richiesto"),
|
||||
description: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
ticket: z.string().optional(),
|
||||
cliente_ideale: z.string().optional(),
|
||||
risultato: z.string().optional(),
|
||||
tempo: z.string().optional(),
|
||||
pain: z.string().optional(),
|
||||
metodo: z.string().optional(),
|
||||
tiers: z.array(tierSchema).max(3),
|
||||
tipoTags: z.array(z.string()),
|
||||
obiettivoTags: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type SaveOfferEditorPayload = z.infer<typeof saveOfferEditorSchema>;
|
||||
|
||||
// Persists the entire offer editor state in one call: macro scalar fields
|
||||
// (incl. category/ticket/transformation-promise), per-tier upsert
|
||||
// (tier_letter validated A/B/C via Zod — defense-in-depth alongside the DB
|
||||
// CHECK constraint from Plan 01) with delete-then-reinsert
|
||||
// offer_tier_services, and Tipo/Obiettivo tags (delete-then-reinsert).
|
||||
export async function saveOfferEditor(macroId: string, payload: SaveOfferEditorPayload) {
|
||||
await requireAdmin();
|
||||
|
||||
const parsed = saveOfferEditorSchema.safeParse(payload);
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||
const data = parsed.data;
|
||||
|
||||
// 1. Update macro scalar fields.
|
||||
await db
|
||||
.update(offer_macros)
|
||||
.set({
|
||||
internal_name: data.internal_name,
|
||||
public_name: data.public_name,
|
||||
description: data.description || null,
|
||||
category: data.category || null,
|
||||
ticket: data.ticket || null,
|
||||
cliente_ideale: data.cliente_ideale || null,
|
||||
risultato: data.risultato || null,
|
||||
tempo: data.tempo || null,
|
||||
pain: data.pain || null,
|
||||
metodo: data.metodo || null,
|
||||
})
|
||||
.where(eq(offer_macros.id, macroId));
|
||||
|
||||
// 2. Upsert each tier, then replace its offer_tier_services assignments.
|
||||
for (const tier of data.tiers) {
|
||||
const publicPrice = tier.public_price != null ? String(tier.public_price) : null;
|
||||
let tierId: string;
|
||||
|
||||
if (tier.id) {
|
||||
await db
|
||||
.update(offer_micros)
|
||||
.set({
|
||||
tier_letter: tier.tier_letter,
|
||||
internal_name: tier.internal_name,
|
||||
public_name: tier.public_name,
|
||||
duration_months: tier.duration_months,
|
||||
public_price: publicPrice,
|
||||
})
|
||||
.where(eq(offer_micros.id, tier.id));
|
||||
tierId = tier.id;
|
||||
} else {
|
||||
const [inserted] = await db
|
||||
.insert(offer_micros)
|
||||
.values({
|
||||
macro_id: macroId,
|
||||
tier_letter: tier.tier_letter,
|
||||
internal_name: tier.internal_name,
|
||||
public_name: tier.public_name,
|
||||
duration_months: tier.duration_months,
|
||||
public_price: publicPrice,
|
||||
})
|
||||
.returning({ id: offer_micros.id });
|
||||
tierId = inserted.id;
|
||||
}
|
||||
|
||||
// Delete-then-reinsert offer_tier_services (mirrors updateMicroOfferServices).
|
||||
await db.delete(offer_tier_services).where(eq(offer_tier_services.tier_id, tierId));
|
||||
if (tier.assignedServiceIds.length > 0) {
|
||||
await db.insert(offer_tier_services).values(
|
||||
tier.assignedServiceIds.map((serviceId) => ({ tier_id: tierId, service_id: serviceId }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Delete-then-reinsert Tipo/Obiettivo tags.
|
||||
await db
|
||||
.delete(tags)
|
||||
.where(
|
||||
and(
|
||||
eq(tags.entity_id, macroId),
|
||||
inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY])
|
||||
)
|
||||
);
|
||||
|
||||
const tagRows = [
|
||||
...data.tipoTags.map((name) => ({ entity_type: OFFER_TIPO_ENTITY, entity_id: macroId, name })),
|
||||
...data.obiettivoTags.map((name) => ({
|
||||
entity_type: OFFER_OBIETTIVO_ENTITY,
|
||||
entity_id: macroId,
|
||||
name,
|
||||
})),
|
||||
];
|
||||
if (tagRows.length > 0) {
|
||||
await db.insert(tags).values(tagRows).onConflictDoNothing();
|
||||
}
|
||||
|
||||
revalidatePath("/admin/offers");
|
||||
revalidatePath(`/admin/offers/${macroId}/edit`);
|
||||
}
|
||||
|
||||
// Toggles the archive flag on an offer macro (OFFER-18).
|
||||
export async function toggleOfferArchived(macroId: string, archived: boolean) {
|
||||
await requireAdmin();
|
||||
await db.update(offer_macros).set({ is_archived: archived }).where(eq(offer_macros.id, macroId));
|
||||
revalidatePath("/admin/offers");
|
||||
}
|
||||
|
||||
// ── Tipo/Obiettivo tag CRUD (mirrors addServiceOption/removeServiceOption) ───
|
||||
|
||||
type OfferTagDimension = "tipo" | "obiettivo";
|
||||
|
||||
const OFFER_TAG_ENTITY: Record<OfferTagDimension, string> = {
|
||||
tipo: OFFER_TIPO_ENTITY,
|
||||
obiettivo: OFFER_OBIETTIVO_ENTITY,
|
||||
};
|
||||
|
||||
export async function addOfferTag(dimension: OfferTagDimension, macroId: string, value: string) {
|
||||
await requireAdmin();
|
||||
if (!(dimension in OFFER_TAG_ENTITY)) throw new Error(`Dimensione non valida: ${dimension}`);
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) throw new Error("Valore richiesto");
|
||||
|
||||
await db
|
||||
.insert(tags)
|
||||
.values({ entity_type: OFFER_TAG_ENTITY[dimension], entity_id: macroId, name: trimmed })
|
||||
.onConflictDoNothing();
|
||||
|
||||
revalidatePath("/admin/offers");
|
||||
}
|
||||
|
||||
export async function removeOfferTag(
|
||||
dimension: OfferTagDimension,
|
||||
macroId: string,
|
||||
value: string
|
||||
) {
|
||||
await requireAdmin();
|
||||
if (!(dimension in OFFER_TAG_ENTITY)) throw new Error(`Dimensione non valida: ${dimension}`);
|
||||
|
||||
await db
|
||||
.delete(tags)
|
||||
.where(
|
||||
and(
|
||||
eq(tags.entity_type, OFFER_TAG_ENTITY[dimension]),
|
||||
eq(tags.entity_id, macroId),
|
||||
eq(tags.name, value)
|
||||
)
|
||||
);
|
||||
|
||||
revalidatePath("/admin/offers");
|
||||
}
|
||||
|
||||
// ── Rename an option everywhere it is used (mirrors renameServiceOption) ────
|
||||
// "categoria"/"ticket" are single-select columns on offer_macros; "tipo"/
|
||||
// "obiettivo" are multi-select pools in the polymorphic `tags` table.
|
||||
|
||||
type OfferOptionField = "categoria" | "ticket" | "tipo" | "obiettivo";
|
||||
|
||||
export async function renameOfferOption(
|
||||
field: OfferOptionField,
|
||||
oldValue: string,
|
||||
newValue: string
|
||||
) {
|
||||
await requireAdmin();
|
||||
const next = newValue.trim();
|
||||
if (next.length === 0) throw new Error("Nuovo nome richiesto");
|
||||
if (next === oldValue) return;
|
||||
|
||||
if (field === "categoria") {
|
||||
await db.update(offer_macros).set({ category: next }).where(eq(offer_macros.category, oldValue));
|
||||
} else if (field === "ticket") {
|
||||
await db.update(offer_macros).set({ ticket: next }).where(eq(offer_macros.ticket, oldValue));
|
||||
} else if (field === "tipo" || field === "obiettivo") {
|
||||
await db
|
||||
.update(tags)
|
||||
.set({ name: next })
|
||||
.where(and(eq(tags.entity_type, OFFER_TAG_ENTITY[field]), eq(tags.name, oldValue)));
|
||||
} else {
|
||||
throw new Error(`Campo non valido: ${field}`);
|
||||
}
|
||||
|
||||
revalidatePath("/admin/offers");
|
||||
}
|
||||
|
||||
// ── Create a new offer macro (Plan 04 "+ Nuova Offerta" target) ─────────────
|
||||
|
||||
const createOfferMacroSchema = z.object({
|
||||
internal_name: z.string().min(1, "Nome interno richiesto"),
|
||||
public_name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function createOfferMacro(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const parsed = createOfferMacroSchema.safeParse({
|
||||
internal_name: formData.get("internal_name"),
|
||||
public_name: formData.get("public_name") ?? "",
|
||||
description: formData.get("description") ?? "",
|
||||
category: formData.get("category") ?? "",
|
||||
});
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||
|
||||
await db.insert(offer_macros).values({
|
||||
internal_name: parsed.data.internal_name,
|
||||
public_name: parsed.data.public_name?.trim() || parsed.data.internal_name,
|
||||
description: parsed.data.description || null,
|
||||
category: parsed.data.category || null,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/offers");
|
||||
}
|
||||
|
||||
@@ -1,238 +1,17 @@
|
||||
import { getCatalogWithMicros, getAllOfferServices } from "@/lib/offer-queries";
|
||||
import {
|
||||
createMacro,
|
||||
createMicro,
|
||||
createOfferService,
|
||||
deleteMacro,
|
||||
deleteMicro,
|
||||
toggleOfferServiceActive,
|
||||
} from "./actions";
|
||||
import { ServiceCheckboxList } from "@/components/admin/offers/ServiceCheckboxList";
|
||||
import { getOfferListCards, getOfferFieldOptions } from "@/lib/offer-queries";
|
||||
import { OfferListClient } from "@/components/admin/offers/OfferListClient";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function OffersPage() {
|
||||
const [catalog, allServices] = await Promise.all([
|
||||
getCatalogWithMicros(),
|
||||
getAllOfferServices(),
|
||||
const [cards, options] = await Promise.all([
|
||||
getOfferListCards(),
|
||||
getOfferFieldOptions(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-8 px-4 space-y-10">
|
||||
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Offerte</h1>
|
||||
|
||||
{/* ── Sezione macro-offerte ── */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-[#1a1a1a] mb-4">Macro-Offerte</h2>
|
||||
|
||||
{/* Create macro form */}
|
||||
<form action={createMacro} className="bg-white rounded-lg border border-[#e5e7eb] p-4 mb-6 space-y-3 max-w-lg">
|
||||
<p className="text-sm font-medium">Nuova Macro-Offerta</p>
|
||||
<input
|
||||
name="internal_name"
|
||||
placeholder="Nome interno (es. Entry Offer)"
|
||||
required
|
||||
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<input
|
||||
name="public_name"
|
||||
placeholder="Nome pubblico (es. Starter Branding)"
|
||||
required
|
||||
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
name="transformation_promise"
|
||||
placeholder="Promessa di trasformazione"
|
||||
rows={2}
|
||||
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-[#1A463C] text-white text-sm px-4 py-1.5 rounded hover:bg-[#163a31]"
|
||||
>
|
||||
Aggiungi Macro
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* List macros */}
|
||||
<div className="space-y-8">
|
||||
{catalog.map((macro) => (
|
||||
<div key={macro.id} className="bg-white rounded-lg border border-[#e5e7eb] p-6">
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<div>
|
||||
<p className="font-semibold text-[#1a1a1a]">{macro.internal_name}</p>
|
||||
<p className="text-sm text-[#71717a]">Pubblico: {macro.public_name}</p>
|
||||
{macro.transformation_promise && (
|
||||
<p className="text-xs text-[#71717a] mt-1 italic">{macro.transformation_promise}</p>
|
||||
)}
|
||||
</div>
|
||||
<form action={deleteMacro.bind(null, macro.id)}>
|
||||
<button type="submit" className="text-xs text-red-600 hover:underline">
|
||||
Elimina
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Micro-offers under this macro */}
|
||||
<div className="mt-4 space-y-4 pl-4 border-l-2 border-[#e5e7eb]">
|
||||
<p className="text-xs font-semibold text-[#71717a] uppercase tracking-wider">Micro-Offerte</p>
|
||||
|
||||
{macro.micros.map((micro) => (
|
||||
<div key={micro.id} className="bg-[#f9f9f9] rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{micro.internal_name}</p>
|
||||
<p className="text-xs text-[#71717a]">
|
||||
Pubblico: {micro.public_name} ·{" "}
|
||||
{micro.duration_months} {micro.duration_months === 1 ? "mese" : "mesi"}
|
||||
</p>
|
||||
{micro.transformation_promise && (
|
||||
<p className="text-xs text-[#71717a] italic">{micro.transformation_promise}</p>
|
||||
)}
|
||||
<p className="text-xs text-[#1a1a1a] mt-1 font-medium">
|
||||
Prezzo cumulativo: €{parseFloat(micro.cumulative_price).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<form action={deleteMicro.bind(null, micro.id)}>
|
||||
<button type="submit" className="text-xs text-red-600 hover:underline">
|
||||
Elimina
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/* Service assignment checkbox list */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#71717a] mb-2">Servizi inclusi:</p>
|
||||
<ServiceCheckboxList
|
||||
allServices={allServices.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
price: String(s.price),
|
||||
}))}
|
||||
assignedIds={micro.services.map((s) => s.id)}
|
||||
microId={micro.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Create micro form */}
|
||||
<form action={createMicro} className="bg-white rounded border border-[#e5e7eb] p-3 space-y-2">
|
||||
<input type="hidden" name="macro_id" value={macro.id} />
|
||||
<p className="text-xs font-medium">Nuova Micro-Offerta</p>
|
||||
<input
|
||||
name="internal_name"
|
||||
placeholder="Nome interno"
|
||||
required
|
||||
className="w-full border rounded px-2 py-1 text-xs"
|
||||
/>
|
||||
<input
|
||||
name="public_name"
|
||||
placeholder="Nome pubblico"
|
||||
required
|
||||
className="w-full border rounded px-2 py-1 text-xs"
|
||||
/>
|
||||
<textarea
|
||||
name="transformation_promise"
|
||||
placeholder="Promessa di trasformazione"
|
||||
rows={2}
|
||||
className="w-full border rounded px-2 py-1 text-xs"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs">Durata (mesi):</label>
|
||||
<input
|
||||
name="duration_months"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue="1"
|
||||
required
|
||||
className="w-16 border rounded px-2 py-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-[#1A463C] text-white text-xs px-3 py-1 rounded hover:bg-[#163a31]"
|
||||
>
|
||||
Aggiungi Micro
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{catalog.length === 0 && (
|
||||
<p className="text-sm text-[#71717a]">Nessuna macro-offerta ancora. Creane una sopra.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Sezione servizi offerta ── */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-[#1a1a1a] mb-4">Servizi Offerta</h2>
|
||||
<p className="text-sm text-[#71717a] mb-4">
|
||||
I servizi qui sono diversi dal catalogo preventivi — hanno una descrizione della
|
||||
trasformazione e vengono raggruppati nelle micro-offerte.
|
||||
</p>
|
||||
|
||||
{/* List services */}
|
||||
<div className="bg-white rounded-lg border border-[#e5e7eb] divide-y divide-[#e5e7eb] mb-6">
|
||||
{allServices.map((svc) => (
|
||||
<div key={svc.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{svc.name}</p>
|
||||
{svc.transformation_description && (
|
||||
<p className="text-xs text-[#71717a]">{svc.transformation_description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-mono">€{parseFloat(String(svc.price)).toFixed(2)}</span>
|
||||
<form action={toggleOfferServiceActive.bind(null, svc.id, !svc.active)}>
|
||||
<button type="submit" className="text-xs text-[#71717a] hover:text-[#1a1a1a]">
|
||||
{svc.active ? "Disattiva" : "Attiva"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{allServices.length === 0 && (
|
||||
<p className="px-4 py-3 text-sm text-[#71717a]">Nessun servizio ancora.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create service form */}
|
||||
<form
|
||||
action={createOfferService}
|
||||
className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-3 max-w-lg"
|
||||
>
|
||||
<p className="text-sm font-medium">Nuovo Servizio Offerta</p>
|
||||
<input
|
||||
name="name"
|
||||
placeholder="Nome servizio"
|
||||
required
|
||||
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<input
|
||||
name="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="Prezzo (€)"
|
||||
required
|
||||
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
name="transformation_description"
|
||||
placeholder="Descrizione trasformazione (marketing)"
|
||||
rows={2}
|
||||
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-[#1A463C] text-white text-sm px-4 py-1.5 rounded hover:bg-[#163a31]"
|
||||
>
|
||||
Aggiungi Servizio
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<div>
|
||||
<OfferListClient cards={cards} categoryOptions={options.categoria} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
saveOfferEditor,
|
||||
toggleOfferArchived,
|
||||
renameOfferOption,
|
||||
type SaveOfferEditorPayload,
|
||||
} from "@/app/admin/offers/actions";
|
||||
import { OptionSelect } from "@/components/ui/option-select";
|
||||
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
|
||||
import { EditableCell } from "@/components/ui/editable-cell";
|
||||
import type { OfferEditorData, OfferFieldOptions, OfferTierData } from "@/lib/offer-queries";
|
||||
|
||||
type TierLetter = "A" | "B" | "C";
|
||||
const TIER_LETTERS: TierLetter[] = ["A", "B", "C"];
|
||||
|
||||
function emptyTier(letter: TierLetter): OfferTierData {
|
||||
return {
|
||||
id: "",
|
||||
tier_letter: letter,
|
||||
internal_name: "",
|
||||
public_name: "",
|
||||
duration_months: 1,
|
||||
public_price: null,
|
||||
assignedServiceIds: [],
|
||||
servicesTotal: "0",
|
||||
};
|
||||
}
|
||||
|
||||
function padTiers(tiers: OfferTierData[]): OfferTierData[] {
|
||||
return TIER_LETTERS.map(
|
||||
(letter) => tiers.find((t) => t.tier_letter === letter) ?? emptyTier(letter)
|
||||
);
|
||||
}
|
||||
|
||||
function formatEuro(value: number): string {
|
||||
return `€${value.toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function formatUnitPrice(raw: string): string {
|
||||
const num = parseFloat(raw);
|
||||
return `€${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
type MacroFields = {
|
||||
internal_name: string;
|
||||
public_name: string;
|
||||
description: string;
|
||||
category: string | null;
|
||||
ticket: string | null;
|
||||
cliente_ideale: string;
|
||||
risultato: string;
|
||||
tempo: string;
|
||||
pain: string;
|
||||
metodo: string;
|
||||
};
|
||||
|
||||
export function OfferEditorClient({
|
||||
data,
|
||||
fieldOptions,
|
||||
}: {
|
||||
data: OfferEditorData;
|
||||
fieldOptions: OfferFieldOptions;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const [macro, setMacro] = useState<MacroFields>({
|
||||
internal_name: data.macro.internal_name,
|
||||
public_name: data.macro.public_name,
|
||||
description: data.macro.description ?? "",
|
||||
category: data.macro.category,
|
||||
ticket: data.macro.ticket,
|
||||
cliente_ideale: data.macro.cliente_ideale ?? "",
|
||||
risultato: data.macro.risultato ?? "",
|
||||
tempo: data.macro.tempo ?? "",
|
||||
pain: data.macro.pain ?? "",
|
||||
metodo: data.macro.metodo ?? "",
|
||||
});
|
||||
|
||||
const [tiers, setTiers] = useState<OfferTierData[]>(() => padTiers(data.tiers));
|
||||
const [tipoTags, setTipoTags] = useState<string[]>(data.tipoTags);
|
||||
const [obiettivoTags, setObiettivoTags] = useState<string[]>(data.obiettivoTags);
|
||||
|
||||
const [categoriaOptions, setCategoriaOptions] = useState<string[]>(fieldOptions.categoria);
|
||||
const [ticketOptions, setTicketOptions] = useState<string[]>(fieldOptions.ticket);
|
||||
const [tipoOptions, setTipoOptions] = useState<string[]>(fieldOptions.tipo);
|
||||
const [obiettivoOptions, setObiettivoOptions] = useState<string[]>(fieldOptions.obiettivo);
|
||||
|
||||
const [initialArchived] = useState<boolean>(data.macro.is_archived);
|
||||
|
||||
const filteredServices = useMemo(() => {
|
||||
return macro.category
|
||||
? data.availableServices.filter((s) => s.category === macro.category)
|
||||
: data.availableServices;
|
||||
}, [macro.category, data.availableServices]);
|
||||
|
||||
const tierTotals = useMemo(() => {
|
||||
return tiers.map((tier) =>
|
||||
tier.assignedServiceIds.reduce((sum, id) => {
|
||||
const service = filteredServices.find((s) => s.id === id);
|
||||
return sum + Number(service?.unit_price ?? 0);
|
||||
}, 0)
|
||||
);
|
||||
}, [tiers, filteredServices]);
|
||||
|
||||
const canSave = tiers.some((t) => t.assignedServiceIds.length > 0);
|
||||
|
||||
function updateMacro<K extends keyof MacroFields>(key: K, value: MacroFields[K]) {
|
||||
setMacro((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function toggleService(tierIdx: number, serviceId: string) {
|
||||
setTiers((prev) =>
|
||||
prev.map((tier, idx) => {
|
||||
if (idx !== tierIdx) return tier;
|
||||
const has = tier.assignedServiceIds.includes(serviceId);
|
||||
return {
|
||||
...tier,
|
||||
assignedServiceIds: has
|
||||
? tier.assignedServiceIds.filter((id) => id !== serviceId)
|
||||
: [...tier.assignedServiceIds, serviceId],
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function updateTierPublicPrice(tierIdx: number, value: string) {
|
||||
setTiers((prev) =>
|
||||
prev.map((tier, idx) => (idx === tierIdx ? { ...tier, public_price: value } : tier))
|
||||
);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
setSaveError(null);
|
||||
const payload: SaveOfferEditorPayload = {
|
||||
internal_name: macro.internal_name,
|
||||
public_name: macro.public_name || macro.internal_name,
|
||||
description: macro.description,
|
||||
category: macro.category ?? undefined,
|
||||
ticket: macro.ticket ?? undefined,
|
||||
cliente_ideale: macro.cliente_ideale,
|
||||
risultato: macro.risultato,
|
||||
tempo: macro.tempo,
|
||||
pain: macro.pain,
|
||||
metodo: macro.metodo,
|
||||
tiers: tiers.map((t) => ({
|
||||
id: t.id || undefined,
|
||||
tier_letter: (t.tier_letter ?? "A") as "A" | "B" | "C",
|
||||
internal_name: t.internal_name || `Tier ${t.tier_letter}`,
|
||||
public_name: t.public_name || t.tier_letter || "Tier",
|
||||
duration_months: t.duration_months || 1,
|
||||
public_price: t.public_price ? Number(t.public_price) : null,
|
||||
assignedServiceIds: t.assignedServiceIds,
|
||||
})),
|
||||
tipoTags,
|
||||
obiettivoTags,
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await saveOfferEditor(data.macro.id, payload);
|
||||
router.push("/admin/offers");
|
||||
} catch {
|
||||
setSaveError("Errore nel salvataggio. Verifica i campi.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleArchive() {
|
||||
if (!window.confirm("Sei sicuro? L'offerta non sarà visibile nella lista.")) return;
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await toggleOfferArchived(data.macro.id, true);
|
||||
router.push("/admin/offers");
|
||||
} catch {
|
||||
setSaveError("Errore nel salvataggio. Verifica i campi.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleRenameCategoria(oldValue: string, newValue: string) {
|
||||
setCategoriaOptions((prev) => prev.map((o) => (o === oldValue ? newValue : o)));
|
||||
if (macro.category === oldValue) updateMacro("category", newValue);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await renameOfferOption("categoria", oldValue, newValue);
|
||||
} catch {
|
||||
setSaveError("Errore nel salvataggio. Verifica i campi.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleRenameTicket(oldValue: string, newValue: string) {
|
||||
setTicketOptions((prev) => prev.map((o) => (o === oldValue ? newValue : o)));
|
||||
if (macro.ticket === oldValue) updateMacro("ticket", newValue);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await renameOfferOption("ticket", oldValue, newValue);
|
||||
} catch {
|
||||
setSaveError("Errore nel salvataggio. Verifica i campi.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleRenameTipo(oldValue: string, newValue: string) {
|
||||
setTipoOptions((prev) => prev.map((o) => (o === oldValue ? newValue : o)));
|
||||
setTipoTags((prev) => prev.map((t) => (t === oldValue ? newValue : t)));
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await renameOfferOption("tipo", oldValue, newValue);
|
||||
} catch {
|
||||
setSaveError("Errore nel salvataggio. Verifica i campi.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleRenameObiettivo(oldValue: string, newValue: string) {
|
||||
setObiettivoOptions((prev) => prev.map((o) => (o === oldValue ? newValue : o)));
|
||||
setObiettivoTags((prev) => prev.map((t) => (t === oldValue ? newValue : t)));
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await renameOfferOption("obiettivo", oldValue, newValue);
|
||||
} catch {
|
||||
setSaveError("Errore nel salvataggio. Verifica i campi.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-8 px-4 space-y-8">
|
||||
{/* Header & navigation */}
|
||||
<div>
|
||||
<Link href="/admin/offers" className="text-sm text-[#71717a] hover:text-[#1a1a1a]">
|
||||
← Indietro
|
||||
</Link>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-[#1a1a1a]">Modifica Offerta</h2>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<h3 className="text-base font-semibold text-[#1a1a1a]">
|
||||
<EditableCell
|
||||
value={macro.internal_name}
|
||||
type="text"
|
||||
required
|
||||
onSave={(v) => updateMacro("internal_name", v)}
|
||||
/>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags block */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-base font-semibold text-[#1a1a1a]">Tag</h3>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#71717a] w-24 shrink-0">Categoria</span>
|
||||
<OptionSelect
|
||||
value={macro.category}
|
||||
options={categoriaOptions}
|
||||
onChange={(v) => updateMacro("category", v)}
|
||||
onRename={handleRenameCategoria}
|
||||
placeholder="Seleziona categoria..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#71717a] w-24 shrink-0">Ticket</span>
|
||||
<OptionSelect
|
||||
value={macro.ticket}
|
||||
options={ticketOptions}
|
||||
onChange={(v) => updateMacro("ticket", v)}
|
||||
onRename={handleRenameTicket}
|
||||
placeholder="Seleziona ticket..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#71717a] w-24 shrink-0">Tipo</span>
|
||||
<OptionMultiSelect
|
||||
values={tipoTags}
|
||||
options={tipoOptions}
|
||||
onAdd={(v) => {
|
||||
setTipoTags((prev) => [...prev, v]);
|
||||
if (!tipoOptions.includes(v)) setTipoOptions((prev) => [...prev, v]);
|
||||
}}
|
||||
onRemove={(v) => setTipoTags((prev) => prev.filter((t) => t !== v))}
|
||||
onRename={handleRenameTipo}
|
||||
placeholder="+ Crea Tipo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#71717a] w-24 shrink-0">Obiettivo</span>
|
||||
<OptionMultiSelect
|
||||
values={obiettivoTags}
|
||||
options={obiettivoOptions}
|
||||
onAdd={(v) => {
|
||||
setObiettivoTags((prev) => [...prev, v]);
|
||||
if (!obiettivoOptions.includes(v)) setObiettivoOptions((prev) => [...prev, v]);
|
||||
}}
|
||||
onRemove={(v) => setObiettivoTags((prev) => prev.filter((t) => t !== v))}
|
||||
onRename={handleRenameObiettivo}
|
||||
placeholder="+ Crea Obiettivo"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Services matrix */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-base font-semibold text-[#1a1a1a]">Servizi Inclusi</h3>
|
||||
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
||||
<tr>
|
||||
<th className="text-left py-2 px-3 font-semibold text-[#71717a]">Servizio</th>
|
||||
<th className="text-right py-2 px-3 font-semibold text-[#71717a]">Prezzo</th>
|
||||
{TIER_LETTERS.map((letter) => (
|
||||
<th
|
||||
key={letter}
|
||||
className="text-center py-2 px-3 font-semibold text-[#71717a] w-12"
|
||||
>
|
||||
{letter}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredServices.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={2 + TIER_LETTERS.length} className="py-3 px-3 text-[#71717a]">
|
||||
Nessun servizio disponibile per questa categoria
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredServices.map((service) => (
|
||||
<tr
|
||||
key={service.id}
|
||||
className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150 h-10"
|
||||
>
|
||||
<td className="py-2 px-3 text-[#1a1a1a]">{service.name}</td>
|
||||
<td className="py-2 px-3 text-right tabular-nums text-[#1a1a1a]">
|
||||
{formatUnitPrice(service.unit_price)}
|
||||
</td>
|
||||
{tiers.map((tier, tierIdx) => (
|
||||
<td key={tier.tier_letter} className="py-2 px-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tier.assignedServiceIds.includes(service.id)}
|
||||
onChange={() => toggleService(tierIdx, service.id)}
|
||||
style={{ accentColor: "#1A463C" }}
|
||||
className="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-[#e5e7eb]">
|
||||
<td className="py-2 px-3 font-semibold text-[#1a1a1a]" colSpan={2}>
|
||||
Totale Servizi
|
||||
</td>
|
||||
{tierTotals.map((total, idx) => (
|
||||
<td
|
||||
key={tiers[idx].tier_letter}
|
||||
className="py-2 px-3 text-center font-semibold text-[#1a1a1a] tabular-nums"
|
||||
>
|
||||
{formatEuro(total)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-semibold text-[#1a1a1a]" colSpan={2}>
|
||||
Prezzo Pubblico
|
||||
</td>
|
||||
{tiers.map((tier, tierIdx) => (
|
||||
<td key={tier.tier_letter} className="py-2 px-3 text-center">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={tier.public_price ?? ""}
|
||||
onChange={(e) => updateTierPublicPrice(tierIdx, e.target.value)}
|
||||
placeholder="€0,00"
|
||||
className="w-20 text-center text-sm border border-[#e5e7eb] rounded px-1 py-1 tabular-nums focus-visible:ring-1 focus-visible:ring-primary focus:outline-none"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Transformation promise block */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-base font-semibold text-[#1a1a1a]">Promessa di Trasformazione</h3>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#71717a] w-24 shrink-0">Aiuto</span>
|
||||
<div className="flex-1">
|
||||
<EditableCell
|
||||
value={macro.cliente_ideale}
|
||||
type="text"
|
||||
placeholder="Aggiungi cliente ideale"
|
||||
onSave={(v) => updateMacro("cliente_ideale", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#71717a] w-24 shrink-0">A ottenere</span>
|
||||
<div className="flex-1">
|
||||
<EditableCell
|
||||
value={macro.risultato}
|
||||
type="text"
|
||||
placeholder="Aggiungi risultato"
|
||||
onSave={(v) => updateMacro("risultato", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#71717a] w-24 shrink-0">In</span>
|
||||
<div className="flex-1">
|
||||
<EditableCell
|
||||
value={macro.tempo}
|
||||
type="text"
|
||||
placeholder="Aggiungi durata (es. 3 mesi)"
|
||||
onSave={(v) => updateMacro("tempo", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#71717a] w-24 shrink-0">Senza</span>
|
||||
<div className="flex-1">
|
||||
<EditableCell
|
||||
value={macro.pain}
|
||||
type="text"
|
||||
placeholder="Aggiungi pain point"
|
||||
onSave={(v) => updateMacro("pain", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#71717a] w-24 shrink-0">Grazie a</span>
|
||||
<div className="flex-1">
|
||||
<EditableCell
|
||||
value={macro.metodo}
|
||||
type="text"
|
||||
placeholder="Aggiungi metodo"
|
||||
onSave={(v) => updateMacro("metodo", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Action buttons */}
|
||||
{saveError && <p className="text-sm text-red-600">{saveError}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave || isPending}
|
||||
title={!canSave ? "Seleziona almeno un servizio" : undefined}
|
||||
className="bg-[#1A463C] text-white text-sm font-semibold px-6 py-4 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[#163a31] transition-colors duration-150"
|
||||
>
|
||||
Salva Offerta
|
||||
</button>
|
||||
<Link
|
||||
href="/admin/offers"
|
||||
className="border border-[#e5e7eb] text-[#1a1a1a] text-sm font-semibold px-6 py-4 rounded hover:bg-[#f9f9f9] transition-colors duration-150"
|
||||
>
|
||||
Annulla
|
||||
</Link>
|
||||
{!initialArchived && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleArchive}
|
||||
disabled={isPending}
|
||||
className="text-[#dc2626] text-sm font-semibold px-6 py-4 rounded hover:bg-red-50 transition-colors duration-150 disabled:opacity-50"
|
||||
>
|
||||
Archivia
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { createOfferMacro } from "@/app/admin/offers/actions";
|
||||
import type { OfferListCard } from "@/lib/offer-queries";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export function OfferListClient({
|
||||
cards,
|
||||
categoryOptions,
|
||||
}: {
|
||||
cards: OfferListCard[];
|
||||
categoryOptions: string[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [newOfferName, setNewOfferName] = useState("");
|
||||
|
||||
const filteredCards = useMemo(() => {
|
||||
return cards.filter(
|
||||
(c) =>
|
||||
(activeCategory === null || c.category === activeCategory) &&
|
||||
(showArchived || !c.is_archived)
|
||||
);
|
||||
}, [cards, activeCategory, showArchived]);
|
||||
|
||||
function handleCreateSubmit(formData: FormData) {
|
||||
startTransition(async () => {
|
||||
await createOfferMacro(formData);
|
||||
setShowCreateForm(false);
|
||||
setNewOfferName("");
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
const createCta = (
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-[#1A463C] text-white hover:bg-[#163a31]"
|
||||
onClick={() => setShowCreateForm((v) => !v)}
|
||||
>
|
||||
+ Nuova Offerta
|
||||
</Button>
|
||||
{showCreateForm && (
|
||||
<form
|
||||
action={handleCreateSubmit}
|
||||
className="flex items-center gap-2 bg-white border border-[#e5e7eb] rounded-lg p-2"
|
||||
>
|
||||
<Input
|
||||
name="internal_name"
|
||||
required
|
||||
placeholder="Nome offerta..."
|
||||
value={newOfferName}
|
||||
onChange={(e) => setNewOfferName(e.target.value)}
|
||||
className="h-9 w-48"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
className="bg-[#1A463C] text-white hover:bg-[#163a31]"
|
||||
>
|
||||
Crea
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-[20px] font-semibold text-[#1a1a1a]">Offerte</h2>
|
||||
{createCta}
|
||||
</div>
|
||||
|
||||
{/* Filter row */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(null)}
|
||||
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
|
||||
activeCategory === null
|
||||
? "border-[#1A463C] text-[#1A463C]"
|
||||
: "border-[#e5e7eb] text-[#71717a]"
|
||||
}`}
|
||||
>
|
||||
Tutti
|
||||
</button>
|
||||
{categoryOptions.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(category)}
|
||||
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
|
||||
activeCategory === category
|
||||
? "border-[#1A463C] text-[#1A463C]"
|
||||
: "border-[#e5e7eb] text-[#71717a]"
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-[#71717a]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showArchived}
|
||||
onChange={(e) => setShowArchived(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-[#e5e7eb]"
|
||||
/>
|
||||
Mostra offerte archiviate
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Card grid / empty state */}
|
||||
{filteredCards.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-[#e5e7eb] bg-white py-16 text-center">
|
||||
<h3 className="text-[16px] font-semibold text-[#1a1a1a]">Nessuna offerta</h3>
|
||||
<p className="text-sm text-[#71717a]">Inizia creando la tua prima offerta</p>
|
||||
<div className="mt-2">{createCta}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredCards.map((card) => (
|
||||
<Link
|
||||
key={card.id}
|
||||
href={`/admin/offers/${card.id}/edit`}
|
||||
className="cursor-pointer rounded-lg border border-[#e5e7eb] bg-white p-4 transition-shadow hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)]"
|
||||
>
|
||||
<h3 className="text-[16px] font-semibold text-[#1a1a1a]">{card.internal_name}</h3>
|
||||
{card.description && (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-[#71717a]">{card.description}</p>
|
||||
)}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{card.category && <Badge variant="secondary">{card.category}</Badge>}
|
||||
{card.is_archived && (
|
||||
<span className="text-xs font-semibold text-[#dc2626]">Archiviata</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Phase 12: Offer Editor — additive schema for tier designations, public pricing,
|
||||
-- archive flag, category/ticket dimensions, structured transformation promise, and
|
||||
-- tier<->services junction. All statements additive/idempotent. No drops/truncates
|
||||
-- (Data Safety LOCKED).
|
||||
|
||||
-- offer_macros: archive flag + short description + category/ticket dimensions +
|
||||
-- structured transformation promise
|
||||
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS description text;
|
||||
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS category text;
|
||||
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS ticket text;
|
||||
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS is_archived boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS cliente_ideale text;
|
||||
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS risultato text;
|
||||
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS tempo text;
|
||||
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS pain text;
|
||||
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS metodo text;
|
||||
|
||||
-- offer_micros: tier designation (A/B/C) + manual public price
|
||||
ALTER TABLE offer_micros ADD COLUMN IF NOT EXISTS tier_letter text;
|
||||
ALTER TABLE offer_micros ADD COLUMN IF NOT EXISTS public_price numeric(10, 2);
|
||||
|
||||
-- CHECK constraint for tier_letter, added separately so the ADD COLUMN above stays
|
||||
-- idempotent even if the constraint already exists (guarded via DO block).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'offer_micros_tier_letter_check'
|
||||
) THEN
|
||||
ALTER TABLE offer_micros
|
||||
ADD CONSTRAINT offer_micros_tier_letter_check
|
||||
CHECK (tier_letter IS NULL OR tier_letter IN ('A', 'B', 'C'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- New junction table: tier (offer_micros) <-> unified services catalog.
|
||||
-- Additive replacement for legacy offer_micro_services (untouched, points to offer_services).
|
||||
CREATE TABLE IF NOT EXISTS offer_tier_services (
|
||||
tier_id text NOT NULL REFERENCES offer_micros(id) ON DELETE CASCADE,
|
||||
service_id text NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY (tier_id, service_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS offer_tier_services_tier_idx ON offer_tier_services USING btree (tier_id);
|
||||
@@ -22,6 +22,13 @@
|
||||
"when": 1781442000000,
|
||||
"tag": "0006_add_tags_table",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1781700000000,
|
||||
"tag": "0008_offer_tier_schema",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -259,6 +259,10 @@ export const settings = pgTable("settings", {
|
||||
});
|
||||
|
||||
// ============ OFFER MACROS ============
|
||||
// Phase 12 additive columns (Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico):
|
||||
// description, category, ticket, is_archived, and the 5 structured
|
||||
// transformation-promise fields (cliente_ideale/risultato/tempo/pain/metodo).
|
||||
// transformation_promise (legacy, free-text) stays untouched.
|
||||
export const offer_macros = pgTable("offer_macros", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
@@ -266,6 +270,16 @@ export const offer_macros = pgTable("offer_macros", {
|
||||
internal_name: text("internal_name").notNull(),
|
||||
public_name: text("public_name").notNull(),
|
||||
transformation_promise: text("transformation_promise"),
|
||||
// Phase 12 additive fields below
|
||||
description: text("description"), // short description shown on offer cards
|
||||
category: text("category"), // single-select "Categoria" (Entry/Signature/Retainer Offer) — OFFER-15/18
|
||||
ticket: text("ticket"), // single-select "Ticket" (Low/Mid/High Ticket) — OFFER-15
|
||||
is_archived: boolean("is_archived").notNull().default(false), // OFFER-18 archive flag
|
||||
cliente_ideale: text("cliente_ideale"), // "Aiuto: [Cliente Ideale]"
|
||||
risultato: text("risultato"), // "A ottenere: [Risultato]"
|
||||
tempo: text("tempo"), // "In: [tempo]"
|
||||
pain: text("pain"), // "Senza: [Pain]"
|
||||
metodo: text("metodo"), // "Grazie a: [Metodo]"
|
||||
sort_order: integer("sort_order").notNull().default(0),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
@@ -283,6 +297,15 @@ export const offer_micros = pgTable("offer_micros", {
|
||||
transformation_promise: text("transformation_promise"),
|
||||
duration_months: integer("duration_months").notNull().default(1),
|
||||
sort_order: integer("sort_order").notNull().default(0),
|
||||
// Phase 12 additive fields below.
|
||||
// tier_letter: nullable, values constrained to 'A'|'B'|'C' via a CHECK
|
||||
// constraint at the SQL level (migration 0008) — not enforced in Drizzle
|
||||
// (no native CHECK helper in this project's pg-core version); validated in
|
||||
// Zod at the server-action layer (Plan 03).
|
||||
tier_letter: text("tier_letter"),
|
||||
// public_price: manual public price per tier (D-5/OFFER-16), independent of
|
||||
// the computed services total (computed at query time, never stored).
|
||||
public_price: numeric("public_price", { precision: 10, scale: 2 }),
|
||||
});
|
||||
|
||||
// ============ OFFER SERVICES (distinct from service_catalog — marketing/transformation semantics) ============
|
||||
@@ -312,6 +335,28 @@ export const offer_micro_services = pgTable(
|
||||
})
|
||||
);
|
||||
|
||||
// ============ OFFER TIER SERVICES (Phase 12 — junction: offer_micros <-> services) ============
|
||||
// Additive replacement for the legacy offer_micro_services (which points to the
|
||||
// deprecated offer_services table). This junction connects a tier (offer_micros row
|
||||
// with tier_letter set) to the unified services catalog (Phase 11). Do NOT modify
|
||||
// offer_micro_services — it remains untouched for legacy data.
|
||||
export const offer_tier_services = pgTable(
|
||||
"offer_tier_services",
|
||||
{
|
||||
tier_id: text("tier_id")
|
||||
.notNull()
|
||||
.references(() => offer_micros.id, { onDelete: "cascade" }),
|
||||
service_id: text("service_id")
|
||||
.notNull()
|
||||
.references(() => services.id, { onDelete: "cascade" }),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
pk: primaryKey({ columns: [t.tier_id, t.service_id] }),
|
||||
tierIdx: index("offer_tier_services_tier_idx").on(t.tier_id),
|
||||
})
|
||||
);
|
||||
|
||||
// ============ PROJECT OFFERS (assignment of micro-offer to project) ============
|
||||
export const project_offers = pgTable("project_offers", {
|
||||
id: text("id")
|
||||
@@ -549,6 +594,7 @@ export const offerMacrosRelations = relations(offer_macros, ({ many }) => ({
|
||||
export const offerMicrosRelations = relations(offer_micros, ({ one, many }) => ({
|
||||
macro: one(offer_macros, { fields: [offer_micros.macro_id], references: [offer_macros.id] }),
|
||||
services: many(offer_micro_services),
|
||||
tierServices: many(offer_tier_services),
|
||||
projectOffers: many(project_offers),
|
||||
}));
|
||||
|
||||
@@ -561,6 +607,11 @@ export const offerMicroServicesRelations = relations(offer_micro_services, ({ on
|
||||
service: one(offer_services, { fields: [offer_micro_services.service_id], references: [offer_services.id] }),
|
||||
}));
|
||||
|
||||
export const offerTierServicesRelations = relations(offer_tier_services, ({ one }) => ({
|
||||
tier: one(offer_micros, { fields: [offer_tier_services.tier_id], references: [offer_micros.id] }),
|
||||
service: one(services, { fields: [offer_tier_services.service_id], references: [services.id] }),
|
||||
}));
|
||||
|
||||
export const projectOffersRelations = relations(project_offers, ({ one }) => ({
|
||||
project: one(projects, { fields: [project_offers.project_id], references: [projects.id] }),
|
||||
micro: one(offer_micros, { fields: [project_offers.micro_id], references: [offer_micros.id] }),
|
||||
@@ -638,6 +689,8 @@ export type OfferService = typeof offer_services.$inferSelect;
|
||||
export type NewOfferService = typeof offer_services.$inferInsert;
|
||||
export type OfferMicroService = typeof offer_micro_services.$inferSelect;
|
||||
export type NewOfferMicroService = typeof offer_micro_services.$inferInsert;
|
||||
export type OfferTierService = typeof offer_tier_services.$inferSelect;
|
||||
export type NewOfferTierService = typeof offer_tier_services.$inferInsert;
|
||||
export type ProjectOffer = typeof project_offers.$inferSelect;
|
||||
export type NewProjectOffer = typeof project_offers.$inferInsert;
|
||||
export type OfferPhase = typeof offer_phases.$inferSelect;
|
||||
|
||||
+188
-1
@@ -4,9 +4,12 @@ import {
|
||||
offer_micros,
|
||||
offer_services,
|
||||
offer_micro_services,
|
||||
offer_tier_services,
|
||||
services,
|
||||
tags,
|
||||
} from "@/db/schema";
|
||||
import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema";
|
||||
import { eq, asc, inArray, sql } from "drizzle-orm";
|
||||
import { eq, and, asc, inArray, sql } from "drizzle-orm";
|
||||
|
||||
export type MicroWithServices = OfferMicro & {
|
||||
services: Array<{ id: string; name: string; price: string }>;
|
||||
@@ -94,3 +97,187 @@ export async function getMicroAssignedServiceIds(microId: string): Promise<strin
|
||||
.where(eq(offer_micro_services.micro_id, microId));
|
||||
return rows.map((r) => r.service_id);
|
||||
}
|
||||
|
||||
// ── Phase 12: Offer Editor query layer ──────────────────────────────────────
|
||||
// entity_type values for the polymorphic `tags` table, scoped to offer_macros'
|
||||
// Tipo/Obiettivo dimensions (D-06 pattern — separate pools from "services"/"leads").
|
||||
const OFFER_TIPO_ENTITY = "offer_macros.tipo";
|
||||
const OFFER_OBIETTIVO_ENTITY = "offer_macros.obiettivo";
|
||||
|
||||
export type OfferListCard = Pick<
|
||||
OfferMacro,
|
||||
"id" | "internal_name" | "description" | "category" | "is_archived"
|
||||
>;
|
||||
|
||||
// List-page cards (Plan 04): one row per offer_macros, with category + archive
|
||||
// status for client-side filtering. Archived offers ARE included — the
|
||||
// "Mostra offerte archiviate" toggle filters client-side per UI-SPEC.
|
||||
export async function getOfferListCards(): Promise<OfferListCard[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: offer_macros.id,
|
||||
internal_name: offer_macros.internal_name,
|
||||
description: offer_macros.description,
|
||||
category: offer_macros.category,
|
||||
is_archived: offer_macros.is_archived,
|
||||
})
|
||||
.from(offer_macros)
|
||||
.orderBy(asc(offer_macros.sort_order), asc(offer_macros.created_at));
|
||||
return rows;
|
||||
}
|
||||
|
||||
export type OfferTierData = {
|
||||
id: string;
|
||||
tier_letter: string | null;
|
||||
internal_name: string;
|
||||
public_name: string;
|
||||
duration_months: number;
|
||||
public_price: string | null;
|
||||
assignedServiceIds: string[];
|
||||
servicesTotal: string;
|
||||
};
|
||||
|
||||
export type OfferEditorData = {
|
||||
macro: OfferMacro;
|
||||
tiers: OfferTierData[];
|
||||
availableServices: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
unit_price: string;
|
||||
category: string | null;
|
||||
}>;
|
||||
tipoTags: string[];
|
||||
obiettivoTags: string[];
|
||||
};
|
||||
|
||||
// Full editor data for a single offer (Plan 05): macro fields (incl.
|
||||
// category/ticket/is_archived/transformation-promise), its A/B/C tiers with
|
||||
// assigned service IDs + computed services total, the category-filtered
|
||||
// service catalog for the composition matrix, and Tipo/Obiettivo tags.
|
||||
export async function getOfferEditorData(macroId: string): Promise<OfferEditorData | null> {
|
||||
const [macro] = await db.select().from(offer_macros).where(eq(offer_macros.id, macroId));
|
||||
if (!macro) return null;
|
||||
|
||||
// Tiers ordered A -> B -> C, with null/unrecognized tier_letter sorted last.
|
||||
const tierOrder = sql`CASE ${offer_micros.tier_letter} WHEN 'A' THEN 1 WHEN 'B' THEN 2 WHEN 'C' THEN 3 ELSE 4 END`;
|
||||
const tiers = await db
|
||||
.select()
|
||||
.from(offer_micros)
|
||||
.where(eq(offer_micros.macro_id, macroId))
|
||||
.orderBy(asc(tierOrder));
|
||||
|
||||
const tierIds = tiers.map((t) => t.id);
|
||||
|
||||
let assignedRows: Array<{ tier_id: string; service_id: string }> = [];
|
||||
let totalsMap = new Map<string, string>();
|
||||
|
||||
if (tierIds.length > 0) {
|
||||
assignedRows = await db
|
||||
.select({
|
||||
tier_id: offer_tier_services.tier_id,
|
||||
service_id: offer_tier_services.service_id,
|
||||
})
|
||||
.from(offer_tier_services)
|
||||
.where(inArray(offer_tier_services.tier_id, tierIds));
|
||||
|
||||
const totalRows = await db
|
||||
.select({
|
||||
tier_id: offer_tier_services.tier_id,
|
||||
servicesTotal: sql<string>`coalesce(sum(${services.unit_price}::numeric), 0)`,
|
||||
})
|
||||
.from(offer_tier_services)
|
||||
.innerJoin(services, eq(offer_tier_services.service_id, services.id))
|
||||
.where(inArray(offer_tier_services.tier_id, tierIds))
|
||||
.groupBy(offer_tier_services.tier_id);
|
||||
|
||||
totalsMap = new Map(totalRows.map((r) => [r.tier_id, r.servicesTotal]));
|
||||
}
|
||||
|
||||
const tiersData: OfferTierData[] = tiers.map((tier) => ({
|
||||
id: tier.id,
|
||||
tier_letter: tier.tier_letter,
|
||||
internal_name: tier.internal_name,
|
||||
public_name: tier.public_name,
|
||||
duration_months: tier.duration_months,
|
||||
public_price: tier.public_price,
|
||||
assignedServiceIds: assignedRows
|
||||
.filter((a) => a.tier_id === tier.id)
|
||||
.map((a) => a.service_id),
|
||||
servicesTotal: totalsMap.get(tier.id) ?? "0",
|
||||
}));
|
||||
|
||||
// Category-filtered service catalog for the composition matrix (D-4
|
||||
// pre-filter, best-effort). If macro.category is unset, return all active
|
||||
// services unfiltered.
|
||||
const availableServicesQuery = db
|
||||
.select({
|
||||
id: services.id,
|
||||
name: services.name,
|
||||
unit_price: services.unit_price,
|
||||
category: services.category,
|
||||
})
|
||||
.from(services);
|
||||
|
||||
const availableServices = macro.category
|
||||
? await availableServicesQuery.where(
|
||||
and(eq(services.active, true), eq(services.category, macro.category))
|
||||
)
|
||||
: await availableServicesQuery.where(eq(services.active, true));
|
||||
|
||||
// Tipo/Obiettivo tags for this macro.
|
||||
const tagRows = await db
|
||||
.select({ name: tags.name, type: tags.entity_type })
|
||||
.from(tags)
|
||||
.where(
|
||||
and(
|
||||
eq(tags.entity_id, macroId),
|
||||
inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY])
|
||||
)
|
||||
);
|
||||
|
||||
const tipoTags = tagRows.filter((r) => r.type === OFFER_TIPO_ENTITY).map((r) => r.name);
|
||||
const obiettivoTags = tagRows
|
||||
.filter((r) => r.type === OFFER_OBIETTIVO_ENTITY)
|
||||
.map((r) => r.name);
|
||||
|
||||
return {
|
||||
macro,
|
||||
tiers: tiersData,
|
||||
availableServices,
|
||||
tipoTags,
|
||||
obiettivoTags,
|
||||
};
|
||||
}
|
||||
|
||||
export type OfferFieldOptions = {
|
||||
categoria: string[];
|
||||
ticket: string[];
|
||||
tipo: string[];
|
||||
obiettivo: string[];
|
||||
};
|
||||
|
||||
// Shared option pools for the offer editor's select fields (Notion-style
|
||||
// dropdowns), mirroring getCatalogFieldOptions from admin-queries.ts.
|
||||
export async function getOfferFieldOptions(): Promise<OfferFieldOptions> {
|
||||
const categoriaRows = await db.selectDistinct({ value: offer_macros.category }).from(offer_macros);
|
||||
const ticketRows = await db.selectDistinct({ value: offer_macros.ticket }).from(offer_macros);
|
||||
|
||||
const tagRows = await db
|
||||
.selectDistinct({ name: tags.name, type: tags.entity_type })
|
||||
.from(tags)
|
||||
.where(inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY]));
|
||||
|
||||
const sortUnique = (arr: (string | null)[]) =>
|
||||
Array.from(new Set(arr.filter((v): v is string => !!v && v.trim().length > 0))).sort(
|
||||
(a, b) => a.localeCompare(b, "it")
|
||||
);
|
||||
|
||||
return {
|
||||
categoria: sortUnique(categoriaRows.map((r) => r.value)),
|
||||
ticket: sortUnique(ticketRows.map((r) => r.value)),
|
||||
tipo: sortUnique(tagRows.filter((r) => r.type === OFFER_TIPO_ENTITY).map((r) => r.name)),
|
||||
obiettivo: sortUnique(
|
||||
tagRows.filter((r) => r.type === OFFER_OBIETTIVO_ENTITY).map((r) => r.name)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user