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>
9.7 KiB
phase, verified, requirements, overall
| phase | verified | requirements | overall | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 12 | 2026-06-18T17:19:00+02:00 |
|
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)updatesassignedServiceIdsin React state immutably.tierTotalsis auseMemothat re-derives a per-tier sum fromassignedServiceIdsandfilteredServices.unit_priceon 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)}andonChange={() => 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):
OptionSelectcomponents render categoria (line 262) and ticket (line 272) as single-select Notion-style dropdowns with rename support.OptionMultiSelectcomponents render tipo (line 284) and obiettivo (line 299) as multi-select withonAdd(creatable — if value not in options, adds to local options state),onRemove, andonRenamecallbacks.- Options are creatable on-the-fly:
onAddat line 288 callssetTipoOptions((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):
activeCategorystate (null = all) drives filter chip selection; "Tutti" chip and one chip per category fromcategoryOptions.showArchivedstate (default false) drives the "Mostra offerte archiviate" checkbox.filteredCardsuseMemo 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 acceptsarchived: booleanso 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
-
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 inactions.tsbut has no UI trigger forarchived=falseinOfferEditorClient.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. -
canSavegate. 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)