feat(11-04): rewrite ServiceTable as database-view (inline edit, tags, quick-add, active/inactive split)

- 6-column table (Nome, Descrizione, Categoria, Prezzo, Tag, Stato), no Azioni column (D-14)
- Every field cell uses EditableCell (text/textarea/number/toggle), saving via updateServiceField
- Tags column uses TagMultiSelect, wired to addTagToService/removeTagFromService
- QuickAddRow creates new services (unit_price=0) via quickAddService on Enter (D-12)
- Inactive services sink below a "Servizi disattivati" divider with opacity-50 (D-13)
- All mutations trigger router.refresh()
This commit is contained in:
2026-06-13 15:58:36 +02:00
parent e424653ece
commit c0bedf300e
+146 -137
View File
@@ -2,24 +2,30 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
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 { EditableCell } from "@/components/ui/editable-cell";
import { updateService, toggleServiceActive } from "@/app/admin/catalog/actions"; import { TagMultiSelect } from "@/components/ui/tag-multi-select";
import type { Service } from "@/db/schema"; import { updateServiceField, quickAddService } from "@/app/admin/catalog/actions";
import type { ServiceWithTags } from "@/lib/admin-queries";
function ServiceRow({ service }: { service: Service }) { function formatPrice(raw: string): string {
const [editing, setEditing] = useState(false); const num = parseFloat(raw);
const [error, setError] = useState<string | null>(null); return `${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2 })}`;
const [, startTransition] = useTransition(); }
function ServiceRow({ service }: { service: ServiceWithTags }) {
const router = useRouter(); const router = useRouter();
const [, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function handleSave(fd: FormData) { function saveField(
field: "name" | "description" | "category" | "unit_price" | "active",
value: string
) {
setError(null); setError(null);
startTransition(async () => { startTransition(async () => {
try { try {
await updateService(service.id, fd); await updateServiceField(service.id, field, value);
setEditing(false);
router.refresh(); router.refresh();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Errore nel salvataggio"); setError(e instanceof Error ? e.message : "Errore nel salvataggio");
@@ -27,148 +33,151 @@ function ServiceRow({ service }: { service: Service }) {
}); });
} }
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 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>
{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 ( return (
<tr <tr
className={`border-b border-[#f4f4f5] hover:bg-[#f9f9f9] transition-colors ${ className={`border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150 ${
!service.active ? "opacity-50" : "" !service.active ? "opacity-50" : ""
}`} }`}
> >
<td className="py-3 px-4 font-medium text-[#1a1a1a]">{service.name}</td> <td className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
<td className="py-3 px-4 text-[#71717a] text-sm max-w-xs truncate"> <EditableCell
{service.description ?? "—"} value={service.name}
type="text"
required
onSave={(v) => saveField("name", v)}
/>
</td> </td>
<td className="py-3 px-4 text-[#71717a] text-sm max-w-xs truncate"> <td className="py-2 px-3 text-[#71717a] min-w-[220px]">
{service.category ?? "—"} <EditableCell
value={service.description ?? ""}
type="textarea"
placeholder="Descrizione..."
onSave={(v) => saveField("description", v)}
/>
</td> </td>
<td className="py-3 px-4 tabular-nums font-mono"> <td className="py-2 px-3 text-[#71717a] min-w-[120px]">
{parseFloat(service.unit_price).toLocaleString("it-IT", { minimumFractionDigits: 2 })} <EditableCell
value={service.category ?? ""}
type="text"
placeholder="Categoria..."
onSave={(v) => saveField("category", v)}
/>
</td> </td>
<td className="py-3 px-4"> <td className="py-2 px-3 tabular-nums min-w-[100px]">
{service.active ? ( <EditableCell
<span className="text-xs font-medium bg-[#1A463C]/10 text-[#1A463C] px-2 py-0.5 rounded-full"> value={service.unit_price}
Attivo type="number"
</span> formatDisplay={formatPrice}
) : ( onSave={(v) => saveField("unit_price", v)}
<span className="text-xs font-medium bg-[#f4f4f5] text-[#71717a] px-2 py-0.5 rounded-full"> />
Disattivato
</span>
)}
</td> </td>
<td className="py-3 px-4 text-right"> <td className="py-2 px-3 min-w-[180px]">
<div className="flex items-center justify-end gap-2"> <TagMultiSelect
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}> tags={service.tags}
Modifica serviceId={service.id}
</Button> onTagsChanged={() => router.refresh()}
<Button variant="ghost" size="sm" onClick={handleToggle}> />
{service.active ? "Disattiva" : "Riattiva"}
</Button>
</div>
</td> </td>
<td className="py-2 px-3 min-w-[100px]">
<EditableCell
value={service.active ? "true" : "false"}
type="toggle"
onSave={(v) => saveField("active", v)}
/>
</td>
{error && (
<td className="py-1 px-3 text-xs text-red-600" colSpan={6}>
{error}
</td>
)}
</tr> </tr>
); );
} }
export function ServiceTable({ services }: { services: Service[] }) { function QuickAddRow() {
const [name, setName] = useState("");
const [, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const router = useRouter();
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key !== "Enter") return;
const trimmed = name.trim();
if (!trimmed) return;
e.preventDefault();
setError(null);
startTransition(async () => {
try {
await quickAddService(trimmed);
setName("");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Errore nel salvataggio");
}
});
}
return (
<tr className="border-b border-[#e5e7eb] bg-[#f9f9f9]">
<td className="py-2 px-3">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="+ Aggiungi servizio"
className="border-0 bg-transparent shadow-none h-8 text-sm placeholder:text-[#71717a] focus-visible:ring-1 focus-visible:ring-primary"
/>
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
</td>
<td colSpan={5}></td>
</tr>
);
}
const COLUMN_HEADERS = ["Nome", "Descrizione", "Categoria", "Prezzo", "Tag", "Stato"];
export function ServiceTable({ services }: { services: ServiceWithTags[] }) {
const activeServices = services.filter((s) => s.active);
const inactiveServices = services.filter((s) => !s.active);
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"> <div className="overflow-x-auto">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]"> <table className="w-full text-sm">
<tr> <thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0 z-[1]">
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Nome</th> <tr>
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Descrizione</th> {COLUMN_HEADERS.map((header) => (
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Categoria</th> <th
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Prezzo</th> key={header}
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Stato</th> className="text-left py-2 px-3 font-semibold text-[#71717a]"
<th className="py-3 px-4"></th> >
</tr> {header}
</thead> </th>
<tbody> ))}
{services.map((s) => ( </tr>
<ServiceRow key={s.id} service={s} /> </thead>
))} <tbody>
</tbody> {activeServices.map((s) => (
</table> <ServiceRow key={s.id} service={s} />
))}
<QuickAddRow />
{inactiveServices.length > 0 && (
<>
<tr className="border-b border-t-2 border-t-[#e5e7eb] border-[#e5e7eb]">
<td colSpan={6} className="py-1.5 px-3 text-xs font-medium text-[#71717a] uppercase tracking-wide">
Servizi disattivati
</td>
</tr>
{inactiveServices.map((s) => (
<ServiceRow key={s.id} service={s} />
))}
</>
)}
</tbody>
</table>
</div>
</div> </div>
); );
} }