Files
btpxpress-frontend/app/(main)/stock/page.tsx
2025-10-01 01:39:07 +00:00

773 lines
30 KiB
TypeScript

'use client';
import React, { useState, useEffect, useRef } from 'react';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { Button } from 'primereact/button';
import { InputText } from 'primereact/inputtext';
import { Card } from 'primereact/card';
import { Dialog } from 'primereact/dialog';
import { Toast } from 'primereact/toast';
import { Toolbar } from 'primereact/toolbar';
import { Tag } from 'primereact/tag';
import { InputNumber } from 'primereact/inputnumber';
import { Dropdown } from 'primereact/dropdown';
import { InputTextarea } from 'primereact/inputtextarea';
import { confirmDialog } from 'primereact/confirmdialog';
import { ConfirmDialog } from 'primereact/confirmdialog';
interface StockItem {
id: string;
nom: string;
reference: string;
description: string;
categorie: string;
unite: string;
quantiteStock: number;
seuilAlerte: number;
prixUnitaire: number;
fournisseur: string;
emplacement: string;
dateAjout: Date;
dateModification: Date;
actif: boolean;
}
interface StockMovement {
id: string;
articleId: string;
type: 'ENTREE' | 'SORTIE';
quantite: number;
motif: string;
chantier?: string;
utilisateur: string;
date: Date;
}
const StockPage = () => {
const [stockItems, setStockItems] = useState<StockItem[]>([]);
const [selectedItems, setSelectedItems] = useState<StockItem[]>([]);
const [loading, setLoading] = useState(true);
const [globalFilter, setGlobalFilter] = useState('');
const [itemDialog, setItemDialog] = useState(false);
const [movementDialog, setMovementDialog] = useState(false);
const [deleteItemDialog, setDeleteItemDialog] = useState(false);
const [item, setItem] = useState<StockItem>({
id: '',
nom: '',
reference: '',
description: '',
categorie: '',
unite: 'unité',
quantiteStock: 0,
seuilAlerte: 10,
prixUnitaire: 0,
fournisseur: '',
emplacement: '',
dateAjout: new Date(),
dateModification: new Date(),
actif: true
});
const [movement, setMovement] = useState<StockMovement>({
id: '',
articleId: '',
type: 'ENTREE',
quantite: 0,
motif: '',
chantier: '',
utilisateur: 'Admin',
date: new Date()
});
const [submitted, setSubmitted] = useState(false);
const toast = useRef<Toast>(null);
const dt = useRef<DataTable<StockItem[]>>(null);
const categories = [
{ label: 'Matériaux', value: 'materiaux' },
{ label: 'Outillage', value: 'outillage' },
{ label: 'Équipement', value: 'equipement' },
{ label: 'Consommables', value: 'consommables' },
{ label: 'Sécurité', value: 'securite' }
];
const unites = [
{ label: 'Unité', value: 'unité' },
{ label: 'Mètre', value: 'm' },
{ label: 'Mètre carré', value: 'm²' },
{ label: 'Mètre cube', value: 'm³' },
{ label: 'Kilogramme', value: 'kg' },
{ label: 'Tonne', value: 't' },
{ label: 'Litre', value: 'l' },
{ label: 'Boîte', value: 'boîte' },
{ label: 'Sac', value: 'sac' },
{ label: 'Palette', value: 'palette' }
];
const emplacements = [
{ label: 'Entrepôt A', value: 'entrepot-a' },
{ label: 'Entrepôt B', value: 'entrepot-b' },
{ label: 'Magasin', value: 'magasin' },
{ label: 'Chantier Mobile', value: 'chantier-mobile' },
{ label: 'Bureau', value: 'bureau' }
];
const typesMovement = [
{ label: 'Entrée', value: 'ENTREE' },
{ label: 'Sortie', value: 'SORTIE' }
];
useEffect(() => {
// Simuler le chargement des données
loadStockItems();
}, []);
const loadStockItems = async () => {
try {
setLoading(true);
// Simulation de données
const mockData: StockItem[] = [
{
id: '1',
nom: 'Ciment Portland',
reference: 'CIM-001',
description: 'Ciment Portland 32.5 pour béton',
categorie: 'materiaux',
unite: 'sac',
quantiteStock: 150,
seuilAlerte: 20,
prixUnitaire: 8.50,
fournisseur: 'Lafarge',
emplacement: 'entrepot-a',
dateAjout: new Date('2024-01-15'),
dateModification: new Date('2024-01-15'),
actif: true
},
{
id: '2',
nom: 'Perceuse électrique',
reference: 'OUT-002',
description: 'Perceuse électrique 850W',
categorie: 'outillage',
unite: 'unité',
quantiteStock: 5,
seuilAlerte: 2,
prixUnitaire: 120.00,
fournisseur: 'Bosch',
emplacement: 'magasin',
dateAjout: new Date('2024-02-01'),
dateModification: new Date('2024-02-01'),
actif: true
},
{
id: '3',
nom: 'Casque de sécurité',
reference: 'SEC-003',
description: 'Casque de sécurité blanc',
categorie: 'securite',
unite: 'unité',
quantiteStock: 8,
seuilAlerte: 5,
prixUnitaire: 25.00,
fournisseur: 'Protecta',
emplacement: 'bureau',
dateAjout: new Date('2024-03-01'),
dateModification: new Date('2024-03-01'),
actif: true
}
];
setStockItems(mockData);
} catch (error) {
console.error('Erreur lors du chargement du stock:', error);
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Impossible de charger les articles',
life: 3000
});
} finally {
setLoading(false);
}
};
const openNew = () => {
setItem({
id: '',
nom: '',
reference: '',
description: '',
categorie: '',
unite: 'unité',
quantiteStock: 0,
seuilAlerte: 10,
prixUnitaire: 0,
fournisseur: '',
emplacement: '',
dateAjout: new Date(),
dateModification: new Date(),
actif: true
});
setSubmitted(false);
setItemDialog(true);
};
const openMovement = (item: StockItem) => {
setMovement({
id: '',
articleId: item.id,
type: 'ENTREE',
quantite: 0,
motif: '',
chantier: '',
utilisateur: 'Admin',
date: new Date()
});
setSubmitted(false);
setMovementDialog(true);
};
const hideDialog = () => {
setSubmitted(false);
setItemDialog(false);
};
const hideMovementDialog = () => {
setSubmitted(false);
setMovementDialog(false);
};
const hideDeleteItemDialog = () => {
setDeleteItemDialog(false);
};
const saveItem = () => {
setSubmitted(true);
if (item.nom.trim() && item.reference.trim() && item.categorie) {
let updatedItems = [...stockItems];
if (item.id) {
// Mise à jour
const index = stockItems.findIndex(i => i.id === item.id);
updatedItems[index] = { ...item, dateModification: new Date() };
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Article mis à jour',
life: 3000
});
} else {
// Création
const newItem = {
...item,
id: Date.now().toString(),
dateAjout: new Date(),
dateModification: new Date()
};
updatedItems.push(newItem);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Article créé',
life: 3000
});
}
setStockItems(updatedItems);
setItemDialog(false);
setItem({
id: '',
nom: '',
reference: '',
description: '',
categorie: '',
unite: 'unité',
quantiteStock: 0,
seuilAlerte: 10,
prixUnitaire: 0,
fournisseur: '',
emplacement: '',
dateAjout: new Date(),
dateModification: new Date(),
actif: true
});
}
};
const saveMovement = () => {
setSubmitted(true);
if (movement.quantite > 0 && movement.motif.trim()) {
const targetItem = stockItems.find(i => i.id === movement.articleId);
if (targetItem) {
const newQuantity = movement.type === 'ENTREE'
? targetItem.quantiteStock + movement.quantite
: targetItem.quantiteStock - movement.quantite;
if (newQuantity < 0) {
toast.current?.show({
severity: 'error',
summary: 'Erreur',
detail: 'Stock insuffisant',
life: 3000
});
return;
}
const updatedItems = stockItems.map(item =>
item.id === movement.articleId
? { ...item, quantiteStock: newQuantity, dateModification: new Date() }
: item
);
setStockItems(updatedItems);
setMovementDialog(false);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: `Mouvement de stock ${movement.type === 'ENTREE' ? 'entrée' : 'sortie'} enregistré`,
life: 3000
});
}
}
};
const editItem = (item: StockItem) => {
setItem({ ...item });
setItemDialog(true);
};
const confirmDeleteItem = (item: StockItem) => {
setItem(item);
setDeleteItemDialog(true);
};
const deleteItem = () => {
const updatedItems = stockItems.filter(i => i.id !== item.id);
setStockItems(updatedItems);
setDeleteItemDialog(false);
toast.current?.show({
severity: 'success',
summary: 'Succès',
detail: 'Article supprimé',
life: 3000
});
};
const exportCSV = () => {
dt.current?.exportCSV();
};
const onInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, name: string) => {
const val = (e.target && e.target.value) || '';
let _item = { ...item };
(_item as any)[name] = val;
setItem(_item);
};
const onNumberChange = (e: any, name: string) => {
let _item = { ...item };
(_item as any)[name] = e.value;
setItem(_item);
};
const onDropdownChange = (e: any, name: string) => {
let _item = { ...item };
(_item as any)[name] = e.value;
setItem(_item);
};
const onMovementInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, name: string) => {
const val = (e.target && e.target.value) || '';
let _movement = { ...movement };
(_movement as any)[name] = val;
setMovement(_movement);
};
const onMovementNumberChange = (e: any, name: string) => {
let _movement = { ...movement };
(_movement as any)[name] = e.value;
setMovement(_movement);
};
const onMovementDropdownChange = (e: any, name: string) => {
let _movement = { ...movement };
(_movement as any)[name] = e.value;
setMovement(_movement);
};
const leftToolbarTemplate = () => {
return (
<div className="flex flex-wrap gap-2">
<Button
label="Nouveau"
icon="pi pi-plus"
severity="success"
onClick={openNew}
/>
<Button
label="Alerte Stock"
icon="pi pi-exclamation-triangle"
severity="warning"
onClick={() => {
const alertes = stockItems.filter(item => item.quantiteStock <= item.seuilAlerte);
if (alertes.length > 0) {
toast.current?.show({
severity: 'warn',
summary: 'Alertes Stock',
detail: `${alertes.length} article(s) en rupture ou sous le seuil`,
life: 5000
});
} else {
toast.current?.show({
severity: 'info',
summary: 'Stock',
detail: 'Aucune alerte stock',
life: 3000
});
}
}}
/>
</div>
);
};
const rightToolbarTemplate = () => {
return (
<Button
label="Exporter"
icon="pi pi-upload"
severity="help"
onClick={exportCSV}
/>
);
};
const actionBodyTemplate = (rowData: StockItem) => {
return (
<div className="flex gap-2">
<Button
icon="pi pi-pencil"
rounded
severity="success"
onClick={() => editItem(rowData)}
/>
<Button
icon="pi pi-arrows-h"
rounded
severity="info"
onClick={() => openMovement(rowData)}
/>
<Button
icon="pi pi-trash"
rounded
severity="warning"
onClick={() => confirmDeleteItem(rowData)}
/>
</div>
);
};
const stockStatusBodyTemplate = (rowData: StockItem) => {
if (rowData.quantiteStock === 0) {
return <Tag value="Rupture" severity="danger" />;
} else if (rowData.quantiteStock <= rowData.seuilAlerte) {
return <Tag value="Alerte" severity="warning" />;
} else {
return <Tag value="Disponible" severity="success" />;
}
};
const categorieBodyTemplate = (rowData: StockItem) => {
const categorie = categories.find(c => c.value === rowData.categorie);
return categorie ? categorie.label : rowData.categorie;
};
const emplacementBodyTemplate = (rowData: StockItem) => {
const emplacement = emplacements.find(e => e.value === rowData.emplacement);
return emplacement ? emplacement.label : rowData.emplacement;
};
const prixBodyTemplate = (rowData: StockItem) => {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR'
}).format(rowData.prixUnitaire);
};
const header = (
<div className="flex flex-column md:flex-row md:justify-content-between md:align-items-center">
<h5 className="m-0">Gestion du Stock</h5>
<span className="block mt-2 md:mt-0 p-input-icon-left">
<i className="pi pi-search" />
<InputText
type="search"
placeholder="Rechercher..."
onInput={(e) => setGlobalFilter(e.currentTarget.value)}
/>
</span>
</div>
);
const itemDialogFooter = (
<div className="flex justify-content-end gap-2">
<Button label="Annuler" icon="pi pi-times" text onClick={hideDialog} />
<Button label="Sauvegarder" icon="pi pi-check" onClick={saveItem} />
</div>
);
const movementDialogFooter = (
<div className="flex justify-content-end gap-2">
<Button label="Annuler" icon="pi pi-times" text onClick={hideMovementDialog} />
<Button label="Enregistrer" icon="pi pi-check" onClick={saveMovement} />
</div>
);
const deleteItemDialogFooter = (
<div className="flex justify-content-end gap-2">
<Button label="Non" icon="pi pi-times" text onClick={hideDeleteItemDialog} />
<Button label="Oui" icon="pi pi-check" onClick={deleteItem} />
</div>
);
return (
<div className="grid">
<div className="col-12">
<Card>
<Toast ref={toast} />
<ConfirmDialog />
<Toolbar className="mb-4" left={leftToolbarTemplate} right={rightToolbarTemplate} />
<DataTable
ref={dt}
value={stockItems}
selection={selectedItems}
onSelectionChange={(e) => setSelectedItems(e.value)}
dataKey="id"
paginator
rows={10}
rowsPerPageOptions={[5, 10, 25]}
className="datatable-responsive"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
currentPageReportTemplate="Affichage de {first} à {last} sur {totalRecords} articles"
globalFilter={globalFilter}
emptyMessage="Aucun article trouvé."
header={header}
responsiveLayout="scroll"
loading={loading}
>
<Column selectionMode="multiple" headerStyle={{ width: '4rem' }} />
<Column field="reference" header="Référence" sortable headerStyle={{ minWidth: '10rem' }} />
<Column field="nom" header="Nom" sortable headerStyle={{ minWidth: '12rem' }} />
<Column field="categorie" header="Catégorie" body={categorieBodyTemplate} sortable headerStyle={{ minWidth: '10rem' }} />
<Column field="quantiteStock" header="Stock" sortable headerStyle={{ minWidth: '8rem' }} />
<Column field="unite" header="Unité" sortable headerStyle={{ minWidth: '8rem' }} />
<Column field="status" header="Statut" body={stockStatusBodyTemplate} headerStyle={{ minWidth: '10rem' }} />
<Column field="prixUnitaire" header="Prix" body={prixBodyTemplate} sortable headerStyle={{ minWidth: '10rem' }} />
<Column field="emplacement" header="Emplacement" body={emplacementBodyTemplate} sortable headerStyle={{ minWidth: '10rem' }} />
<Column body={actionBodyTemplate} headerStyle={{ minWidth: '12rem' }} />
</DataTable>
<Dialog
visible={itemDialog}
style={{ width: '600px' }}
header="Détails de l'article"
modal
className="p-fluid"
footer={itemDialogFooter}
onHide={hideDialog}
>
<div className="formgrid grid">
<div className="field col-12 md:col-6">
<label htmlFor="nom">Nom</label>
<InputText
id="nom"
value={item.nom}
onChange={(e) => onInputChange(e, 'nom')}
required
className={submitted && !item.nom ? 'p-invalid' : ''}
/>
{submitted && !item.nom && <small className="p-invalid">Le nom est requis.</small>}
</div>
<div className="field col-12 md:col-6">
<label htmlFor="reference">Référence</label>
<InputText
id="reference"
value={item.reference}
onChange={(e) => onInputChange(e, 'reference')}
required
className={submitted && !item.reference ? 'p-invalid' : ''}
/>
{submitted && !item.reference && <small className="p-invalid">La référence est requise.</small>}
</div>
<div className="field col-12">
<label htmlFor="description">Description</label>
<InputTextarea
id="description"
value={item.description}
onChange={(e) => onInputChange(e, 'description')}
rows={3}
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="categorie">Catégorie</label>
<Dropdown
id="categorie"
value={item.categorie}
options={categories}
onChange={(e) => onDropdownChange(e, 'categorie')}
placeholder="Sélectionnez une catégorie"
required
className={submitted && !item.categorie ? 'p-invalid' : ''}
/>
{submitted && !item.categorie && <small className="p-invalid">La catégorie est requise.</small>}
</div>
<div className="field col-12 md:col-6">
<label htmlFor="unite">Unité</label>
<Dropdown
id="unite"
value={item.unite}
options={unites}
onChange={(e) => onDropdownChange(e, 'unite')}
placeholder="Sélectionnez une unité"
/>
</div>
<div className="field col-12 md:col-4">
<label htmlFor="quantiteStock">Stock actuel</label>
<InputNumber
id="quantiteStock"
value={item.quantiteStock}
onValueChange={(e) => onNumberChange(e, 'quantiteStock')}
min={0}
/>
</div>
<div className="field col-12 md:col-4">
<label htmlFor="seuilAlerte">Seuil d'alerte</label>
<InputNumber
id="seuilAlerte"
value={item.seuilAlerte}
onValueChange={(e) => onNumberChange(e, 'seuilAlerte')}
min={0}
/>
</div>
<div className="field col-12 md:col-4">
<label htmlFor="prixUnitaire">Prix unitaire (€)</label>
<InputNumber
id="prixUnitaire"
value={item.prixUnitaire}
onValueChange={(e) => onNumberChange(e, 'prixUnitaire')}
mode="currency"
currency="EUR"
locale="fr-FR"
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="fournisseur">Fournisseur</label>
<InputText
id="fournisseur"
value={item.fournisseur}
onChange={(e) => onInputChange(e, 'fournisseur')}
/>
</div>
<div className="field col-12 md:col-6">
<label htmlFor="emplacement">Emplacement</label>
<Dropdown
id="emplacement"
value={item.emplacement}
options={emplacements}
onChange={(e) => onDropdownChange(e, 'emplacement')}
placeholder="Sélectionnez un emplacement"
/>
</div>
</div>
</Dialog>
<Dialog
visible={movementDialog}
style={{ width: '450px' }}
header="Mouvement de stock"
modal
className="p-fluid"
footer={movementDialogFooter}
onHide={hideMovementDialog}
>
<div className="formgrid grid">
<div className="field col-12">
<label htmlFor="type">Type de mouvement</label>
<Dropdown
id="type"
value={movement.type}
options={typesMovement}
onChange={(e) => onMovementDropdownChange(e, 'type')}
/>
</div>
<div className="field col-12">
<label htmlFor="quantite">Quantité</label>
<InputNumber
id="quantite"
value={movement.quantite}
onValueChange={(e) => onMovementNumberChange(e, 'quantite')}
min={1}
required
/>
</div>
<div className="field col-12">
<label htmlFor="motif">Motif</label>
<InputTextarea
id="motif"
value={movement.motif}
onChange={(e) => onMovementInputChange(e, 'motif')}
rows={3}
required
/>
</div>
<div className="field col-12">
<label htmlFor="chantier">Chantier (optionnel)</label>
<InputText
id="chantier"
value={movement.chantier}
onChange={(e) => onMovementInputChange(e, 'chantier')}
/>
</div>
</div>
</Dialog>
<Dialog
visible={deleteItemDialog}
style={{ width: '450px' }}
header="Confirmer"
modal
footer={deleteItemDialogFooter}
onHide={hideDeleteItemDialog}
>
<div className="flex align-items-center justify-content-center">
<i className="pi pi-exclamation-triangle mr-3" style={{ fontSize: '2rem' }} />
{item && (
<span>
Êtes-vous sûr de vouloir supprimer l'article <b>{item.nom}</b> ?
</span>
)}
</div>
</Dialog>
</Card>
</div>
</div>
);
};
export default StockPage;