chore: merge executor worktree (worktree-agent-a44f854f45ac76582)

This commit is contained in:
2026-06-15 10:28:07 +02:00
3 changed files with 643 additions and 0 deletions
@@ -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<void>` 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)
+23
View File
@@ -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 <OfferEditorClient data={data} fieldOptions={options} />;
}
@@ -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<string | null>(null);
const [macro, setMacro] = useState<MacroFields>({
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<OfferTierData[]>(() => padTiers(data.tiers));
const [tipoTags, setTipoTags] = useState<string[]>(data.tipoTags);
const [obiettivoTags, setObiettivoTags] = useState<string[]>(data.obiettivoTags);
const [categoriaOptions, setCategoriaOptions] = useState<string[]>(fieldOptions.categoria);
const [ticketOptions, setTicketOptions] = useState<string[]>(fieldOptions.ticket);
const [tipoOptions, setTipoOptions] = useState<string[]>(fieldOptions.tipo);
const [obiettivoOptions, setObiettivoOptions] = useState<string[]>(fieldOptions.obiettivo);
const [initialArchived] = useState<boolean>(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<K extends keyof MacroFields>(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 (
<div className="max-w-4xl mx-auto py-8 px-4 space-y-8">
{/* Header & navigation */}
<div>
<Link href="/admin/offers" className="text-sm text-[#71717a] hover:text-[#1a1a1a]">
Indietro
</Link>
<div className="mt-2 flex items-center justify-between">
<h2 className="text-xl font-semibold text-[#1a1a1a]">Modifica Offerta</h2>
</div>
<div className="mt-2">
<h3 className="text-base font-semibold text-[#1a1a1a]">
<EditableCell
value={macro.internal_name}
type="text"
required
onSave={(v) => updateMacro("internal_name", v)}
/>
</h3>
</div>
</div>
{/* Tags block */}
<section className="space-y-4">
<h3 className="text-base font-semibold text-[#1a1a1a]">Tag</h3>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">Categoria</span>
<OptionSelect
value={macro.category}
options={categoriaOptions}
onChange={(v) => updateMacro("category", v)}
onRename={handleRenameCategoria}
placeholder="Seleziona categoria..."
/>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">Ticket</span>
<OptionSelect
value={macro.ticket}
options={ticketOptions}
onChange={(v) => updateMacro("ticket", v)}
onRename={handleRenameTicket}
placeholder="Seleziona ticket..."
/>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">Tipo</span>
<OptionMultiSelect
values={tipoTags}
options={tipoOptions}
onAdd={(v) => {
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"
/>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">Obiettivo</span>
<OptionMultiSelect
values={obiettivoTags}
options={obiettivoOptions}
onAdd={(v) => {
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"
/>
</div>
</section>
{/* Services matrix */}
<section className="space-y-3">
<h3 className="text-base font-semibold text-[#1a1a1a]">Servizi Inclusi</h3>
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
<tr>
<th className="text-left py-2 px-3 font-semibold text-[#71717a]">Servizio</th>
<th className="text-right py-2 px-3 font-semibold text-[#71717a]">Prezzo</th>
{TIER_LETTERS.map((letter) => (
<th
key={letter}
className="text-center py-2 px-3 font-semibold text-[#71717a] w-12"
>
{letter}
</th>
))}
</tr>
</thead>
<tbody>
{filteredServices.length === 0 ? (
<tr>
<td colSpan={2 + TIER_LETTERS.length} className="py-3 px-3 text-[#71717a]">
Nessun servizio disponibile per questa categoria
</td>
</tr>
) : (
filteredServices.map((service) => (
<tr
key={service.id}
className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150 h-10"
>
<td className="py-2 px-3 text-[#1a1a1a]">{service.name}</td>
<td className="py-2 px-3 text-right tabular-nums text-[#1a1a1a]">
{formatUnitPrice(service.unit_price)}
</td>
{tiers.map((tier, tierIdx) => (
<td key={tier.tier_letter} className="py-2 px-3 text-center">
<input
type="checkbox"
checked={tier.assignedServiceIds.includes(service.id)}
onChange={() => toggleService(tierIdx, service.id)}
style={{ accentColor: "#1A463C" }}
className="h-4 w-4 cursor-pointer"
/>
</td>
))}
</tr>
))
)}
</tbody>
<tfoot>
<tr className="border-t border-[#e5e7eb]">
<td className="py-2 px-3 font-semibold text-[#1a1a1a]" colSpan={2}>
Totale Servizi
</td>
{tierTotals.map((total, idx) => (
<td
key={tiers[idx].tier_letter}
className="py-2 px-3 text-center font-semibold text-[#1a1a1a] tabular-nums"
>
{formatEuro(total)}
</td>
))}
</tr>
<tr>
<td className="py-2 px-3 font-semibold text-[#1a1a1a]" colSpan={2}>
Prezzo Pubblico
</td>
{tiers.map((tier, tierIdx) => (
<td key={tier.tier_letter} className="py-2 px-3 text-center">
<input
type="number"
min="0"
step="0.01"
value={tier.public_price ?? ""}
onChange={(e) => 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"
/>
</td>
))}
</tr>
</tfoot>
</table>
</div>
</div>
</section>
{/* Transformation promise block */}
<section className="space-y-4">
<h3 className="text-base font-semibold text-[#1a1a1a]">Promessa di Trasformazione</h3>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">Aiuto</span>
<div className="flex-1">
<EditableCell
value={macro.cliente_ideale}
type="text"
placeholder="Aggiungi cliente ideale"
onSave={(v) => updateMacro("cliente_ideale", v)}
/>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">A ottenere</span>
<div className="flex-1">
<EditableCell
value={macro.risultato}
type="text"
placeholder="Aggiungi risultato"
onSave={(v) => updateMacro("risultato", v)}
/>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">In</span>
<div className="flex-1">
<EditableCell
value={macro.tempo}
type="text"
placeholder="Aggiungi durata (es. 3 mesi)"
onSave={(v) => updateMacro("tempo", v)}
/>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">Senza</span>
<div className="flex-1">
<EditableCell
value={macro.pain}
type="text"
placeholder="Aggiungi pain point"
onSave={(v) => updateMacro("pain", v)}
/>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-[#71717a] w-24 shrink-0">Grazie a</span>
<div className="flex-1">
<EditableCell
value={macro.metodo}
type="text"
placeholder="Aggiungi metodo"
onSave={(v) => updateMacro("metodo", v)}
/>
</div>
</div>
</section>
{/* Action buttons */}
{saveError && <p className="text-sm text-red-600">{saveError}</p>}
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleSave}
disabled={!canSave || isPending}
title={!canSave ? "Seleziona almeno un servizio" : undefined}
className="bg-[#1A463C] text-white text-sm font-semibold px-6 py-4 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[#163a31] transition-colors duration-150"
>
Salva Offerta
</button>
<Link
href="/admin/offers"
className="border border-[#e5e7eb] text-[#1a1a1a] text-sm font-semibold px-6 py-4 rounded hover:bg-[#f9f9f9] transition-colors duration-150"
>
Annulla
</Link>
{!initialArchived && (
<button
type="button"
onClick={handleArchive}
disabled={isPending}
className="text-[#dc2626] text-sm font-semibold px-6 py-4 rounded hover:bg-red-50 transition-colors duration-150 disabled:opacity-50"
>
Archivia
</button>
)}
</div>
</div>
);
}