feat(07-unified-service-catalog): rewire /admin/catalog CRUD + query layer to services table

- Task 1: Update getAllServices() and activeServices queries to read from services table instead of service_catalog
  - Both ClientFullDetail and ProjectFullDetail now return Service[] (not ServiceCatalog[])
  - Quote items label join remains unchanged — still references service_catalog for historical quote_items.service_id FK integrity

- Task 2: Rewire /admin/catalog actions and components to operate on services table
  - src/app/admin/catalog/actions.ts: createService/updateService/toggleServiceActive now write to services with optional category field
  - ServiceTable.tsx: Updated to Service[] type, added category column rendering
  - ServiceForm.tsx: Added optional category input field
  - QuoteTab.tsx: Updated activeServices type annotation to Service[]

- New services created via /admin/catalog have migrated_from=null, migrated_id=null (not migrated)
- TypeScript compiles successfully
- npm run build: PASS

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 06:26:03 +02:00
parent bbc89136f5
commit 4ed2f8b105
5 changed files with 47 additions and 22 deletions
+11 -8
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { db } from "@/db"; import { db } from "@/db";
import { service_catalog } from "@/db/schema"; import { services } from "@/db/schema";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
@@ -12,6 +12,7 @@ const serviceSchema = z.object({
name: z.string().min(1, "Nome richiesto"), name: z.string().min(1, "Nome richiesto"),
description: z.string().optional(), description: z.string().optional(),
unit_price: z.coerce.number().min(0.01, "Prezzo deve essere maggiore di 0"), unit_price: z.coerce.number().min(0.01, "Prezzo deve essere maggiore di 0"),
category: z.string().optional(),
}); });
async function requireAdmin() { async function requireAdmin() {
@@ -25,12 +26,15 @@ export async function createService(formData: FormData) {
name: formData.get("name"), name: formData.get("name"),
description: formData.get("description") ?? "", description: formData.get("description") ?? "",
unit_price: formData.get("unit_price"), unit_price: formData.get("unit_price"),
category: formData.get("category") ?? "",
}); });
if (!parsed.success) throw new Error(parsed.error.issues[0].message); if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await db.insert(service_catalog).values({ // New rows created from /admin/catalog are NOT migrated — migrated_from/migrated_id stay null
await db.insert(services).values({
name: parsed.data.name, name: parsed.data.name,
description: parsed.data.description ?? null, description: parsed.data.description ?? null,
unit_price: parsed.data.unit_price.toFixed(2), unit_price: parsed.data.unit_price.toFixed(2),
category: parsed.data.category || null,
}); });
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
} }
@@ -41,24 +45,23 @@ export async function updateService(serviceId: string, formData: FormData) {
name: formData.get("name"), name: formData.get("name"),
description: formData.get("description") ?? "", description: formData.get("description") ?? "",
unit_price: formData.get("unit_price"), unit_price: formData.get("unit_price"),
category: formData.get("category") ?? "",
}); });
if (!parsed.success) throw new Error(parsed.error.issues[0].message); if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await db await db
.update(service_catalog) .update(services)
.set({ .set({
name: parsed.data.name, name: parsed.data.name,
description: parsed.data.description ?? null, description: parsed.data.description ?? null,
unit_price: parsed.data.unit_price.toFixed(2), unit_price: parsed.data.unit_price.toFixed(2),
category: parsed.data.category || null,
}) })
.where(eq(service_catalog.id, serviceId)); .where(eq(services.id, serviceId));
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
} }
export async function toggleServiceActive(serviceId: string, active: boolean) { export async function toggleServiceActive(serviceId: string, active: boolean) {
await requireAdmin(); await requireAdmin();
await db await db.update(services).set({ active }).where(eq(services.id, serviceId));
.update(service_catalog)
.set({ active })
.where(eq(service_catalog.id, serviceId));
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
} }
@@ -55,6 +55,14 @@ export function ServiceForm() {
placeholder="es. Incluso: analisi competitor, posizionamento" placeholder="es. Incluso: analisi competitor, posizionamento"
/> />
</div> </div>
<div className="space-y-1">
<Label htmlFor="category">Categoria (opzionale)</Label>
<Input
id="category"
name="category"
placeholder="es. Branding, Social media, Consulenza"
/>
</div>
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="unit_price">Prezzo unitario ()</Label> <Label htmlFor="unit_price">Prezzo unitario ()</Label>
<Input <Input
+15 -3
View File
@@ -6,9 +6,9 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { updateService, toggleServiceActive } from "@/app/admin/catalog/actions"; import { updateService, toggleServiceActive } from "@/app/admin/catalog/actions";
import type { ServiceCatalog } from "@/db/schema"; import type { Service } from "@/db/schema";
function ServiceRow({ service }: { service: ServiceCatalog }) { function ServiceRow({ service }: { service: Service }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
@@ -72,6 +72,14 @@ function ServiceRow({ service }: { service: ServiceCatalog }) {
required required
/> />
</div> </div>
<div className="flex-1 min-w-[120px] space-y-1">
<Label htmlFor={`category-${service.id}`}>Categoria</Label>
<Input
id={`category-${service.id}`}
name="category"
defaultValue={service.category ?? ""}
/>
</div>
</div> </div>
{error && <p className="text-xs text-red-600">{error}</p>} {error && <p className="text-xs text-red-600">{error}</p>}
<div className="flex gap-2"> <div className="flex gap-2">
@@ -110,6 +118,9 @@ function ServiceRow({ service }: { service: ServiceCatalog }) {
<td className="py-3 px-4 text-[#71717a] text-sm max-w-xs truncate"> <td className="py-3 px-4 text-[#71717a] text-sm max-w-xs truncate">
{service.description ?? "—"} {service.description ?? "—"}
</td> </td>
<td className="py-3 px-4 text-[#71717a] text-sm max-w-xs truncate">
{service.category ?? "—"}
</td>
<td className="py-3 px-4 tabular-nums font-mono"> <td className="py-3 px-4 tabular-nums font-mono">
{parseFloat(service.unit_price).toLocaleString("it-IT", { minimumFractionDigits: 2 })} {parseFloat(service.unit_price).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</td> </td>
@@ -138,7 +149,7 @@ function ServiceRow({ service }: { service: ServiceCatalog }) {
); );
} }
export function ServiceTable({ services }: { services: ServiceCatalog[] }) { export function ServiceTable({ services }: { services: Service[] }) {
return ( return (
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden"> <div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -146,6 +157,7 @@ export function ServiceTable({ services }: { services: ServiceCatalog[] }) {
<tr> <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]">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]">Descrizione</th>
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Categoria</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]">Prezzo</th>
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Stato</th> <th className="text-left py-3 px-4 font-medium text-[#71717a]">Stato</th>
<th className="py-3 px-4"></th> <th className="py-3 px-4"></th>
+2 -2
View File
@@ -11,12 +11,12 @@ import {
updateAcceptedTotal, updateAcceptedTotal,
} from "@/app/admin/clients/[id]/quote-actions"; } from "@/app/admin/clients/[id]/quote-actions";
import type { QuoteItemWithLabel } from "@/lib/admin-queries"; import type { QuoteItemWithLabel } from "@/lib/admin-queries";
import type { ServiceCatalog } from "@/db/schema"; import type { Service } from "@/db/schema";
type Props = { type Props = {
clientId: string; clientId: string;
items: QuoteItemWithLabel[]; items: QuoteItemWithLabel[];
activeServices: ServiceCatalog[]; activeServices: Service[];
acceptedTotal: string; acceptedTotal: string;
}; };
+11 -9
View File
@@ -12,6 +12,7 @@ import {
time_entries, time_entries,
quote_items, quote_items,
service_catalog, service_catalog,
services,
settings, settings,
offer_micros, offer_micros,
project_offers, project_offers,
@@ -28,6 +29,7 @@ import type {
Note, Note,
Comment, Comment,
ServiceCatalog, ServiceCatalog,
Service,
OfferMicro, OfferMicro,
ProjectOffer, ProjectOffer,
} from "@/db/schema"; } from "@/db/schema";
@@ -221,7 +223,7 @@ export type ClientFullDetail = {
notes: Note[]; notes: Note[];
comments: Comment[]; comments: Comment[];
quoteItems: QuoteItemWithLabel[]; quoteItems: QuoteItemWithLabel[];
activeServices: ServiceCatalog[]; activeServices: Service[];
}; };
export async function getClientFullDetail(id: string): Promise<ClientFullDetail | null> { export async function getClientFullDetail(id: string): Promise<ClientFullDetail | null> {
@@ -238,9 +240,9 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
const activeServiceRows = await db const activeServiceRows = await db
.select() .select()
.from(service_catalog) .from(services)
.where(eq(service_catalog.active, true)) .where(eq(services.active, true))
.orderBy(asc(service_catalog.name)); .orderBy(asc(services.name));
if (projectIds.length === 0) { if (projectIds.length === 0) {
return { return {
@@ -344,11 +346,11 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
}; };
} }
export async function getAllServices(): Promise<ServiceCatalog[]> { export async function getAllServices(): Promise<Service[]> {
return db return db
.select() .select()
.from(service_catalog) .from(services)
.orderBy(asc(service_catalog.name)); .orderBy(asc(services.name));
} }
// ── ProjectWithPayments — used by /admin/projects list ─────────────────────── // ── ProjectWithPayments — used by /admin/projects list ───────────────────────
@@ -456,7 +458,7 @@ export type ProjectFullDetail = {
notes: Note[]; notes: Note[];
comments: Comment[]; comments: Comment[];
quoteItems: QuoteItemWithLabel[]; quoteItems: QuoteItemWithLabel[];
activeServices: ServiceCatalog[]; activeServices: Service[];
activeTimerEntryId: string | null; activeTimerEntryId: string | null;
activeTimerStartedAt: Date | null; activeTimerStartedAt: Date | null;
totalTrackedSeconds: number; totalTrackedSeconds: number;
@@ -527,7 +529,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
.leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id)) .leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id))
.where(eq(quote_items.project_id, id)) .where(eq(quote_items.project_id, id))
.orderBy(asc(quote_items.id)), .orderBy(asc(quote_items.id)),
db.select().from(service_catalog).where(eq(service_catalog.active, true)).orderBy(asc(service_catalog.name)), db.select().from(services).where(eq(services.active, true)).orderBy(asc(services.name)),
db db
.select({ id: time_entries.id, started_at: time_entries.started_at }) .select({ id: time_entries.id, started_at: time_entries.started_at })
.from(time_entries) .from(time_entries)