diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 9bff1e5..ec11677 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -13,11 +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'admin compone un'offerta a 3 tier (A/B/C) assegnando i servizi del catalogo unificato `services` tramite matrice di checkbox (servizio × tier); il totale servizi di ogni tier si aggiorna live | Pending | -| OFFER-15 | Ogni offerta ha tag su 4 dimensioni (categoria, ticket, tipo, obiettivo) creabili al volo, riusando il sistema tag polimorfico di Phase 11 | Pending | -| OFFER-16 | Per ogni tier l'admin imposta un "prezzo pubblico" manuale, distinto e indipendente dal totale dei servizi | Pending | -| OFFER-17 | Ogni offerta ha una promessa di trasformazione strutturata (Cliente Ideale / Risultato / tempo / Pain / Metodo) | Pending | -| OFFER-18 | La lista offerte è filtrabile per categoria e supporta l'archiviazione (mostra/nascondi archiviate) | Pending | +| OFFER-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) @@ -95,11 +95,11 @@ 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-15 | Phase 12 | Pending | -| OFFER-16 | Phase 12 | Pending | -| OFFER-17 | Phase 12 | Pending | -| OFFER-18 | 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 | @@ -120,4 +120,4 @@ Popolata dal roadmapper durante la creazione della roadmap. --- *Requirements defined: 2026-06-13* -*Last updated: 2026-06-14 — Phase 12 re-scoped da mockup utente: editor offerte (OFFER-11 ridefinito + OFFER-15..18), import CSV OFFER-12 rimandato* +*Last updated: 2026-06-18 — Phase 12 complete: OFFER-11, OFFER-15, OFFER-16, OFFER-17, OFFER-18 → Complete* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 97a4900..bfe85d3 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -142,7 +142,7 @@ Plans: | Phase | Plans | Status | Completed | |-------|-------|--------|-----------| | 11. Catalog Database-View UX & Legacy Consolidation | 4/4 | ✅ Done | 2026-06-13 | -| 12. Offer Editor — Tier A/B/C, Tag & Prezzo Pubblico | 5/5 | Complete | 2026-06-15 | +| 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) | — | diff --git a/.planning/STATE.md b/.planning/STATE.md index dcc2193..ac47300 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,15 +2,15 @@ gsd_state_version: 1.0 milestone: v2.1 milestone_name: milestone -status: executing -stopped_at: Phase 12 planned (5 piani, 4 wave) e verificato; pronto per /gsd-execute-phase 12 -last_updated: "2026-06-14T19:04:45.211Z" -last_activity: 2026-06-14 -- Phase 12 (offer editor) re-scoped da mockup + pianificato (planner + plan-checker pass) +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 12 — Offer Editor (Tier A/B/C, Tag & Prezzo Pubblico). Pianificata e verificata, pronta per l'esecuzione. (Phase 15 resta bloccata su mockup utente.) +**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: 12 -Plan: Not started — 5 piani in 4 wave pronti (12-01..12-05) -Status: Planned & verified (planner + plan-checker pass; 1 blocker dipendenza risolto). Ready for /gsd-execute-phase 12 -Last activity: 2026-06-14 -- Phase 12 re-scoped da mockup utente (editor offerte), pianificata (5 piani, 4 wave) +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% diff --git a/.planning/phases/12-offer-composition-drag-drop-csv-import/12-REVIEW.md b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-REVIEW.md new file mode 100644 index 0000000..9dd8488 --- /dev/null +++ b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-REVIEW.md @@ -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 && ( + +)} +``` + +### 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: `