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() {
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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