--- plan_id: 05-02 phase: 5 wave: 2 title: "Offer catalog admin CRUD — /admin/offers (macro + micro + services + multi-select assignment)" type: execute depends_on: [05-01] files_modified: - src/app/admin/offers/page.tsx - src/app/admin/offers/actions.ts - src/lib/offer-queries.ts - src/components/admin/NavBar.tsx requirements_addressed: [OFFER-01, OFFER-02, OFFER-03] autonomous: true must_haves: truths: - "Admin can create a macro-offer with internal_name, public_name, transformation_promise" - "Admin can create a micro-offer as a child of a macro, with internal_name, public_name, transformation_promise, duration_months" - "Admin can create offer services with name, price, transformation_description" - "Admin can assign services to a micro-offer via a checkbox list (many-to-many)" - "NavBar has an 'Offerte' link pointing to /admin/offers" artifacts: - path: "src/app/admin/offers/page.tsx" provides: "RSC page listing macros, their micros, and offer services" contains: "OfferMacroForm, OfferServiceForm" - path: "src/app/admin/offers/actions.ts" provides: "Server actions for all offer catalog mutations" contains: "createMacro, createMicro, createOfferService, updateMicroServices" - path: "src/lib/offer-queries.ts" provides: "Read queries for offer catalog" contains: "getCatalogWithMicros, getAllOfferServices" - path: "src/components/admin/NavBar.tsx" provides: "NavBar with Offerte link" contains: "/admin/offers" key_links: - from: "src/app/admin/offers/page.tsx" to: "src/lib/offer-queries.ts" via: "getCatalogWithMicros() server import" pattern: "getCatalogWithMicros" - from: "src/app/admin/offers/actions.ts" to: "src/db/schema.ts" via: "offer_macros, offer_micros, offer_services, offer_micro_services imports" pattern: "offer_macros|offer_micro_services" --- Build the full admin offer catalog at `/admin/offers`: create/list macro-offers, create/list micro-offers (children of macros), create/list offer services, and assign services to micro-offers via a checkbox list. Purpose: This is the data entry point for the entire offer system. Without catalog data, Plans 03 and 04 have nothing to assign or display. Output: `/admin/offers` page fully functional; server actions for all mutations; query layer in `offer-queries.ts`; NavBar updated. @/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/workflows/execute-plan.md @/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/templates/summary.md @/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md @/Users/simonecavalli/IAMCAVALLI/.planning/STATE.md @/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-RESEARCH.md @/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-01-SUMMARY.md ```typescript export const offer_macros = pgTable("offer_macros", { id: text("id").primaryKey().$defaultFn(() => nanoid()), internal_name: text("internal_name").notNull(), public_name: text("public_name").notNull(), transformation_promise: text("transformation_promise"), sort_order: integer("sort_order").notNull().default(0), created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); export const offer_micros = pgTable("offer_micros", { id: text("id").primaryKey().$defaultFn(() => nanoid()), macro_id: text("macro_id").notNull().references(() => offer_macros.id, { onDelete: "cascade" }), internal_name: text("internal_name").notNull(), public_name: text("public_name").notNull(), transformation_promise: text("transformation_promise"), duration_months: integer("duration_months").notNull().default(1), sort_order: integer("sort_order").notNull().default(0), }); export const offer_services = pgTable("offer_services", { id: text("id").primaryKey().$defaultFn(() => nanoid()), name: text("name").notNull(), price: numeric("price", { precision: 10, scale: 2 }).notNull(), transformation_description: text("transformation_description"), active: boolean("active").notNull().default(true), }); export const offer_micro_services = pgTable("offer_micro_services", { micro_id: text("micro_id").notNull(), service_id: text("service_id").notNull(), }, (t) => ({ pk: primaryKey({ columns: [t.micro_id, t.service_id] }) })); export type OfferMacro = typeof offer_macros.$inferSelect; export type OfferMicro = typeof offer_micros.$inferSelect; export type OfferService = typeof offer_services.$inferSelect; ``` ```typescript "use server"; async function requireAdmin() { const session = await getServerSession(authOptions); if (!session) throw new Error("Non autorizzato"); } // All actions: call requireAdmin() first, then zod parse, then db mutation, then revalidatePath("/admin/offers") ``` ```typescript // Add between Catalogo and Impostazioni: Offerte ``` Task 1: offer-queries.ts + server actions (actions.ts) src/lib/offer-queries.ts src/app/admin/offers/actions.ts - src/db/schema.ts — confirm column names for offer_macros, offer_micros, offer_services, offer_micro_services (after 05-01) - src/app/admin/catalog/actions.ts — copy requireAdmin() pattern, zod schema pattern, revalidatePath usage - src/lib/admin-queries.ts — copy import style (db, eq, inArray, asc, sql from drizzle-orm) **Create `src/lib/offer-queries.ts`:** ```typescript import { db } from "@/db"; import { offer_macros, offer_micros, offer_services, offer_micro_services, } from "@/db/schema"; import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema"; import { eq, asc, inArray, sql } from "drizzle-orm"; export type MicroWithServices = OfferMicro & { services: Array<{ id: string; name: string; price: string }>; cumulative_price: string; }; export type MacroWithMicros = OfferMacro & { micros: MicroWithServices[]; }; export async function getCatalogWithMicros(): Promise { const macros = await db .select() .from(offer_macros) .orderBy(asc(offer_macros.sort_order), asc(offer_macros.created_at)); if (macros.length === 0) return []; const macroIds = macros.map((m) => m.id); const micros = await db .select() .from(offer_micros) .where(inArray(offer_micros.macro_id, macroIds)) .orderBy(asc(offer_micros.sort_order)); const microIds = micros.map((m) => m.id); if (microIds.length === 0) { return macros.map((m) => ({ ...m, micros: [] })); } // Fetch junction rows + service data in one query const assignments = await db .select({ micro_id: offer_micro_services.micro_id, service_id: offer_services.id, name: offer_services.name, price: offer_services.price, }) .from(offer_micro_services) .innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id)) .where(inArray(offer_micro_services.micro_id, microIds)); // Cumulative price per micro const cumulRows = await db .select({ micro_id: offer_micro_services.micro_id, cumulative_price: sql`coalesce(sum(${offer_services.price}::numeric), 0)`, }) .from(offer_micro_services) .innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id)) .where(inArray(offer_micro_services.micro_id, microIds)) .groupBy(offer_micro_services.micro_id); const cumulMap = new Map(cumulRows.map((r) => [r.micro_id, r.cumulative_price])); const microsWithServices: MicroWithServices[] = micros.map((micro) => ({ ...micro, services: assignments .filter((a) => a.micro_id === micro.id) .map((a) => ({ id: a.service_id, name: a.name, price: String(a.price) })), cumulative_price: cumulMap.get(micro.id) ?? "0", })); return macros.map((macro) => ({ ...macro, micros: microsWithServices.filter((m) => m.macro_id === macro.id), })); } export async function getAllOfferServices(): Promise { return db .select() .from(offer_services) .where(eq(offer_services.active, true)) .orderBy(asc(offer_services.name)); } // Returns assigned service IDs for a given micro-offer (used by ServiceCheckboxList) export async function getMicroAssignedServiceIds(microId: string): Promise { const rows = await db .select({ service_id: offer_micro_services.service_id }) .from(offer_micro_services) .where(eq(offer_micro_services.micro_id, microId)); return rows.map((r) => r.service_id); } ``` **Create `src/app/admin/offers/actions.ts`:** ```typescript "use server"; import { db } from "@/db"; import { offer_macros, offer_micros, offer_services, offer_micro_services, } from "@/db/schema"; import { revalidatePath } from "next/cache"; import { eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; async function requireAdmin() { const session = await getServerSession(authOptions); if (!session) throw new Error("Non autorizzato"); } // ── Macro-offer CRUD ───────────────────────────────────────────────────────── const macroSchema = z.object({ internal_name: z.string().min(1, "Nome interno richiesto"), public_name: z.string().min(1, "Nome pubblico richiesto"), transformation_promise: z.string().optional(), }); export async function createMacro(formData: FormData) { await requireAdmin(); const parsed = macroSchema.safeParse({ internal_name: formData.get("internal_name"), public_name: formData.get("public_name"), transformation_promise: formData.get("transformation_promise") ?? "", }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); await db.insert(offer_macros).values({ internal_name: parsed.data.internal_name, public_name: parsed.data.public_name, transformation_promise: parsed.data.transformation_promise || null, }); revalidatePath("/admin/offers"); } export async function deleteMacro(macroId: string) { await requireAdmin(); await db.delete(offer_macros).where(eq(offer_macros.id, macroId)); revalidatePath("/admin/offers"); } // ── Micro-offer CRUD ───────────────────────────────────────────────────────── const microSchema = z.object({ macro_id: z.string().min(1, "Macro offerta richiesta"), internal_name: z.string().min(1, "Nome interno richiesto"), public_name: z.string().min(1, "Nome pubblico richiesto"), transformation_promise: z.string().optional(), duration_months: z.coerce.number().int().min(1, "Durata minima 1 mese"), }); export async function createMicro(formData: FormData) { await requireAdmin(); const parsed = microSchema.safeParse({ macro_id: formData.get("macro_id"), internal_name: formData.get("internal_name"), public_name: formData.get("public_name"), transformation_promise: formData.get("transformation_promise") ?? "", duration_months: formData.get("duration_months"), }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); await db.insert(offer_micros).values({ macro_id: parsed.data.macro_id, internal_name: parsed.data.internal_name, public_name: parsed.data.public_name, transformation_promise: parsed.data.transformation_promise || null, duration_months: parsed.data.duration_months, }); revalidatePath("/admin/offers"); } export async function deleteMicro(microId: string) { await requireAdmin(); // offer_micro_services will cascade; project_offers will restrict (DB constraint) await db.delete(offer_micros).where(eq(offer_micros.id, microId)); revalidatePath("/admin/offers"); } // ── Offer service CRUD ─────────────────────────────────────────────────────── const offerServiceSchema = z.object({ name: z.string().min(1, "Nome richiesto"), price: z.coerce.number().min(0, "Prezzo non può essere negativo"), transformation_description: z.string().optional(), }); export async function createOfferService(formData: FormData) { await requireAdmin(); const parsed = offerServiceSchema.safeParse({ name: formData.get("name"), price: formData.get("price"), transformation_description: formData.get("transformation_description") ?? "", }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); await db.insert(offer_services).values({ name: parsed.data.name, price: parsed.data.price.toFixed(2), transformation_description: parsed.data.transformation_description || null, }); revalidatePath("/admin/offers"); } export async function toggleOfferServiceActive(serviceId: string, active: boolean) { await requireAdmin(); await db.update(offer_services).set({ active }).where(eq(offer_services.id, serviceId)); revalidatePath("/admin/offers"); } // ── Multi-select service assignment ───────────────────────────────────────── // Replaces all assignments for a micro with the provided serviceIds (upsert-replace pattern) export async function updateMicroOfferServices(microId: string, serviceIds: string[]) { await requireAdmin(); // Delete existing assignments for this micro await db.delete(offer_micro_services).where(eq(offer_micro_services.micro_id, microId)); // Re-insert with new set (empty array = unassign all) if (serviceIds.length > 0) { await db.insert(offer_micro_services).values( serviceIds.map((serviceId) => ({ micro_id: microId, service_id: serviceId })) ); } revalidatePath("/admin/offers"); } ``` cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20 - `npx tsc --noEmit` exits 0 - `grep -c "requireAdmin" src/app/admin/offers/actions.ts` returns 5 or more (every exported action calls it) - `grep "revalidatePath" src/app/admin/offers/actions.ts` returns at least 5 matches (every mutating action revalidates) - `grep "getCatalogWithMicros\|getAllOfferServices\|getMicroAssignedServiceIds" src/lib/offer-queries.ts` returns 3 matches offer-queries.ts and actions.ts created; TypeScript compiles; every action guards with requireAdmin() Task 2: /admin/offers RSC page + NavBar update src/app/admin/offers/page.tsx src/components/admin/NavBar.tsx - src/app/admin/catalog/page.tsx — read for the admin page structure pattern (RSC, revalidate = 0, form patterns) - src/components/admin/NavBar.tsx — read full file before editing (must preserve all existing links) - src/lib/offer-queries.ts — confirm getCatalogWithMicros and getAllOfferServices signatures (just created in Task 1) - src/app/admin/offers/actions.ts — confirm action names (just created in Task 1) **Create `src/app/admin/offers/page.tsx`:** This is an RSC page. No `"use client"` at the top level. Inline client sub-components that need interactivity are permissible as separate files or as local `"use client"` components in the same file using the pattern established by existing admin pages. The page has three sections: 1. **Macro-offers** — list with "create" form inline; each macro shows its micro-offer children 2. **Micro-offers** per macro — create form inside each macro card; each micro shows assigned services and a `ServiceCheckboxList` 3. **Offer Services catalog** — list all offer services + create form at bottom For `ServiceCheckboxList`: this is a `"use client"` component. Create it as a named export in the same page file or as a separate file at `src/components/admin/offers/ServiceCheckboxList.tsx`. It uses `useState(Set)` + `useTransition` + `router.refresh()` pattern from the research (Pattern 3). ```typescript // src/components/admin/offers/ServiceCheckboxList.tsx "use client"; import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { updateMicroOfferServices } from "@/app/admin/offers/actions"; export function ServiceCheckboxList({ allServices, assignedIds, microId, }: { allServices: Array<{ id: string; name: string; price: string }>; assignedIds: string[]; microId: string; }) { const [selected, setSelected] = useState(new Set(assignedIds)); const [isPending, startTransition] = useTransition(); const router = useRouter(); function toggle(serviceId: string) { const next = new Set(selected); if (next.has(serviceId)) next.delete(serviceId); else next.add(serviceId); setSelected(next); startTransition(async () => { await updateMicroOfferServices(microId, [...next]); router.refresh(); }); } return (
{allServices.map((svc) => ( ))} {allServices.length === 0 && (

Nessun servizio nel catalogo ancora.

)}
); } ``` **Page structure for `src/app/admin/offers/page.tsx`:** ```typescript import { getCatalogWithMicros, getAllOfferServices } from "@/lib/offer-queries"; import { createMacro, createMicro, createOfferService, deleteMacro, deleteMicro, toggleOfferServiceActive } from "./actions"; import { ServiceCheckboxList } from "@/components/admin/offers/ServiceCheckboxList"; export const revalidate = 0; export default async function OffersPage() { const [catalog, allServices] = await Promise.all([ getCatalogWithMicros(), getAllOfferServices(), ]); return (

Catalogo Offerte

{/* ── Sezione macro-offerte ── */}

Macro-Offerte

{/* Create macro form */}

Nuova Macro-Offerta