implementata logica del vecchio gestionale nella gestione delle giacenze per magazzino.
This commit is contained in:
parent
be228bca5c
commit
fa9503a37e
10 changed files with 705 additions and 138 deletions
|
|
@ -1,12 +1,122 @@
|
|||
"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";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Article, ArticleFamily } from "@/src/types/article";
|
||||
import type { Stabilimento, Magazzino } from "@/src/types/stabilimento";
|
||||
import type { GiacenzePerArticolo } from "@/src/types/giacenza";
|
||||
import ArticleCard from "@/src/components/ArticleCard";
|
||||
import ArticleModal from "@/src/components/ArticleModal";
|
||||
import StabilimentoMagazzinoSelector from "@/src/components/StabilimentoMagazzinoSelector";
|
||||
|
||||
export default function ArticoliPage() {
|
||||
const [articles, setArticles] = useState<Article[]>([]);
|
||||
const [families, setFamilies] = useState<ArticleFamily[]>([]);
|
||||
const [giacenze, setGiacenze] = useState<GiacenzePerArticolo>({});
|
||||
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 [selectedStabilimento, setSelectedStabilimento] = useState<Stabilimento | null>(null);
|
||||
const [selectedMagazzino, setSelectedMagazzino] = useState<Magazzino | null>(null);
|
||||
|
||||
const fetchArticles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/articoli");
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchGiacenze = useCallback(async () => {
|
||||
if (!selectedStabilimento) {
|
||||
setGiacenze({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedMagazzino) {
|
||||
params.set("magazzinoId", selectedMagazzino.id.toString());
|
||||
} else {
|
||||
params.set("stabilimentoId", selectedStabilimento.id.toString());
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/giacenze?${params}`);
|
||||
if (!res.ok) throw new Error("Errore caricamento giacenze");
|
||||
|
||||
const data = await res.json();
|
||||
setGiacenze(data.giacenze);
|
||||
} catch (err) {
|
||||
console.error("Errore giacenze:", err);
|
||||
setGiacenze({});
|
||||
}
|
||||
}, [selectedStabilimento, selectedMagazzino]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchArticles();
|
||||
}, [fetchArticles]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGiacenze();
|
||||
}, [fetchGiacenze]);
|
||||
|
||||
const handleSelectionChange = ({ stabilimento, magazzino }: {
|
||||
stabilimento: Stabilimento | null;
|
||||
magazzino: Magazzino | null
|
||||
}) => {
|
||||
setSelectedStabilimento(stabilimento);
|
||||
setSelectedMagazzino(magazzino);
|
||||
};
|
||||
|
||||
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);
|
||||
fetchGiacenze();
|
||||
};
|
||||
|
||||
const filteredArticles = articles.filter((article) => {
|
||||
const matchesSearch = !search ||
|
||||
article.descrizione?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
article.codice?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
article.barcode?.toLowerCase().includes(search.toLowerCase());
|
||||
|
||||
const matchesFamily = !selectedFamily || article.id_famiglia === selectedFamily;
|
||||
|
||||
return matchesSearch && matchesFamily;
|
||||
});
|
||||
|
||||
const sottoScorta = articles.filter((a) => a.quantita < a.quantita_minima).length;
|
||||
const totaleValore = articles.reduce((sum, a) => sum + a.prezzo * a.quantita, 0);
|
||||
const magazzinoNome = selectedMagazzino?.descrizione ||
|
||||
(selectedStabilimento ? `${selectedStabilimento.descrizione}` : undefined);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black">
|
||||
|
|
@ -14,42 +124,124 @@ export default function ArticoliPage() {
|
|||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white">Articoli</h1>
|
||||
<p className="text-sm text-zinc-500">Catalogo ricambi e componenti</p>
|
||||
<p className="text-sm text-zinc-500">Catalogo completo ricambi e componenti</p>
|
||||
</div>
|
||||
|
||||
{/* Selezione stabilimento */}
|
||||
{/* Statistiche */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<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}</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>
|
||||
|
||||
{/* Selettore Stabilimento/Magazzino */}
|
||||
<div className="mb-6 p-4 rounded-lg bg-zinc-900 border border-zinc-800">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm text-zinc-400">Stabilimento:</p>
|
||||
{selectedStabilimento && (
|
||||
<button
|
||||
onClick={() => setSelectedStabilimento(null)}
|
||||
className="text-xs text-zinc-500 hover:text-white transition-colors"
|
||||
>
|
||||
Cambia
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{selectedStabilimento ? (
|
||||
<p className="font-medium text-white">{selectedStabilimento.descrizione}</p>
|
||||
) : (
|
||||
<StabilimentoSelector
|
||||
selectedId={null}
|
||||
onSelect={setSelectedStabilimento}
|
||||
/>
|
||||
)}
|
||||
<StabilimentoMagazzinoSelector onSelectionChange={handleSelectionChange} />
|
||||
</div>
|
||||
|
||||
{/* Contenuto */}
|
||||
{selectedStabilimento ? (
|
||||
<ArticlesGrid stabilimento={selectedStabilimento} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<svg className="w-16 h-16 text-zinc-800 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between mb-6">
|
||||
<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"
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<option value="">Tutte le famiglie</option>
|
||||
{families.map((fam) => (
|
||||
<option key={fam.id} value={fam.id}>{fam.nome}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Pulsante nuovo */}
|
||||
<button
|
||||
onClick={() => { setEditingArticle(null); setShowModal(true); }}
|
||||
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>
|
||||
|
||||
{/* Lista articoli */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20 text-zinc-500">
|
||||
<svg className="w-6 h-6 animate-spin mr-3" 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 12h4z" />
|
||||
</svg>
|
||||
Caricamento articoli...
|
||||
</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">
|
||||
Riprova
|
||||
</button>
|
||||
</div>
|
||||
) : filteredArticles.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>
|
||||
<p className="text-zinc-500">Seleziona uno stabilimento per visualizzare gli articoli</p>
|
||||
<h3 className="text-lg font-medium text-white mb-2">Nessun articolo trovato</h3>
|
||||
<p className="text-zinc-500">Prova a modificare i filtri di ricerca</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredArticles.map((article) => (
|
||||
<ArticleCard
|
||||
key={article.id}
|
||||
article={article}
|
||||
giacenzaMagazzino={selectedStabilimento ? giacenze[article.id] : undefined}
|
||||
magazzinoNome={magazzinoNome}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<ArticleModal
|
||||
article={editingArticle}
|
||||
families={families}
|
||||
stabilimentoId={selectedStabilimento?.id || 0}
|
||||
onClose={() => { setShowModal(false); setEditingArticle(null); }}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,74 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Stabilimento } from "@/src/types/stabilimento";
|
||||
import StabilimentoSelector from "@/src/components/StabilimentoSelector";
|
||||
import ArticlesList from "@/src/components/ArticlesList";
|
||||
import MacchineTree from "@/src/components/MacchineTree";
|
||||
|
||||
export default function Home() {
|
||||
const [selectedStabilimento, setSelectedStabilimento] = useState<Stabilimento | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-64px)] bg-black">
|
||||
<div className="flex-1 flex flex-col max-w-7xl mx-auto w-full px-4 py-6 overflow-hidden">
|
||||
{/* Header + Selezione stabilimento */}
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex-shrink-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">MagRicambi</h1>
|
||||
<p className="text-sm text-zinc-500">Gestione magazzino ricambi</p>
|
||||
</div>
|
||||
{selectedStabilimento && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-zinc-500">Stabilimento:</span>
|
||||
<span className="font-medium text-white">{selectedStabilimento.descrizione}</span>
|
||||
<button
|
||||
onClick={() => setSelectedStabilimento(null)}
|
||||
className="text-zinc-500 hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedStabilimento && (
|
||||
<div className="p-4 rounded-lg bg-zinc-900 border border-zinc-800">
|
||||
<p className="text-sm text-zinc-400 mb-3">Seleziona uno stabilimento per iniziare:</p>
|
||||
<StabilimentoSelector
|
||||
selectedId={null}
|
||||
onSelect={setSelectedStabilimento}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contenuto principale: split 50/50 */}
|
||||
{selectedStabilimento ? (
|
||||
<div className="flex-1 grid grid-cols-2 gap-6 min-h-0">
|
||||
{/* Colonna sinistra: Articoli */}
|
||||
<div className="flex flex-col min-h-0 p-4 rounded-xl bg-zinc-900/50 border border-zinc-800">
|
||||
<ArticlesList stabilimento={selectedStabilimento} />
|
||||
<ArticlesList />
|
||||
</div>
|
||||
|
||||
{/* Colonna destra: Macchine */}
|
||||
{/* Colonna destra: Macchine (placeholder) */}
|
||||
<div className="flex flex-col min-h-0 p-4 rounded-xl bg-zinc-900/50 border border-zinc-800">
|
||||
<MacchineTree stabilimento={selectedStabilimento} />
|
||||
<MacchineTree />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<svg className="w-16 h-16 mx-auto text-zinc-800 mb-4" 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>
|
||||
<p className="text-zinc-500">Seleziona uno stabilimento per visualizzare articoli e macchine</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
44
src/app/api/giacenze/route.ts
Normal file
44
src/app/api/giacenze/route.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { NextResponse } from "next/server";
|
||||
|
||||
import { auth } from "@/src/auth";
|
||||
import {
|
||||
getGiacenzeMapByMagazzino,
|
||||
getGiacenzeMapByStabilimento
|
||||
} from "@/src/lib/giacenze";
|
||||
|
||||
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 magazzinoId = searchParams.get("magazzinoId");
|
||||
const stabilimentoId = searchParams.get("stabilimentoId");
|
||||
|
||||
if (!magazzinoId && !stabilimentoId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Specificare magazzinoId o stabilimentoId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let giacenze: Record<number, number>;
|
||||
|
||||
if (magazzinoId) {
|
||||
giacenze = await getGiacenzeMapByMagazzino(parseInt(magazzinoId, 10));
|
||||
} else {
|
||||
giacenze = await getGiacenzeMapByStabilimento(parseInt(stabilimentoId!, 10));
|
||||
}
|
||||
|
||||
return NextResponse.json({ giacenze });
|
||||
} catch (error) {
|
||||
console.error("[API giacenze] Errore:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Errore nel recupero delle giacenze" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,15 +5,24 @@ import type { Article } from "@/src/types/article";
|
|||
|
||||
interface ArticleCardProps {
|
||||
article: Article;
|
||||
giacenzaMagazzino?: number;
|
||||
magazzinoNome?: string;
|
||||
onEdit: (article: Article) => void;
|
||||
onDelete: (article: Article) => void;
|
||||
}
|
||||
|
||||
export default function ArticleCard({ article, onEdit, onDelete }: ArticleCardProps) {
|
||||
export default function ArticleCard({
|
||||
article,
|
||||
giacenzaMagazzino,
|
||||
magazzinoNome,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: ArticleCardProps) {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const isSottoScorta = article.quantita < article.quantita_minima;
|
||||
const disponibile = article.quantita - article.assegnata_macchine;
|
||||
const hasGiacenzaMagazzino = giacenzaMagazzino !== undefined;
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(`Eliminare "${article.descrizione}"?`)) return;
|
||||
|
|
@ -56,18 +65,50 @@ export default function ArticleCard({ article, onEdit, onDelete }: ArticleCardPr
|
|||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-zinc-500 mt-0.5">
|
||||
<span className="font-mono">{article.codice}</span>
|
||||
<span>
|
||||
Qta: <span className={isSottoScorta ? "text-red-400" : "text-white"}>{article.quantita}</span>
|
||||
{isSottoScorta && <span className="text-red-400"> (min {article.quantita_minima})</span>}
|
||||
</span>
|
||||
<span>
|
||||
Disp: <span className={disponibile <= 0 ? "text-yellow-400" : "text-green-400"}>{disponibile}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantità */}
|
||||
<div className="flex items-center gap-4 flex-shrink-0">
|
||||
{/* Quantità totale */}
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] text-zinc-600 uppercase">Totale</div>
|
||||
<div className={`text-sm font-semibold ${isSottoScorta ? "text-red-400" : "text-white"}`}>
|
||||
{article.quantita}
|
||||
{isSottoScorta && (
|
||||
<span className="text-[10px] text-red-400 ml-1">(min {article.quantita_minima})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantità magazzino (se disponibile) */}
|
||||
{hasGiacenzaMagazzino && (
|
||||
<div className="text-right border-l border-zinc-800 pl-4">
|
||||
<div className="text-[10px] text-zinc-600 uppercase truncate max-w-[80px]" title={magazzinoNome}>
|
||||
{magazzinoNome || "Mag."}
|
||||
</div>
|
||||
<div className={`text-sm font-semibold ${
|
||||
giacenzaMagazzino === 0 ? "text-zinc-600" :
|
||||
giacenzaMagazzino! > 0 ? "text-green-400" : "text-white"
|
||||
}`}>
|
||||
{giacenzaMagazzino}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disponibile */}
|
||||
<div className="text-right border-l border-zinc-800 pl-4">
|
||||
<div className="text-[10px] text-zinc-600 uppercase">Disp.</div>
|
||||
<div className={`text-sm font-semibold ${
|
||||
disponibile <= 0 ? "text-yellow-400" : "text-green-400"
|
||||
}`}>
|
||||
{disponibile}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Azioni */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<div className="flex items-center gap-1 flex-shrink-0 border-l border-zinc-800 pl-3">
|
||||
<button
|
||||
onClick={() => onEdit(article)}
|
||||
className="p-1.5 rounded text-zinc-500 hover:text-blue-400 hover:bg-zinc-700 transition-colors"
|
||||
|
|
|
|||
|
|
@ -2,17 +2,16 @@
|
|||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Article, ArticleFamily } from "@/src/types/article";
|
||||
import type { Stabilimento } from "@/src/types/stabilimento";
|
||||
import type { Stabilimento, Magazzino } from "@/src/types/stabilimento";
|
||||
import type { GiacenzePerArticolo } from "@/src/types/giacenza";
|
||||
import ArticleCard from "./ArticleCard";
|
||||
import ArticleModal from "./ArticleModal";
|
||||
import StabilimentoMagazzinoSelector from "./StabilimentoMagazzinoSelector";
|
||||
|
||||
interface ArticlesListProps {
|
||||
stabilimento: Stabilimento;
|
||||
}
|
||||
|
||||
export default function ArticlesList({ stabilimento }: ArticlesListProps) {
|
||||
export default function ArticlesList() {
|
||||
const [articles, setArticles] = useState<Article[]>([]);
|
||||
const [families, setFamilies] = useState<ArticleFamily[]>([]);
|
||||
const [giacenze, setGiacenze] = useState<GiacenzePerArticolo>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -20,16 +19,15 @@ export default function ArticlesList({ stabilimento }: ArticlesListProps) {
|
|||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingArticle, setEditingArticle] = useState<Article | null>(null);
|
||||
|
||||
const [selectedStabilimento, setSelectedStabilimento] = useState<Stabilimento | null>(null);
|
||||
const [selectedMagazzino, setSelectedMagazzino] = useState<Magazzino | 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);
|
||||
|
||||
const res = await fetch(`/api/articoli?${params}`);
|
||||
const res = await fetch("/api/articoli");
|
||||
if (!res.ok) throw new Error("Errore nel caricamento");
|
||||
|
||||
const data = await res.json();
|
||||
|
|
@ -40,12 +38,49 @@ export default function ArticlesList({ stabilimento }: ArticlesListProps) {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stabilimento.id, search]);
|
||||
}, []);
|
||||
|
||||
const fetchGiacenze = useCallback(async () => {
|
||||
if (!selectedStabilimento) {
|
||||
setGiacenze({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedMagazzino) {
|
||||
params.set("magazzinoId", selectedMagazzino.id.toString());
|
||||
} else {
|
||||
params.set("stabilimentoId", selectedStabilimento.id.toString());
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/giacenze?${params}`);
|
||||
if (!res.ok) throw new Error("Errore caricamento giacenze");
|
||||
|
||||
const data = await res.json();
|
||||
setGiacenze(data.giacenze);
|
||||
} catch (err) {
|
||||
console.error("Errore giacenze:", err);
|
||||
setGiacenze({});
|
||||
}
|
||||
}, [selectedStabilimento, selectedMagazzino]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchArticles();
|
||||
}, [fetchArticles]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGiacenze();
|
||||
}, [fetchGiacenze]);
|
||||
|
||||
const handleSelectionChange = ({ stabilimento, magazzino }: {
|
||||
stabilimento: Stabilimento | null;
|
||||
magazzino: Magazzino | null
|
||||
}) => {
|
||||
setSelectedStabilimento(stabilimento);
|
||||
setSelectedMagazzino(magazzino);
|
||||
};
|
||||
|
||||
const handleEdit = (article: Article) => {
|
||||
setEditingArticle(article);
|
||||
setShowModal(true);
|
||||
|
|
@ -63,9 +98,22 @@ export default function ArticlesList({ stabilimento }: ArticlesListProps) {
|
|||
}
|
||||
setShowModal(false);
|
||||
setEditingArticle(null);
|
||||
fetchGiacenze();
|
||||
};
|
||||
|
||||
const filteredArticles = articles.filter((article) => {
|
||||
if (!search) return true;
|
||||
const searchLower = search.toLowerCase();
|
||||
return (
|
||||
article.descrizione?.toLowerCase().includes(searchLower) ||
|
||||
article.codice?.toLowerCase().includes(searchLower) ||
|
||||
article.barcode?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
const sottoScorta = articles.filter((a) => a.quantita < a.quantita_minima).length;
|
||||
const magazzinoNome = selectedMagazzino?.descrizione ||
|
||||
(selectedStabilimento ? `${selectedStabilimento.descrizione}` : undefined);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
|
|
@ -74,7 +122,8 @@ export default function ArticlesList({ stabilimento }: ArticlesListProps) {
|
|||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Articoli</h2>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{articles.length} articoli {sottoScorta > 0 && <span className="text-red-400">({sottoScorta} sotto scorta)</span>}
|
||||
{articles.length} articoli
|
||||
{sottoScorta > 0 && <span className="text-red-400 ml-1">({sottoScorta} sotto scorta)</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -88,6 +137,11 @@ export default function ArticlesList({ stabilimento }: ArticlesListProps) {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selettore Stabilimento/Magazzino */}
|
||||
<div className="mb-4 p-3 rounded-lg bg-zinc-800/50 border border-zinc-800">
|
||||
<StabilimentoMagazzinoSelector onSelectionChange={handleSelectionChange} />
|
||||
</div>
|
||||
|
||||
{/* Ricerca */}
|
||||
<div className="relative mb-4">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -117,15 +171,17 @@ export default function ArticlesList({ stabilimento }: ArticlesListProps) {
|
|||
<p className="text-red-400 text-sm mb-2">{error}</p>
|
||||
<button onClick={fetchArticles} className="text-xs text-blue-400 hover:underline">Riprova</button>
|
||||
</div>
|
||||
) : articles.length === 0 ? (
|
||||
) : filteredArticles.length === 0 ? (
|
||||
<div className="text-center py-8 text-zinc-500 text-sm">
|
||||
{search ? "Nessun articolo trovato" : "Nessun articolo in questo stabilimento"}
|
||||
{search ? "Nessun articolo trovato" : "Nessun articolo"}
|
||||
</div>
|
||||
) : (
|
||||
articles.map((article) => (
|
||||
filteredArticles.map((article) => (
|
||||
<ArticleCard
|
||||
key={article.id}
|
||||
article={article}
|
||||
giacenzaMagazzino={selectedStabilimento ? giacenze[article.id] : undefined}
|
||||
magazzinoNome={magazzinoNome}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
|
@ -138,7 +194,7 @@ export default function ArticlesList({ stabilimento }: ArticlesListProps) {
|
|||
<ArticleModal
|
||||
article={editingArticle}
|
||||
families={families}
|
||||
stabilimentoId={stabilimento.id}
|
||||
stabilimentoId={selectedStabilimento?.id || 0}
|
||||
onClose={() => { setShowModal(false); setEditingArticle(null); }}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import type { Stabilimento } from "@/src/types/stabilimento";
|
||||
|
||||
interface MacchineTreeProps {
|
||||
stabilimento: Stabilimento;
|
||||
}
|
||||
|
||||
export default function MacchineTree({ stabilimento }: MacchineTreeProps) {
|
||||
export default function MacchineTree() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
|
|
|
|||
161
src/components/StabilimentoMagazzinoSelector.tsx
Normal file
161
src/components/StabilimentoMagazzinoSelector.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Stabilimento, Magazzino } from "@/src/types/stabilimento";
|
||||
|
||||
interface Selection {
|
||||
stabilimento: Stabilimento | null;
|
||||
magazzino: Magazzino | null;
|
||||
}
|
||||
|
||||
interface StabilimentoMagazzinoSelectorProps {
|
||||
onSelectionChange: (selection: Selection) => void;
|
||||
}
|
||||
|
||||
export default function StabilimentoMagazzinoSelector({
|
||||
onSelectionChange,
|
||||
}: StabilimentoMagazzinoSelectorProps) {
|
||||
const [stabilimenti, setStabilimenti] = useState<Stabilimento[]>([]);
|
||||
const [magazzini, setMagazzini] = useState<Magazzino[]>([]);
|
||||
const [selectedStabilimento, setSelectedStabilimento] = useState<Stabilimento | null>(null);
|
||||
const [selectedMagazzino, setSelectedMagazzino] = useState<Magazzino | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [stabRes, magRes] = await Promise.all([
|
||||
fetch("/api/stabilimenti"),
|
||||
fetch("/api/magazzini"),
|
||||
]);
|
||||
|
||||
if (stabRes.ok && magRes.ok) {
|
||||
const stabData = await stabRes.json();
|
||||
const magData = await magRes.json();
|
||||
setStabilimenti(stabData.stabilimenti);
|
||||
setMagazzini(magData.magazzini);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Errore caricamento dati:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleStabilimentoSelect = (stab: Stabilimento | null) => {
|
||||
setSelectedStabilimento(stab);
|
||||
setSelectedMagazzino(null);
|
||||
onSelectionChange({ stabilimento: stab, magazzino: null });
|
||||
};
|
||||
|
||||
const handleMagazzinoSelect = (mag: Magazzino | null) => {
|
||||
setSelectedMagazzino(mag);
|
||||
onSelectionChange({ stabilimento: selectedStabilimento, magazzino: mag });
|
||||
};
|
||||
|
||||
const filteredMagazzini = selectedStabilimento
|
||||
? magazzini.filter((m) => m.id_stabilimento === selectedStabilimento.id)
|
||||
: [];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-zinc-500 text-sm">
|
||||
<svg className="animate-spin h-4 w-4" 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 12h4z" />
|
||||
</svg>
|
||||
Caricamento...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* Stabilimenti */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-500 uppercase tracking-wider">Stabilimento:</span>
|
||||
<div className="flex gap-1">
|
||||
{stabilimenti.map((stab) => (
|
||||
<button
|
||||
key={stab.id}
|
||||
onClick={() => handleStabilimentoSelect(
|
||||
selectedStabilimento?.id === stab.id ? null : stab
|
||||
)}
|
||||
className={`
|
||||
px-3 py-1.5 text-sm rounded-lg font-medium transition-all
|
||||
${selectedStabilimento?.id === stab.id
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-white"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{stab.descrizione}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Magazzini (visibile solo se stabilimento selezionato) */}
|
||||
{selectedStabilimento && filteredMagazzini.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-500 uppercase tracking-wider">Magazzino:</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => handleMagazzinoSelect(null)}
|
||||
className={`
|
||||
px-3 py-1.5 text-sm rounded-lg font-medium transition-all
|
||||
${!selectedMagazzino
|
||||
? "bg-green-600 text-white"
|
||||
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-white"
|
||||
}
|
||||
`}
|
||||
>
|
||||
Tutti
|
||||
</button>
|
||||
{filteredMagazzini.map((mag) => (
|
||||
<button
|
||||
key={mag.id}
|
||||
onClick={() => handleMagazzinoSelect(
|
||||
selectedMagazzino?.id === mag.id ? null : mag
|
||||
)}
|
||||
className={`
|
||||
px-3 py-1.5 text-sm rounded-lg font-medium transition-all
|
||||
${selectedMagazzino?.id === mag.id
|
||||
? "bg-green-600 text-white"
|
||||
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-white"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{mag.descrizione}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info selezione */}
|
||||
{selectedStabilimento && (
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<span className="text-xs text-zinc-600">
|
||||
{selectedMagazzino
|
||||
? `Filtro: ${selectedMagazzino.descrizione}`
|
||||
: `Tutti i magazzini di ${selectedStabilimento.descrizione}`
|
||||
}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleStabilimentoSelect(null)}
|
||||
className="p-1 text-zinc-500 hover:text-white transition-colors"
|
||||
title="Reset selezione"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -87,14 +87,15 @@ function parseArticleInput(input: Partial<ArticleInput>): ParsedArticle {
|
|||
}
|
||||
|
||||
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;
|
||||
const { famigliaId, search } = options;
|
||||
|
||||
const params: Record<string, string | number> = {};
|
||||
const conditions: string[] = [];
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
|
|
@ -112,7 +113,6 @@ export async function listArticles(options: ListArticlesOptions = {}): Promise<A
|
|||
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
|
||||
|
|
@ -120,19 +120,6 @@ export async function listArticles(options: ListArticlesOptions = {}): Promise<A
|
|||
) 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;
|
||||
|
|
|
|||
122
src/lib/giacenze/index.ts
Normal file
122
src/lib/giacenze/index.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import type { RowDataPacket } from "mysql2";
|
||||
|
||||
import { db } from "@/src/lib/db";
|
||||
import type { Giacenza, GiacenzePerArticolo } from "@/src/types/giacenza";
|
||||
|
||||
type GiacenzaRow = Giacenza & RowDataPacket;
|
||||
|
||||
export async function getGiacenzeByMagazzino(magazzinoId: number): Promise<Giacenza[]> {
|
||||
const [rows] = await db.execute<GiacenzaRow[]>(
|
||||
`SELECT
|
||||
g.id,
|
||||
g.articolo_id,
|
||||
g.id_magazzino,
|
||||
g.quantita,
|
||||
a.codice AS articolo_codice,
|
||||
a.descrizione AS articolo_descrizione,
|
||||
m.descrizione AS magazzino_descrizione,
|
||||
s.descrizione AS stabilimento_descrizione
|
||||
FROM giacenze g
|
||||
LEFT JOIN articoli a ON g.articolo_id = a.id
|
||||
LEFT JOIN magazzini m ON g.id_magazzino = m.id
|
||||
LEFT JOIN stabilimenti s ON m.id_stabilimento = s.id
|
||||
WHERE g.id_magazzino = :magazzinoId
|
||||
ORDER BY a.descrizione ASC`,
|
||||
{ magazzinoId }
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
articolo_id: row.articolo_id,
|
||||
id_magazzino: row.id_magazzino,
|
||||
quantita: Number(row.quantita) || 0,
|
||||
articolo_codice: row.articolo_codice,
|
||||
articolo_descrizione: row.articolo_descrizione,
|
||||
magazzino_descrizione: row.magazzino_descrizione,
|
||||
stabilimento_descrizione: row.stabilimento_descrizione,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getGiacenzeByStabilimento(stabilimentoId: number): Promise<Giacenza[]> {
|
||||
const [rows] = await db.execute<GiacenzaRow[]>(
|
||||
`SELECT
|
||||
g.id,
|
||||
g.articolo_id,
|
||||
g.id_magazzino,
|
||||
g.quantita,
|
||||
a.codice AS articolo_codice,
|
||||
a.descrizione AS articolo_descrizione,
|
||||
m.descrizione AS magazzino_descrizione,
|
||||
s.descrizione AS stabilimento_descrizione
|
||||
FROM giacenze g
|
||||
LEFT JOIN articoli a ON g.articolo_id = a.id
|
||||
LEFT JOIN magazzini m ON g.id_magazzino = m.id
|
||||
LEFT JOIN stabilimenti s ON m.id_stabilimento = s.id
|
||||
WHERE m.id_stabilimento = :stabilimentoId
|
||||
ORDER BY a.descrizione ASC`,
|
||||
{ stabilimentoId }
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
articolo_id: row.articolo_id,
|
||||
id_magazzino: row.id_magazzino,
|
||||
quantita: Number(row.quantita) || 0,
|
||||
articolo_codice: row.articolo_codice,
|
||||
articolo_descrizione: row.articolo_descrizione,
|
||||
magazzino_descrizione: row.magazzino_descrizione,
|
||||
stabilimento_descrizione: row.stabilimento_descrizione,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getGiacenzeMapByMagazzino(magazzinoId: number): Promise<GiacenzePerArticolo> {
|
||||
const giacenze = await getGiacenzeByMagazzino(magazzinoId);
|
||||
|
||||
const map: GiacenzePerArticolo = {};
|
||||
for (const g of giacenze) {
|
||||
map[g.articolo_id] = g.quantita;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function getGiacenzeMapByStabilimento(stabilimentoId: number): Promise<GiacenzePerArticolo> {
|
||||
const giacenze = await getGiacenzeByStabilimento(stabilimentoId);
|
||||
|
||||
const map: GiacenzePerArticolo = {};
|
||||
for (const g of giacenze) {
|
||||
if (!map[g.articolo_id]) {
|
||||
map[g.articolo_id] = 0;
|
||||
}
|
||||
map[g.articolo_id] += g.quantita;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function getGiacenzaArticolo(articoloId: number): Promise<Giacenza[]> {
|
||||
const [rows] = await db.execute<GiacenzaRow[]>(
|
||||
`SELECT
|
||||
g.id,
|
||||
g.articolo_id,
|
||||
g.id_magazzino,
|
||||
g.quantita,
|
||||
m.descrizione AS magazzino_descrizione,
|
||||
s.descrizione AS stabilimento_descrizione
|
||||
FROM giacenze g
|
||||
LEFT JOIN magazzini m ON g.id_magazzino = m.id
|
||||
LEFT JOIN stabilimenti s ON m.id_stabilimento = s.id
|
||||
WHERE g.articolo_id = :articoloId
|
||||
ORDER BY s.descrizione, m.descrizione`,
|
||||
{ articoloId }
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
articolo_id: row.articolo_id,
|
||||
id_magazzino: row.id_magazzino,
|
||||
quantita: Number(row.quantita) || 0,
|
||||
magazzino_descrizione: row.magazzino_descrizione,
|
||||
stabilimento_descrizione: row.stabilimento_descrizione,
|
||||
}));
|
||||
}
|
||||
14
src/types/giacenza.ts
Normal file
14
src/types/giacenza.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export interface Giacenza {
|
||||
id: number;
|
||||
articolo_id: number;
|
||||
id_magazzino: number;
|
||||
quantita: number;
|
||||
articolo_codice?: string;
|
||||
articolo_descrizione?: string;
|
||||
magazzino_descrizione?: string;
|
||||
stabilimento_descrizione?: string;
|
||||
}
|
||||
|
||||
export interface GiacenzePerArticolo {
|
||||
[articoloId: number]: number;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue