creati tipi magazzini/stabilimenti/, API per stabilimenti/magazzini e componenti: ArticleCar, StabilimentoSelector, Modale Articoli
Some checks failed
Deploy to VPS / build (push) Failing after 38s
Deploy to VPS / deploy (push) Has been skipped

This commit is contained in:
AV77web 2026-05-28 22:03:03 +02:00
parent 47367cb74c
commit 1e38a4f8c6
13 changed files with 1425 additions and 55 deletions

View file

@ -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() { export default function Home() {
const [selectedStabilimento, setSelectedStabilimento] = useState<Stabilimento | null>(null);
return ( return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <div className="min-h-screen bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> <div className="max-w-7xl mx-auto px-4 py-8">
<Image {/* Header */}
className="dark:invert" <div className="mb-8">
src="/next.svg" <h1 className="text-3xl font-bold text-white mb-2">
alt="Next.js logo" MagRicambi
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, PROVA PROVA edit the page.tsx file.
</h1> </h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400"> <p className="text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "} Gestione magazzino ricambi
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p> </p>
</div> </div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a {/* Selezione stabilimento */}
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]" <div className="mb-8">
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" <h2 className="text-lg font-medium text-zinc-300 mb-4">
target="_blank" Seleziona stabilimento
rel="noopener noreferrer" </h2>
> <StabilimentoSelector
<Image selectedId={selectedStabilimento?.id ?? null}
className="dark:invert" onSelect={setSelectedStabilimento}
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/> />
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div> </div>
</main>
{/* Contenuto principale */}
{selectedStabilimento ? (
<div>
<div className="flex items-center gap-3 mb-6">
<h2 className="text-xl font-semibold text-white">
Articoli - {selectedStabilimento.descrizione}
</h2>
<button
onClick={() => setSelectedStabilimento(null)}
className="text-sm text-zinc-500 hover:text-white transition-colors"
>
(cambia)
</button>
</div>
<ArticlesGrid stabilimento={selectedStabilimento} />
</div>
) : (
<div className="flex flex-col items-center justify-center py-20 text-center">
<svg
className="w-20 h-20 text-zinc-800 mb-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<h3 className="text-xl font-medium text-zinc-400 mb-2">
Nessuno stabilimento selezionato
</h3>
<p className="text-zinc-600 max-w-md">
Seleziona uno stabilimento dalla lista sopra per visualizzare e gestire gli articoli del magazzino.
</p>
</div>
)}
</div>
</div> </div>
); );
} }

View file

@ -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<number> {
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 },
);
}
}

View file

@ -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 },
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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 (
<div
className={`
relative bg-zinc-900 rounded-xl border transition-all duration-200
hover:shadow-lg hover:shadow-black/20 hover:-translate-y-1
${isSottoScorta ? "border-red-500/50" : "border-zinc-800"}
`}
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => setShowActions(false)}
>
{/* Header */}
<div className="p-4 border-b border-zinc-800">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-white truncate" title={article.descrizione}>
{article.descrizione}
</h3>
<p className="text-sm text-zinc-500 font-mono">{article.codice}</p>
</div>
{/* Badge famiglia */}
{article.famiglia && (
<span className="px-2 py-1 text-xs font-medium rounded-full bg-zinc-800 text-zinc-400 whitespace-nowrap">
{article.famiglia}
</span>
)}
</div>
</div>
{/* Body */}
<div className="p-4 space-y-3">
{/* Quantità */}
<div className="grid grid-cols-2 gap-3">
<div>
<p className="text-xs text-zinc-500 uppercase tracking-wider">Quantità</p>
<p className={`text-lg font-bold ${isSottoScorta ? "text-red-400" : "text-white"}`}>
{article.quantita}
{isSottoScorta && (
<span className="ml-2 text-xs font-normal text-red-400">
(min: {article.quantita_minima})
</span>
)}
</p>
</div>
<div>
<p className="text-xs text-zinc-500 uppercase tracking-wider">Disponibile</p>
<p className={`text-lg font-bold ${disponibile <= 0 ? "text-yellow-400" : "text-green-400"}`}>
{disponibile}
</p>
</div>
</div>
{/* Assegnate a macchine */}
{article.assegnata_macchine > 0 && (
<div className="flex items-center gap-2 text-sm">
<svg className="w-4 h-4 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
<span className="text-zinc-400">
{article.assegnata_macchine} assegnate a macchine
</span>
</div>
)}
{/* Prezzo */}
{article.prezzo > 0 && (
<div className="flex items-center justify-between pt-2 border-t border-zinc-800">
<span className="text-sm text-zinc-500">Prezzo</span>
<span className="font-semibold text-white">
{article.prezzo.toFixed(2)}
</span>
</div>
)}
{/* Barcode */}
{article.barcode && (
<div className="flex items-center gap-2 text-xs text-zinc-500">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
<span className="font-mono">{article.barcode}</span>
</div>
)}
</div>
{/* Action buttons (hover) */}
<div
className={`
absolute top-2 right-2 flex gap-1 transition-opacity duration-200
${showActions ? "opacity-100" : "opacity-0"}
`}
>
<button
onClick={() => onEdit(article)}
className="p-2 rounded-lg bg-zinc-800 text-zinc-400 hover:bg-blue-600 hover:text-white transition-colors"
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={handleDelete}
disabled={deleting}
className="p-2 rounded-lg bg-zinc-800 text-zinc-400 hover:bg-red-600 hover:text-white transition-colors disabled:opacity-50"
title="Elimina"
>
{deleting ? (
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : (
<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>
{/* Sotto scorta indicator */}
{isSottoScorta && (
<div className="absolute top-0 left-4 -translate-y-1/2">
<span className="px-2 py-0.5 text-xs font-bold rounded-full bg-red-500 text-white shadow-lg">
SOTTO SCORTA
</span>
</div>
)}
</div>
);
}

View file

@ -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<HTMLDivElement>(null);
const [formData, setFormData] = useState<ArticleInput>({
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<string | null>(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<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
) => {
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div
ref={modalRef}
className="w-full max-w-lg bg-zinc-900 rounded-2xl shadow-2xl border border-zinc-800 overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
<h2 className="text-xl font-semibold text-white">
{isEdit ? "Modifica Articolo" : "Nuovo Articolo"}
</h2>
<button
onClick={onClose}
className="p-2 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>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/50 text-red-400 text-sm">
{error}
</div>
)}
{/* Descrizione */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Descrizione *
</label>
<input
type="text"
name="descrizione"
value={formData.descrizione}
onChange={handleChange}
required
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"
placeholder="Nome dell'articolo"
/>
</div>
{/* Codice e Barcode */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Codice
</label>
<input
type="text"
name="codice"
value={formData.codice}
onChange={handleChange}
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 font-mono"
placeholder="Auto-generato"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Barcode
</label>
<input
type="text"
name="barcode"
value={formData.barcode || ""}
onChange={handleChange}
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 font-mono"
placeholder="Codice a barre"
/>
</div>
</div>
{/* Famiglia */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Famiglia
</label>
<select
name="id_famiglia"
value={formData.id_famiglia ?? ""}
onChange={handleChange}
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"
>
<option value="">Nessuna famiglia</option>
{families.map((fam) => (
<option key={fam.id} value={fam.id}>
{fam.nome}
</option>
))}
</select>
</div>
{/* Prezzo e Quantità minima */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Prezzo ()
</label>
<input
type="number"
name="prezzo"
value={formData.prezzo}
onChange={handleChange}
min="0"
step="0.01"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Quantità minima
</label>
<input
type="number"
name="quantita_minima"
value={formData.quantita_minima}
onChange={handleChange}
min="0"
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"
/>
</div>
</div>
{/* Quantità iniziale (solo per nuovo articolo) */}
{!isEdit && (
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Quantità iniziale
</label>
<input
type="number"
name="quantita"
value={formData.quantita}
onChange={handleChange}
min="0"
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"
/>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t border-zinc-800">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors"
>
Annulla
</button>
<button
type="submit"
disabled={saving}
className="px-6 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{saving && (
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)}
{isEdit ? "Salva modifiche" : "Crea articolo"}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -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<Article[]>([]);
const [families, setFamilies] = useState<ArticleFamily[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [selectedFamily, setSelectedFamily] = useState<number | null>(null);
const [showModal, setShowModal] = useState(false);
const [editingArticle, setEditingArticle] = useState<Article | null>(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 (
<div className="space-y-6">
{/* Header con statistiche */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 rounded-xl bg-zinc-900 border border-zinc-800">
<p className="text-sm text-zinc-500">Totale Articoli</p>
<p className="text-2xl font-bold text-white">{articles.length}</p>
</div>
<div className="p-4 rounded-xl bg-zinc-900 border border-zinc-800">
<p className="text-sm text-zinc-500">Famiglie</p>
<p className="text-2xl font-bold text-white">{families.length}</p>
</div>
<div className="p-4 rounded-xl bg-zinc-900 border border-red-500/30">
<p className="text-sm text-zinc-500">Sotto Scorta</p>
<p className="text-2xl font-bold text-red-400">{sottoScorta.length}</p>
</div>
<div className="p-4 rounded-xl bg-zinc-900 border border-zinc-800">
<p className="text-sm text-zinc-500">Valore Totale</p>
<p className="text-2xl font-bold text-green-400">{totaleValore.toFixed(2)}</p>
</div>
</div>
{/* Toolbar: ricerca, filtri, azioni */}
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between">
<div className="flex flex-1 gap-4 w-full md:w-auto">
{/* Ricerca */}
<div className="relative flex-1 max-w-md">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
value={search}
onChange={(e) => 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"
/>
</div>
{/* Filtro famiglia */}
<select
value={selectedFamily ?? ""}
onChange={(e) =>
setSelectedFamily(e.target.value ? parseInt(e.target.value, 10) : null)
}
className="px-4 py-2 rounded-lg bg-zinc-900 border border-zinc-800 text-white focus:outline-none focus:border-blue-500 transition-colors"
>
<option value="">Tutte le famiglie</option>
{families.map((fam) => (
<option key={fam.id} value={fam.id}>
{fam.nome}
</option>
))}
</select>
</div>
{/* Pulsante nuovo articolo */}
<button
onClick={handleNewArticle}
className="px-4 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-500 transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Nuovo Articolo
</button>
</div>
{/* Contenuto principale */}
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="flex items-center gap-3 text-zinc-400">
<svg className="w-6 h-6 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>Caricamento articoli...</span>
</div>
</div>
) : error ? (
<div className="p-6 rounded-xl bg-red-500/10 border border-red-500/30 text-center">
<p className="text-red-400">{error}</p>
<button
onClick={fetchArticles}
className="mt-4 px-4 py-2 rounded-lg bg-red-600 text-white hover:bg-red-500 transition-colors"
>
Riprova
</button>
</div>
) : articles.length === 0 ? (
<div className="p-12 rounded-xl bg-zinc-900 border border-zinc-800 text-center">
<svg
className="w-16 h-16 mx-auto text-zinc-700 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
<h3 className="text-lg font-medium text-white mb-2">Nessun articolo trovato</h3>
<p className="text-zinc-500 mb-6">
{search || selectedFamily
? "Prova a modificare i filtri di ricerca"
: "Inizia aggiungendo il primo articolo"}
</p>
{!search && !selectedFamily && (
<button
onClick={handleNewArticle}
className="px-4 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-500 transition-colors"
>
Aggiungi articolo
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{articles.map((article) => (
<ArticleCard
key={article.id}
article={article}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
{/* Modal */}
{showModal && (
<ArticleModal
article={editingArticle}
families={families}
stabilimentoId={stabilimento.id}
onClose={() => {
setShowModal(false);
setEditingArticle(null);
}}
onSave={handleSave}
/>
)}
</div>
);
}

View file

@ -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<Stabilimento[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="flex items-center gap-2 text-zinc-400">
<svg className="animate-spin h-5 w-5" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>Caricamento stabilimenti...</span>
</div>
);
}
if (error) {
return (
<div className="text-red-500 text-sm">
{error}
</div>
);
}
return (
<div className="flex flex-wrap gap-3">
{stabilimenti.map((stab) => (
<button
key={stab.id}
onClick={() => onSelect(selectedId === stab.id ? null : stab)}
className={`
px-4 py-2 rounded-lg font-medium transition-all duration-200
${
selectedId === stab.id
? "bg-blue-600 text-white shadow-lg shadow-blue-600/30"
: "bg-zinc-800 text-zinc-300 hover:bg-zinc-700 hover:text-white"
}
`}
>
{stab.descrizione}
</button>
))}
</div>
);
}

273
src/lib/articles/index.ts Normal file
View file

@ -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<Article, "prezzo" | "quantita_minima" | "quantita" | "assegnata_macchine"> &
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>): 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<Article[]> {
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<string, string | number> = {};
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<ArticleRow[]>(query, params);
return rows.map(normalizeArticle);
}
export async function listFamilies(): Promise<ArticleFamily[]> {
const [rows] = await db.execute<FamilyRow[]>(
"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<ArticleInput>): Promise<Article> {
const article = parseArticleInput(input);
const connection = await db.getConnection();
try {
await connection.beginTransaction();
const [result] = await connection.execute<ResultSetHeader>(
`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<ResultSetHeader>(
`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<ArticleInput>): Promise<Article> {
const article = parseArticleInput(input);
await db.execute<ResultSetHeader>(
`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<void> {
await db.execute<ResultSetHeader>("DELETE FROM articoli WHERE id = :id", { id });
}
export async function getArticleById(id: number): Promise<Article> {
const [rows] = await db.execute<ArticleRow[]>(
`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);
}

View file

@ -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<Stabilimento[]> {
const [rows] = await db.execute<StabilimentoRow[]>(
"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<Stabilimento | null> {
const [rows] = await db.execute<StabilimentoRow[]>(
"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<Magazzino[]> {
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<string, number> = {};
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<MagazzinoRow[]>(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<StabilimentoWithMagazzini[]> {
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<Magazzino | null> {
const [rows] = await db.execute<MagazzinoRow[]>(
`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,
};
}

30
src/types/article.ts Normal file
View file

@ -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;
}

15
src/types/stabilimento.ts Normal file
View file

@ -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[];
}