- Correction des erreurs TypeScript dans userService.ts et workflowTester.ts - Ajout des propriétés manquantes aux objets User mockés - Conversion des dates de string vers objets Date - Correction des appels asynchrones et des types incompatibles - Ajout de dynamic rendering pour résoudre les erreurs useSearchParams - Enveloppement de useSearchParams dans Suspense boundary - Configuration de force-dynamic au niveau du layout principal Build réussi: 126 pages générées avec succès 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
524 lines
22 KiB
TypeScript
524 lines
22 KiB
TypeScript
'use client';
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
|
|
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
|
|
} as any);
|
|
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
|
|
} as any);
|
|
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; |