Initial commit
This commit is contained in:
522
app/(main)/dashboard/stocks/page.tsx
Normal file
522
app/(main)/dashboard/stocks/page.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { InputTextarea } from 'primereact/inputtextarea';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { Menu } from 'primereact/menu';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { useStocks } from '../../../../hooks/useStocks';
|
||||
import { Stock, StockAlert, StockStats } from '../../../../types/stocks';
|
||||
|
||||
const DashboardStocksPage = () => {
|
||||
const { stocks, loading, refresh, entreeStock, sortieStock, inventaire, reserverStock } = useStocks();
|
||||
const [globalFilter, setGlobalFilter] = useState('');
|
||||
const [selectedStock, setSelectedStock] = useState<Stock | null>(null);
|
||||
const [showEntreeDialog, setShowEntreeDialog] = useState(false);
|
||||
const [showSortieDialog, setShowSortieDialog] = useState(false);
|
||||
const [showInventaireDialog, setShowInventaireDialog] = useState(false);
|
||||
const [showReservationDialog, setShowReservationDialog] = useState(false);
|
||||
|
||||
const [quantite, setQuantite] = useState<number>(0);
|
||||
const [coutUnitaire, setCoutUnitaire] = useState<number>(0);
|
||||
const [motif, setMotif] = useState('');
|
||||
const [quantiteReelle, setQuantiteReelle] = useState<number>(0);
|
||||
const [observations, setObservations] = useState('');
|
||||
|
||||
const toast = useRef<Toast>(null);
|
||||
const menuRef = useRef<Menu>(null);
|
||||
|
||||
const statutColors: Record<StatutStock, string> = {
|
||||
[StatutStock.ACTIF]: 'success',
|
||||
[StatutStock.INACTIF]: 'warning',
|
||||
[StatutStock.OBSOLETE]: 'warning',
|
||||
[StatutStock.SUPPRIME]: 'danger',
|
||||
[StatutStock.EN_COMMANDE]: 'info',
|
||||
[StatutStock.EN_TRANSIT]: 'info',
|
||||
[StatutStock.EN_CONTROLE]: 'warning',
|
||||
[StatutStock.QUARANTAINE]: 'danger',
|
||||
[StatutStock.DEFECTUEUX]: 'danger',
|
||||
[StatutStock.PERDU]: 'danger',
|
||||
[StatutStock.RESERVE]: 'info',
|
||||
[StatutStock.EN_REPARATION]: 'warning'
|
||||
};
|
||||
|
||||
const categorieLabels: Record<CategorieStock, string> = {
|
||||
[CategorieStock.MATERIAUX_CONSTRUCTION]: 'Matériaux',
|
||||
[CategorieStock.OUTILLAGE]: 'Outillage',
|
||||
[CategorieStock.QUINCAILLERIE]: 'Quincaillerie',
|
||||
[CategorieStock.EQUIPEMENTS_SECURITE]: 'Sécurité',
|
||||
[CategorieStock.EQUIPEMENTS_TECHNIQUES]: 'Technique',
|
||||
[CategorieStock.CONSOMMABLES]: 'Consommables',
|
||||
[CategorieStock.VEHICULES_ENGINS]: 'Véhicules',
|
||||
[CategorieStock.FOURNITURES_BUREAU]: 'Bureau',
|
||||
[CategorieStock.PRODUITS_CHIMIQUES]: 'Chimiques',
|
||||
[CategorieStock.PIECES_DETACHEES]: 'Pièces',
|
||||
[CategorieStock.EQUIPEMENTS_MESURE]: 'Mesure',
|
||||
[CategorieStock.MOBILIER]: 'Mobilier',
|
||||
[CategorieStock.AUTRE]: 'Autre'
|
||||
};
|
||||
|
||||
const statutTemplate = (stock: Stock) => {
|
||||
return <Tag value={stock.statut} severity={statutColors[stock.statut]} />;
|
||||
};
|
||||
|
||||
const categorieTemplate = (stock: Stock) => {
|
||||
return <Tag value={categorieLabels[stock.categorie]} className="p-tag-secondary" />;
|
||||
};
|
||||
|
||||
const quantiteTemplate = (stock: Stock) => {
|
||||
const quantiteDisponible = stock.quantiteStock - (stock.quantiteReservee || 0);
|
||||
const severity = stock.quantiteStock === 0 ? 'danger' :
|
||||
stock.quantiteMinimum && stock.quantiteStock < stock.quantiteMinimum ? 'warning' :
|
||||
'success';
|
||||
|
||||
return (
|
||||
<div className="flex align-items-center gap-2">
|
||||
<Badge value={stock.quantiteStock.toString()} severity={severity} />
|
||||
{stock.quantiteReservee && stock.quantiteReservee > 0 && (
|
||||
<span className="text-sm text-500">({quantiteDisponible} dispo)</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const valeurTemplate = (stock: Stock) => {
|
||||
const valeur = stock.quantiteStock * (stock.coutMoyenPondere || 0);
|
||||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(valeur);
|
||||
};
|
||||
|
||||
const emplacementTemplate = (stock: Stock) => {
|
||||
const parts = [];
|
||||
if (stock.codeZone) parts.push(stock.codeZone);
|
||||
if (stock.codeAllee) parts.push(stock.codeAllee);
|
||||
if (stock.codeEtagere) parts.push(stock.codeEtagere);
|
||||
return parts.join('-') || stock.emplacementStockage || '-';
|
||||
};
|
||||
|
||||
const alertesTemplate = (stock: Stock) => {
|
||||
const alertes = [];
|
||||
|
||||
if (stock.quantiteStock === 0) {
|
||||
alertes.push(<Tag key="rupture" value="Rupture" severity="danger" className="mr-1" />);
|
||||
} else if (stock.quantiteMinimum && stock.quantiteStock < stock.quantiteMinimum) {
|
||||
alertes.push(<Tag key="minimum" value="Stock faible" severity="warning" className="mr-1" />);
|
||||
}
|
||||
|
||||
if (stock.datePeremption && new Date(stock.datePeremption) < new Date()) {
|
||||
alertes.push(<Tag key="perime" value="Périmé" severity="danger" className="mr-1" />);
|
||||
}
|
||||
|
||||
if (stock.articleDangereux) {
|
||||
alertes.push(<Tag key="danger" value="Dangereux" severity="danger" className="mr-1" />);
|
||||
}
|
||||
|
||||
return <div className="flex flex-wrap">{alertes}</div>;
|
||||
};
|
||||
|
||||
const actionTemplate = (stock: Stock) => {
|
||||
const items = [
|
||||
{
|
||||
label: 'Entrée de stock',
|
||||
icon: 'pi pi-plus',
|
||||
command: () => {
|
||||
setSelectedStock(stock);
|
||||
setQuantite(0);
|
||||
setCoutUnitaire(stock.coutDerniereEntree || 0);
|
||||
setMotif('');
|
||||
setShowEntreeDialog(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Sortie de stock',
|
||||
icon: 'pi pi-minus',
|
||||
command: () => {
|
||||
setSelectedStock(stock);
|
||||
setQuantite(0);
|
||||
setMotif('');
|
||||
setShowSortieDialog(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Inventaire',
|
||||
icon: 'pi pi-check-square',
|
||||
command: () => {
|
||||
setSelectedStock(stock);
|
||||
setQuantiteReelle(stock.quantiteStock);
|
||||
setObservations('');
|
||||
setShowInventaireDialog(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Réserver',
|
||||
icon: 'pi pi-lock',
|
||||
command: () => {
|
||||
setSelectedStock(stock);
|
||||
setQuantite(0);
|
||||
setShowReservationDialog(true);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon="pi pi-ellipsis-v"
|
||||
className="p-button-text p-button-rounded"
|
||||
onClick={(e) => {
|
||||
setSelectedStock(stock);
|
||||
menuRef.current?.toggle(e);
|
||||
}}
|
||||
/>
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
model={items}
|
||||
popup
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const handleEntree = async () => {
|
||||
if (!selectedStock) return;
|
||||
|
||||
try {
|
||||
await entreeStock({
|
||||
stockId: selectedStock.id!,
|
||||
quantite,
|
||||
coutUnitaire,
|
||||
motif
|
||||
});
|
||||
toast.current?.show({ severity: 'success', summary: 'Succès', detail: 'Entrée de stock effectuée' });
|
||||
setShowEntreeDialog(false);
|
||||
} catch (error) {
|
||||
toast.current?.show({ severity: 'error', summary: 'Erreur', detail: 'Erreur lors de l\'entrée de stock' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSortie = async () => {
|
||||
if (!selectedStock) return;
|
||||
|
||||
try {
|
||||
await sortieStock({
|
||||
stockId: selectedStock.id!,
|
||||
quantite,
|
||||
motif
|
||||
});
|
||||
toast.current?.show({ severity: 'success', summary: 'Succès', detail: 'Sortie de stock effectuée' });
|
||||
setShowSortieDialog(false);
|
||||
} catch (error) {
|
||||
toast.current?.show({ severity: 'error', summary: 'Erreur', detail: 'Erreur lors de la sortie de stock' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleInventaire = async () => {
|
||||
if (!selectedStock) return;
|
||||
|
||||
try {
|
||||
await inventaire(selectedStock.id!, quantiteReelle, observations);
|
||||
toast.current?.show({ severity: 'success', summary: 'Succès', detail: 'Inventaire effectué' });
|
||||
setShowInventaireDialog(false);
|
||||
} catch (error) {
|
||||
toast.current?.show({ severity: 'error', summary: 'Erreur', detail: 'Erreur lors de l\'inventaire' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleReservation = async () => {
|
||||
if (!selectedStock) return;
|
||||
|
||||
try {
|
||||
await reserverStock(selectedStock.id!, quantite);
|
||||
toast.current?.show({ severity: 'success', summary: 'Succès', detail: 'Réservation effectuée' });
|
||||
setShowReservationDialog(false);
|
||||
} catch (error) {
|
||||
toast.current?.show({ severity: 'error', summary: 'Erreur', detail: 'Erreur lors de la réservation' });
|
||||
}
|
||||
};
|
||||
|
||||
// Statistiques
|
||||
const stocksEnRupture = stocks.filter(s => s.quantiteStock === 0).length;
|
||||
const stocksSousMinimum = stocks.filter(s => s.quantiteMinimum && s.quantiteStock < s.quantiteMinimum).length;
|
||||
const valeurTotale = stocks.reduce((sum, s) => sum + (s.quantiteStock * (s.coutMoyenPondere || 0)), 0);
|
||||
const articlesActifs = stocks.filter(s => s.statut === StatutStock.ACTIF).length;
|
||||
|
||||
const header = (
|
||||
<div className="flex justify-content-between align-items-center">
|
||||
<h5 className="m-0">Gestion des stocks</h5>
|
||||
<span className="p-input-icon-left">
|
||||
<i className="pi pi-search" />
|
||||
<InputText
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
placeholder="Rechercher..."
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<Toast ref={toast} />
|
||||
|
||||
<div className="col-12">
|
||||
<h4>Tableau de bord des stocks</h4>
|
||||
</div>
|
||||
|
||||
{/* Statistiques */}
|
||||
<div className="col-12 md:col-6 lg:col-3">
|
||||
<Card className="mb-0">
|
||||
<div className="flex justify-content-between mb-3">
|
||||
<div>
|
||||
<span className="block text-500 font-medium mb-3">Articles actifs</span>
|
||||
<div className="text-900 font-medium text-xl">{articlesActifs}</div>
|
||||
</div>
|
||||
<div className="flex align-items-center justify-content-center bg-blue-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
|
||||
<i className="pi pi-box text-blue-500 text-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="col-12 md:col-6 lg:col-3">
|
||||
<Card className="mb-0">
|
||||
<div className="flex justify-content-between mb-3">
|
||||
<div>
|
||||
<span className="block text-500 font-medium mb-3">En rupture</span>
|
||||
<div className="text-900 font-medium text-xl">{stocksEnRupture}</div>
|
||||
</div>
|
||||
<div className="flex align-items-center justify-content-center bg-red-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
|
||||
<i className="pi pi-exclamation-circle text-red-500 text-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="col-12 md:col-6 lg:col-3">
|
||||
<Card className="mb-0">
|
||||
<div className="flex justify-content-between mb-3">
|
||||
<div>
|
||||
<span className="block text-500 font-medium mb-3">Stock faible</span>
|
||||
<div className="text-900 font-medium text-xl">{stocksSousMinimum}</div>
|
||||
</div>
|
||||
<div className="flex align-items-center justify-content-center bg-orange-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
|
||||
<i className="pi pi-exclamation-triangle text-orange-500 text-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="col-12 md:col-6 lg:col-3">
|
||||
<Card className="mb-0">
|
||||
<div className="flex justify-content-between mb-3">
|
||||
<div>
|
||||
<span className="block text-500 font-medium mb-3">Valeur totale</span>
|
||||
<div className="text-900 font-medium text-xl">
|
||||
{new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(valeurTotale)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex align-items-center justify-content-center bg-green-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
|
||||
<i className="pi pi-euro text-green-500 text-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Table des stocks */}
|
||||
<div className="col-12">
|
||||
<Card header={header}>
|
||||
<DataTable
|
||||
value={stocks}
|
||||
loading={loading}
|
||||
paginator
|
||||
rows={10}
|
||||
rowsPerPageOptions={[10, 25, 50]}
|
||||
className="p-datatable-gridlines"
|
||||
emptyMessage="Aucun article trouvé"
|
||||
globalFilter={globalFilter}
|
||||
globalFilterFields={['reference', 'designation', 'categorie', 'marque']}
|
||||
>
|
||||
<Column field="reference" header="Référence" sortable style={{ width: '10%' }} />
|
||||
<Column field="designation" header="Désignation" sortable />
|
||||
<Column header="Catégorie" body={categorieTemplate} sortable />
|
||||
<Column header="Quantité" body={quantiteTemplate} sortable />
|
||||
<Column field="uniteMesure" header="Unité" />
|
||||
<Column header="Valeur" body={valeurTemplate} sortable />
|
||||
<Column header="Emplacement" body={emplacementTemplate} />
|
||||
<Column header="Statut" body={statutTemplate} />
|
||||
<Column header="Alertes" body={alertesTemplate} />
|
||||
<Column header="Actions" body={actionTemplate} />
|
||||
</DataTable>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Dialog entrée */}
|
||||
<Dialog
|
||||
header="Entrée de stock"
|
||||
visible={showEntreeDialog}
|
||||
style={{ width: '450px' }}
|
||||
onHide={() => setShowEntreeDialog(false)}
|
||||
footer={
|
||||
<div>
|
||||
<Button label="Annuler" icon="pi pi-times" className="p-button-text" onClick={() => setShowEntreeDialog(false)} />
|
||||
<Button label="Valider" icon="pi pi-check" onClick={handleEntree} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="p-fluid">
|
||||
<div className="field">
|
||||
<label htmlFor="quantite">Quantité</label>
|
||||
<InputNumber
|
||||
id="quantite"
|
||||
value={quantite}
|
||||
onValueChange={(e) => setQuantite(e.value || 0)}
|
||||
min={0}
|
||||
showButtons
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="cout">Coût unitaire HT</label>
|
||||
<InputNumber
|
||||
id="cout"
|
||||
value={coutUnitaire}
|
||||
onValueChange={(e) => setCoutUnitaire(e.value || 0)}
|
||||
mode="currency"
|
||||
currency="EUR"
|
||||
locale="fr-FR"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="motif">Motif</label>
|
||||
<InputTextarea
|
||||
id="motif"
|
||||
value={motif}
|
||||
onChange={(e) => setMotif(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog sortie */}
|
||||
<Dialog
|
||||
header="Sortie de stock"
|
||||
visible={showSortieDialog}
|
||||
style={{ width: '450px' }}
|
||||
onHide={() => setShowSortieDialog(false)}
|
||||
footer={
|
||||
<div>
|
||||
<Button label="Annuler" icon="pi pi-times" className="p-button-text" onClick={() => setShowSortieDialog(false)} />
|
||||
<Button label="Valider" icon="pi pi-check" onClick={handleSortie} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="p-fluid">
|
||||
<div className="field">
|
||||
<label htmlFor="quantite">Quantité</label>
|
||||
<InputNumber
|
||||
id="quantite"
|
||||
value={quantite}
|
||||
onValueChange={(e) => setQuantite(e.value || 0)}
|
||||
min={0}
|
||||
max={selectedStock?.quantiteStock}
|
||||
showButtons
|
||||
/>
|
||||
{selectedStock && (
|
||||
<small>Stock disponible: {selectedStock.quantiteStock - (selectedStock.quantiteReservee || 0)}</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="motif">Motif</label>
|
||||
<InputTextarea
|
||||
id="motif"
|
||||
value={motif}
|
||||
onChange={(e) => setMotif(e.target.value)}
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog inventaire */}
|
||||
<Dialog
|
||||
header="Inventaire"
|
||||
visible={showInventaireDialog}
|
||||
style={{ width: '450px' }}
|
||||
onHide={() => setShowInventaireDialog(false)}
|
||||
footer={
|
||||
<div>
|
||||
<Button label="Annuler" icon="pi pi-times" className="p-button-text" onClick={() => setShowInventaireDialog(false)} />
|
||||
<Button label="Valider" icon="pi pi-check" onClick={handleInventaire} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="p-fluid">
|
||||
<div className="field">
|
||||
<label htmlFor="quantiteReelle">Quantité réelle comptée</label>
|
||||
<InputNumber
|
||||
id="quantiteReelle"
|
||||
value={quantiteReelle}
|
||||
onValueChange={(e) => setQuantiteReelle(e.value || 0)}
|
||||
min={0}
|
||||
showButtons
|
||||
/>
|
||||
{selectedStock && (
|
||||
<small>Stock théorique: {selectedStock.quantiteStock}</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="observations">Observations</label>
|
||||
<InputTextarea
|
||||
id="observations"
|
||||
value={observations}
|
||||
onChange={(e) => setObservations(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog réservation */}
|
||||
<Dialog
|
||||
header="Réservation de stock"
|
||||
visible={showReservationDialog}
|
||||
style={{ width: '450px' }}
|
||||
onHide={() => setShowReservationDialog(false)}
|
||||
footer={
|
||||
<div>
|
||||
<Button label="Annuler" icon="pi pi-times" className="p-button-text" onClick={() => setShowReservationDialog(false)} />
|
||||
<Button label="Réserver" icon="pi pi-check" onClick={handleReservation} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="p-fluid">
|
||||
<div className="field">
|
||||
<label htmlFor="quantite">Quantité à réserver</label>
|
||||
<InputNumber
|
||||
id="quantite"
|
||||
value={quantite}
|
||||
onValueChange={(e) => setQuantite(e.value || 0)}
|
||||
min={0}
|
||||
max={selectedStock ? selectedStock.quantiteStock - (selectedStock.quantiteReservee || 0) : 0}
|
||||
showButtons
|
||||
/>
|
||||
{selectedStock && (
|
||||
<small>Disponible à réserver: {selectedStock.quantiteStock - (selectedStock.quantiteReservee || 0)}</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardStocksPage;
|
||||
Reference in New Issue
Block a user