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