docs(12): create phase plan
Re-scoped Offer Editor (Tier A/B/C, Tag & Prezzo Pubblico) per 2026-06-14 user decision: 5-plan wave structure (schema migration -> prod apply checkpoint -> query/action layer -> list + detail editor UI), OFFER-12 CSV/Notion import deferred.
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
---
|
||||
phase: 12
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [1, 3]
|
||||
files_modified:
|
||||
- src/app/admin/offers/[id]/edit/page.tsx
|
||||
- src/components/admin/offers/OfferEditorClient.tsx
|
||||
autonomous: true
|
||||
requirements: [OFFER-11, OFFER-15, OFFER-16, OFFER-17]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Admin can open /admin/offers/[id]/edit and see the offer's name, category/ticket chips, 4 tag dimensions, services matrix, transformation promise, and Salva/Annulla/Archivia actions"
|
||||
- "Admin can select Categoria and Ticket (single-select), and add/remove Tipo and Obiettivo tags (multi-select, creatable on the fly) — OFFER-15"
|
||||
- "Admin sees a service x tier (A/B/C) checkbox matrix pre-filtered to services matching the offer's category, with live-updating 'Totale Servizi' per tier as checkboxes toggle — OFFER-11"
|
||||
- "Admin can enter an independent manual 'Prezzo Pubblico' per tier, separate from 'Totale Servizi' — OFFER-16"
|
||||
- "Admin can edit 5 transformation-promise fields (Aiuto/A ottenere/In/Senza/Grazie a) — OFFER-17"
|
||||
- "Clicking 'Salva Offerta' persists all changes (macro fields, tier data, tier-service assignments, tags) via saveOfferEditor and redirects to /admin/offers"
|
||||
- "Clicking 'Archivia' toggles is_archived (with confirmation) via toggleOfferArchived"
|
||||
artifacts:
|
||||
- path: "src/app/admin/offers/[id]/edit/page.tsx"
|
||||
provides: "Server component: fetches getOfferEditorData(id) + getOfferFieldOptions(), renders OfferEditorClient or notFound()"
|
||||
- path: "src/components/admin/offers/OfferEditorClient.tsx"
|
||||
provides: "Client component: editable name/category/ticket, 4-dimension tags, services matrix with live totals, transformation promise fields, Salva/Annulla/Archivia actions"
|
||||
exports: ["OfferEditorClient"]
|
||||
key_links:
|
||||
- from: "src/components/admin/offers/OfferEditorClient.tsx (Salva Offerta)"
|
||||
to: "saveOfferEditor server action (Plan 03)"
|
||||
via: "useTransition + router.push('/admin/offers') on success"
|
||||
pattern: "saveOfferEditor"
|
||||
- from: "src/components/admin/offers/OfferEditorClient.tsx (services matrix)"
|
||||
to: "OfferEditorData.tiers[].assignedServiceIds + availableServices"
|
||||
via: "client-side state, checkbox toggles update local tier.assignedServiceIds, Totale Servizi recomputed via useMemo from unit_price sums"
|
||||
pattern: "Totale Servizi|assignedServiceIds"
|
||||
- from: "src/components/admin/offers/OfferEditorClient.tsx (Archivia)"
|
||||
to: "toggleOfferArchived server action (Plan 03)"
|
||||
via: "confirm dialog + useTransition + router.push('/admin/offers')"
|
||||
pattern: "toggleOfferArchived"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the Offer Editor detail page at `/admin/offers/[id]/edit`: a full-page editor covering
|
||||
the offer's name/category/ticket, 4-dimension tags (Categoria/Ticket single-select,
|
||||
Tipo/Obiettivo multi-select creatable on the fly), the service x tier (A/B/C) checkbox
|
||||
matrix pre-filtered by category with live "Totale Servizi" totals, independent manual
|
||||
"Prezzo Pubblico" per tier, the 5-field transformation promise block, and
|
||||
Salva/Annulla/Archivia actions.
|
||||
|
||||
Purpose: Deliver OFFER-11 (3-tier checkbox matrix with live totals), OFFER-15 (4-dimension
|
||||
tags), OFFER-16 (manual public price per tier), and OFFER-17 (structured transformation
|
||||
promise) per the locked UI-SPEC section 2 and CONTEXT.md decisions D-2 through D-5.
|
||||
Output: `src/app/admin/offers/[id]/edit/page.tsx` (new server component) and
|
||||
`src/components/admin/offers/OfferEditorClient.tsx` (new client component) implementing the
|
||||
full editor.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md
|
||||
@src/components/admin/catalog/ServiceTable.tsx
|
||||
@src/components/ui/option-select.tsx
|
||||
@src/components/ui/option-multi-select.tsx
|
||||
@src/components/ui/editable-cell.tsx
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 03 (src/lib/offer-queries.ts) — use directly, no further exploration needed. -->
|
||||
|
||||
```typescript
|
||||
export type OfferTierData = {
|
||||
id: string;
|
||||
tier_letter: string | null; // "A" | "B" | "C" | null
|
||||
internal_name: string;
|
||||
public_name: string;
|
||||
duration_months: number;
|
||||
public_price: string | null; // numeric as string, e.g. "499.00"
|
||||
assignedServiceIds: string[];
|
||||
servicesTotal: string; // pre-computed sum, numeric as string e.g. "350.00"
|
||||
};
|
||||
|
||||
export type OfferEditorData = {
|
||||
macro: OfferMacro; // includes id, internal_name, public_name, description,
|
||||
// category, ticket, is_archived, cliente_ideale,
|
||||
// risultato, tempo, pain, metodo (all from Plan 01 schema)
|
||||
tiers: OfferTierData[]; // ordered A, B, C (0-3 entries)
|
||||
availableServices: Array<{ id: string; name: string; unit_price: string; category: string | null }>;
|
||||
tipoTags: string[]; // current Tipo tag values for this offer
|
||||
obiettivoTags: string[]; // current Obiettivo tag values for this offer
|
||||
};
|
||||
|
||||
export async function getOfferEditorData(macroId: string): Promise<OfferEditorData | null>;
|
||||
|
||||
export type OfferFieldOptions = { categoria: string[]; ticket: string[]; tipo: string[]; obiettivo: string[] };
|
||||
export async function getOfferFieldOptions(): Promise<OfferFieldOptions>;
|
||||
```
|
||||
|
||||
<!-- From Plan 03 (src/app/admin/offers/actions.ts) — use directly. -->
|
||||
|
||||
```typescript
|
||||
// Zod-validated; tiers array max 3, each tier has tier_letter "A"|"B"|"C",
|
||||
// internal_name, public_name, duration_months (int >= 1), public_price (number >= 0 | null),
|
||||
// assignedServiceIds (string[]). id optional per tier (omit for new tier, include to update).
|
||||
export type SaveOfferEditorPayload = {
|
||||
internal_name: string;
|
||||
public_name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
ticket?: string;
|
||||
cliente_ideale?: string;
|
||||
risultato?: string;
|
||||
tempo?: string;
|
||||
pain?: string;
|
||||
metodo?: string;
|
||||
tiers: Array<{
|
||||
id?: string;
|
||||
tier_letter: "A" | "B" | "C";
|
||||
internal_name: string;
|
||||
public_name: string;
|
||||
duration_months: number;
|
||||
public_price?: number | null;
|
||||
assignedServiceIds: string[];
|
||||
}>;
|
||||
tipoTags: string[];
|
||||
obiettivoTags: string[];
|
||||
};
|
||||
|
||||
export async function saveOfferEditor(macroId: string, payload: SaveOfferEditorPayload): Promise<void>;
|
||||
export async function toggleOfferArchived(macroId: string, archived: boolean): Promise<void>;
|
||||
export async function addOfferTag(dimension: "tipo" | "obiettivo", macroId: string, value: string): Promise<void>;
|
||||
export async function removeOfferTag(dimension: "tipo" | "obiettivo", macroId: string, value: string): Promise<void>;
|
||||
export async function renameOfferOption(field: "categoria" | "ticket" | "tipo" | "obiettivo", oldValue: string, newValue: string): Promise<void>;
|
||||
```
|
||||
|
||||
<!-- From src/components/ui/option-select.tsx and option-multi-select.tsx — use directly for
|
||||
Categoria/Ticket (single-select) and Tipo/Obiettivo (multi-select) dimension rows. -->
|
||||
|
||||
```typescript
|
||||
export interface OptionSelectProps {
|
||||
value: string | null;
|
||||
options: string[];
|
||||
onChange: (value: string | null) => void;
|
||||
onRename?: (oldValue: string, newValue: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
export function OptionSelect(props: OptionSelectProps): JSX.Element;
|
||||
|
||||
export interface OptionMultiSelectProps {
|
||||
values: string[];
|
||||
options: string[];
|
||||
onAdd: (value: string) => void;
|
||||
onRemove: (value: string) => void;
|
||||
onRename?: (oldValue: string, newValue: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
export function OptionMultiSelect(props: OptionMultiSelectProps): JSX.Element;
|
||||
```
|
||||
|
||||
<!-- From src/components/ui/editable-cell.tsx — use directly for Offerta Name and
|
||||
transformation-promise fields. -->
|
||||
|
||||
```typescript
|
||||
export interface EditableCellProps {
|
||||
value: string;
|
||||
type?: "text" | "number";
|
||||
onSave: (value: string) => void | Promise<void>;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
formatDisplay?: (value: string) => string;
|
||||
}
|
||||
export function EditableCell(props: EditableCellProps): JSX.Element;
|
||||
```
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create editor route + page.tsx server component</name>
|
||||
<files>src/app/admin/offers/[id]/edit/page.tsx</files>
|
||||
<read_first>
|
||||
- src/app/admin/offers/page.tsx (current, post-Plan-04 — server component fetch pattern)
|
||||
- src/lib/offer-queries.ts (Plan 03 — getOfferEditorData, getOfferFieldOptions signatures, confirm null-handling)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/app/admin/offers/[id]/edit/page.tsx` as a server component (Next.js 16 App
|
||||
Router, async `params`):
|
||||
|
||||
```typescript
|
||||
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} />;
|
||||
}
|
||||
```
|
||||
|
||||
Use the Next.js 16 async `params` pattern (consistent with other dynamic routes in this
|
||||
project — confirm by checking an existing `[id]` route if one exists, e.g. under
|
||||
`/admin/leads/[id]` from Phase 14, otherwise this signature is correct for Next 16 App
|
||||
Router).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && grep -c "getOfferEditorData\|getOfferFieldOptions\|OfferEditorClient\|notFound" src/app/admin/offers/\[id\]/edit/page.tsx</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File exists at `src/app/admin/offers/[id]/edit/page.tsx`
|
||||
- Server component (no `"use client"` directive)
|
||||
- Calls `getOfferEditorData(id)` and `getOfferFieldOptions()` via `Promise.all`
|
||||
- Calls `notFound()` when `data === null`
|
||||
- Renders `<OfferEditorClient data={data} fieldOptions={options} />`
|
||||
</acceptance_criteria>
|
||||
<done>page.tsx exists, fetches editor data + field options, handles not-found, delegates to OfferEditorClient.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 2: Build OfferEditorClient — tags, services matrix with live totals, promise block, actions</name>
|
||||
<files>src/components/admin/offers/OfferEditorClient.tsx</files>
|
||||
<read_first>
|
||||
- src/components/admin/catalog/ServiceTable.tsx (EditableCell/OptionSelect/OptionMultiSelect
|
||||
usage patterns, table layout conventions)
|
||||
- .planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md section 2 (full
|
||||
editor layout, color tokens, copywriting contract table, visual states)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/components/admin/offers/OfferEditorClient.tsx`:
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useState, useMemo, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
saveOfferEditor,
|
||||
toggleOfferArchived,
|
||||
addOfferTag,
|
||||
removeOfferTag,
|
||||
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 } from "@/lib/offer-queries";
|
||||
|
||||
export function OfferEditorClient({
|
||||
data,
|
||||
fieldOptions,
|
||||
}: {
|
||||
data: OfferEditorData;
|
||||
fieldOptions: OfferFieldOptions;
|
||||
}) {
|
||||
// ... implementation below
|
||||
}
|
||||
```
|
||||
|
||||
Implementation requirements (per UI-SPEC section 2 + copywriting contract table — all
|
||||
literal strings verbatim Italian):
|
||||
|
||||
1. **Local editable state** (all derived from `data` on mount, no re-fetch on change):
|
||||
- `macro` fields: `internal_name`, `public_name`, `description`, `category`,
|
||||
`ticket`, `cliente_ideale`, `risultato`, `tempo`, `pain`, `metodo` — each as
|
||||
individual `useState<string>` or one combined `useState<typeof initialMacro>`
|
||||
object updated via spread.
|
||||
- `tiers: OfferTierData[]` state, seeded from `data.tiers`. If `data.tiers.length <
|
||||
3`, pad to exactly 3 tiers (A, B, C) with empty placeholders: `{ id: "", tier_letter:
|
||||
"A"|"B"|"C", internal_name: "", public_name: "", duration_months: 1, public_price:
|
||||
null, assignedServiceIds: [], servicesTotal: "0" }` for any missing letter, so the
|
||||
matrix always renders 3 columns.
|
||||
- `tipoTags: string[]`, `obiettivoTags: string[]` state, seeded from
|
||||
`data.tipoTags`/`data.obiettivoTags`.
|
||||
- `categoriaOptions`/`ticketOptions`/`tipoOptions`/`obiettivoOptions` from
|
||||
`fieldOptions`, kept in local state so `renameOfferOption` can update them
|
||||
optimistically.
|
||||
|
||||
2. **Header & navigation**:
|
||||
- "← Indietro" link (text, `#71717a`) -> `Link href="/admin/offers"`.
|
||||
- "Modifica Offerta" as h2 (20px/600).
|
||||
- Offer name as `EditableCell` (text, required) bound to `macro.internal_name`, styled
|
||||
as h3 (16px/600).
|
||||
|
||||
3. **Tags block** ("Tag" h3 section label, 16px between rows):
|
||||
- **Categoria** row: label "Categoria" (12px/400, `#71717a`) + `<OptionSelect
|
||||
value={macro.category} options={categoriaOptions} onChange={...} onRename={(old,
|
||||
next) => { renameOfferOption("categoria", old, next); /* update local options +
|
||||
macro.category if it matched old */ }} placeholder="Seleziona categoria..." />`.
|
||||
When `macro.category` changes, the services matrix (step 5) must re-filter
|
||||
`availableServices` by the new category (D-4) — implement via `useMemo` keyed on
|
||||
`macro.category`.
|
||||
- **Ticket** row: same pattern with `macro.ticket` / `ticketOptions` /
|
||||
`renameOfferOption("ticket", ...)`, placeholder "Seleziona ticket...".
|
||||
- **Tipo** row: label "Tipo" + `<OptionMultiSelect values={tipoTags}
|
||||
options={tipoOptions} onAdd={(v) => { setTipoTags([...tipoTags, v]); if
|
||||
(!tipoOptions.includes(v)) setTipoOptions([...tipoOptions, v]); }}
|
||||
onRemove={(v) => setTipoTags(tipoTags.filter(t => t !== v))}
|
||||
onRename={(old, next) => renameOfferOption("tipo", old, next)} placeholder="+ Crea
|
||||
Tipo" />`. Tag additions/removals are tracked in local state only and persisted on
|
||||
"Salva Offerta" via `tipoTags` in the payload — do NOT call `addOfferTag`/
|
||||
`removeOfferTag` on every click (avoids partial-save inconsistency); these two
|
||||
actions exist for potential future inline-only flows but this editor batches via
|
||||
`saveOfferEditor`.
|
||||
- **Obiettivo** row: same pattern as Tipo, using `obiettivoTags`/`obiettivoOptions`,
|
||||
placeholder "+ Crea Obiettivo".
|
||||
|
||||
4. **Services matrix** ("Servizi Inclusi" h3 section label):
|
||||
- `filteredServices = useMemo(() => macro.category ? data.availableServices.filter(s
|
||||
=> s.category === macro.category) : data.availableServices, [macro.category,
|
||||
data.availableServices])`.
|
||||
- Table: header row "Servizio" (left) | "Prezzo" (right-aligned) | "A" | "B" | "C"
|
||||
(centered), header bg `#f9f9f9`, border-bottom `#e5e7eb`.
|
||||
- One row per `filteredServices` item: service `name` (left), `unit_price` formatted
|
||||
as "€X,XX" (right-aligned, tabular-nums), then one `<input type="checkbox">` per
|
||||
tier (A/B/C), checked if `tiers[tierIdx].assignedServiceIds.includes(service.id)`.
|
||||
On change: toggle the service id in/out of that tier's `assignedServiceIds` array
|
||||
(immutable update via `setTiers`). Checkbox accent color `#1A463C` (use
|
||||
`accent-[#1A463C]` Tailwind class or inline style `accentColor: "#1A463C"`). Row
|
||||
height ~40px, hover bg `#f9f9f9`, border-bottom `#e5e7eb`.
|
||||
- If `filteredServices.length === 0`: render a single placeholder row spanning all
|
||||
columns with text "Nessun servizio disponibile per questa categoria" (`#71717a`),
|
||||
table headers still visible.
|
||||
- **Totale Servizi row** (sticky/bold, bottom of table, computed via `useMemo`):
|
||||
label "Totale Servizi" (bold 14px/600, `#1a1a1a`), then per tier:
|
||||
`tiers[i].assignedServiceIds.reduce((sum, id) => sum +
|
||||
Number(filteredServices.find(s => s.id === id)?.unit_price ?? 0), 0)` formatted as
|
||||
"€X.XXX,XX" (Italian locale, e.g. `toLocaleString("it-IT", { minimumFractionDigits:
|
||||
2, maximumFractionDigits: 2 })`). Recomputes instantly on every checkbox toggle (no
|
||||
network call) — this is the OFFER-11 live-total requirement.
|
||||
- **Prezzo Pubblico row** (bold label, bottom of table, below Totale Servizi): label
|
||||
"Prezzo Pubblico" (bold 14px/600), then per tier an `<input type="number" min="0"
|
||||
step="0.01">` bound to `tiers[i].public_price` (string | null -> controlled value
|
||||
`tiers[i].public_price ?? ""`), placeholder "€0,00". On change, update
|
||||
`tiers[i].public_price` in state (store as string; convert to number on save).
|
||||
Independent of Totale Servizi (D-5) — no auto-sync between the two rows.
|
||||
|
||||
5. **Transformation Promise block** ("Promessa di Trasformazione" h3 section label, 16px
|
||||
gap between fields): 5 rows, each `label (12px/400, #71717a) + EditableCell(text,
|
||||
optional)`:
|
||||
- "Aiuto" -> `macro.cliente_ideale`, placeholder "Aggiungi cliente ideale"
|
||||
- "A ottenere" -> `macro.risultato`, placeholder "Aggiungi risultato"
|
||||
- "In" -> `macro.tempo`, placeholder "Aggiungi durata (es. 3 mesi)"
|
||||
- "Senza" -> `macro.pain`, placeholder "Aggiungi pain point"
|
||||
- "Grazie a" -> `macro.metodo`, placeholder "Aggiungi metodo"
|
||||
All optional (no `required` prop), `onSave` updates the corresponding `macro.*` state
|
||||
field.
|
||||
|
||||
6. **Action buttons** (bottom, 8px gap):
|
||||
- **"Salva Offerta"** (primary, bg `#1A463C`, text white, 14px/600, padding `16px
|
||||
24px`): disabled if NO tier has `assignedServiceIds.length > 0` (tooltip "Seleziona
|
||||
almeno un servizio" via `title` attribute when disabled). On click:
|
||||
`startTransition(async () => { const payload: SaveOfferEditorPayload = { ...macro
|
||||
fields, tiers: tiers.map(t => ({ id: t.id || undefined, tier_letter: t.tier_letter,
|
||||
internal_name: t.internal_name || t.tier_letter (e.g. "Tier A"), public_name:
|
||||
t.public_name || t.tier_letter, duration_months: t.duration_months || 1,
|
||||
public_price: t.public_price ? Number(t.public_price) : null, assignedServiceIds:
|
||||
t.assignedServiceIds })), tipoTags, obiettivoTags }; try { await
|
||||
saveOfferEditor(data.macro.id, payload); router.push("/admin/offers"); } catch (e) {
|
||||
/* show inline error text "Errore nel salvataggio. Verifica i campi." */ } })`.
|
||||
- **"Annulla"** (secondary, border `#e5e7eb`, text `#1a1a1a`): `Link
|
||||
href="/admin/offers"` — no dirty-check confirmation in this iteration (acceptable
|
||||
simplification per UI-SPEC note "Auto-save optional (planner decides)" — explicit
|
||||
Salva is the persistence model, Annulla is a plain navigation link).
|
||||
- **"Archivia"** (destructive, text `#dc2626`, no fill), visible only if
|
||||
`!macro_initial.is_archived` (track initial archived state separately from any
|
||||
local toggle): on click, `window.confirm("Sei sicuro? L'offerta non sarà visibile
|
||||
nella lista.")`, if confirmed `startTransition(async () => { await
|
||||
toggleOfferArchived(data.macro.id, true); router.push("/admin/offers"); })`.
|
||||
|
||||
All literal copy strings (Italian) per the UI-SPEC copywriting contract table: "←
|
||||
Indietro", "Modifica Offerta", "Tag", "Categoria", "Ticket", "Tipo", "Obiettivo", "+ Crea
|
||||
Tipo", "+ Crea Obiettivo", "Servizi Inclusi", "Servizio", "Prezzo", "A", "B", "C",
|
||||
"Totale Servizi", "Prezzo Pubblico", "Promessa di Trasformazione", "Aiuto", "A
|
||||
ottenere", "In", "Senza", "Grazie a", "Salva Offerta", "Annulla", "Archivia", "Sei
|
||||
sicuro? L'offerta non sarà visibile nella lista.", "Nessun servizio disponibile per
|
||||
questa categoria", "Errore nel salvataggio. Verifica i campi.", "Seleziona almeno un
|
||||
servizio".
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30 && npx eslint src/app/admin/offers/\[id\]/edit/page.tsx src/components/admin/offers/OfferEditorClient.tsx 2>&1 | tail -30</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npx tsc --noEmit` passes with zero errors for the full project
|
||||
- `npx eslint src/app/admin/offers/[id]/edit/page.tsx src/components/admin/offers/OfferEditorClient.tsx` reports zero errors
|
||||
- `grep -c "saveOfferEditor\|toggleOfferArchived" src/components/admin/offers/OfferEditorClient.tsx` >= 2
|
||||
- `grep -c "OptionSelect\|OptionMultiSelect" src/components/admin/offers/OfferEditorClient.tsx` >= 2 (Categoria/Ticket via OptionSelect, Tipo/Obiettivo via OptionMultiSelect)
|
||||
- `grep -c "Totale Servizi\|Prezzo Pubblico" src/components/admin/offers/OfferEditorClient.tsx` >= 2
|
||||
- `grep -c "Promessa di Trasformazione\|cliente_ideale\|risultato\|tempo\|pain\|metodo" src/components/admin/offers/OfferEditorClient.tsx` >= 6
|
||||
- `grep -c "type=\"checkbox\"" src/components/admin/offers/OfferEditorClient.tsx` >= 1
|
||||
- `grep -c "Nessun servizio disponibile per questa categoria" src/components/admin/offers/OfferEditorClient.tsx` == 1
|
||||
</acceptance_criteria>
|
||||
<done>OfferEditorClient renders the full editor per UI-SPEC section 2: tags (4 dimensions), category-filtered services matrix with live Totale Servizi + independent Prezzo Pubblico per tier, transformation promise block, and working Salva/Annulla/Archivia actions; project typechecks and lints clean.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Admin browser -> saveOfferEditor | Free-text macro fields, tier data, service-id arrays, and tag arrays cross into `offer_macros`/`offer_micros`/`offer_tier_services`/`tags` rows |
|
||||
| Admin browser -> toggleOfferArchived | Boolean flag crosses into `offer_macros.is_archived` |
|
||||
| URL param `[id]` -> getOfferEditorData | Route param crosses into a DB lookup; returns `null` (404) for unknown/foreign ids |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-12-12 | Tampering | "Salva Offerta" payload (tier_letter, public_price, assignedServiceIds, tag arrays) | mitigate | `saveOfferEditor` (Plan 03) Zod-validates `tier_letter` against `z.enum(["A","B","C"])`, `public_price` against `z.coerce.number().min(0)`, and the CHECK constraint on `offer_micros.tier_letter` (Plan 01) provides DB-level defense in depth |
|
||||
| T-12-13 | Tampering | `assignedServiceIds` referencing services outside the offer's category or inactive services | accept | `availableServices` is server-fetched and category-filtered (D-4); a malicious client could submit arbitrary service ids in the payload, but `offer_tier_services` has FK `ON DELETE CASCADE` to `services.id` (Plan 01) — invalid ids fail the FK constraint, no data corruption possible; low-value target (admin-only route) |
|
||||
| T-12-14 | Elevation of Privilege | All editor mutations (`saveOfferEditor`, `toggleOfferArchived`, `renameOfferOption`) | mitigate | Plan 03 wraps every action in `requireAdmin()`; this plan adds no new server-side surface, only UI calling the already-gated actions |
|
||||
| T-12-15 | Information Disclosure | `/admin/offers/[id]/edit` exposes full offer internals (internal_name, all tier data, transformation promise) | accept | Route is under `/admin/*`, Auth.js session-gated middleware (existing project-wide constraint); consistent with Plan 04's list page disposition (T-12-11) |
|
||||
| T-12-16 | Denial of Service | Unbounded `[id]` route param triggers a DB query for every request | accept | `getOfferEditorData` returns `null` -> `notFound()` for any non-matching id; standard Next.js 404 handling, no amplification risk on an admin-only route |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `npx tsc --noEmit` passes for the full project.
|
||||
2. `npx eslint` clean for both modified/created files.
|
||||
3. `/admin/offers/[id]/edit` renders the offer name, Categoria/Ticket single-select chips, Tipo/Obiettivo multi-select chips (with "+ Crea Tipo"/"+ Crea Obiettivo" inline creation), and the services matrix pre-filtered by `macro.category`.
|
||||
4. Toggling a service checkbox in any tier column instantly updates that tier's "Totale Servizi" value (client-side, no network call).
|
||||
5. Entering a value in "Prezzo Pubblico" for a tier does not affect "Totale Servizi" for that tier (independent fields, D-5).
|
||||
6. All 5 transformation-promise fields (Aiuto/A ottenere/In/Senza/Grazie a) are editable via EditableCell and map to `cliente_ideale`/`risultato`/`tempo`/`pain`/`metodo`.
|
||||
7. "Salva Offerta" is disabled (with tooltip) when no tier has any assigned service; when enabled, clicking it calls `saveOfferEditor` and navigates back to `/admin/offers`.
|
||||
8. "Archivia" (visible only for non-archived offers) prompts for confirmation, then calls `toggleOfferArchived(id, true)` and navigates back to `/admin/offers`.
|
||||
9. "Annulla" navigates back to `/admin/offers` without persisting changes.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `/admin/offers/[id]/edit` matches the UI-SPEC section 2 layout: header/back-link, name + tag dimensions, category-filtered services matrix with live totals and independent public price, transformation promise block, and Salva/Annulla/Archivia actions.
|
||||
- OFFER-11 satisfied: 3-tier (A/B/C) checkbox matrix with live "Totale Servizi" recalculation.
|
||||
- OFFER-15 satisfied: Categoria/Ticket single-select, Tipo/Obiettivo multi-select with on-the-fly creation, all backed by `getOfferFieldOptions`/`renameOfferOption`/`saveOfferEditor`.
|
||||
- OFFER-16 satisfied: "Prezzo Pubblico" per tier is a manual numeric input independent of "Totale Servizi".
|
||||
- OFFER-17 satisfied: all 5 transformation-promise fields editable and persisted via `saveOfferEditor`.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-05-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user