diff --git a/src/app/(main)/macchine/page.tsx b/src/app/(main)/macchine/page.tsx index 6bb336a..8c81a97 100644 --- a/src/app/(main)/macchine/page.tsx +++ b/src/app/(main)/macchine/page.tsx @@ -1,3 +1,5 @@ +import MacchineTree from "@/src/components/MacchineTree"; + export default function MachinePage() { return (
@@ -11,10 +13,8 @@ export default function MachinePage() {

-
-

- Contenuto della pagina macchine in arrivo... -

+
+
diff --git a/src/app/api/macchine/[id]/route.ts b/src/app/api/macchine/[id]/route.ts new file mode 100644 index 0000000..e7fcc0a --- /dev/null +++ b/src/app/api/macchine/[id]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/macchine/route.ts b/src/app/api/macchine/route.ts new file mode 100644 index 0000000..afad7f9 --- /dev/null +++ b/src/app/api/macchine/route.ts @@ -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 }); + } +} diff --git a/src/components/MacchinaModal.tsx b/src/components/MacchinaModal.tsx new file mode 100644 index 0000000..69244d6 --- /dev/null +++ b/src/components/MacchinaModal.tsx @@ -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; +} + +export default function MacchinaModal({ + isOpen, + editingMacchina, + stabilimenti, + parentId, + parentStabilimento, + onClose, + onConfirm, +}: MacchinaModalProps) { + const [formData, setFormData] = useState({ + nome: "", + tipo: "macchina", + id_stabilimento: "", + colore_testo: "#3b82f6", + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errors, setErrors] = useState>({}); + + 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 = {}; + + 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 ( +
+
+ +
+ {/* Header */} +
+

{getTitle()}

+ +
+ + {/* Content */} +
+ {/* Nome */} +
+ + 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 &&

{errors.nome}

} +
+ + {/* Tipo */} + {!isEditing && ( +
+ + +
+ )} + + {/* Stabilimento - solo per macchine root */} + {formData.tipo === "macchina" && !parentId && ( +
+ + + {errors.id_stabilimento &&

{errors.id_stabilimento}

} +
+ )} + + {/* Info ereditarietà stabilimento */} + {parentId && parentStabilimento && ( +
+

+ Lo stabilimento verrà ereditato automaticamente dal padre. +

+
+ )} + + {/* Colore */} +
+ +
+ handleFieldChange("colore_testo", e.target.value)} + disabled={isSubmitting} + className="w-10 h-10 rounded-lg border border-zinc-700 cursor-pointer" + /> + 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" + /> +
+
+ + {/* Error generale */} + {errors.submit && ( +
+

{errors.submit}

+
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/src/components/MacchineTree.tsx b/src/components/MacchineTree.tsx index d991136..bf6691a 100644 --- a/src/components/MacchineTree.tsx +++ b/src/components/MacchineTree.tsx @@ -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([]); + const [stabilimenti, setStabilimenti] = useState([]); + const [articlesByMacchina, setArticlesByMacchina] = useState({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [copiedNodeId, setCopiedNodeId] = useState(null); + const [isCloning, setIsCloning] = useState(false); + + const [showModal, setShowModal] = useState(false); + const [editingMacchina, setEditingMacchina] = useState(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 (
{/* Header */}

Macchine

-

Albero macchine e parti

+

+ {nodes.length} elementi nell'albero +

- {/* Placeholder contenuto */} -
- - - -

Albero macchine

-

- Trascina gli articoli qui per assegnarli alle macchine -

+ {/* Indicatore clonazione */} + {isCloning && ( +
+ + + + + Clonazione in corso... +
+ )} + + {/* Indicatore copia */} + {copiedNodeId && ( +
+ + Nodo copiato. Clicca su "Incolla" per inserirlo. + + +
+ )} + + {/* Contenuto */} +
+ {loading ? ( +
+ + + + + Caricamento... +
+ ) : error ? ( +
+

{error}

+ +
+ ) : rootNodes.length === 0 ? ( +
+ + + +

Nessuna macchina presente

+

+ Clicca sul pulsante + per aggiungere la prima macchina +

+ +
+ ) : ( +
+ {rootNodes.map((node) => ( + + ))} +
+ )}
+ + {/* Modal */} + { + setShowModal(false); + setEditingMacchina(null); + setParentInfo({ parentId: null, parentStabilimento: null }); + }} + onConfirm={handleConfirmModal} + /> + + {/* Delete Confirmation */} + {deleteConfirm && ( +
+
setDeleteConfirm(null)} /> +
+

Conferma eliminazione

+

+ Sei sicuro di voler eliminare {deleteConfirm.nome}? +
+ Verranno eliminati anche tutti i sotto-elementi. +

+
+ + +
+
+
+ )}
); } diff --git a/src/components/TreeNode.tsx b/src/components/TreeNode.tsx new file mode 100644 index 0000000..49ab3ca --- /dev/null +++ b/src/components/TreeNode.tsx @@ -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) => { + 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) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + }; + + const handleDragLeave = (e: DragEvent) => { + 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 ( +
+
+ {children.length > 0 && ( + + )} + + {children.length === 0 &&
} + + {getNodeTypeIcon()} + + + {node.nome} + + + {totalQuantity > 0 && ( + + )} + +
+ + + + + + + + + {copiedNodeId && ( + + )} + + +
+
+ + {isExpanded && children.length > 0 && ( +
+ {children.map((child) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/lib/macchine/index.ts b/src/lib/macchine/index.ts new file mode 100644 index 0000000..f7fa565 --- /dev/null +++ b/src/lib/macchine/index.ts @@ -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 { + const [rows] = await db.execute(` + 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 { + const [rows] = await db.execute( + `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 { + 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( + "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( + `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 { + 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( + "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 { + const [childRows] = await db.execute( + "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( + "DELETE FROM macchine_parti WHERE id = :id", + { id } + ); + + return result.affectedRows > 0; +} + +export async function cloneMacchina( + id: number, + newParentId: number | null = null +): Promise { + const original = await getMacchinaById(id); + if (!original) return null; + + let copyName = `${original.nome} (copia)`; + let counter = 1; + + const [existingRows] = await db.execute( + "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( + "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( + "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 { + 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 { + const ids: number[] = []; + + const [children] = await db.execute( + "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; +} diff --git a/src/types/macchina.ts b/src/types/macchina.ts new file mode 100644 index 0000000..3bf9c0a --- /dev/null +++ b/src/types/macchina.ts @@ -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[]; +}