diff --git a/src/app/(management)/movimenti/page.tsx b/src/app/(management)/movimenti/page.tsx index ea14196..5fb2b13 100644 --- a/src/app/(management)/movimenti/page.tsx +++ b/src/app/(management)/movimenti/page.tsx @@ -1,3 +1,177 @@ -export default function Movimenti(){ - return

Movimenti

-} \ No newline at end of file +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import type { Movimento } from "@/src/types/movimento"; + +export default function MovimentiPage() { + const [movimenti, setMovimenti] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchMovimenti = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const res = await fetch("/api/movimenti?limit=200"); + if (!res.ok) throw new Error("Errore nel caricamento"); + + const data = await res.json(); + setMovimenti(data.movimenti); + } catch (err) { + setError(err instanceof Error ? err.message : "Errore sconosciuto"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchMovimenti(); + }, [fetchMovimenti]); + + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + return date.toLocaleString("it-IT", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + return ( +
+
+ {/* Header */} +
+

Movimenti

+

Storico carichi e scarichi

+
+ + {/* Statistiche rapide */} +
+
+

Totale movimenti

+

{movimenti.length}

+
+
+

Carichi

+

+ {movimenti.filter((m) => m.tipo_movimento === 1).length} +

+
+
+

Scarichi

+

+ {movimenti.filter((m) => m.tipo_movimento === 2).length} +

+
+
+ + {/* Lista movimenti */} + {loading ? ( +
+ + + + + Caricamento movimenti... +
+ ) : error ? ( +
+

{error}

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

Nessun movimento

+

I movimenti appariranno qui quando effettuerai carichi o scarichi

+
+ ) : ( +
+ {movimenti.map((movimento) => ( +
+ {/* Icona tipo */} +
+ {movimento.tipo_movimento === 1 ? ( + + + + ) : ( + + + + )} +
+ + {/* Info articolo */} +
+

+ {movimento.articolo_descrizione} +

+

+ {movimento.articolo_codice} +

+
+ + {/* Quantità */} +
+ {movimento.tipo_movimento === 1 ? "+" : "-"}{movimento.quantita} +
+ + {/* Magazzino */} +
+

+ {movimento.tipo_movimento === 1 ? "Destinazione" : "Origine"} +

+

+ {movimento.tipo_movimento === 1 + ? movimento.magazzino_destinazione + : movimento.magazzino_origine} +

+
+ + {/* Data e utente */} +
+

+ {formatDate(movimento.data_movimento)} +

+

+ {movimento.utente} +

+
+ + {/* Causale */} + {movimento.causale && ( +
+

+ {movimento.causale} +

+
+ )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/app/api/movimenti/route.ts b/src/app/api/movimenti/route.ts new file mode 100644 index 0000000..16fac46 --- /dev/null +++ b/src/app/api/movimenti/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/src/auth"; +import { listMovimenti, createMovimento } from "@/src/lib/movimenti"; +import { getArticleById } from "@/src/lib/articles"; +import type { TipoMovimento } from "@/src/types/movimento"; + +export async function GET(request: Request) { + const session = await auth(); + + if (!session?.user) { + return NextResponse.json({ error: "Non autorizzato" }, { status: 401 }); + } + + try { + const { searchParams } = new URL(request.url); + const articoloId = searchParams.get("articoloId"); + const magazzinoId = searchParams.get("magazzinoId"); + const limit = searchParams.get("limit"); + + const movimenti = await listMovimenti({ + articoloId: articoloId ? parseInt(articoloId, 10) : undefined, + magazzinoId: magazzinoId ? parseInt(magazzinoId, 10) : undefined, + limit: limit ? parseInt(limit, 10) : 100, + }); + + return NextResponse.json({ movimenti }); + } catch (error) { + console.error("[API movimenti] Errore GET:", error); + return NextResponse.json( + { error: "Errore nel recupero dei movimenti" }, + { 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(); + const { id_articolo, quantita, tipo, id_magazzino, causale } = body as { + id_articolo: number; + quantita: number; + tipo: TipoMovimento; + id_magazzino: number; + causale?: string; + }; + + if (!id_articolo || !quantita || !tipo || !id_magazzino) { + return NextResponse.json( + { error: "Campi obbligatori mancanti: id_articolo, quantita, tipo, id_magazzino" }, + { status: 400 } + ); + } + + if (quantita <= 0) { + return NextResponse.json( + { error: "La quantità deve essere maggiore di zero" }, + { status: 400 } + ); + } + + if (!["carico", "scarico"].includes(tipo)) { + return NextResponse.json( + { error: "Tipo movimento non valido. Usare 'carico' o 'scarico'" }, + { status: 400 } + ); + } + + const articolo = await getArticleById(id_articolo); + if (!articolo) { + return NextResponse.json( + { error: "Articolo non trovato" }, + { status: 404 } + ); + } + + const tipoMovimentoDB = tipo === "carico" ? 1 : 2; + const utente = session.user.name || session.user.email || "Sistema"; + + const movimentoId = await createMovimento({ + id_articolo, + articolo_codice: articolo.codice, + articolo_descrizione: articolo.descrizione, + quantita, + tipo_movimento: tipoMovimentoDB, + causale: causale || null, + utente, + id_magazzino_origine: tipo === "scarico" ? id_magazzino : null, + id_magazzino_destinazione: tipo === "carico" ? id_magazzino : null, + }); + + return NextResponse.json( + { + message: `${tipo === "carico" ? "Carico" : "Scarico"} registrato con successo`, + id: movimentoId + }, + { status: 201 } + ); + } catch (error) { + console.error("[API movimenti] Errore POST:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Errore nella registrazione del movimento" }, + { status: 400 } + ); + } +} diff --git a/src/components/ArticleCard.tsx b/src/components/ArticleCard.tsx index 106c073..bea9a52 100644 --- a/src/components/ArticleCard.tsx +++ b/src/components/ArticleCard.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import type { Article } from "@/src/types/article"; +import type { TipoMovimento } from "@/src/types/movimento"; interface ArticleCardProps { article: Article; @@ -9,6 +10,7 @@ interface ArticleCardProps { magazzinoNome?: string; onEdit: (article: Article) => void; onDelete: (article: Article) => void; + onMovimento?: (article: Article, tipo: TipoMovimento) => void; } export default function ArticleCard({ @@ -16,7 +18,8 @@ export default function ArticleCard({ giacenzaMagazzino, magazzinoNome, onEdit, - onDelete + onDelete, + onMovimento, }: ArticleCardProps) { const [deleting, setDeleting] = useState(false); @@ -107,7 +110,31 @@ export default function ArticleCard({ - {/* Azioni */} + {/* Azioni movimenti */} + {onMovimento && ( +
+ + +
+ )} + + {/* Azioni CRUD */}
- {/* Modal */} + {/* Modal Articolo */} {showModal && ( )} + + {/* Modal Movimento */} + {showMovimentoModal && movimentoArticle && ( + { setShowMovimentoModal(false); setMovimentoArticle(null); }} + onSuccess={handleMovimentoSuccess} + /> + )} ); } diff --git a/src/components/MovimentoModal.tsx b/src/components/MovimentoModal.tsx new file mode 100644 index 0000000..580ab81 --- /dev/null +++ b/src/components/MovimentoModal.tsx @@ -0,0 +1,281 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import type { Article } from "@/src/types/article"; +import type { Stabilimento, Magazzino } from "@/src/types/stabilimento"; +import type { TipoMovimento } from "@/src/types/movimento"; + +interface MovimentoModalProps { + article: Article; + tipo: TipoMovimento; + onClose: () => void; + onSuccess: () => void; +} + +export default function MovimentoModal({ + article, + tipo, + onClose, + onSuccess, +}: MovimentoModalProps) { + const modalRef = useRef(null); + const isCarico = tipo === "carico"; + + const [stabilimenti, setStabilimenti] = useState([]); + const [magazzini, setMagazzini] = useState([]); + const [selectedStabilimento, setSelectedStabilimento] = useState(null); + const [selectedMagazzino, setSelectedMagazzino] = useState(null); + const [quantita, setQuantita] = useState(1); + const [causale, setCausale] = useState(""); + + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchData() { + try { + const [stabRes, magRes] = await Promise.all([ + fetch("/api/stabilimenti"), + fetch("/api/magazzini"), + ]); + + if (stabRes.ok && magRes.ok) { + const stabData = await stabRes.json(); + const magData = await magRes.json(); + setStabilimenti(stabData.stabilimenti); + setMagazzini(magData.magazzini); + } + } catch (error) { + console.error("Errore caricamento dati:", error); + } + } + + fetchData(); + }, []); + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + + function handleClickOutside(e: MouseEvent) { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + onClose(); + } + } + + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("mousedown", handleClickOutside); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [onClose]); + + const filteredMagazzini = selectedStabilimento + ? magazzini.filter((m) => m.id_stabilimento === selectedStabilimento) + : []; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!selectedMagazzino) { + setError("Seleziona un magazzino"); + return; + } + + if (quantita <= 0) { + setError("La quantità deve essere maggiore di zero"); + return; + } + + setSaving(true); + + try { + const res = await fetch("/api/movimenti", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id_articolo: article.id, + quantita, + tipo, + id_magazzino: selectedMagazzino, + causale: causale || undefined, + }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Errore durante la registrazione"); + } + + onSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : "Errore sconosciuto"); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ {/* Header */} +
+
+
+ {isCarico ? ( + + + + ) : ( + + + + )} +
+
+

+ {isCarico ? "Carico" : "Scarico"} +

+

Registra movimento

+
+
+ +
+ + {/* Articolo info */} +
+

Articolo

+

{article.descrizione}

+

{article.codice}

+

+ Giacenza attuale: {article.quantita} +

+
+ + {/* Form */} +
+ {error && ( +
+ {error} +
+ )} + + {/* Stabilimento */} +
+ + +
+ + {/* Magazzino */} + {selectedStabilimento && ( +
+ + +
+ )} + + {/* Quantità */} +
+ + setQuantita(parseFloat(e.target.value) || 0)} + min="1" + className="w-full px-4 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white focus:outline-none focus:border-blue-500 transition-colors text-lg font-semibold" + /> +
+ + {/* Causale */} +
+ + setCausale(e.target.value)} + placeholder={isCarico ? "Es: Acquisto fornitore" : "Es: Utilizzo manutenzione"} + className="w-full px-4 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-blue-500 transition-colors" + /> +
+ + {/* Actions */} +
+ + +
+
+
+
+ ); +} diff --git a/src/lib/movimenti/index.ts b/src/lib/movimenti/index.ts new file mode 100644 index 0000000..3730c8e --- /dev/null +++ b/src/lib/movimenti/index.ts @@ -0,0 +1,199 @@ +import type { ResultSetHeader, RowDataPacket } from "mysql2"; + +import { db, getDbConnection } from "@/src/lib/db"; +import type { Movimento, CreateMovimentoPayload } from "@/src/types/movimento"; + +type MovimentoRow = Movimento & RowDataPacket; + +export async function listMovimenti(options: { + articoloId?: number; + magazzinoId?: number; + limit?: number; +} = {}): Promise { + const { articoloId, magazzinoId, limit = 100 } = options; + + const conditions: string[] = []; + const params: Record = {}; + + if (articoloId) { + conditions.push("m.id_articolo = :articoloId"); + params.articoloId = articoloId; + } + + if (magazzinoId) { + conditions.push("(m.id_magazzino_origine = :magazzinoId OR m.id_magazzino_destinazione = :magazzinoId)"); + params.magazzinoId = magazzinoId; + } + + let query = ` + SELECT + m.id, + m.id_articolo, + m.articolo_codice, + m.articolo_descrizione, + m.id_macchina_parte, + m.nome_macchina_parte, + m.quantita, + m.data_movimento, + m.tipo_movimento, + m.causale, + m.utente, + m.id_intervento, + m.id_magazzino_origine, + m.id_magazzino_destinazione, + mo.descrizione AS magazzino_origine, + md.descrizione AS magazzino_destinazione + FROM movimenti m + LEFT JOIN magazzini mo ON mo.id = m.id_magazzino_origine + LEFT JOIN magazzini md ON md.id = m.id_magazzino_destinazione + `; + + if (conditions.length > 0) { + query += " WHERE " + conditions.join(" AND "); + } + + query += ` ORDER BY m.data_movimento DESC LIMIT ${limit}`; + + const [rows] = await db.execute(query, params); + + return rows.map((row) => ({ + id: row.id, + id_articolo: row.id_articolo, + articolo_codice: row.articolo_codice, + articolo_descrizione: row.articolo_descrizione, + id_macchina_parte: row.id_macchina_parte, + nome_macchina_parte: row.nome_macchina_parte, + quantita: Number(row.quantita) || 0, + data_movimento: row.data_movimento, + tipo_movimento: row.tipo_movimento, + causale: row.causale, + utente: row.utente, + id_intervento: row.id_intervento, + id_magazzino_origine: row.id_magazzino_origine, + id_magazzino_destinazione: row.id_magazzino_destinazione, + magazzino_origine: row.magazzino_origine, + magazzino_destinazione: row.magazzino_destinazione, + })); +} + +export async function createMovimento( + payload: CreateMovimentoPayload +): Promise { + const connection = await getDbConnection(); + + try { + await connection.beginTransaction(); + + const [result] = await connection.execute( + `INSERT INTO movimenti + (id_articolo, articolo_codice, articolo_descrizione, quantita, tipo_movimento, causale, utente, id_magazzino_origine, id_magazzino_destinazione) + VALUES (:id_articolo, :articolo_codice, :articolo_descrizione, :quantita, :tipo_movimento, :causale, :utente, :id_magazzino_origine, :id_magazzino_destinazione)`, + { + id_articolo: payload.id_articolo, + articolo_codice: payload.articolo_codice, + articolo_descrizione: payload.articolo_descrizione, + quantita: payload.quantita, + tipo_movimento: payload.tipo_movimento, + causale: payload.causale, + utente: payload.utente, + id_magazzino_origine: payload.id_magazzino_origine, + id_magazzino_destinazione: payload.id_magazzino_destinazione, + } + ); + + const movimentoId = result.insertId; + + const idMagazzino = payload.tipo_movimento === 1 + ? payload.id_magazzino_destinazione + : payload.id_magazzino_origine; + + if (!idMagazzino) { + throw new Error("Magazzino non specificato"); + } + + const [existingRows] = await connection.execute( + "SELECT id, quantita FROM giacenze WHERE articolo_id = :articoloId AND id_magazzino = :magazzinoId", + { articoloId: payload.id_articolo, magazzinoId: idMagazzino } + ); + + const existing = existingRows[0]; + + if (payload.tipo_movimento === 1) { + if (existing) { + await connection.execute( + "UPDATE giacenze SET quantita = quantita + :quantita WHERE id = :id", + { quantita: payload.quantita, id: existing.id } + ); + } else { + await connection.execute( + "INSERT INTO giacenze (articolo_id, id_magazzino, quantita) VALUES (:articoloId, :magazzinoId, :quantita)", + { articoloId: payload.id_articolo, magazzinoId: idMagazzino, quantita: payload.quantita } + ); + } + } else { + if (!existing || Number(existing.quantita) < payload.quantita) { + throw new Error( + `Quantità insufficiente. Disponibile: ${existing?.quantita || 0}, richiesta: ${payload.quantita}` + ); + } + + const nuovaQuantita = Number(existing.quantita) - payload.quantita; + + if (nuovaQuantita === 0) { + await connection.execute( + "DELETE FROM giacenze WHERE id = :id", + { id: existing.id } + ); + } else { + await connection.execute( + "UPDATE giacenze SET quantita = :quantita WHERE id = :id", + { quantita: nuovaQuantita, id: existing.id } + ); + } + } + + await connection.commit(); + return movimentoId; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } +} + +export async function getMovimentoById(id: number): Promise { + const [rows] = await db.execute( + `SELECT + m.*, + mo.descrizione AS magazzino_origine, + md.descrizione AS magazzino_destinazione + FROM movimenti m + LEFT JOIN magazzini mo ON mo.id = m.id_magazzino_origine + LEFT JOIN magazzini md ON md.id = m.id_magazzino_destinazione + WHERE m.id = :id`, + { id } + ); + + const row = rows[0]; + if (!row) return null; + + return { + id: row.id, + id_articolo: row.id_articolo, + articolo_codice: row.articolo_codice, + articolo_descrizione: row.articolo_descrizione, + id_macchina_parte: row.id_macchina_parte, + nome_macchina_parte: row.nome_macchina_parte, + quantita: Number(row.quantita) || 0, + data_movimento: row.data_movimento, + tipo_movimento: row.tipo_movimento, + causale: row.causale, + utente: row.utente, + id_intervento: row.id_intervento, + id_magazzino_origine: row.id_magazzino_origine, + id_magazzino_destinazione: row.id_magazzino_destinazione, + magazzino_origine: row.magazzino_origine, + magazzino_destinazione: row.magazzino_destinazione, + }; +} diff --git a/src/types/movimento.ts b/src/types/movimento.ts new file mode 100644 index 0000000..aa7db1a --- /dev/null +++ b/src/types/movimento.ts @@ -0,0 +1,40 @@ +export type TipoMovimento = "carico" | "scarico"; + +export interface Movimento { + id: number; + id_articolo: number; + articolo_codice: string | null; + articolo_descrizione: string | null; + id_macchina_parte: number | null; + nome_macchina_parte: string | null; + quantita: number; + data_movimento: string; + tipo_movimento: number; // 1 = carico, 2 = scarico + causale: string | null; + utente: string | null; + id_intervento: number | null; + id_magazzino_origine: number | null; + id_magazzino_destinazione: number | null; + magazzino_origine?: string; + magazzino_destinazione?: string; +} + +export interface MovimentoInput { + id_articolo: number; + quantita: number; + tipo: TipoMovimento; + id_magazzino: number; + causale?: string; +} + +export interface CreateMovimentoPayload { + id_articolo: number; + articolo_codice: string; + articolo_descrizione: string; + quantita: number; + tipo_movimento: number; + causale: string | null; + utente: string; + id_magazzino_origine: number | null; + id_magazzino_destinazione: number | null; +}