chore: merge executor worktree (worktree-agent-a31348f014c34118e) — plan 03-02
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
---
|
||||
phase: 03-service-catalog-quote-builder
|
||||
plan: "02"
|
||||
subsystem: admin-ui
|
||||
tags: [catalog, server-actions, drizzle, zod, nextjs, tailwind]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 03-service-catalog-quote-builder
|
||||
plan: "01"
|
||||
provides: service_catalog table + ServiceCatalog type in schema.ts
|
||||
provides:
|
||||
- /admin/catalog route: fully functional service catalog CRUD page
|
||||
- createService server action (with requireAdmin + Zod validation)
|
||||
- updateService server action (with requireAdmin + Zod validation)
|
||||
- toggleServiceActive server action (with requireAdmin guard)
|
||||
- getAllServices() query in admin-queries.ts
|
||||
- NavBar Catalogo link
|
||||
affects:
|
||||
- 03-03 (quote builder — consumes getAllServices() for active services dropdown)
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Server Action + requireAdmin() guard: getServerSession(authOptions) at top of every action"
|
||||
- "Zod coerce.number for unit_price: z.coerce.number().min(0.01)"
|
||||
- "Inline edit pattern: useTransition + form action + router.refresh() (mirrors DocumentRow.tsx)"
|
||||
- "Soft-delete visibility: opacity-50 on inactive rows; badge Disattivato/Attivo"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- src/app/admin/catalog/actions.ts
|
||||
- src/app/admin/catalog/page.tsx
|
||||
- src/components/admin/catalog/ServiceTable.tsx
|
||||
- src/components/admin/catalog/ServiceForm.tsx
|
||||
modified:
|
||||
- src/lib/admin-queries.ts
|
||||
- src/components/admin/NavBar.tsx
|
||||
|
||||
key-decisions:
|
||||
- "requireAdmin() added to all three Server Actions — enforces session check even though /admin/* middleware protects the route (defense in depth for T-03-02-01)"
|
||||
- "unit_price stored as .toFixed(2) string in DB (numeric column) — consistent with existing payments/quote_items pattern"
|
||||
- "Inactive services remain visible in table at opacity-50 — filtering for quote builder dropdown happens in 03-03 query"
|
||||
|
||||
# Metrics
|
||||
duration: 15min
|
||||
completed: 2026-05-17
|
||||
---
|
||||
|
||||
# Phase 03 Plan 02: Service Catalog CRUD UI Summary
|
||||
|
||||
**Vertical slice completo `/admin/catalog`: NavBar link + pagina catalogo + tabella con edit inline + form aggiunta servizio + soft-delete toggle, con Server Actions protetti da Zod e requireAdmin()**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~15 min
|
||||
- **Started:** 2026-05-17T09:40:00Z
|
||||
- **Completed:** 2026-05-17T09:55:00Z
|
||||
- **Tasks:** 2
|
||||
- **Files created:** 4 | **Files modified:** 2
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Creato `src/app/admin/catalog/actions.ts` con tre Server Actions (`createService`, `updateService`, `toggleServiceActive`) — ogni action chiama `requireAdmin()` e valida i dati via Zod
|
||||
- Aggiunto `getAllServices()` a `src/lib/admin-queries.ts` con import di `service_catalog` e tipo `ServiceCatalog`
|
||||
- Creato `src/app/admin/catalog/page.tsx` — Server Component che carica tutti i servizi e renderizza `ServiceForm` + `ServiceTable`
|
||||
- Creato `src/components/admin/catalog/ServiceForm.tsx` — form add-new con toggle open/closed, useTransition, gestione errori
|
||||
- Creato `src/components/admin/catalog/ServiceTable.tsx` — tabella con per-row inline edit (pattern DocumentRow), badge Attivo/Disattivato, opacity-50 per servizi inattivi
|
||||
- Aggiunto link "Catalogo" in `src/components/admin/NavBar.tsx` tra Statistiche e Esci
|
||||
- TypeScript clean (zero errori), `npm run build` compilato con successo
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Nome | Commit | File |
|
||||
|------|------|--------|------|
|
||||
| 1 | Server Actions + getAllServices query | `efbc235` | src/app/admin/catalog/actions.ts, src/lib/admin-queries.ts |
|
||||
| 2 | Catalog page + components + NavBar link | `4aae2e0` | src/app/admin/catalog/page.tsx, src/components/admin/catalog/ServiceTable.tsx, src/components/admin/catalog/ServiceForm.tsx, src/components/admin/NavBar.tsx |
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
**Creati:**
|
||||
- `src/app/admin/catalog/actions.ts` — tre Server Actions con requireAdmin() + Zod
|
||||
- `src/app/admin/catalog/page.tsx` — Server Component per il catalogo
|
||||
- `src/components/admin/catalog/ServiceTable.tsx` — tabella + inline edit per riga
|
||||
- `src/components/admin/catalog/ServiceForm.tsx` — form aggiunta nuovo servizio
|
||||
|
||||
**Modificati:**
|
||||
- `src/lib/admin-queries.ts` — aggiunto `getAllServices()`, import `service_catalog` e `ServiceCatalog`
|
||||
- `src/components/admin/NavBar.tsx` — aggiunto link Catalogo dopo Statistiche
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- `requireAdmin()` presente in ogni Server Action anche se `/admin/*` è già protetto da middleware — defense in depth per T-03-02-01
|
||||
- `unit_price` salvato come `.toFixed(2)` string in campo `numeric` — coerente con pattern pagamenti e quote_items già presenti
|
||||
- I servizi inattivi rimangono visibili in tabella con opacity-50 — il filtro `active = true` per il dropdown del Quote Builder sarà nella query di 03-03
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Mancanza DATABASE_URL nel worktree per il build**
|
||||
- **Found during:** Task 2 verifica `npm run build`
|
||||
- **Issue:** Il worktree non aveva `.env.local` — il build falliva con "DATABASE_URL env var is required"
|
||||
- **Fix:** Symlink di `/Users/simonecavalli/IAMCAVALLI/.env.local` nel worktree root
|
||||
- **Files modified:** nessun file sorgente — solo symlink di configurazione
|
||||
- **Commit:** nessun commit aggiuntivo (symlink non tracciato in git)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (1 ambiente/blocking)
|
||||
**Impact on plan:** Fix immediato, nessuno scope creep. Il build finale è compilato con successo.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Nessuno — tutti i componenti leggono dati reali dal DB via Server Actions e query Drizzle.
|
||||
|
||||
## Threat Surface Scan
|
||||
|
||||
Nessuna nuova superficie di sicurezza introdotta oltre a quanto già coperto dal threat model del piano:
|
||||
- T-03-02-01: `requireAdmin()` implementato in tutti e tre i Server Actions (mitigato)
|
||||
- T-03-02-02: Zod `unit_price: z.coerce.number().min(0.01)` implementato (mitigato)
|
||||
- T-03-02-03: `serviceId` bound a livello di Server Action (mitigato)
|
||||
- T-03-02-04: Rotta sotto middleware Auth.js `/admin/*` (accettato)
|
||||
- T-03-02-05: Nessun `dangerouslySetInnerHTML`, JSX auto-escape (accettato)
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: `src/app/admin/catalog/actions.ts`
|
||||
- FOUND: `src/app/admin/catalog/page.tsx`
|
||||
- FOUND: `src/components/admin/catalog/ServiceTable.tsx`
|
||||
- FOUND: `src/components/admin/catalog/ServiceForm.tsx`
|
||||
- FOUND: `getAllServices` in `src/lib/admin-queries.ts`
|
||||
- FOUND: `/admin/catalog` in `src/components/admin/NavBar.tsx`
|
||||
- FOUND: commit `efbc235` (Task 1)
|
||||
- FOUND: commit `4aae2e0` (Task 2)
|
||||
- OK: TypeScript compila senza errori
|
||||
- OK: `npm run build` — "Compiled successfully"
|
||||
|
||||
---
|
||||
|
||||
*Phase: 03-service-catalog-quote-builder*
|
||||
*Completed: 2026-05-17*
|
||||
@@ -0,0 +1,64 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { service_catalog } from "@/db/schema";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
const serviceSchema = z.object({
|
||||
name: z.string().min(1, "Nome richiesto"),
|
||||
description: z.string().optional(),
|
||||
unit_price: z.coerce.number().min(0.01, "Prezzo deve essere maggiore di 0"),
|
||||
});
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("Non autorizzato");
|
||||
}
|
||||
|
||||
export async function createService(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const parsed = serviceSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
description: formData.get("description") ?? "",
|
||||
unit_price: formData.get("unit_price"),
|
||||
});
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||
await db.insert(service_catalog).values({
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description ?? null,
|
||||
unit_price: parsed.data.unit_price.toFixed(2),
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
}
|
||||
|
||||
export async function updateService(serviceId: string, formData: FormData) {
|
||||
await requireAdmin();
|
||||
const parsed = serviceSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
description: formData.get("description") ?? "",
|
||||
unit_price: formData.get("unit_price"),
|
||||
});
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||
await db
|
||||
.update(service_catalog)
|
||||
.set({
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description ?? null,
|
||||
unit_price: parsed.data.unit_price.toFixed(2),
|
||||
})
|
||||
.where(eq(service_catalog.id, serviceId));
|
||||
revalidatePath("/admin/catalog");
|
||||
}
|
||||
|
||||
export async function toggleServiceActive(serviceId: string, active: boolean) {
|
||||
await requireAdmin();
|
||||
await db
|
||||
.update(service_catalog)
|
||||
.set({ active })
|
||||
.where(eq(service_catalog.id, serviceId));
|
||||
revalidatePath("/admin/catalog");
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getAllServices } from "@/lib/admin-queries";
|
||||
import { ServiceTable } from "@/components/admin/catalog/ServiceTable";
|
||||
import { ServiceForm } from "@/components/admin/catalog/ServiceForm";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function CatalogPage() {
|
||||
const services = await getAllServices();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Servizi</h1>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<ServiceForm />
|
||||
</div>
|
||||
|
||||
{services.length === 0 ? (
|
||||
<p className="text-sm text-[#71717a]">
|
||||
Nessun servizio nel catalogo. Aggiungi il primo servizio qui sopra.
|
||||
</p>
|
||||
) : (
|
||||
<ServiceTable services={services} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,9 @@ export function NavBar() {
|
||||
<Link href="/admin/analytics" className="text-sm text-white/70 hover:text-white transition-colors">
|
||||
Statistiche
|
||||
</Link>
|
||||
<Link href="/admin/catalog" className="text-sm text-white/70 hover:text-white transition-colors">
|
||||
Catalogo
|
||||
</Link>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { createService } from "@/app/admin/catalog/actions";
|
||||
|
||||
export function ServiceForm() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
function handleSubmit(fd: FormData) {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await createService(fd);
|
||||
formRef.current?.reset();
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => setOpen(true)}
|
||||
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
|
||||
>
|
||||
+ Aggiungi servizio
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-4">
|
||||
<h3 className="font-medium text-[#1a1a1a]">Nuovo servizio</h3>
|
||||
<form ref={formRef} action={handleSubmit} className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name">Nome</Label>
|
||||
<Input id="name" name="name" placeholder="es. Strategia di brand" required />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="description">Descrizione (opzionale)</Label>
|
||||
<Input
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="es. Incluso: analisi competitor, posizionamento"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="unit_price">Prezzo unitario (€)</Label>
|
||||
<Input
|
||||
id="unit_price"
|
||||
name="unit_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
|
||||
>
|
||||
Aggiungi
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Annulla
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { updateService, toggleServiceActive } from "@/app/admin/catalog/actions";
|
||||
import type { ServiceCatalog } from "@/db/schema";
|
||||
|
||||
function ServiceRow({ service }: { service: ServiceCatalog }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
function handleSave(fd: FormData) {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateService(service.id, fd);
|
||||
setEditing(false);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleToggle() {
|
||||
startTransition(async () => {
|
||||
await toggleServiceActive(service.id, !service.active);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-3">
|
||||
<form
|
||||
action={handleSave}
|
||||
className="space-y-2 bg-[#f9f9f9] rounded-lg p-3 border border-[#1A463C]/20"
|
||||
>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-[140px] space-y-1">
|
||||
<Label htmlFor={`name-${service.id}`}>Nome</Label>
|
||||
<Input
|
||||
id={`name-${service.id}`}
|
||||
name="name"
|
||||
defaultValue={service.name}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-[2] min-w-[180px] space-y-1">
|
||||
<Label htmlFor={`desc-${service.id}`}>Descrizione</Label>
|
||||
<Input
|
||||
id={`desc-${service.id}`}
|
||||
name="description"
|
||||
defaultValue={service.description ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28 space-y-1">
|
||||
<Label htmlFor={`price-${service.id}`}>Prezzo (€)</Label>
|
||||
<Input
|
||||
id={`price-${service.id}`}
|
||||
name="unit_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
defaultValue={parseFloat(service.unit_price).toFixed(2)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
|
||||
>
|
||||
Salva
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Annulla
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={`border-b border-[#f4f4f5] hover:bg-[#f9f9f9] transition-colors ${
|
||||
!service.active ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="py-3 px-4 font-medium text-[#1a1a1a]">{service.name}</td>
|
||||
<td className="py-3 px-4 text-[#71717a] text-sm max-w-xs truncate">
|
||||
{service.description ?? "—"}
|
||||
</td>
|
||||
<td className="py-3 px-4 tabular-nums font-mono">
|
||||
€{parseFloat(service.unit_price).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{service.active ? (
|
||||
<span className="text-xs font-medium bg-[#1A463C]/10 text-[#1A463C] px-2 py-0.5 rounded-full">
|
||||
Attivo
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-medium bg-[#f4f4f5] text-[#71717a] px-2 py-0.5 rounded-full">
|
||||
Disattivato
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}>
|
||||
Modifica
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleToggle}>
|
||||
{service.active ? "Disattiva" : "Riattiva"}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServiceTable({ services }: { services: ServiceCatalog[] }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Nome</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Descrizione</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Prezzo</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Stato</th>
|
||||
<th className="py-3 px-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{services.map((s) => (
|
||||
<ServiceRow key={s.id} service={s} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
documents,
|
||||
notes,
|
||||
time_entries,
|
||||
service_catalog,
|
||||
} from "@/db/schema";
|
||||
import { eq, inArray, asc, isNull, sql } from "drizzle-orm";
|
||||
import type {
|
||||
@@ -20,6 +21,7 @@ import type {
|
||||
Document,
|
||||
Note,
|
||||
Comment,
|
||||
ServiceCatalog,
|
||||
} from "@/db/schema";
|
||||
|
||||
export type ClientWithPayments = {
|
||||
@@ -189,3 +191,10 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
|
||||
comments: commentsRows,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAllServices(): Promise<ServiceCatalog[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(service_catalog)
|
||||
.orderBy(asc(service_catalog.name));
|
||||
}
|
||||
Reference in New Issue
Block a user