.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>
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)