creati tipi magazzini/stabilimenti/, API per stabilimenti/magazzini e componenti: ArticleCar, StabilimentoSelector, Modale Articoli
This commit is contained in:
parent
47367cb74c
commit
1e38a4f8c6
13 changed files with 1425 additions and 55 deletions
|
|
@ -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<Stabilimento | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark: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">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
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.
|
||||
<div className="min-h-screen bg-black">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">
|
||||
MagRicambi
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<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 className="text-zinc-400">
|
||||
Gestione magazzino ricambi
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
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]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
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>
|
||||
|
||||
{/* Selezione stabilimento */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-medium text-zinc-300 mb-4">
|
||||
Seleziona stabilimento
|
||||
</h2>
|
||||
<StabilimentoSelector
|
||||
selectedId={selectedStabilimento?.id ?? null}
|
||||
onSelect={setSelectedStabilimento}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
56
src/app/api/articoli/[id]/route.ts
Normal file
56
src/app/api/articoli/[id]/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
35
src/app/api/articoli/route.ts
Normal file
35
src/app/api/articoli/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
29
src/app/api/magazzini/route.ts
Normal file
29
src/app/api/magazzini/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/app/api/stabilimenti/route.ts
Normal file
31
src/app/api/stabilimenti/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
172
src/components/ArticleCard.tsx
Normal file
172
src/components/ArticleCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
290
src/components/ArticleModal.tsx
Normal file
290
src/components/ArticleModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
250
src/components/ArticlesGrid.tsx
Normal file
250
src/components/ArticlesGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/components/StabilimentoSelector.tsx
Normal file
88
src/components/StabilimentoSelector.tsx
Normal 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
273
src/lib/articles/index.ts
Normal 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);
|
||||
}
|
||||
|
||||
89
src/lib/stabilimenti/index.ts
Normal file
89
src/lib/stabilimenti/index.ts
Normal 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
30
src/types/article.ts
Normal 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
15
src/types/stabilimento.ts
Normal 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[];
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue