From 1e38a4f8c6483e5dc60323e9a4d5cd49d9a306a8 Mon Sep 17 00:00:00 2001 From: AV77web Date: Thu, 28 May 2026 22:03:03 +0200 Subject: [PATCH] creati tipi magazzini/stabilimenti/, API per stabilimenti/magazzini e componenti: ArticleCar, StabilimentoSelector, Modale Articoli --- src/app/(main)/page.tsx | 122 +++++----- src/app/api/articoli/[id]/route.ts | 56 +++++ src/app/api/articoli/route.ts | 35 +++ src/app/api/magazzini/route.ts | 29 +++ src/app/api/stabilimenti/route.ts | 31 +++ src/components/ArticleCard.tsx | 172 ++++++++++++++ src/components/ArticleModal.tsx | 290 ++++++++++++++++++++++++ src/components/ArticlesGrid.tsx | 250 ++++++++++++++++++++ src/components/StabilimentoSelector.tsx | 88 +++++++ src/lib/articles/index.ts | 273 ++++++++++++++++++++++ src/lib/stabilimenti/index.ts | 89 ++++++++ src/types/article.ts | 30 +++ src/types/stabilimento.ts | 15 ++ 13 files changed, 1425 insertions(+), 55 deletions(-) create mode 100644 src/app/api/articoli/[id]/route.ts create mode 100644 src/app/api/articoli/route.ts create mode 100644 src/app/api/magazzini/route.ts create mode 100644 src/app/api/stabilimenti/route.ts create mode 100644 src/components/ArticleCard.tsx create mode 100644 src/components/ArticleModal.tsx create mode 100644 src/components/ArticlesGrid.tsx create mode 100644 src/components/StabilimentoSelector.tsx create mode 100644 src/lib/articles/index.ts create mode 100644 src/lib/stabilimenti/index.ts create mode 100644 src/types/article.ts create mode 100644 src/types/stabilimento.ts diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index de78a90..7d9c203 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -1,65 +1,77 @@ -import Image from "next/image"; +"use client"; + +import { useState } from "react"; +import type { Stabilimento } from "@/src/types/stabilimento"; +import StabilimentoSelector from "@/src/components/StabilimentoSelector"; +import ArticlesGrid from "@/src/components/ArticlesGrid"; export default function Home() { + const [selectedStabilimento, setSelectedStabilimento] = useState(null); + return ( -
-
- Next.js logo -
-

- To get started, PROVA PROVA edit the page.tsx file. +
+
+ {/* Header */} +
+

+ MagRicambi

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. +

+ Gestione magazzino ricambi

-
- - Vercel logomark - Deploy Now - - - Documentation - + + {/* Selezione stabilimento */} +
+

+ Seleziona stabilimento +

+
-

+ + {/* Contenuto principale */} + {selectedStabilimento ? ( +
+
+

+ Articoli - {selectedStabilimento.descrizione} +

+ +
+ +
+ ) : ( +
+ + + +

+ Nessuno stabilimento selezionato +

+

+ Seleziona uno stabilimento dalla lista sopra per visualizzare e gestire gli articoli del magazzino. +

+
+ )} +
); } diff --git a/src/app/api/articoli/[id]/route.ts b/src/app/api/articoli/[id]/route.ts new file mode 100644 index 0000000..56e8128 --- /dev/null +++ b/src/app/api/articoli/[id]/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/src/auth"; +import { deleteArticle, updateArticle } from "@/src/lib/articles"; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +async function readId(params: Promise<{ id: string }>): Promise { + const { id } = await params; + const articleId = Number(id); + + if (!Number.isInteger(articleId) || articleId <= 0) { + throw new Error("ID articolo non valido."); + } + + return articleId; +} + +export async function PUT(request: Request, context: RouteContext) { + const session = await auth(); + + if (!session?.user) { + return NextResponse.json({ error: "Non autenticato" }, { status: 401 }); + } + + try { + const article = await updateArticle(await readId(context.params), await request.json()); + return NextResponse.json({ article }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Errore modifica articolo" }, + { status: 400 }, + ); + } +} + +export async function DELETE(_request: Request, context: RouteContext) { + const session = await auth(); + + if (!session?.user) { + return NextResponse.json({ error: "Non autenticato" }, { status: 401 }); + } + + try { + await deleteArticle(await readId(context.params)); + return NextResponse.json({ ok: true }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Errore eliminazione articolo" }, + { status: 400 }, + ); + } +} + diff --git a/src/app/api/articoli/route.ts b/src/app/api/articoli/route.ts new file mode 100644 index 0000000..b9fa261 --- /dev/null +++ b/src/app/api/articoli/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/src/auth"; +import { createArticle, listArticles, listFamilies } from "@/src/lib/articles"; + +export async function GET() { + const session = await auth(); + + if (!session?.user) { + return NextResponse.json({ error: "Non autenticato" }, { status: 401 }); + } + + const [articles, families] = await Promise.all([listArticles(), listFamilies()]); + + return NextResponse.json({ articles, families }); +} + +export async function POST(request: Request) { + const session = await auth(); + + if (!session?.user) { + return NextResponse.json({ error: "Non autenticato" }, { status: 401 }); + } + + try { + const article = await createArticle(await request.json()); + return NextResponse.json({ article }, { status: 201 }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Errore creazione articolo" }, + { status: 400 }, + ); + } +} + diff --git a/src/app/api/magazzini/route.ts b/src/app/api/magazzini/route.ts new file mode 100644 index 0000000..dc04d3c --- /dev/null +++ b/src/app/api/magazzini/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/src/auth"; +import { listMagazzini } from "@/src/lib/stabilimenti"; + +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 stabilimentoId = searchParams.get("stabilimentoId"); + + const magazzini = await listMagazzini( + stabilimentoId ? parseInt(stabilimentoId, 10) : undefined + ); + + return NextResponse.json({ magazzini }); + } catch (error) { + console.error("[API magazzini] Errore:", error); + return NextResponse.json( + { error: "Errore nel recupero dei magazzini" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/stabilimenti/route.ts b/src/app/api/stabilimenti/route.ts new file mode 100644 index 0000000..2fd9dac --- /dev/null +++ b/src/app/api/stabilimenti/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/src/auth"; +import { listStabilimenti, getStabilimentiWithMagazzini } from "@/src/lib/stabilimenti"; + +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 includeMagazzini = searchParams.get("includeMagazzini") === "true"; + + if (includeMagazzini) { + const stabilimenti = await getStabilimentiWithMagazzini(); + return NextResponse.json({ stabilimenti }); + } + + const stabilimenti = await listStabilimenti(); + return NextResponse.json({ stabilimenti }); + } catch (error) { + console.error("[API stabilimenti] Errore:", error); + return NextResponse.json( + { error: "Errore nel recupero degli stabilimenti" }, + { status: 500 } + ); + } +} diff --git a/src/components/ArticleCard.tsx b/src/components/ArticleCard.tsx new file mode 100644 index 0000000..126b3c9 --- /dev/null +++ b/src/components/ArticleCard.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useState } from "react"; +import type { Article } from "@/src/types/article"; + +interface ArticleCardProps { + article: Article; + onEdit: (article: Article) => void; + onDelete: (article: Article) => void; +} + +export default function ArticleCard({ article, onEdit, onDelete }: ArticleCardProps) { + const [showActions, setShowActions] = useState(false); + const [deleting, setDeleting] = useState(false); + + const isSottoScorta = article.quantita < article.quantita_minima; + const disponibile = article.quantita - article.assegnata_macchine; + + const handleDelete = async () => { + if (!confirm(`Sei sicuro di voler eliminare l'articolo "${article.descrizione}"?`)) { + return; + } + + setDeleting(true); + try { + const res = await fetch(`/api/articoli/${article.id}`, { + method: "DELETE", + }); + + if (!res.ok) { + throw new Error("Errore durante l'eliminazione"); + } + + onDelete(article); + } catch (error) { + alert(error instanceof Error ? error.message : "Errore durante l'eliminazione"); + } finally { + setDeleting(false); + } + }; + + return ( +
setShowActions(true)} + onMouseLeave={() => setShowActions(false)} + > + {/* Header */} +
+
+
+

+ {article.descrizione} +

+

{article.codice}

+
+ + {/* Badge famiglia */} + {article.famiglia && ( + + {article.famiglia} + + )} +
+
+ + {/* Body */} +
+ {/* Quantità */} +
+
+

Quantità

+

+ {article.quantita} + {isSottoScorta && ( + + (min: {article.quantita_minima}) + + )} +

+
+
+

Disponibile

+

+ {disponibile} +

+
+
+ + {/* Assegnate a macchine */} + {article.assegnata_macchine > 0 && ( +
+ + + + + {article.assegnata_macchine} assegnate a macchine + +
+ )} + + {/* Prezzo */} + {article.prezzo > 0 && ( +
+ Prezzo + + €{article.prezzo.toFixed(2)} + +
+ )} + + {/* Barcode */} + {article.barcode && ( +
+ + + + {article.barcode} +
+ )} +
+ + {/* Action buttons (hover) */} +
+ + +
+ + {/* Sotto scorta indicator */} + {isSottoScorta && ( +
+ + SOTTO SCORTA + +
+ )} +
+ ); +} diff --git a/src/components/ArticleModal.tsx b/src/components/ArticleModal.tsx new file mode 100644 index 0000000..dc07a97 --- /dev/null +++ b/src/components/ArticleModal.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import type { Article, ArticleInput, ArticleFamily } from "@/src/types/article"; + +interface ArticleModalProps { + article: Article | null; + families: ArticleFamily[]; + stabilimentoId: number; + onClose: () => void; + onSave: (article: Article) => void; +} + +export default function ArticleModal({ + article, + families, + stabilimentoId, + onClose, + onSave, +}: ArticleModalProps) { + const isEdit = article !== null; + const modalRef = useRef(null); + + const [formData, setFormData] = useState({ + codice: "", + descrizione: "", + barcode: "", + id_famiglia: null, + id_magazzino: null, + prezzo: 0, + quantita_minima: 0, + quantita: 0, + }); + + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (article) { + setFormData({ + codice: article.codice, + descrizione: article.descrizione, + barcode: article.barcode || "", + id_famiglia: article.id_famiglia, + id_magazzino: article.id_magazzino, + prezzo: article.prezzo, + quantita_minima: article.quantita_minima, + quantita: article.quantita, + }); + } + }, [article]); + + 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 handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSaving(true); + + try { + const url = isEdit ? `/api/articoli/${article.id}` : "/api/articoli"; + const method = isEdit ? "PUT" : "POST"; + + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Errore durante il salvataggio"); + } + + const data = await res.json(); + onSave(data.article); + } catch (err) { + setError(err instanceof Error ? err.message : "Errore sconosciuto"); + } finally { + setSaving(false); + } + }; + + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value, type } = e.target; + + setFormData((prev) => ({ + ...prev, + [name]: + type === "number" + ? value === "" ? 0 : parseFloat(value) + : name === "id_famiglia" + ? value === "" ? null : parseInt(value, 10) + : value, + })); + }; + + return ( +
+
+ {/* Header */} +
+

+ {isEdit ? "Modifica Articolo" : "Nuovo Articolo"} +

+ +
+ + {/* Form */} +
+ {error && ( +
+ {error} +
+ )} + + {/* Descrizione */} +
+ + +
+ + {/* Codice e Barcode */} +
+
+ + +
+
+ + +
+
+ + {/* Famiglia */} +
+ + +
+ + {/* Prezzo e Quantità minima */} +
+
+ + +
+
+ + +
+
+ + {/* Quantità iniziale (solo per nuovo articolo) */} + {!isEdit && ( +
+ + +
+ )} + + {/* Actions */} +
+ + +
+
+
+
+ ); +} diff --git a/src/components/ArticlesGrid.tsx b/src/components/ArticlesGrid.tsx new file mode 100644 index 0000000..12837b2 --- /dev/null +++ b/src/components/ArticlesGrid.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import type { Article, ArticleFamily } from "@/src/types/article"; +import type { Stabilimento } from "@/src/types/stabilimento"; +import ArticleCard from "./ArticleCard"; +import ArticleModal from "./ArticleModal"; + +interface ArticlesGridProps { + stabilimento: Stabilimento; +} + +export default function ArticlesGrid({ stabilimento }: ArticlesGridProps) { + const [articles, setArticles] = useState([]); + const [families, setFamilies] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [search, setSearch] = useState(""); + const [selectedFamily, setSelectedFamily] = useState(null); + const [showModal, setShowModal] = useState(false); + const [editingArticle, setEditingArticle] = useState
(null); + + const fetchArticles = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const params = new URLSearchParams(); + params.set("stabilimentoId", stabilimento.id.toString()); + + if (search) params.set("search", search); + if (selectedFamily) params.set("famigliaId", selectedFamily.toString()); + + const res = await fetch(`/api/articoli?${params}`); + if (!res.ok) throw new Error("Errore nel caricamento"); + + const data = await res.json(); + setArticles(data.articles); + setFamilies(data.families); + } catch (err) { + setError(err instanceof Error ? err.message : "Errore sconosciuto"); + } finally { + setLoading(false); + } + }, [stabilimento.id, search, selectedFamily]); + + useEffect(() => { + fetchArticles(); + }, [fetchArticles]); + + const handleEdit = (article: Article) => { + setEditingArticle(article); + setShowModal(true); + }; + + const handleDelete = (deleted: Article) => { + setArticles((prev) => prev.filter((a) => a.id !== deleted.id)); + }; + + const handleSave = (saved: Article) => { + if (editingArticle) { + setArticles((prev) => + prev.map((a) => (a.id === saved.id ? saved : a)) + ); + } else { + setArticles((prev) => [saved, ...prev]); + } + setShowModal(false); + setEditingArticle(null); + }; + + const handleNewArticle = () => { + setEditingArticle(null); + setShowModal(true); + }; + + const sottoScorta = articles.filter((a) => a.quantita < a.quantita_minima); + const totaleValore = articles.reduce((sum, a) => sum + a.prezzo * a.quantita, 0); + + return ( +
+ {/* Header con statistiche */} +
+
+

Totale Articoli

+

{articles.length}

+
+
+

Famiglie

+

{families.length}

+
+
+

Sotto Scorta

+

{sottoScorta.length}

+
+
+

Valore Totale

+

€{totaleValore.toFixed(2)}

+
+
+ + {/* Toolbar: ricerca, filtri, azioni */} +
+
+ {/* Ricerca */} +
+ + + + setSearch(e.target.value)} + placeholder="Cerca articoli..." + className="w-full pl-10 pr-4 py-2 rounded-lg bg-zinc-900 border border-zinc-800 text-white placeholder-zinc-500 focus:outline-none focus:border-blue-500 transition-colors" + /> +
+ + {/* Filtro famiglia */} + +
+ + {/* Pulsante nuovo articolo */} + +
+ + {/* Contenuto principale */} + {loading ? ( +
+
+ + + + + Caricamento articoli... +
+
+ ) : error ? ( +
+

{error}

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

Nessun articolo trovato

+

+ {search || selectedFamily + ? "Prova a modificare i filtri di ricerca" + : "Inizia aggiungendo il primo articolo"} +

+ {!search && !selectedFamily && ( + + )} +
+ ) : ( +
+ {articles.map((article) => ( + + ))} +
+ )} + + {/* Modal */} + {showModal && ( + { + setShowModal(false); + setEditingArticle(null); + }} + onSave={handleSave} + /> + )} +
+ ); +} diff --git a/src/components/StabilimentoSelector.tsx b/src/components/StabilimentoSelector.tsx new file mode 100644 index 0000000..2e6794b --- /dev/null +++ b/src/components/StabilimentoSelector.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useState, useEffect } from "react"; +import type { Stabilimento } from "@/src/types/stabilimento"; + +interface StabilimentoSelectorProps { + selectedId: number | null; + onSelect: (stabilimento: Stabilimento | null) => void; +} + +export default function StabilimentoSelector({ + selectedId, + onSelect, +}: StabilimentoSelectorProps) { + const [stabilimenti, setStabilimenti] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchStabilimenti() { + try { + const res = await fetch("/api/stabilimenti"); + if (!res.ok) throw new Error("Errore nel caricamento"); + const data = await res.json(); + setStabilimenti(data.stabilimenti); + } catch (err) { + setError(err instanceof Error ? err.message : "Errore sconosciuto"); + } finally { + setLoading(false); + } + } + + fetchStabilimenti(); + }, []); + + if (loading) { + return ( +
+ + + + + Caricamento stabilimenti... +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+ {stabilimenti.map((stab) => ( + + ))} +
+ ); +} diff --git a/src/lib/articles/index.ts b/src/lib/articles/index.ts new file mode 100644 index 0000000..1a19cac --- /dev/null +++ b/src/lib/articles/index.ts @@ -0,0 +1,273 @@ +import type { ResultSetHeader, RowDataPacket } from "mysql2"; + +import { db } from "@/src/lib/db"; +import type { Article, ArticleFamily, ArticleInput } from "@/src/types/article"; + +type ArticleRow = Omit & + RowDataPacket & { + prezzo: string | number | null; + quantita_minima: string | number | null; + quantita: string | number | null; + assegnata_macchine: string | number | null; + }; + +type FamilyRow = ArticleFamily & RowDataPacket; + +function toNumber(value: string | number | null | undefined): number { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; +} + +function normalizeArticle(row: ArticleRow): Article { + return { + id: row.id, + codice: row.codice, + descrizione: row.descrizione, + barcode: row.barcode, + id_famiglia: row.id_famiglia, + famiglia: row.famiglia, + id_magazzino: row.id_magazzino, + prezzo: toNumber(row.prezzo), + quantita_minima: toNumber(row.quantita_minima), + quantita: toNumber(row.quantita), + assegnata_macchine: toNumber(row.assegnata_macchine), + }; +} + +function cleanText(value: unknown): string { + return String(value ?? "").trim(); +} + +function nullableNumber(value: unknown): number | null { + if (value === null || value === undefined || value === "") { + return null; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function positiveNumber(value: unknown, fallback = 0): number { + const parsed = Number(value ?? fallback); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +function createCode(): string { + return `ART-${crypto.randomUUID().slice(0, 6)}`; +} + +function parseArticleInput(input: Partial): ArticleInput { + const descrizione = cleanText(input.descrizione); + + if (!descrizione) { + throw new Error("La descrizione dell'articolo e obbligatoria."); + } + + return { + codice: cleanText(input.codice) || createCode(), + descrizione, + barcode: cleanText(input.barcode) || null, + id_famiglia: nullableNumber(input.id_famiglia), + id_magazzino: nullableNumber(input.id_magazzino), + prezzo: positiveNumber(input.prezzo), + quantita_minima: positiveNumber(input.quantita_minima), + quantita: positiveNumber(input.quantita), + }; +} + +export interface ListArticlesOptions { + stabilimentoId?: number; + magazzinoId?: number; + famigliaId?: number; + search?: string; +} + +export async function listArticles(options: ListArticlesOptions = {}): Promise { + const { stabilimentoId, magazzinoId, famigliaId, search } = options; + + let query = ` + SELECT + a.id, + a.codice, + a.descrizione, + a.barcode, + a.id_famiglia, + f.nome AS famiglia, + a.id_magazzino, + a.prezzo, + a.quantita_minima, + COALESCE(SUM(g.quantita), 0) AS quantita, + COALESCE(machine_totals.quantita, 0) AS assegnata_macchine + FROM articoli a + LEFT JOIN famiglie f ON f.id = a.id_famiglia + LEFT JOIN giacenze g ON g.articolo_id = a.id + LEFT JOIN magazzini m ON m.id = g.id_magazzino + LEFT JOIN ( + SELECT idart, SUM(qtain - qtaout) AS quantita + FROM art_macchine_parti + GROUP BY idart + ) machine_totals ON machine_totals.idart = a.id + `; + + const conditions: string[] = []; + const params: Record = {}; + + if (stabilimentoId) { + conditions.push("m.id_stabilimento = :stabilimentoId"); + params.stabilimentoId = stabilimentoId; + } + + if (magazzinoId) { + conditions.push("g.id_magazzino = :magazzinoId"); + params.magazzinoId = magazzinoId; + } + + if (famigliaId) { + conditions.push("a.id_famiglia = :famigliaId"); + params.famigliaId = famigliaId; + } + + if (search) { + conditions.push("(a.descrizione LIKE :search OR a.codice LIKE :search OR a.barcode LIKE :search)"); + params.search = `%${search}%`; + } + + if (conditions.length > 0) { + query += " WHERE " + conditions.join(" AND "); + } + + query += ` + GROUP BY + a.id, + a.codice, + a.descrizione, + a.barcode, + a.id_famiglia, + f.nome, + a.id_magazzino, + a.prezzo, + a.quantita_minima, + machine_totals.quantita + ORDER BY a.descrizione ASC + `; + + const [rows] = await db.execute(query, params); + + return rows.map(normalizeArticle); +} + +export async function listFamilies(): Promise { + const [rows] = await db.execute( + "SELECT id, nome FROM famiglie ORDER BY nome ASC", + ); + + return rows.map((row) => ({ id: row.id, nome: row.nome })); +} + +export async function createArticle(input: Partial): Promise
{ + const article = parseArticleInput(input); + const connection = await db.getConnection(); + + try { + await connection.beginTransaction(); + + const [result] = await connection.execute( + `INSERT INTO articoli + (codice, descrizione, barcode, id_famiglia, id_magazzino, prezzo, quantita_minima) + VALUES (:codice, :descrizione, :barcode, :id_famiglia, :id_magazzino, :prezzo, :quantita_minima)`, + article, + ); + + if (article.id_magazzino && article.quantita && article.quantita > 0) { + await connection.execute( + `INSERT INTO giacenze (articolo_id, id_magazzino, quantita) + VALUES (:articoloId, :idMagazzino, :quantita) + ON DUPLICATE KEY UPDATE quantita = quantita + VALUES(quantita)`, + { + articoloId: result.insertId, + idMagazzino: article.id_magazzino, + quantita: article.quantita, + }, + ); + } + + await connection.commit(); + return getArticleById(result.insertId); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } +} + +export async function updateArticle(id: number, input: Partial): Promise
{ + const article = parseArticleInput(input); + + await db.execute( + `UPDATE articoli + SET codice = :codice, + descrizione = :descrizione, + barcode = :barcode, + id_famiglia = :id_famiglia, + id_magazzino = :id_magazzino, + prezzo = :prezzo, + quantita_minima = :quantita_minima + WHERE id = :id`, + { ...article, id }, + ); + + return getArticleById(id); +} + +export async function deleteArticle(id: number): Promise { + await db.execute("DELETE FROM articoli WHERE id = :id", { id }); +} + +export async function getArticleById(id: number): Promise
{ + const [rows] = await db.execute( + `SELECT + a.id, + a.codice, + a.descrizione, + a.barcode, + a.id_famiglia, + f.nome AS famiglia, + a.id_magazzino, + a.prezzo, + a.quantita_minima, + COALESCE(SUM(g.quantita), 0) AS quantita, + COALESCE(machine_totals.quantita, 0) AS assegnata_macchine + FROM articoli a + LEFT JOIN famiglie f ON f.id = a.id_famiglia + LEFT JOIN giacenze g ON g.articolo_id = a.id + LEFT JOIN ( + SELECT idart, SUM(qtain - qtaout) AS quantita + FROM art_macchine_parti + GROUP BY idart + ) machine_totals ON machine_totals.idart = a.id + WHERE a.id = :id + GROUP BY + a.id, + a.codice, + a.descrizione, + a.barcode, + a.id_famiglia, + f.nome, + a.id_magazzino, + a.prezzo, + a.quantita_minima, + machine_totals.quantita + LIMIT 1`, + { id }, + ); + + const row = rows[0]; + + if (!row) { + throw new Error("Articolo non trovato."); + } + + return normalizeArticle(row); +} + diff --git a/src/lib/stabilimenti/index.ts b/src/lib/stabilimenti/index.ts new file mode 100644 index 0000000..405a456 --- /dev/null +++ b/src/lib/stabilimenti/index.ts @@ -0,0 +1,89 @@ +import type { RowDataPacket } from "mysql2"; + +import { db } from "@/src/lib/db"; +import type { Stabilimento, Magazzino, StabilimentoWithMagazzini } from "@/src/types/stabilimento"; + +type StabilimentoRow = Stabilimento & RowDataPacket; +type MagazzinoRow = Magazzino & RowDataPacket; + +export async function listStabilimenti(): Promise { + const [rows] = await db.execute( + "SELECT id, descrizione FROM stabilimenti ORDER BY descrizione ASC" + ); + + return rows.map((row) => ({ + id: row.id, + descrizione: row.descrizione, + })); +} + +export async function getStabilimentoById(id: number): Promise { + const [rows] = await db.execute( + "SELECT id, descrizione FROM stabilimenti WHERE id = :id LIMIT 1", + { id } + ); + + const row = rows[0]; + if (!row) return null; + + return { + id: row.id, + descrizione: row.descrizione, + }; +} + +export async function listMagazzini(stabilimentoId?: number): Promise { + let query = ` + SELECT m.id, m.descrizione, m.id_stabilimento, s.descrizione AS stabilimento + FROM magazzini m + LEFT JOIN stabilimenti s ON s.id = m.id_stabilimento + `; + + const params: Record = {}; + + if (stabilimentoId) { + query += " WHERE m.id_stabilimento = :stabilimentoId"; + params.stabilimentoId = stabilimentoId; + } + + query += " ORDER BY s.descrizione ASC, m.descrizione ASC"; + + const [rows] = await db.execute(query, params); + + return rows.map((row) => ({ + id: row.id, + descrizione: row.descrizione, + id_stabilimento: row.id_stabilimento, + stabilimento: row.stabilimento, + })); +} + +export async function getStabilimentiWithMagazzini(): Promise { + const stabilimenti = await listStabilimenti(); + const magazzini = await listMagazzini(); + + return stabilimenti.map((stab) => ({ + ...stab, + magazzini: magazzini.filter((mag) => mag.id_stabilimento === stab.id), + })); +} + +export async function getMagazzinoById(id: number): Promise { + const [rows] = await db.execute( + `SELECT m.id, m.descrizione, m.id_stabilimento, s.descrizione AS stabilimento + FROM magazzini m + LEFT JOIN stabilimenti s ON s.id = m.id_stabilimento + WHERE m.id = :id LIMIT 1`, + { id } + ); + + const row = rows[0]; + if (!row) return null; + + return { + id: row.id, + descrizione: row.descrizione, + id_stabilimento: row.id_stabilimento, + stabilimento: row.stabilimento, + }; +} diff --git a/src/types/article.ts b/src/types/article.ts new file mode 100644 index 0000000..9806f9f --- /dev/null +++ b/src/types/article.ts @@ -0,0 +1,30 @@ +export interface Article { + id: number; + codice: string; + descrizione: string; + barcode: string | null; + id_famiglia: number | null; + famiglia: string | null; + id_magazzino: number | null; + prezzo: number; + quantita_minima: number; + quantita: number; + assegnata_macchine: number; +} + +export interface ArticleFamily { + id: number; + nome: string; +} + +export interface ArticleInput { + codice?: string; + descrizione: string; + barcode?: string | null; + id_famiglia?: number | null; + id_magazzino?: number | null; + prezzo?: number; + quantita_minima?: number; + quantita?: number; +} + diff --git a/src/types/stabilimento.ts b/src/types/stabilimento.ts new file mode 100644 index 0000000..d812be8 --- /dev/null +++ b/src/types/stabilimento.ts @@ -0,0 +1,15 @@ +export interface Stabilimento { + id: number; + descrizione: string; +} + +export interface Magazzino { + id: number; + descrizione: string; + id_stabilimento: number; + stabilimento?: string; +} + +export interface StabilimentoWithMagazzini extends Stabilimento { + magazzini: Magazzino[]; +}