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:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user