Files
btpxpress-frontend/app/(main)/dashboard/stocks/page.tsx
2025-10-13 05:29:32 +02:00

522 lines
22 KiB
TypeScript

'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, StatutStock, CategorieStock } 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] as any} />;
};
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;