From 8e0e4b9c597522d767074fa106a2baff13445bbf Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Mon, 15 Jun 2026 10:24:49 +0200 Subject: [PATCH 1/3] feat(12-05): create offer editor route server component - Add src/app/admin/offers/[id]/edit/page.tsx as async server component - Fetches getOfferEditorData(id) + getOfferFieldOptions() via Promise.all - Calls notFound() when offer id does not exist - Delegates rendering to OfferEditorClient --- src/app/admin/offers/[id]/edit/page.tsx | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/app/admin/offers/[id]/edit/page.tsx diff --git a/src/app/admin/offers/[id]/edit/page.tsx b/src/app/admin/offers/[id]/edit/page.tsx new file mode 100644 index 0000000..92bd24b --- /dev/null +++ b/src/app/admin/offers/[id]/edit/page.tsx @@ -0,0 +1,23 @@ +import { notFound } from "next/navigation"; +import { getOfferEditorData, getOfferFieldOptions } from "@/lib/offer-queries"; +import { OfferEditorClient } from "@/components/admin/offers/OfferEditorClient"; + +export const revalidate = 0; + +export default async function OfferEditPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const [data, options] = await Promise.all([ + getOfferEditorData(id), + getOfferFieldOptions(), + ]); + + if (!data) { + notFound(); + } + + return ; +} From 8c5c91830444962bb2193be4c5af6aff3e667759 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Mon, 15 Jun 2026 10:25:02 +0200 Subject: [PATCH 2/3] feat(12-05): build offer editor client component - Add OfferEditorClient: full editor for /admin/offers/[id]/edit - Categoria/Ticket single-select (OptionSelect) + Tipo/Obiettivo multi-select with on-the-fly creation (OptionMultiSelect) - Services matrix (A/B/C checkboxes) pre-filtered by macro.category, with live "Totale Servizi" recalculation via useMemo (OFFER-11) - Independent manual "Prezzo Pubblico" per tier (OFFER-16) - 5-field "Promessa di Trasformazione" block via EditableCell (OFFER-17) - Salva Offerta / Annulla / Archivia actions wired to saveOfferEditor, toggleOfferArchived and renameOfferOption (OFFER-15) --- .../admin/offers/OfferEditorClient.tsx | 500 ++++++++++++++++++ 1 file changed, 500 insertions(+) create mode 100644 src/components/admin/offers/OfferEditorClient.tsx diff --git a/src/components/admin/offers/OfferEditorClient.tsx b/src/components/admin/offers/OfferEditorClient.tsx new file mode 100644 index 0000000..f3ec2e9 --- /dev/null +++ b/src/components/admin/offers/OfferEditorClient.tsx @@ -0,0 +1,500 @@ +"use client"; + +import { useState, useMemo, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { + saveOfferEditor, + toggleOfferArchived, + renameOfferOption, + type SaveOfferEditorPayload, +} from "@/app/admin/offers/actions"; +import { OptionSelect } from "@/components/ui/option-select"; +import { OptionMultiSelect } from "@/components/ui/option-multi-select"; +import { EditableCell } from "@/components/ui/editable-cell"; +import type { OfferEditorData, OfferFieldOptions, OfferTierData } from "@/lib/offer-queries"; + +type TierLetter = "A" | "B" | "C"; +const TIER_LETTERS: TierLetter[] = ["A", "B", "C"]; + +function emptyTier(letter: TierLetter): OfferTierData { + return { + id: "", + tier_letter: letter, + internal_name: "", + public_name: "", + duration_months: 1, + public_price: null, + assignedServiceIds: [], + servicesTotal: "0", + }; +} + +function padTiers(tiers: OfferTierData[]): OfferTierData[] { + return TIER_LETTERS.map( + (letter) => tiers.find((t) => t.tier_letter === letter) ?? emptyTier(letter) + ); +} + +function formatEuro(value: number): string { + return `€${value.toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +function formatUnitPrice(raw: string): string { + const num = parseFloat(raw); + return `€${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +type MacroFields = { + internal_name: string; + public_name: string; + description: string; + category: string | null; + ticket: string | null; + cliente_ideale: string; + risultato: string; + tempo: string; + pain: string; + metodo: string; +}; + +export function OfferEditorClient({ + data, + fieldOptions, +}: { + data: OfferEditorData; + fieldOptions: OfferFieldOptions; +}) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [saveError, setSaveError] = useState(null); + + const [macro, setMacro] = useState({ + internal_name: data.macro.internal_name, + public_name: data.macro.public_name, + description: data.macro.description ?? "", + category: data.macro.category, + ticket: data.macro.ticket, + cliente_ideale: data.macro.cliente_ideale ?? "", + risultato: data.macro.risultato ?? "", + tempo: data.macro.tempo ?? "", + pain: data.macro.pain ?? "", + metodo: data.macro.metodo ?? "", + }); + + const [tiers, setTiers] = useState(() => padTiers(data.tiers)); + const [tipoTags, setTipoTags] = useState(data.tipoTags); + const [obiettivoTags, setObiettivoTags] = useState(data.obiettivoTags); + + const [categoriaOptions, setCategoriaOptions] = useState(fieldOptions.categoria); + const [ticketOptions, setTicketOptions] = useState(fieldOptions.ticket); + const [tipoOptions, setTipoOptions] = useState(fieldOptions.tipo); + const [obiettivoOptions, setObiettivoOptions] = useState(fieldOptions.obiettivo); + + const [initialArchived] = useState(data.macro.is_archived); + + const filteredServices = useMemo(() => { + return macro.category + ? data.availableServices.filter((s) => s.category === macro.category) + : data.availableServices; + }, [macro.category, data.availableServices]); + + const tierTotals = useMemo(() => { + return tiers.map((tier) => + tier.assignedServiceIds.reduce((sum, id) => { + const service = filteredServices.find((s) => s.id === id); + return sum + Number(service?.unit_price ?? 0); + }, 0) + ); + }, [tiers, filteredServices]); + + const canSave = tiers.some((t) => t.assignedServiceIds.length > 0); + + function updateMacro(key: K, value: MacroFields[K]) { + setMacro((prev) => ({ ...prev, [key]: value })); + } + + function toggleService(tierIdx: number, serviceId: string) { + setTiers((prev) => + prev.map((tier, idx) => { + if (idx !== tierIdx) return tier; + const has = tier.assignedServiceIds.includes(serviceId); + return { + ...tier, + assignedServiceIds: has + ? tier.assignedServiceIds.filter((id) => id !== serviceId) + : [...tier.assignedServiceIds, serviceId], + }; + }) + ); + } + + function updateTierPublicPrice(tierIdx: number, value: string) { + setTiers((prev) => + prev.map((tier, idx) => (idx === tierIdx ? { ...tier, public_price: value } : tier)) + ); + } + + function handleSave() { + setSaveError(null); + const payload: SaveOfferEditorPayload = { + internal_name: macro.internal_name, + public_name: macro.public_name || macro.internal_name, + description: macro.description, + category: macro.category ?? undefined, + ticket: macro.ticket ?? undefined, + cliente_ideale: macro.cliente_ideale, + risultato: macro.risultato, + tempo: macro.tempo, + pain: macro.pain, + metodo: macro.metodo, + tiers: tiers.map((t) => ({ + id: t.id || undefined, + tier_letter: (t.tier_letter ?? "A") as "A" | "B" | "C", + internal_name: t.internal_name || `Tier ${t.tier_letter}`, + public_name: t.public_name || t.tier_letter || "Tier", + duration_months: t.duration_months || 1, + public_price: t.public_price ? Number(t.public_price) : null, + assignedServiceIds: t.assignedServiceIds, + })), + tipoTags, + obiettivoTags, + }; + + startTransition(async () => { + try { + await saveOfferEditor(data.macro.id, payload); + router.push("/admin/offers"); + } catch { + setSaveError("Errore nel salvataggio. Verifica i campi."); + } + }); + } + + function handleArchive() { + if (!window.confirm("Sei sicuro? L'offerta non sarà visibile nella lista.")) return; + startTransition(async () => { + try { + await toggleOfferArchived(data.macro.id, true); + router.push("/admin/offers"); + } catch { + setSaveError("Errore nel salvataggio. Verifica i campi."); + } + }); + } + + function handleRenameCategoria(oldValue: string, newValue: string) { + setCategoriaOptions((prev) => prev.map((o) => (o === oldValue ? newValue : o))); + if (macro.category === oldValue) updateMacro("category", newValue); + startTransition(async () => { + try { + await renameOfferOption("categoria", oldValue, newValue); + } catch { + setSaveError("Errore nel salvataggio. Verifica i campi."); + } + }); + } + + function handleRenameTicket(oldValue: string, newValue: string) { + setTicketOptions((prev) => prev.map((o) => (o === oldValue ? newValue : o))); + if (macro.ticket === oldValue) updateMacro("ticket", newValue); + startTransition(async () => { + try { + await renameOfferOption("ticket", oldValue, newValue); + } catch { + setSaveError("Errore nel salvataggio. Verifica i campi."); + } + }); + } + + function handleRenameTipo(oldValue: string, newValue: string) { + setTipoOptions((prev) => prev.map((o) => (o === oldValue ? newValue : o))); + setTipoTags((prev) => prev.map((t) => (t === oldValue ? newValue : t))); + startTransition(async () => { + try { + await renameOfferOption("tipo", oldValue, newValue); + } catch { + setSaveError("Errore nel salvataggio. Verifica i campi."); + } + }); + } + + function handleRenameObiettivo(oldValue: string, newValue: string) { + setObiettivoOptions((prev) => prev.map((o) => (o === oldValue ? newValue : o))); + setObiettivoTags((prev) => prev.map((t) => (t === oldValue ? newValue : t))); + startTransition(async () => { + try { + await renameOfferOption("obiettivo", oldValue, newValue); + } catch { + setSaveError("Errore nel salvataggio. Verifica i campi."); + } + }); + } + + return ( +
+ {/* Header & navigation */} +
+ + ← Indietro + +
+

Modifica Offerta

+
+
+

+ updateMacro("internal_name", v)} + /> +

+
+
+ + {/* Tags block */} +
+

Tag

+ +
+ Categoria + updateMacro("category", v)} + onRename={handleRenameCategoria} + placeholder="Seleziona categoria..." + /> +
+ +
+ Ticket + updateMacro("ticket", v)} + onRename={handleRenameTicket} + placeholder="Seleziona ticket..." + /> +
+ +
+ Tipo + { + setTipoTags((prev) => [...prev, v]); + if (!tipoOptions.includes(v)) setTipoOptions((prev) => [...prev, v]); + }} + onRemove={(v) => setTipoTags((prev) => prev.filter((t) => t !== v))} + onRename={handleRenameTipo} + placeholder="+ Crea Tipo" + /> +
+ +
+ Obiettivo + { + setObiettivoTags((prev) => [...prev, v]); + if (!obiettivoOptions.includes(v)) setObiettivoOptions((prev) => [...prev, v]); + }} + onRemove={(v) => setObiettivoTags((prev) => prev.filter((t) => t !== v))} + onRename={handleRenameObiettivo} + placeholder="+ Crea Obiettivo" + /> +
+
+ + {/* Services matrix */} +
+

Servizi Inclusi

+ +
+
+ + + + + + {TIER_LETTERS.map((letter) => ( + + ))} + + + + {filteredServices.length === 0 ? ( + + + + ) : ( + filteredServices.map((service) => ( + + + + {tiers.map((tier, tierIdx) => ( + + ))} + + )) + )} + + + + + {tierTotals.map((total, idx) => ( + + ))} + + + + {tiers.map((tier, tierIdx) => ( + + ))} + + +
ServizioPrezzo + {letter} +
+ Nessun servizio disponibile per questa categoria +
{service.name} + {formatUnitPrice(service.unit_price)} + + toggleService(tierIdx, service.id)} + style={{ accentColor: "#1A463C" }} + className="h-4 w-4 cursor-pointer" + /> +
+ Totale Servizi + + {formatEuro(total)} +
+ Prezzo Pubblico + + updateTierPublicPrice(tierIdx, e.target.value)} + placeholder="€0,00" + className="w-20 text-center text-sm border border-[#e5e7eb] rounded px-1 py-1 tabular-nums focus-visible:ring-1 focus-visible:ring-primary focus:outline-none" + /> +
+
+
+
+ + {/* Transformation promise block */} +
+

Promessa di Trasformazione

+ +
+ Aiuto +
+ updateMacro("cliente_ideale", v)} + /> +
+
+ +
+ A ottenere +
+ updateMacro("risultato", v)} + /> +
+
+ +
+ In +
+ updateMacro("tempo", v)} + /> +
+
+ +
+ Senza +
+ updateMacro("pain", v)} + /> +
+
+ +
+ Grazie a +
+ updateMacro("metodo", v)} + /> +
+
+
+ + {/* Action buttons */} + {saveError &&

{saveError}

} +
+ + + Annulla + + {!initialArchived && ( + + )} +
+
+ ); +} From b7203c3da2f5acd8d694c71a2b25bcb85a246e59 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Mon, 15 Jun 2026 10:26:24 +0200 Subject: [PATCH 3/3] docs(12-05): complete offer editor detail page plan - Add 12-05-SUMMARY.md documenting OfferEditorClient + edit route --- .../12-05-SUMMARY.md | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 .planning/phases/12-offer-composition-drag-drop-csv-import/12-05-SUMMARY.md diff --git a/.planning/phases/12-offer-composition-drag-drop-csv-import/12-05-SUMMARY.md b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-05-SUMMARY.md new file mode 100644 index 0000000..0ce9e07 --- /dev/null +++ b/.planning/phases/12-offer-composition-drag-drop-csv-import/12-05-SUMMARY.md @@ -0,0 +1,120 @@ +--- +phase: 12-offer-composition-drag-drop-csv-import +plan: 05 +subsystem: ui +tags: [next.js, react, tailwind, server-actions, drizzle] + +# Dependency graph +requires: + - phase: 12-offer-composition-drag-drop-csv-import (Plan 03) + provides: getOfferEditorData/getOfferFieldOptions query layer + saveOfferEditor/toggleOfferArchived/renameOfferOption/addOfferTag/removeOfferTag server actions +provides: + - "/admin/offers/[id]/edit route: full offer editor page (server component + client component)" + - OfferEditorClient — editable name, Categoria/Ticket/Tipo/Obiettivo tags, services x tier (A/B/C) checkbox matrix with live totals, independent Prezzo Pubblico, Promessa di Trasformazione block, Salva/Annulla/Archivia actions +affects: [12-04-offer-list-page] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Tier padding to exactly 3 (A/B/C) via padTiers() so the matrix always renders 3 columns even for offers with <3 persisted tiers" + - "Client-side useMemo for category-filtered services + live per-tier Totale Servizi (OFFER-11), zero network calls on checkbox toggle" + - "Batched save: all macro/tier/tag edits held in local state, persisted in one saveOfferEditor() call on 'Salva Offerta' (no per-keystroke server calls for tags)" + +key-files: + created: + - src/app/admin/offers/[id]/edit/page.tsx + - src/components/admin/offers/OfferEditorClient.tsx + modified: [] + +key-decisions: + - "EditableCell.onSave is synchronous (value: string) => void in this codebase (not Promise-returning as the plan's interface sketch suggested) — all macro-field onSave handlers call setMacro synchronously, consistent with the actual component signature" + - "renameOfferOption calls (Categoria/Ticket/Tipo/Obiettivo rename) are fired via startTransition with optimistic local-state updates to options/tags, matching ServiceTable's existing rename pattern from Phase 11" + +patterns-established: [] + +requirements-completed: [OFFER-11, OFFER-15, OFFER-16, OFFER-17] + +# Metrics +duration: 18min +completed: 2026-06-15 +--- + +# Phase 12 Plan 05: Offer Editor Detail Page Summary + +**Full-page Offer Editor at `/admin/offers/[id]/edit`: 4-dimension tags, category-filtered A/B/C services checkbox matrix with live "Totale Servizi", independent "Prezzo Pubblico" per tier, 5-field transformation promise, and Salva/Annulla/Archivia actions.** + +## Performance + +- **Duration:** 18 min +- **Started:** 2026-06-15T08:07:00Z (approx) +- **Completed:** 2026-06-15T08:25:22Z +- **Tasks:** 2 completed +- **Files modified:** 2 (both new) + +## Accomplishments + +- Created `src/app/admin/offers/[id]/edit/page.tsx`: async server component using Next.js 16's `Promise<{ id: string }>` params pattern, fetches `getOfferEditorData(id)` + `getOfferFieldOptions()` via `Promise.all`, calls `notFound()` for unknown ids, delegates to `OfferEditorClient` +- Created `src/components/admin/offers/OfferEditorClient.tsx`: client component covering the full UI-SPEC section 2 contract: + - Header with "← Indietro" link, "Modifica Offerta" heading, editable offer name via `EditableCell` + - "Tag" section: Categoria/Ticket via `OptionSelect` (single-select, with rename), Tipo/Obiettivo via `OptionMultiSelect` (multi-select, creatable on the fly via "+ Crea Tipo"/"+ Crea Obiettivo") + - "Servizi Inclusi" matrix: services filtered by `macro.category` (live-recomputed via `useMemo` when Categoria changes), one checkbox column per tier (A/B/C), "Totale Servizi" row recalculated instantly client-side on every toggle (OFFER-11), independent "Prezzo Pubblico" numeric input per tier (OFFER-16, D-5) + - "Promessa di Trasformazione" block: 5 `EditableCell` fields (Aiuto/A ottenere/In/Senza/Grazie a) mapped to `cliente_ideale`/`risultato`/`tempo`/`pain`/`metodo` (OFFER-17) + - "Salva Offerta" (disabled with tooltip unless at least one tier has an assigned service) → `saveOfferEditor` → redirect to `/admin/offers`; "Annulla" → plain link back; "Archivia" (visible only when not already archived) → confirm dialog → `toggleOfferArchived(id, true)` → redirect +- All literal Italian copy strings from the UI-SPEC copywriting contract table implemented verbatim + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Create editor route + page.tsx server component** - `8e0e4b9` (feat) +2. **Task 2: Build OfferEditorClient — tags, services matrix with live totals, promise block, actions** - `8c5c918` (feat) + +**Plan metadata:** (this commit, follows) + +## Files Created/Modified + +- `src/app/admin/offers/[id]/edit/page.tsx` - New server component: fetches editor data + field options, 404s on missing offer, renders `OfferEditorClient` +- `src/components/admin/offers/OfferEditorClient.tsx` - New client component: the entire offer editor UI and its Salva/Annulla/Archivia/rename interactions + +## Decisions Made + +- `EditableCell.onSave` in this codebase is `(value: string) => void` (synchronous), not `(value: string) => void | Promise` as the plan's interface sketch suggested — implemented all macro-field handlers as synchronous `setMacro` updates, matching the real signature (verified via `npx tsc --noEmit`) +- Tier padding: `padTiers()` always produces exactly 3 entries (A, B, C), filling missing tiers with empty placeholders (`id: ""`, `assignedServiceIds: []`, `servicesTotal: "0"`) so the matrix always renders 3 columns regardless of how many tiers exist in the DB +- Tag rename (Categoria/Ticket/Tipo/Obiettivo) follows the existing `ServiceTable`/`renameServiceOption` optimistic-update pattern: local state updates immediately, `renameOfferOption` server action fires via `startTransition` + +## Deviations from Plan + +None - plan executed exactly as written. The only adjustment was using the actual (synchronous) `EditableCell.onSave` signature instead of the plan's sketch signature, which is a same-behavior implementation detail (Rule 1 — code correctness), not a scope change. + +## Issues Encountered + +None. + +## User Setup Required + +None - no external service configuration required. This plan is pure UI (server + client components) consuming the already-deployed Plan 03 query layer and server actions against the live production schema (migration 0008). + +## Next Phase Readiness + +- `/admin/offers/[id]/edit` is fully functional and type-checks/lints clean across the whole project (`npx tsc --noEmit` and `npx eslint` both pass with zero errors) +- Plan 04's list page (`/admin/offers` + `OfferListClient.tsx`, built in parallel in a separate worktree) can link directly to `/admin/offers/[id]/edit` — no shared files were touched, no merge conflicts expected +- All four target requirements (OFFER-11, OFFER-15, OFFER-16, OFFER-17) are implemented end-to-end: UI -> `saveOfferEditor`/`toggleOfferArchived`/`renameOfferOption` (Plan 03) -> `offer_macros`/`offer_micros`/`offer_tier_services`/`tags` (Plan 01/02, live on prod) + +--- + +*Phase: 12-offer-composition-drag-drop-csv-import* +*Completed: 2026-06-15* + +## Self-Check: PASSED + +All created files verified present on disk: + +- FOUND: src/app/admin/offers/[id]/edit/page.tsx +- FOUND: src/components/admin/offers/OfferEditorClient.tsx +- FOUND: .planning/phases/12-offer-composition-drag-drop-csv-import/12-05-SUMMARY.md + +All task commits verified present in git log: + +- FOUND: 8e0e4b9 (Task 1) +- FOUND: 8c5c918 (Task 2)