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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 17:24:19 +02:00

9.7 KiB
Raw Permalink Blame History

phase, verified, requirements, overall
phase verified requirements overall
12 2026-06-18T17:19:00+02:00
OFFER-11 OFFER-15 OFFER-16 OFFER-17 OFFER-18
PASS PASS PASS PASS PASS
PASS

Phase 12: Offer Editor — Verification Report

Phase Goal: Build a full offer editor allowing admin to compose offers with 3 tiers (A/B/C), assign services via checkbox matrix, set per-tier public price, add 4-dimension tags (tipo/obiettivo creatable, categoria/ticket single-select), write a structured transformation promise (5 fields), and manage an offer list with category filter and archive toggle.

Verified: 2026-06-18T17:19:00+02:00 Status: PASS Re-verification: No — initial verification


OFFER-11: Tier A/B/C checkbox matrix with live totals

Verdict: PASS

Schema (src/db/schema.ts): offer_micros.tier_letter (text, values A/B/C enforced by DB CHECK constraint in migration 0008) and offer_tier_services junction table (tier_id → offer_micros.id, service_id → services.id, composite PK) are both present and properly defined. Relations are wired: offerMicrosRelations includes tierServices: many(offer_tier_services).

Migration (src/db/migrations/0008_offer_tier_schema.sql): Adds tier_letter and public_price columns to offer_micros, adds a guarded CHECK constraint (tier_letter IN ('A','B','C')), and creates the offer_tier_services table — all with IF NOT EXISTS guards, fully additive.

Query layer (src/lib/offer-queries.ts, getOfferEditorData): Fetches tiers ordered A→B→C via a CASE expression, then fetches offer_tier_services rows for all tier IDs in one query, and computes servicesTotal per tier with a SUM(unit_price) GROUP BY. Returns assignedServiceIds[] and servicesTotal per tier.

Editor UI (src/components/admin/offers/OfferEditorClient.tsx):

  • padTiers() ensures all three tier slots (A, B, C) are always present in state, creating empty tier objects for any letter not yet persisted.
  • toggleService(tierIdx, serviceId) updates assignedServiceIds in React state immutably.
  • tierTotals is a useMemo that re-derives a per-tier sum from assignedServiceIds and filteredServices.unit_price on every render — this is the live total.
  • The tfoot row "Totale Servizi" renders tierTotals[idx] for each column (lines 371378).
  • Checkboxes at lines 353360 bind checked={tier.assignedServiceIds.includes(service.id)} and onChange={() => toggleService(tierIdx, service.id)} — the matrix is fully wired.

Persistence (src/app/admin/offers/actions.ts, saveOfferEditor): Zod schema enforces tier_letter: z.enum(["A","B","C"]). Each tier is upserted (update if id present, insert otherwise), then offer_tier_services is delete-then-reinserted — correct upsert-replace pattern.


OFFER-15: 4-dimension tags (categoria, ticket, tipo, obiettivo)

Verdict: PASS

Schema: offer_macros.category (text, single-select) and offer_macros.ticket (text, single-select) added in migration 0008. Tipo/Obiettivo reuse the Phase 11 polymorphic tags table with entity_type scoped to "offer_macros.tipo" and "offer_macros.obiettivo" — separate pools from "services" and "leads" per the D-06 pattern documented in schema comments.

Query layer (getOfferEditorData): Fetches tipo/obiettivo tag rows filtered by inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY]) and splits them into tipoTags[] and obiettivoTags[]. getOfferFieldOptions returns categoria, ticket, tipo, obiettivo option pools via selectDistinct.

Editor UI (OfferEditorClient.tsx):

  • OptionSelect components render categoria (line 262) and ticket (line 272) as single-select Notion-style dropdowns with rename support.
  • OptionMultiSelect components render tipo (line 284) and obiettivo (line 299) as multi-select with onAdd (creatable — if value not in options, adds to local options state), onRemove, and onRename callbacks.
  • Options are creatable on-the-fly: onAdd at line 288 calls setTipoOptions((prev) => [...prev, v]) when the value is new.

Persistence (saveOfferEditor): Tipo/Obiettivo tags are delete-then-reinserted (lines 242263) with onConflictDoNothing(). Categoria/ticket are saved as scalar columns on offer_macros. Standalone addOfferTag/removeOfferTag actions exist for granular tag mutations.

Rename propagation (renameOfferOption): Updates offer_macros.category / offer_macros.ticket in bulk for single-select dimensions, and tags.name for tipo/obiettivo — correct cross-row rename.


OFFER-16: Per-tier manual public price

Verdict: PASS

Schema (offer_micros.public_price): numeric(10,2), nullable, added in migration 0008. Distinct from the servicesTotal which is computed at query time and never stored.

Query layer (getOfferEditorData): Returns public_price: tier.public_price (raw DB value, nullable string) per tier in OfferTierData.

Editor UI (OfferEditorClient.tsx lines 381397): The tfoot row "Prezzo Pubblico" renders one <input type="number"> per tier column, bound to tier.public_price and calling updateTierPublicPrice(tierIdx, e.target.value). This is independent of the "Totale Servizi" row above it — two separate tfoot rows, two separate state values.

Persistence (saveOfferEditor): tier.public_price is converted with tier.public_price != null ? String(tier.public_price) : null and written to offer_micros.public_price on upsert. Zod schema: public_price: z.coerce.number().min(0).optional().nullable().


OFFER-17: Structured transformation promise (5 fields)

Verdict: PASS

Schema (offer_macros): Five columns added in migration 0008 — cliente_ideale, risultato, tempo, pain, metodo — all text, nullable. These are distinct from the pre-existing free-text transformation_promise column (retained untouched).

Query layer (getOfferEditorData): db.select().from(offer_macros) returns all columns including the five new ones. The OfferMacro type (inferred from schema) includes them.

Editor UI (OfferEditorClient.tsx lines 404467): "Promessa di Trasformazione" section renders five EditableCell fields:

  • "Aiuto" → cliente_ideale
  • "A ottenere" → risultato
  • "In" → tempo
  • "Senza" → pain
  • "Grazie a" → metodo

Each calls updateMacro(key, v) on save, updating the macro state object. All five are initialized from data.macro.* at component mount.

Persistence (saveOfferEditor): All five fields are explicitly mapped in the db.update(offer_macros).set({...}) call (lines 186196), with || null coercion for empty strings.


OFFER-18: Offer list filterable by category, archive toggle

Verdict: PASS

Schema (offer_macros.is_archived): boolean NOT NULL DEFAULT false, added in migration 0008.

Query layer (getOfferListCards): Returns all offers including archived ones (archived filtering is client-side per spec). Returns id, internal_name, description, category, is_archived.

List page (src/app/admin/offers/page.tsx): Calls getOfferListCards() and getOfferFieldOptions() in parallel, passes cards and options.categoria to OfferListClient.

List component (OfferListClient.tsx):

  • activeCategory state (null = all) drives filter chip selection; "Tutti" chip and one chip per category from categoryOptions.
  • showArchived state (default false) drives the "Mostra offerte archiviate" checkbox.
  • filteredCards useMemo at lines 2632 applies both filters simultaneously: (activeCategory === null || c.category === activeCategory) && (showArchived || !c.is_archived).
  • Archived offers render with a red "Archiviata" label on the card.
  • toggleOfferArchived(macroId, true) server action archives an offer from the editor (OFFER-18 archive action). Restore path: the action accepts archived: boolean so it can also unarchive, though no unarchive button exists in the editor UI — unarchiving requires the list to show the card via the toggle and navigate to edit.

Archive action (actions.ts line 267271): toggleOfferArchived updates offer_macros.is_archived and calls revalidatePath("/admin/offers").


Anti-patterns scan

No placeholder returns (return null, return {}, return [] as stubs) found in the key files. The canSave guard (tiers.some((t) => t.assignedServiceIds.length > 0)) prevents saving a hollow offer but is not a stub — it is a real UX validation. The emptyTier() function returns zero-state structs used to pad the three-slot display; they are overwritten by real data fetched from the DB.

The servicesTotal: "0" in emptyTier is an initial display value for unpersisted tiers — not a stub, as the live tierTotals useMemo recomputes from assignedServiceIds state on every checkbox change.


Notes

  1. Unarchive flow is implicit. There is no "Unarchive" button in the editor. The only way to restore an archived offer is: enable "Mostra offerte archiviate" on the list page, click into the offer, and call toggleOfferArchived(id, false) — which exists in actions.ts but has no UI trigger for archived=false in OfferEditorClient.tsx. This is a minor UX gap but does not block any of OFFER-11 through OFFER-18 as specified. The archiving requirement (OFFER-18) asks for "archive toggle" — the toggle on the list is the show/hide toggle; archiving itself works via the editor button. Unarchiving is not called out in the requirements.

  2. canSave gate. The "Salva Offerta" button is disabled unless at least one tier has a service assigned. This means an offer with only transformation promise fields filled and no services cannot be saved. This aligns with OFFER-11's framing of service assignment as the core editor operation.


Verified: 2026-06-18T17:19:00+02:00 Verifier: Claude (gsd-verifier)