feat(03-02): catalog page + ServiceTable + ServiceForm + NavBar link
- Create src/app/admin/catalog/page.tsx: server component, fetches all services, renders ServiceForm + ServiceTable - Create src/components/admin/catalog/ServiceForm.tsx: add-new-service form with open/collapse toggle - Create src/components/admin/catalog/ServiceTable.tsx: per-row inline edit (DocumentRow pattern), Attivo/Disattivato badges, opacity-50 for inactive - Modify src/components/admin/NavBar.tsx: add Catalogo link after Statistiche - TypeScript clean, npm run build compiled successfully
This commit is contained in:
@@ -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">
|
<Link href="/admin/analytics" className="text-sm text-white/70 hover:text-white transition-colors">
|
||||||
Statistiche
|
Statistiche
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/admin/catalog" className="text-sm text-white/70 hover:text-white transition-colors">
|
||||||
|
Catalogo
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user