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,284 @@
|
||||
---
|
||||
phase: 12
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [1, 3]
|
||||
files_modified:
|
||||
- src/app/admin/offers/page.tsx
|
||||
- src/components/admin/offers/OfferListClient.tsx
|
||||
autonomous: true
|
||||
requirements: [OFFER-18]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Admin sees a card grid of all offers, each showing internal name, short description, and category chip"
|
||||
- "Admin can filter the card grid by category (Tutti / Entry Offer / Signature Offer / Retainer Offer, derived dynamically from existing category values)"
|
||||
- "Admin can toggle 'Mostra offerte archiviate' to show/hide archived offers (hidden by default)"
|
||||
- "Admin can create a new offer via '+ Nuova Offerta', which creates an offer_macros row and the new card appears in the grid"
|
||||
- "Each card links to the Plan 05 editor route /admin/offers/[id]/edit"
|
||||
artifacts:
|
||||
- path: "src/app/admin/offers/page.tsx"
|
||||
provides: "Server component: fetches getOfferListCards() + getOfferFieldOptions(), renders OfferListClient"
|
||||
- path: "src/components/admin/offers/OfferListClient.tsx"
|
||||
provides: "Client component: category filter chips, archive toggle, card grid, '+ Nuova Offerta' button calling createOfferMacro"
|
||||
exports: ["OfferListClient"]
|
||||
key_links:
|
||||
- from: "src/components/admin/offers/OfferListClient.tsx"
|
||||
to: "/admin/offers/[id]/edit"
|
||||
via: "next/link Link per card"
|
||||
pattern: "admin/offers/\\$\\{.*\\}/edit"
|
||||
- from: "src/components/admin/offers/OfferListClient.tsx (+ Nuova Offerta)"
|
||||
to: "createOfferMacro server action (Plan 03)"
|
||||
via: "useTransition + router.refresh on success, new card appears in grid"
|
||||
pattern: "createOfferMacro"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Redesign `/admin/offers` (currently a flat macro/micro CRUD list — the legacy page) into the
|
||||
Phase 12 UI-SPEC card-grid list: filterable by category chips, archive toggle, "+ Nuova
|
||||
Offerta" CTA, each card linking to the new per-offer editor route (`/admin/offers/[id]/edit`,
|
||||
built in Plan 05).
|
||||
|
||||
Purpose: Deliver OFFER-18 (category-filterable, archive-aware offer list) per the locked
|
||||
UI-SPEC (`12-UI-SPEC.md` section 1) and D-1 (full editor list+detail, CSV import deferred).
|
||||
Output: `src/app/admin/offers/page.tsx` rewritten as the new list page;
|
||||
`src/components/admin/offers/OfferListClient.tsx` (new) implementing filter/grid/CTA logic.
|
||||
</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/app/admin/catalog/CatalogSearch.tsx
|
||||
@src/components/admin/AdminSidebar.tsx
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 03 (src/lib/offer-queries.ts) — use directly, no further exploration needed. -->
|
||||
|
||||
```typescript
|
||||
export type OfferListCard = Pick<OfferMacro, "id" | "internal_name" | "description" | "category" | "is_archived">;
|
||||
export async function getOfferListCards(): Promise<OfferListCard[]>;
|
||||
|
||||
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
|
||||
// Creates a new offer_macros row from FormData { internal_name, public_name?, description?, category? }
|
||||
// public_name defaults to internal_name if omitted. Returns void (revalidates /admin/offers).
|
||||
// NOTE: createOfferMacro does NOT return the new row's id (FormData server actions can't
|
||||
// easily return values to client callers in the <form action={fn}> pattern). For the
|
||||
// "+ Nuova Offerta" flow, Task 2 calls createOfferMacro via startTransition + FormData,
|
||||
// then router.refresh() — the new card appears in the grid; admin clicks it to open the
|
||||
// editor (Plan 05). No need to extend Plan 03's contract.
|
||||
export async function createOfferMacro(formData: FormData): Promise<void>;
|
||||
```
|
||||
|
||||
<!-- From src/app/admin/catalog/CatalogSearch.tsx — reference pattern for a client component
|
||||
wrapping a server-fetched list with client-side filter state (search/category chips). Read
|
||||
this file's structure (useState + useMemo filter, "use client") before writing OfferListClient. -->
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Rewrite /admin/offers page.tsx as the new list page entry point</name>
|
||||
<files>src/app/admin/offers/page.tsx</files>
|
||||
<read_first>
|
||||
- src/app/admin/offers/page.tsx (current — full file, ~239 lines, the legacy macro/micro
|
||||
CRUD page being replaced)
|
||||
- src/app/admin/catalog/page.tsx (pattern: thin server component, `Promise.all` fetch,
|
||||
delegates to a client component for interactivity)
|
||||
- .planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md (section 1:
|
||||
Offer List Page layout, header, filter row, card grid, empty state, copywriting
|
||||
contract table)
|
||||
</read_first>
|
||||
<action>
|
||||
Replace the entire contents of `src/app/admin/offers/page.tsx` with a thin server
|
||||
component matching the `catalog/page.tsx` pattern:
|
||||
|
||||
```typescript
|
||||
import { getOfferListCards, getOfferFieldOptions } from "@/lib/offer-queries";
|
||||
import { OfferListClient } from "@/components/admin/offers/OfferListClient";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function OffersPage() {
|
||||
const [cards, options] = await Promise.all([
|
||||
getOfferListCards(),
|
||||
getOfferFieldOptions(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<OfferListClient cards={cards} categoryOptions={options.categoria} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The legacy macro/micro/offer_services CRUD UI previously on this page (forms for
|
||||
`createMacro`/`createMicro`/`createOfferService`, `ServiceCheckboxList`, etc.) is
|
||||
REMOVED from this route per D-1 (full editor redesign) and the UI-SPEC (section 1 — no
|
||||
legacy CRUD forms in the new list page). The underlying `offer_macros`/`offer_micros`
|
||||
legacy actions (`createMacro`, `deleteMacro`, `createMicro`, `deleteMicro`,
|
||||
`updateMicroOfferServices`) remain exported from `actions.ts` (Plan 03 did not remove
|
||||
them) but are no longer referenced from any page — this is intentional dead-export
|
||||
cleanup deferred to a future phase if needed (do NOT delete the exports now, only
|
||||
routes/usages, to avoid breaking `npx tsc --noEmit` if anything else imports them; grep
|
||||
confirms nothing else does, but leaving the exports is zero-risk and out of this plan's
|
||||
scope to remove).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `src/app/admin/offers/page.tsx` is a server component importing `getOfferListCards`, `getOfferFieldOptions`, and `OfferListClient`
|
||||
- `grep -c "ServiceCheckboxList\|createMacro\|createMicro\|createOfferService" src/app/admin/offers/page.tsx` == 0 (legacy forms removed from this page)
|
||||
- `npx tsc --noEmit` passes (will fail until Task 2 creates `OfferListClient` — run combined verification after both tasks)
|
||||
</acceptance_criteria>
|
||||
<done>page.tsx is a thin server component delegating to OfferListClient with the new data shape.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Build OfferListClient — category filter, archive toggle, card grid, create CTA</name>
|
||||
<files>src/components/admin/offers/OfferListClient.tsx</files>
|
||||
<read_first>
|
||||
- src/app/admin/catalog/CatalogSearch.tsx (client component pattern: "use client",
|
||||
useState filter state, useMemo derived list, search input)
|
||||
- src/components/ui/button.tsx, src/components/ui/badge.tsx, src/components/ui/input.tsx
|
||||
(exported component signatures — use these for CTA button, category chips/badges,
|
||||
"+ Nuova Offerta" form input)
|
||||
- .planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md (section 1:
|
||||
exact layout ASCII diagram, color tokens #1A463C/#DEF168/#dc2626/#71717a/#e5e7eb,
|
||||
card structure, empty state copy, filter chip active/inactive styling, copywriting
|
||||
contract table for all literal strings)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `src/components/admin/offers/OfferListClient.tsx`:
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
import { useState, useMemo, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { createOfferMacro } from "@/app/admin/offers/actions";
|
||||
import type { OfferListCard } from "@/lib/offer-queries";
|
||||
|
||||
export function OfferListClient({
|
||||
cards,
|
||||
categoryOptions,
|
||||
}: {
|
||||
cards: OfferListCard[];
|
||||
categoryOptions: string[];
|
||||
}) {
|
||||
// ... implementation below
|
||||
}
|
||||
```
|
||||
|
||||
Implementation requirements (per UI-SPEC section 1 + copywriting contract table):
|
||||
|
||||
1. **State**: `activeCategory: string | null` (null = "Tutti"), `showArchived: boolean`
|
||||
(default `false`), `showCreateForm: boolean` + a controlled text input for the new
|
||||
offer's `internal_name`.
|
||||
|
||||
2. **Filter row**:
|
||||
- Chip row: "Tutti" + one chip per `categoryOptions` value. Active chip: border
|
||||
`#1A463C`, text `#1A463C`. Inactive: border `#e5e7eb`, text `#71717a`. 8px gap
|
||||
(`gap-2`).
|
||||
- "Mostra offerte archiviate" checkbox + label (`#71717a` text), toggles
|
||||
`showArchived`.
|
||||
|
||||
3. **Filtered list** (`useMemo`): `cards.filter(c => (activeCategory === null ||
|
||||
c.category === activeCategory) && (showArchived || !c.is_archived))`.
|
||||
|
||||
4. **Card grid**: `grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4`. Each card:
|
||||
- `Link href={`/admin/offers/${card.id}/edit`}` wrapping a `div` with white bg,
|
||||
`border border-[#e5e7eb] rounded-lg p-4 hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)]
|
||||
cursor-pointer transition-shadow`.
|
||||
- `internal_name` as h3 (16px/600).
|
||||
- `description` (if present) as body text, `line-clamp-2`, `#71717a`.
|
||||
- Category badge: small chip showing `category` (if present) — reuse
|
||||
`src/components/ui/badge.tsx` `<Badge>` or a styled `<span>`.
|
||||
- If `is_archived`: red "Archiviata" badge (text `#dc2626`).
|
||||
|
||||
5. **Empty state**: if `filteredCards.length === 0`, show centered block: heading
|
||||
"Nessuna offerta" (h3), body "Inizia creando la tua prima offerta" (`#71717a`), and
|
||||
the "+ Nuova Offerta" CTA (same as header).
|
||||
|
||||
6. **"+ Nuova Offerta" CTA** (top-right of page header, "Offerte" h2 title to its left):
|
||||
- Clicking "+ Nuova Offerta" toggles `showCreateForm`, revealing a small inline
|
||||
`<form>` with one text `<input name="internal_name" required placeholder="Nome
|
||||
offerta...">` and a submit button "Crea".
|
||||
- On submit: `startTransition(async () => { await createOfferMacro(formData);
|
||||
setShowCreateForm(false); router.refresh(); })`. After `router.refresh()`, the new
|
||||
card appears in the grid — do NOT attempt to auto-navigate to the editor in this
|
||||
plan (acceptable per UI-SPEC: admin clicks the new card to open the editor). Button
|
||||
style: bg `#1A463C`, text white, per UI-SPEC.
|
||||
|
||||
7. **Page header**: "Offerte" (h2, 20px/600, `#1a1a1a`) + "+ Nuova Offerta" button,
|
||||
`flex items-center justify-between`.
|
||||
|
||||
All literal copy strings come from the UI-SPEC copywriting contract table verbatim
|
||||
(Italian): "Offerte", "+ Nuova Offerta", "Mostra offerte archiviate", "Tutti",
|
||||
"Nessuna offerta", "Inizia creando la tua prima offerta", "Archiviata", "Crea", "Nome
|
||||
offerta...".
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30 && npx eslint src/app/admin/offers/page.tsx src/components/admin/offers/OfferListClient.tsx 2>&1 | tail -30</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npx tsc --noEmit` passes with zero errors (page.tsx + OfferListClient.tsx + rest of project)
|
||||
- `npx eslint src/app/admin/offers/page.tsx src/components/admin/offers/OfferListClient.tsx` reports zero errors
|
||||
- `grep -c "OfferListCard" src/components/admin/offers/OfferListClient.tsx` >= 1
|
||||
- `grep -c "createOfferMacro" src/components/admin/offers/OfferListClient.tsx` >= 1
|
||||
- `grep -c "admin/offers/\${" src/components/admin/offers/OfferListClient.tsx` >= 1 (card -> editor link)
|
||||
- `grep -c "Mostra offerte archiviate\|Nessuna offerta\|Nuova Offerta" src/components/admin/offers/OfferListClient.tsx` >= 3 (UI-SPEC copy present)
|
||||
</acceptance_criteria>
|
||||
<done>OfferListClient renders the category-filterable, archive-aware card grid with working "+ Nuova Offerta" creation; page.tsx + OfferListClient.tsx typecheck and lint clean.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Admin browser -> createOfferMacro (via "+ Nuova Offerta") | Free-text `internal_name` input crosses into a new `offer_macros` row |
|
||||
| Admin browser -> /admin/offers/[id]/edit links | Card `id` values are server-fetched (not user input) — links are safe by construction |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-12-10 | Tampering | "+ Nuova Offerta" inline form | accept | `createOfferMacro` (Plan 03) already calls `requireAdmin()` + Zod-validates `internal_name` non-empty; this plan only adds the UI trigger, no new validation surface |
|
||||
| T-12-11 | Information Disclosure | Offer list exposes `description`/`category`/`is_archived` for all offers | accept | Route is under `/admin/*`, Auth.js session-gated middleware (existing project-wide constraint); no change to access control in this plan |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. `npx tsc --noEmit` passes for the full project.
|
||||
2. `npx eslint` clean for both modified/created files.
|
||||
3. `/admin/offers` renders a card grid sourced from `getOfferListCards()`, with category filter chips from `getOfferFieldOptions().categoria` and an archive toggle (default off).
|
||||
4. "+ Nuova Offerta" creates a new `offer_macros` row via `createOfferMacro` and the new card appears after `router.refresh()`.
|
||||
5. Each card links to `/admin/offers/${id}/edit` (Plan 05's route — may 404 until Plan 05 lands, but the link target is correct).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `/admin/offers` matches the UI-SPEC section 1 layout: header + CTA, category filter chips, archive toggle, card grid, empty state.
|
||||
- OFFER-18 satisfied: list filterable by category, archive toggle hides archived offers by default.
|
||||
- No legacy macro/micro CRUD forms remain on `/admin/offers`.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-04-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user