f7eb7eec23
.planning/ documentava in dettaglio cio che era vecchio e per niente cio
che e in produzione: le fasi 11-22 (v2.1 e v2.2, chiuse a giugno) erano
ancora in phases/ mentre v1.0 e v2.0 stavano gia in milestones/, e il
lavoro degli ultimi due mesi - gate OTP e ciclo di vita dei retainer, cioe
quello che gira su hub.iamcavalli.net - non aveva nessuna cartella.
- phases/{11,12,14} -> milestones/v2.1-phases/, phases/{18..22} ->
milestones/v2.2-phases/. Ora phases/ contiene solo la milestone in
corso, che e quello che state.cjs conta per il progresso
- v2.1-ROADMAP.md ricostruito: era l'unica milestone senza archivio,
interrotta dal reset del 19/06 e mai chiusa formalmente
- v2.3-ROADMAP.md + v2.3-REQUIREMENTS.md: v2.3 e stata eseguita fuori dal
ciclo GSD, non esistono PLAN/SUMMARY per fase. L'archivio E la doc
- REQUIREMENTS.md riscritto per v2.4 con il backlog reale
- phases/13 e phases/26: SUMMARY ricostruiti da commit, migration e
STATUS.md. 26 e il primo numero libero
- research/: cancellate 4 varianti dello stesso PITFALLS e FEATURES/
SUMMARY, superati da PROJECT.md. Diverse anti-feature erano ormai
contraddette dai fatti (il Kanban e stato costruito in Phase 19,
l'email in v2.3, il time tracking esiste)
- cancellati UI-RULES.md e DESIGN-SYSTEM.md (CLAUDE.md li dichiara
superseded: impongono l'inverso della regola attuale) e HANDOFF.md,
fermo al 13/06
- SECURITY-*.md -> security/: audit chiuso, ma i report restano la doc di
cosa e stato ruotato e perche
- PROJECT.md/MILESTONES.md/ROADMAP.md allineati: milestone corrente v2.4,
sessione OTP 90gg non 30, migrazioni fino alla 0016
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
137 lines
9.7 KiB
Markdown
137 lines
9.7 KiB
Markdown
---
|
||
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)_
|