creati: fujnzioni CRUD per le macchine, le API routes TreeNode e MacchineTree
All checks were successful
Deploy to VPS / build (push) Successful in 40s
Deploy to VPS / deploy (push) Successful in 27s

This commit is contained in:
AV77web 2026-05-29 15:52:39 +02:00
parent 3e3f05136c
commit 723d1d88b7
8 changed files with 1395 additions and 15 deletions

View file

@ -1,3 +1,5 @@
import MacchineTree from "@/src/components/MacchineTree";
export default function MachinePage() {
return (
<div className="flex flex-1 flex-col bg-zinc-50 p-6 dark:bg-black">
@ -11,10 +13,8 @@ export default function MachinePage() {
</p>
</header>
<div className="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-zinc-600 dark:text-zinc-400">
Contenuto della pagina macchine in arrivo...
</p>
<div className="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 min-h-[500px]">
<MacchineTree />
</div>
</div>
</div>

View file

@ -0,0 +1,122 @@
import { NextResponse } from "next/server";
import { auth } from "@/src/auth";
import {
getMacchinaById,
updateMacchina,
deleteMacchina,
} from "@/src/lib/macchine";
import type { MacchinaUpdateInput } from "@/src/types/macchina";
interface RouteParams {
params: Promise<{ id: string }>;
}
export async function GET(request: Request, { params }: RouteParams) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Non autorizzato" }, { status: 401 });
}
const { id } = await params;
const macchinaId = parseInt(id, 10);
if (isNaN(macchinaId)) {
return NextResponse.json({ error: "ID non valido" }, { status: 400 });
}
try {
const macchina = await getMacchinaById(macchinaId);
if (!macchina) {
return NextResponse.json(
{ error: "Macchina non trovata" },
{ status: 404 }
);
}
return NextResponse.json({ macchina });
} catch (error) {
console.error("[API macchine/:id] Errore GET:", error);
return NextResponse.json(
{ error: "Errore nel recupero della macchina" },
{ status: 500 }
);
}
}
export async function PUT(request: Request, { params }: RouteParams) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Non autorizzato" }, { status: 401 });
}
const { id } = await params;
const macchinaId = parseInt(id, 10);
if (isNaN(macchinaId)) {
return NextResponse.json({ error: "ID non valido" }, { status: 400 });
}
try {
const body = await request.json();
const input: MacchinaUpdateInput = {};
if (body.nome !== undefined) input.nome = body.nome;
if (body.tipo !== undefined) input.tipo = body.tipo;
if (body.id_padre !== undefined) input.id_padre = body.id_padre;
if (body.id_stabilimento !== undefined) input.id_stabilimento = body.id_stabilimento;
if (body.colore_testo !== undefined) input.colore_testo = body.colore_testo;
const macchina = await updateMacchina(macchinaId, input);
if (!macchina) {
return NextResponse.json(
{ error: "Macchina non trovata" },
{ status: 404 }
);
}
return NextResponse.json({ macchina });
} catch (error) {
console.error("[API macchine/:id] Errore PUT:", error);
const message = error instanceof Error ? error.message : "Errore nell'aggiornamento";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function DELETE(request: Request, { params }: RouteParams) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Non autorizzato" }, { status: 401 });
}
const { id } = await params;
const macchinaId = parseInt(id, 10);
if (isNaN(macchinaId)) {
return NextResponse.json({ error: "ID non valido" }, { status: 400 });
}
try {
const deleted = await deleteMacchina(macchinaId);
if (!deleted) {
return NextResponse.json(
{ error: "Macchina non trovata" },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("[API macchine/:id] Errore DELETE:", error);
return NextResponse.json(
{ error: "Errore nell'eliminazione" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import { auth } from "@/src/auth";
import {
listMacchine,
createMacchina,
cloneMacchina,
} from "@/src/lib/macchine";
import type { MacchinaCreateInput } from "@/src/types/macchina";
export async function GET() {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Non autorizzato" }, { status: 401 });
}
try {
const macchine = await listMacchine();
return NextResponse.json({ macchine });
} catch (error) {
console.error("[API macchine] Errore GET:", error);
return NextResponse.json(
{ error: "Errore nel recupero delle macchine" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Non autorizzato" }, { status: 401 });
}
try {
const body = await request.json();
if (body.action === "clone" && body.id) {
const macchina = await cloneMacchina(body.id, body.newParentId);
if (!macchina) {
return NextResponse.json(
{ error: "Macchina non trovata" },
{ status: 404 }
);
}
return NextResponse.json({ macchina });
}
const input: MacchinaCreateInput = {
nome: body.nome,
tipo: body.tipo || "macchina",
id_padre: body.id_padre || null,
id_stabilimento: body.id_stabilimento || null,
colore_testo: body.colore_testo || "#000000",
};
if (!input.nome?.trim()) {
return NextResponse.json(
{ error: "Nome obbligatorio" },
{ status: 400 }
);
}
const macchina = await createMacchina(input);
return NextResponse.json({ macchina }, { status: 201 });
} catch (error) {
console.error("[API macchine] Errore POST:", error);
const message = error instanceof Error ? error.message : "Errore nella creazione";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View file

@ -0,0 +1,254 @@
"use client";
import { useState, useEffect } from "react";
import type { MacchinaWithDetails, TipoMacchina } from "@/src/types/macchina";
import type { Stabilimento } from "@/src/types/stabilimento";
interface FormData {
nome: string;
tipo: TipoMacchina;
id_stabilimento: string;
colore_testo: string;
}
interface MacchinaModalProps {
isOpen: boolean;
editingMacchina: MacchinaWithDetails | null;
stabilimenti: Stabilimento[];
parentId: number | null;
parentStabilimento: number | null;
onClose: () => void;
onConfirm: (data: FormData & { id_padre?: number | null }) => Promise<void>;
}
export default function MacchinaModal({
isOpen,
editingMacchina,
stabilimenti,
parentId,
parentStabilimento,
onClose,
onConfirm,
}: MacchinaModalProps) {
const [formData, setFormData] = useState<FormData>({
nome: "",
tipo: "macchina",
id_stabilimento: "",
colore_testo: "#3b82f6",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const isEditing = !!editingMacchina?.id;
useEffect(() => {
if (isOpen) {
if (isEditing && editingMacchina) {
setFormData({
nome: editingMacchina.nome,
tipo: editingMacchina.tipo,
id_stabilimento: editingMacchina.id_stabilimento?.toString() || "",
colore_testo: editingMacchina.colore_testo || "#3b82f6",
});
} else {
setFormData({
nome: "",
tipo: parentId ? "gruppo" : "macchina",
id_stabilimento: parentStabilimento?.toString() || "",
colore_testo: "#3b82f6",
});
}
setErrors({});
}
}, [isOpen, editingMacchina, parentId, parentStabilimento, isEditing]);
const handleFieldChange = (field: keyof FormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: "" }));
}
};
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.nome.trim()) {
newErrors.nome = "Il nome è obbligatorio";
}
if (formData.tipo === "macchina" && !parentId && !formData.id_stabilimento) {
newErrors.id_stabilimento = "Stabilimento obbligatorio per macchine di livello root";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validateForm()) return;
setIsSubmitting(true);
try {
await onConfirm({
...formData,
id_padre: isEditing ? undefined : parentId,
});
onClose();
} catch (error) {
const message = error instanceof Error ? error.message : "Errore sconosciuto";
setErrors({ submit: message });
} finally {
setIsSubmitting(false);
}
};
if (!isOpen) return null;
const getTitle = () => {
const action = isEditing ? "Modifica" : "Nuova";
const typeLabel = formData.tipo === "macchina" ? "Macchina" : formData.tipo === "gruppo" ? "Gruppo" : "Parte";
return `${action} ${typeLabel}`;
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-md bg-zinc-900 rounded-xl border border-zinc-800 shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-800">
<h3 className="text-lg font-semibold text-white">{getTitle()}</h3>
<button
onClick={onClose}
disabled={isSubmitting}
className="p-1 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="px-5 py-4 space-y-4">
{/* Nome */}
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">
Nome <span className="text-red-400">*</span>
</label>
<input
type="text"
value={formData.nome}
onChange={(e) => handleFieldChange("nome", e.target.value)}
placeholder="Inserisci nome..."
disabled={isSubmitting}
className={`w-full px-3 py-2 text-sm rounded-lg bg-zinc-800 border text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50
${errors.nome ? "border-red-500" : "border-zinc-700"}`}
/>
{errors.nome && <p className="mt-1 text-xs text-red-400">{errors.nome}</p>}
</div>
{/* Tipo */}
{!isEditing && (
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Tipo</label>
<select
value={formData.tipo}
onChange={(e) => handleFieldChange("tipo", e.target.value as TipoMacchina)}
disabled={isSubmitting}
className="w-full px-3 py-2 text-sm rounded-lg bg-zinc-800 border border-zinc-700 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50"
>
<option value="macchina">Macchina</option>
<option value="gruppo">Gruppo</option>
<option value="parte">Parte</option>
</select>
</div>
)}
{/* Stabilimento - solo per macchine root */}
{formData.tipo === "macchina" && !parentId && (
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">
Stabilimento <span className="text-red-400">*</span>
</label>
<select
value={formData.id_stabilimento}
onChange={(e) => handleFieldChange("id_stabilimento", e.target.value)}
disabled={isSubmitting}
className={`w-full px-3 py-2 text-sm rounded-lg bg-zinc-800 border text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50
${errors.id_stabilimento ? "border-red-500" : "border-zinc-700"}`}
>
<option value="">Seleziona stabilimento...</option>
{stabilimenti.map((s) => (
<option key={s.id} value={s.id}>{s.descrizione}</option>
))}
</select>
{errors.id_stabilimento && <p className="mt-1 text-xs text-red-400">{errors.id_stabilimento}</p>}
</div>
)}
{/* Info ereditarietà stabilimento */}
{parentId && parentStabilimento && (
<div className="p-3 rounded-lg bg-blue-900/20 border border-blue-700/30">
<p className="text-xs text-blue-300">
Lo stabilimento verrà ereditato automaticamente dal padre.
</p>
</div>
)}
{/* Colore */}
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Colore testo</label>
<div className="flex items-center gap-3">
<input
type="color"
value={formData.colore_testo}
onChange={(e) => handleFieldChange("colore_testo", e.target.value)}
disabled={isSubmitting}
className="w-10 h-10 rounded-lg border border-zinc-700 cursor-pointer"
/>
<input
type="text"
value={formData.colore_testo}
onChange={(e) => handleFieldChange("colore_testo", e.target.value)}
disabled={isSubmitting}
className="flex-1 px-3 py-2 text-sm rounded-lg bg-zinc-800 border border-zinc-700 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
</div>
</div>
{/* Error generale */}
{errors.submit && (
<div className="p-3 rounded-lg bg-red-900/20 border border-red-700/30">
<p className="text-sm text-red-400">{errors.submit}</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-zinc-800">
<button
onClick={onClose}
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium text-zinc-300 hover:text-white rounded-lg hover:bg-zinc-800 transition-colors"
>
Annulla
</button>
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isSubmitting && (
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{isEditing ? "Aggiorna" : "Crea"}
</button>
</div>
</div>
</div>
);
}

View file

@ -1,16 +1,219 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import type { MacchinaWithDetails, ArticoliPerMacchina } from "@/src/types/macchina";
import type { Stabilimento } from "@/src/types/stabilimento";
import TreeNode from "./TreeNode";
import MacchinaModal from "./MacchinaModal";
export default function MacchineTree() {
const [nodes, setNodes] = useState<MacchinaWithDetails[]>([]);
const [stabilimenti, setStabilimenti] = useState<Stabilimento[]>([]);
const [articlesByMacchina, setArticlesByMacchina] = useState<ArticoliPerMacchina>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copiedNodeId, setCopiedNodeId] = useState<number | null>(null);
const [isCloning, setIsCloning] = useState(false);
const [showModal, setShowModal] = useState(false);
const [editingMacchina, setEditingMacchina] = useState<MacchinaWithDetails | null>(null);
const [parentInfo, setParentInfo] = useState<{
parentId: number | null;
parentStabilimento: number | null;
}>({ parentId: null, parentStabilimento: null });
const [deleteConfirm, setDeleteConfirm] = useState<{ id: number; nome: string } | null>(null);
const fetchNodes = useCallback(async () => {
try {
const res = await fetch("/api/macchine");
if (!res.ok) throw new Error("Errore nel caricamento");
const data = await res.json();
setNodes(data.macchine || []);
} catch (err) {
setError(err instanceof Error ? err.message : "Errore sconosciuto");
}
}, []);
const fetchStabilimenti = useCallback(async () => {
try {
const res = await fetch("/api/stabilimenti");
if (!res.ok) throw new Error("Errore nel caricamento stabilimenti");
const data = await res.json();
setStabilimenti(data.stabilimenti || []);
} catch (err) {
console.error("Errore stabilimenti:", err);
}
}, []);
const loadInitialData = useCallback(async () => {
setLoading(true);
setError(null);
await Promise.all([fetchNodes(), fetchStabilimenti()]);
setLoading(false);
}, [fetchNodes, fetchStabilimenti]);
useEffect(() => {
loadInitialData();
}, [loadInitialData]);
const getParentStabilimento = (parentId: number | null): number | null => {
if (!parentId) return null;
const parent = nodes.find((n) => n.id === parentId);
return parent?.id_stabilimento || null;
};
const handleAddMachine = () => {
setEditingMacchina(null);
setParentInfo({ parentId: null, parentStabilimento: null });
setShowModal(true);
};
const handleAddChild = (parentId: number) => {
setEditingMacchina(null);
const parentStabilimento = getParentStabilimento(parentId);
setParentInfo({ parentId, parentStabilimento });
setShowModal(true);
};
const handleEdit = (node: MacchinaWithDetails) => {
setEditingMacchina(node);
setParentInfo({
parentId: node.id_padre,
parentStabilimento: getParentStabilimento(node.id_padre),
});
setShowModal(true);
};
const handleConfirmModal = async (formData: {
nome: string;
tipo: string;
id_stabilimento: string;
colore_testo: string;
id_padre?: number | null;
}) => {
const payload = {
nome: formData.nome,
tipo: formData.tipo,
id_stabilimento: formData.id_stabilimento ? parseInt(formData.id_stabilimento) : null,
colore_testo: formData.colore_testo,
id_padre: formData.id_padre,
};
if (editingMacchina) {
const res = await fetch(`/api/macchine/${editingMacchina.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Errore nell'aggiornamento");
}
} else {
const res = await fetch("/api/macchine", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Errore nella creazione");
}
}
await fetchNodes();
};
const handleDelete = (id: number) => {
const node = nodes.find((n) => n.id === id);
if (node) {
setDeleteConfirm({ id, nome: node.nome });
}
};
const confirmDelete = async () => {
if (!deleteConfirm) return;
try {
const res = await fetch(`/api/macchine/${deleteConfirm.id}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Errore nell'eliminazione");
await fetchNodes();
} catch (err) {
console.error("Errore eliminazione:", err);
} finally {
setDeleteConfirm(null);
}
};
const handleClone = async (node: MacchinaWithDetails) => {
setIsCloning(true);
try {
const res = await fetch("/api/macchine", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "clone", id: node.id, newParentId: node.id_padre }),
});
if (!res.ok) throw new Error("Errore nella clonazione");
await fetchNodes();
} catch (err) {
console.error("Errore clonazione:", err);
} finally {
setIsCloning(false);
}
};
const handleCopy = (node: MacchinaWithDetails) => {
setCopiedNodeId(node.id);
};
const handlePaste = async (targetId: number) => {
if (!copiedNodeId) return;
setIsCloning(true);
try {
const res = await fetch("/api/macchine", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "clone", id: copiedNodeId, newParentId: targetId }),
});
if (!res.ok) throw new Error("Errore nell'incolla");
await fetchNodes();
setCopiedNodeId(null);
} catch (err) {
console.error("Errore incolla:", err);
} finally {
setIsCloning(false);
}
};
const handleShowArticles = (nodeId: number) => {
console.log("Show articles for node:", nodeId);
};
const handleDropArticle = (macchinaId: number, article: unknown) => {
console.log("Drop article on machine:", macchinaId, article);
};
const rootNodes = nodes.filter((n) => n.id_padre === null);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between gap-4 mb-4">
<div>
<h2 className="text-lg font-semibold text-white">Macchine</h2>
<p className="text-xs text-zinc-500">Albero macchine e parti</p>
<p className="text-xs text-zinc-500">
{nodes.length} elementi nell'albero
</p>
</div>
<button
className="p-2 rounded-lg bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-white transition-colors"
onClick={handleAddMachine}
disabled={isCloning}
className="p-2 rounded-lg bg-blue-600 text-white hover:bg-blue-500 transition-colors disabled:opacity-50"
title="Nuova macchina"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -19,16 +222,133 @@ export default function MacchineTree() {
</button>
</div>
{/* Placeholder contenuto */}
{/* Indicatore clonazione */}
{isCloning && (
<div className="mb-3 px-3 py-2 rounded-lg bg-blue-900/30 border border-blue-700/50 flex items-center gap-2">
<svg className="w-4 h-4 animate-spin text-blue-400" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm text-blue-300">Clonazione in corso...</span>
</div>
)}
{/* Indicatore copia */}
{copiedNodeId && (
<div className="mb-3 px-3 py-2 rounded-lg bg-yellow-900/30 border border-yellow-700/50 flex items-center justify-between">
<span className="text-sm text-yellow-300">
Nodo copiato. Clicca su "Incolla" per inserirlo.
</span>
<button
onClick={() => setCopiedNodeId(null)}
className="text-xs text-yellow-400 hover:text-yellow-200"
>
Annulla
</button>
</div>
)}
{/* Contenuto */}
<div className="flex-1 overflow-y-auto pr-1">
{loading ? (
<div className="flex items-center justify-center py-8 text-zinc-500 text-sm">
<svg className="w-5 h-5 animate-spin mr-2" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Caricamento...
</div>
) : error ? (
<div className="text-center py-8">
<p className="text-red-400 text-sm mb-2">{error}</p>
<button onClick={loadInitialData} className="text-xs text-blue-400 hover:underline">
Riprova
</button>
</div>
) : rootNodes.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center p-8 border-2 border-dashed border-zinc-800 rounded-lg">
<svg className="w-12 h-12 text-zinc-700 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<p className="text-zinc-500 text-sm mb-2">Albero macchine</p>
<p className="text-zinc-600 text-xs">
Trascina gli articoli qui per assegnarli alle macchine
<p className="text-zinc-500 text-sm mb-2">Nessuna macchina presente</p>
<p className="text-zinc-600 text-xs mb-4">
Clicca sul pulsante + per aggiungere la prima macchina
</p>
<button
onClick={handleAddMachine}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors"
>
Aggiungi macchina
</button>
</div>
) : (
<div className="space-y-1">
{rootNodes.map((node) => (
<TreeNode
key={node.id}
node={node}
allNodes={nodes}
level={0}
articlesByMacchina={articlesByMacchina}
isCloning={isCloning}
copiedNodeId={copiedNodeId}
onAdd={handleAddChild}
onEdit={handleEdit}
onDelete={handleDelete}
onClone={handleClone}
onCopy={handleCopy}
onPaste={handlePaste}
onShowArticles={handleShowArticles}
onDropArticle={handleDropArticle}
/>
))}
</div>
)}
</div>
{/* Modal */}
<MacchinaModal
isOpen={showModal}
editingMacchina={editingMacchina}
stabilimenti={stabilimenti}
parentId={parentInfo.parentId}
parentStabilimento={parentInfo.parentStabilimento}
onClose={() => {
setShowModal(false);
setEditingMacchina(null);
setParentInfo({ parentId: null, parentStabilimento: null });
}}
onConfirm={handleConfirmModal}
/>
{/* Delete Confirmation */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setDeleteConfirm(null)} />
<div className="relative w-full max-w-sm bg-zinc-900 rounded-xl border border-zinc-800 shadow-2xl p-5">
<h3 className="text-lg font-semibold text-white mb-2">Conferma eliminazione</h3>
<p className="text-sm text-zinc-400 mb-4">
Sei sicuro di voler eliminare <strong className="text-white">{deleteConfirm.nome}</strong>?
<br />
<span className="text-red-400">Verranno eliminati anche tutti i sotto-elementi.</span>
</p>
<div className="flex items-center justify-end gap-3">
<button
onClick={() => setDeleteConfirm(null)}
className="px-4 py-2 text-sm font-medium text-zinc-300 hover:text-white rounded-lg hover:bg-zinc-800 transition-colors"
>
Annulla
</button>
<button
onClick={confirmDelete}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-500 transition-colors"
>
Elimina
</button>
</div>
</div>
</div>
)}
</div>
);
}

304
src/components/TreeNode.tsx Normal file
View file

@ -0,0 +1,304 @@
"use client";
import { useState, type DragEvent } from "react";
import type { MacchinaWithDetails, ArticoliPerMacchina } from "@/src/types/macchina";
function toRgb(color: string | null | undefined): { r: number; g: number; b: number } | null {
if (!color || typeof color !== "string") return null;
const c = color.trim();
if (c.startsWith("#")) {
const hex = c.slice(1);
if (hex.length === 3) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
if ([r, g, b].some((v) => Number.isNaN(v))) return null;
return { r, g, b };
}
if (hex.length === 6) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
if ([r, g, b].some((v) => Number.isNaN(v))) return null;
return { r, g, b };
}
}
return null;
}
function relativeLuminance(rgb: { r: number; g: number; b: number }): number {
const srgb = [rgb.r, rgb.g, rgb.b].map((v) => v / 255);
const lin = srgb.map((v) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)));
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
}
function getReadableTextColor(inputColor: string | null | undefined): string {
const rgb = toRgb(inputColor);
if (!rgb) return "#e5e5e5";
const L = relativeLuminance(rgb);
if (L > 0.8) return "#18181b";
return inputColor || "#e5e5e5";
}
interface TreeNodeProps {
node: MacchinaWithDetails;
allNodes: MacchinaWithDetails[];
level?: number;
articlesByMacchina?: ArticoliPerMacchina;
isCloning?: boolean;
copiedNodeId?: number | null;
onAdd: (parentId: number) => void;
onEdit: (node: MacchinaWithDetails) => void;
onDelete: (id: number) => void;
onClone: (node: MacchinaWithDetails) => void;
onCopy: (node: MacchinaWithDetails) => void;
onPaste: (targetId: number) => void;
onShowArticles?: (nodeId: number) => void;
onDropArticle?: (macchinaId: number, article: unknown) => void;
}
export default function TreeNode({
node,
allNodes,
level = 0,
articlesByMacchina = {},
isCloning = false,
copiedNodeId,
onAdd,
onEdit,
onDelete,
onClone,
onCopy,
onPaste,
onShowArticles,
onDropArticle,
}: TreeNodeProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const children = allNodes.filter((n) => n.id_padre === node.id);
const nodeArticles = articlesByMacchina[node.id] || [];
const getTotalQuantity = (currentNode: MacchinaWithDetails): number => {
const currentArticles = articlesByMacchina[currentNode.id] || [];
const currentQty = currentArticles.reduce(
(sum, art) => sum + (art.quantita || 0),
0
);
const childNodes = allNodes.filter((n) => n.id_padre === currentNode.id);
const childrenQty = childNodes.reduce(
(sum, child) => sum + getTotalQuantity(child),
0
);
return currentQty + childrenQty;
};
const totalQuantity = getTotalQuantity(node);
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
try {
const data = e.dataTransfer.getData("application/json");
if (data && onDropArticle) {
const article = JSON.parse(data);
onDropArticle(node.id, article);
}
} catch {
// Ignore parsing errors
}
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
};
const safeTextColor = getReadableTextColor(node.colore_testo);
const getNodeTypeIcon = () => {
switch (node.tipo) {
case "macchina": return "🔧";
case "gruppo": return "📁";
case "parte": return "⚙️";
default: return "📄";
}
};
const getNodeTypeBg = () => {
switch (node.tipo) {
case "macchina": return "bg-blue-900/30 border-blue-700/50";
case "gruppo": return "bg-purple-900/30 border-purple-700/50";
case "parte": return "bg-zinc-800/50 border-zinc-700/50";
default: return "bg-zinc-800 border-zinc-700";
}
};
return (
<div
className="mb-1"
style={{ marginLeft: `${level * 16}px` }}
>
<div
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-all group
${getNodeTypeBg()}
${isDragOver ? "ring-2 ring-blue-500 bg-blue-900/50" : ""}
hover:bg-zinc-800/70`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{children.length > 0 && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-5 h-5 flex items-center justify-center text-zinc-400 hover:text-white transition-colors"
title={isExpanded ? "Comprimi" : "Espandi"}
>
<svg
className={`w-4 h-4 transition-transform ${isExpanded ? "rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
{children.length === 0 && <div className="w-5" />}
<span className="text-sm">{getNodeTypeIcon()}</span>
<span
className="flex-1 text-sm font-medium truncate"
style={{ color: safeTextColor }}
>
{node.nome}
</span>
{totalQuantity > 0 && (
<button
onClick={() => onShowArticles?.(node.id)}
className="px-2 py-0.5 text-xs rounded bg-teal-900/50 text-teal-300 border border-teal-700/50 hover:bg-teal-800/50 transition-colors"
title={`${totalQuantity} articoli assegnati`}
>
📦 {totalQuantity}
</button>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => onAdd(node.id)}
disabled={isCloning}
className="p-1 rounded text-zinc-400 hover:text-green-400 hover:bg-green-900/30 transition-colors disabled:opacity-50"
title="Aggiungi sotto-elemento"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
<button
onClick={() => onEdit(node)}
disabled={isCloning}
className="p-1 rounded text-zinc-400 hover:text-blue-400 hover:bg-blue-900/30 transition-colors disabled:opacity-50"
title="Modifica"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => onCopy(node)}
disabled={isCloning}
className="p-1 rounded text-zinc-400 hover:text-yellow-400 hover:bg-yellow-900/30 transition-colors disabled:opacity-50"
title="Copia"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
<button
onClick={() => onClone(node)}
disabled={isCloning}
className="p-1 rounded text-zinc-400 hover:text-purple-400 hover:bg-purple-900/30 transition-colors disabled:opacity-50"
title="Clona con sotto-elementi"
>
{isCloning ? (
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
)}
</button>
{copiedNodeId && (
<button
onClick={() => onPaste(node.id)}
disabled={isCloning}
className="p-1 rounded text-zinc-400 hover:text-orange-400 hover:bg-orange-900/30 transition-colors disabled:opacity-50"
title="Incolla"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</button>
)}
<button
onClick={() => onDelete(node.id)}
disabled={isCloning}
className="p-1 rounded text-zinc-400 hover:text-red-400 hover:bg-red-900/30 transition-colors disabled:opacity-50"
title="Elimina"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
{isExpanded && children.length > 0 && (
<div className="mt-1">
{children.map((child) => (
<TreeNode
key={child.id}
node={child}
allNodes={allNodes}
level={level + 1}
articlesByMacchina={articlesByMacchina}
isCloning={isCloning}
copiedNodeId={copiedNodeId}
onAdd={onAdd}
onEdit={onEdit}
onDelete={onDelete}
onClone={onClone}
onCopy={onCopy}
onPaste={onPaste}
onShowArticles={onShowArticles}
onDropArticle={onDropArticle}
/>
))}
</div>
)}
</div>
);
}

260
src/lib/macchine/index.ts Normal file
View file

@ -0,0 +1,260 @@
import type { RowDataPacket, ResultSetHeader } from "mysql2";
import { db } from "@/src/lib/db";
import type {
Macchina,
MacchinaWithDetails,
MacchinaCreateInput,
MacchinaUpdateInput,
TipoMacchina,
} from "@/src/types/macchina";
type MacchinaRow = Macchina & RowDataPacket;
type MacchinaWithDetailsRow = MacchinaWithDetails & RowDataPacket;
export async function listMacchine(): Promise<MacchinaWithDetails[]> {
const [rows] = await db.execute<MacchinaWithDetailsRow[]>(`
SELECT
m.id,
m.nome,
m.tipo,
m.id_padre,
m.id_stabilimento,
m.colore_testo,
s.descrizione AS stabilimento_nome,
p.nome AS padre_nome
FROM macchine_parti m
LEFT JOIN stabilimenti s ON s.id = m.id_stabilimento
LEFT JOIN macchine_parti p ON p.id = m.id_padre
ORDER BY ISNULL(m.id_padre) DESC, m.id_padre ASC, m.nome ASC
`);
return rows.map((row) => ({
id: row.id,
nome: row.nome,
tipo: row.tipo as TipoMacchina,
id_padre: row.id_padre,
id_stabilimento: row.id_stabilimento,
colore_testo: row.colore_testo || "#000000",
stabilimento_nome: row.stabilimento_nome,
padre_nome: row.padre_nome,
}));
}
export async function getMacchinaById(id: number): Promise<MacchinaWithDetails | null> {
const [rows] = await db.execute<MacchinaWithDetailsRow[]>(
`SELECT
m.id,
m.nome,
m.tipo,
m.id_padre,
m.id_stabilimento,
m.colore_testo,
s.descrizione AS stabilimento_nome,
p.nome AS padre_nome
FROM macchine_parti m
LEFT JOIN stabilimenti s ON s.id = m.id_stabilimento
LEFT JOIN macchine_parti p ON p.id = m.id_padre
WHERE m.id = :id
LIMIT 1`,
{ id }
);
const row = rows[0];
if (!row) return null;
return {
id: row.id,
nome: row.nome,
tipo: row.tipo as TipoMacchina,
id_padre: row.id_padre,
id_stabilimento: row.id_stabilimento,
colore_testo: row.colore_testo || "#000000",
stabilimento_nome: row.stabilimento_nome,
padre_nome: row.padre_nome,
};
}
export async function createMacchina(input: MacchinaCreateInput): Promise<Macchina> {
const { nome, tipo, id_padre, id_stabilimento, colore_testo = "#000000" } = input;
let stabilimentoId = id_stabilimento;
if (tipo === "macchina" && !id_padre && !stabilimentoId) {
throw new Error("Stabilimento obbligatorio per macchine di livello root");
}
if (id_padre && !stabilimentoId) {
const [parentRows] = await db.execute<MacchinaRow[]>(
"SELECT id_stabilimento FROM macchine_parti WHERE id = :id_padre LIMIT 1",
{ id_padre }
);
const parent = parentRows[0];
if (parent?.id_stabilimento) {
stabilimentoId = parent.id_stabilimento;
}
}
const [result] = await db.execute<ResultSetHeader>(
`INSERT INTO macchine_parti (nome, tipo, id_padre, id_stabilimento, colore_testo)
VALUES (:nome, :tipo, :id_padre, :id_stabilimento, :colore_testo)`,
{
nome,
tipo,
id_padre: id_padre || null,
id_stabilimento: stabilimentoId || null,
colore_testo,
}
);
return {
id: result.insertId,
nome,
tipo,
id_padre: id_padre || null,
id_stabilimento: stabilimentoId || null,
colore_testo,
};
}
export async function updateMacchina(
id: number,
input: MacchinaUpdateInput
): Promise<MacchinaWithDetails | null> {
const current = await getMacchinaById(id);
if (!current) return null;
const nome = input.nome ?? current.nome;
const tipo = input.tipo ?? current.tipo;
const id_padre = input.id_padre !== undefined ? input.id_padre : current.id_padre;
const colore_testo = input.colore_testo ?? current.colore_testo;
let id_stabilimento = input.id_stabilimento !== undefined
? input.id_stabilimento
: current.id_stabilimento;
if (id_padre && !id_stabilimento) {
const [parentRows] = await db.execute<MacchinaRow[]>(
"SELECT id_stabilimento FROM macchine_parti WHERE id = :id_padre LIMIT 1",
{ id_padre }
);
const parent = parentRows[0];
if (parent?.id_stabilimento) {
id_stabilimento = parent.id_stabilimento;
}
}
await db.execute(
`UPDATE macchine_parti
SET nome = :nome, tipo = :tipo, id_padre = :id_padre,
id_stabilimento = :id_stabilimento, colore_testo = :colore_testo
WHERE id = :id`,
{
id,
nome,
tipo,
id_padre: id_padre || null,
id_stabilimento: id_stabilimento || null,
colore_testo,
}
);
return getMacchinaById(id);
}
export async function deleteMacchina(id: number): Promise<boolean> {
const [childRows] = await db.execute<MacchinaRow[]>(
"SELECT id FROM macchine_parti WHERE id_padre = :id",
{ id }
);
if (childRows.length > 0) {
for (const child of childRows) {
await deleteMacchina(child.id);
}
}
const [result] = await db.execute<ResultSetHeader>(
"DELETE FROM macchine_parti WHERE id = :id",
{ id }
);
return result.affectedRows > 0;
}
export async function cloneMacchina(
id: number,
newParentId: number | null = null
): Promise<Macchina | null> {
const original = await getMacchinaById(id);
if (!original) return null;
let copyName = `${original.nome} (copia)`;
let counter = 1;
const [existingRows] = await db.execute<MacchinaRow[]>(
"SELECT nome FROM macchine_parti WHERE nome LIKE :pattern",
{ pattern: `${original.nome} (copia%` }
);
const existingNames = new Set(existingRows.map((r) => r.nome));
while (existingNames.has(copyName)) {
copyName = `${original.nome} (copia ${counter})`;
counter++;
}
let stabilimentoId = original.id_stabilimento;
if (newParentId) {
const [parentRows] = await db.execute<MacchinaRow[]>(
"SELECT id_stabilimento FROM macchine_parti WHERE id = :id LIMIT 1",
{ id: newParentId }
);
if (parentRows[0]?.id_stabilimento) {
stabilimentoId = parentRows[0].id_stabilimento;
}
}
const newMacchina = await createMacchina({
nome: copyName,
tipo: original.tipo,
id_padre: newParentId ?? original.id_padre,
id_stabilimento: stabilimentoId,
colore_testo: original.colore_testo,
});
const [children] = await db.execute<MacchinaRow[]>(
"SELECT id FROM macchine_parti WHERE id_padre = :id",
{ id }
);
for (const child of children) {
await cloneMacchina(child.id, newMacchina.id);
}
return newMacchina;
}
export async function getChildrenCount(id: number): Promise<number> {
const [rows] = await db.execute<(RowDataPacket & { count: number })[]>(
"SELECT COUNT(*) as count FROM macchine_parti WHERE id_padre = :id",
{ id }
);
return rows[0]?.count ?? 0;
}
export async function getAllDescendantIds(id: number): Promise<number[]> {
const ids: number[] = [];
const [children] = await db.execute<MacchinaRow[]>(
"SELECT id FROM macchine_parti WHERE id_padre = :id",
{ id }
);
for (const child of children) {
ids.push(child.id);
const descendantIds = await getAllDescendantIds(child.id);
ids.push(...descendantIds);
}
return ids;
}

47
src/types/macchina.ts Normal file
View file

@ -0,0 +1,47 @@
export type TipoMacchina = "macchina" | "gruppo" | "parte";
export interface Macchina {
id: number;
nome: string;
tipo: TipoMacchina;
id_padre: number | null;
id_stabilimento: number | null;
colore_testo: string;
}
export interface MacchinaWithDetails extends Macchina {
stabilimento_nome?: string;
padre_nome?: string;
}
export interface MacchinaCreateInput {
nome: string;
tipo: TipoMacchina;
id_padre?: number | null;
id_stabilimento?: number | null;
colore_testo?: string;
}
export interface MacchinaUpdateInput {
nome?: string;
tipo?: TipoMacchina;
id_padre?: number | null;
id_stabilimento?: number | null;
colore_testo?: string;
}
export interface ArticoloAssegnato {
id: number;
id_articolo: number;
id_macchina: number;
descrizione: string;
codice: string;
barcode?: string;
quantita: number;
qtain: number;
qtaout: number;
}
export interface ArticoliPerMacchina {
[macchinaId: number]: ArticoloAssegnato[];
}