Initial commit
This commit is contained in:
589
app/(main)/devis/templates/page.tsx
Normal file
589
app/(main)/devis/templates/page.tsx
Normal file
@@ -0,0 +1,589 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Button } from 'primereact/button';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputTextarea } from 'primereact/inputtextarea';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { Toolbar } from 'primereact/toolbar';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { ConfirmDialog } from 'primereact/confirmdialog';
|
||||
import { Menu } from 'primereact/menu';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { devisService } from '../../../../services/api';
|
||||
import { formatDate } from '../../../../utils/formatters';
|
||||
|
||||
interface DevisTemplate {
|
||||
id: string;
|
||||
nom: string;
|
||||
description: string;
|
||||
categorie: string;
|
||||
lignes: Array<{
|
||||
designation: string;
|
||||
quantite: number;
|
||||
unite: string;
|
||||
prixUnitaire: number;
|
||||
}>;
|
||||
tauxTVA: number;
|
||||
actif: boolean;
|
||||
dateCreation: Date;
|
||||
utilisations: number;
|
||||
}
|
||||
|
||||
const DevisTemplatesPage = () => {
|
||||
const toast = useRef<Toast>(null);
|
||||
const menuRef = useRef<Menu>(null);
|
||||
|
||||
const [templates, setTemplates] = useState<DevisTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [editingTemplate, setEditingTemplate] = useState<DevisTemplate | null>(null);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<DevisTemplate | null>(null);
|
||||
const [globalFilter, setGlobalFilter] = useState('');
|
||||
|
||||
const [formData, setFormData] = useState<Partial<DevisTemplate>>({
|
||||
nom: '',
|
||||
description: '',
|
||||
categorie: '',
|
||||
lignes: [],
|
||||
tauxTVA: 20,
|
||||
actif: true
|
||||
});
|
||||
|
||||
const categorieOptions = [
|
||||
{ label: 'Gros œuvre', value: 'GROS_OEUVRE' },
|
||||
{ label: 'Second œuvre', value: 'SECOND_OEUVRE' },
|
||||
{ label: 'Finitions', value: 'FINITIONS' },
|
||||
{ label: 'Plomberie', value: 'PLOMBERIE' },
|
||||
{ label: 'Électricité', value: 'ELECTRICITE' },
|
||||
{ label: 'Chauffage', value: 'CHAUFFAGE' },
|
||||
{ label: 'Rénovation', value: 'RENOVATION' },
|
||||
{ label: 'Maintenance', value: 'MAINTENANCE' }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, []);
|
||||
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// TODO: Remplacer par un vrai appel API
|
||||
// const response = await devisService.getTemplates();
|
||||
|
||||
// Données simulées pour la démonstration
|
||||
const mockTemplates: DevisTemplate[] = [
|
||||
{
|
||||
id: '1',
|
||||
nom: 'Rénovation Salle de Bain Standard',
|
||||
description: 'Template pour rénovation complète salle de bain 6m²',
|
||||
categorie: 'RENOVATION',
|
||||
lignes: [
|
||||
{ designation: 'Démolition existant', quantite: 1, unite: 'forfait', prixUnitaire: 800 },
|
||||
{ designation: 'Carrelage sol', quantite: 6, unite: 'm²', prixUnitaire: 45 },
|
||||
{ designation: 'Carrelage mural', quantite: 20, unite: 'm²', prixUnitaire: 35 },
|
||||
{ designation: 'Sanitaires standard', quantite: 1, unite: 'forfait', prixUnitaire: 1200 }
|
||||
],
|
||||
tauxTVA: 20,
|
||||
actif: true,
|
||||
dateCreation: new Date('2024-01-15'),
|
||||
utilisations: 23
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
nom: 'Extension Maison 20m²',
|
||||
description: 'Template pour extension plain-pied 20m²',
|
||||
categorie: 'GROS_OEUVRE',
|
||||
lignes: [
|
||||
{ designation: 'Fondations', quantite: 20, unite: 'm²', prixUnitaire: 120 },
|
||||
{ designation: 'Murs parpaings', quantite: 40, unite: 'm²', prixUnitaire: 85 },
|
||||
{ designation: 'Charpente', quantite: 20, unite: 'm²', prixUnitaire: 95 },
|
||||
{ designation: 'Couverture', quantite: 22, unite: 'm²', prixUnitaire: 65 }
|
||||
],
|
||||
tauxTVA: 20,
|
||||
actif: true,
|
||||
dateCreation: new Date('2024-02-10'),
|
||||
utilisations: 15
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
nom: 'Installation Électrique Complète',
|
||||
description: 'Template pour installation électrique maison 100m²',
|
||||
categorie: 'ELECTRICITE',
|
||||
lignes: [
|
||||
{ designation: 'Tableau électrique', quantite: 1, unite: 'unité', prixUnitaire: 450 },
|
||||
{ designation: 'Prises de courant', quantite: 25, unite: 'unité', prixUnitaire: 35 },
|
||||
{ designation: 'Points lumineux', quantite: 15, unite: 'unité', prixUnitaire: 45 },
|
||||
{ designation: 'Câblage', quantite: 1, unite: 'forfait', prixUnitaire: 1200 }
|
||||
],
|
||||
tauxTVA: 20,
|
||||
actif: true,
|
||||
dateCreation: new Date('2024-03-05'),
|
||||
utilisations: 31
|
||||
}
|
||||
];
|
||||
|
||||
setTemplates(mockTemplates);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des templates:', error);
|
||||
toast.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Erreur',
|
||||
detail: 'Impossible de charger les templates'
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
setEditingTemplate(null);
|
||||
setFormData({
|
||||
nom: '',
|
||||
description: '',
|
||||
categorie: '',
|
||||
lignes: [],
|
||||
tauxTVA: 20,
|
||||
actif: true
|
||||
});
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const handleEdit = (template: DevisTemplate) => {
|
||||
setEditingTemplate(template);
|
||||
setFormData({ ...template });
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
if (editingTemplate) {
|
||||
// TODO: Appel API pour mise à jour
|
||||
// await devisService.updateTemplate(editingTemplate.id, formData);
|
||||
toast.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Succès',
|
||||
detail: 'Template modifié avec succès'
|
||||
});
|
||||
} else {
|
||||
// TODO: Appel API pour création
|
||||
// await devisService.createTemplate(formData);
|
||||
toast.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Succès',
|
||||
detail: 'Template créé avec succès'
|
||||
});
|
||||
}
|
||||
|
||||
setShowDialog(false);
|
||||
loadTemplates();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde:', error);
|
||||
toast.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Erreur',
|
||||
detail: 'Erreur lors de la sauvegarde'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (template: DevisTemplate) => {
|
||||
try {
|
||||
// TODO: Appel API pour suppression
|
||||
// await devisService.deleteTemplate(template.id);
|
||||
|
||||
toast.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Succès',
|
||||
detail: 'Template supprimé avec succès'
|
||||
});
|
||||
|
||||
loadTemplates();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression:', error);
|
||||
toast.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Erreur',
|
||||
detail: 'Erreur lors de la suppression'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseTemplate = async (template: DevisTemplate) => {
|
||||
try {
|
||||
// TODO: Créer un nouveau devis basé sur le template
|
||||
toast.current?.show({
|
||||
severity: 'info',
|
||||
summary: 'Info',
|
||||
detail: 'Redirection vers la création de devis...'
|
||||
});
|
||||
|
||||
// Simuler la redirection
|
||||
setTimeout(() => {
|
||||
window.location.href = `/devis/nouveau?template=${template.id}`;
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'utilisation du template:', error);
|
||||
toast.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Erreur',
|
||||
detail: 'Erreur lors de l\'utilisation du template'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getMenuItems = (template: DevisTemplate) => [
|
||||
{
|
||||
label: 'Utiliser',
|
||||
icon: 'pi pi-plus',
|
||||
command: () => handleUseTemplate(template)
|
||||
},
|
||||
{
|
||||
label: 'Modifier',
|
||||
icon: 'pi pi-pencil',
|
||||
command: () => handleEdit(template)
|
||||
},
|
||||
{
|
||||
label: 'Dupliquer',
|
||||
icon: 'pi pi-copy',
|
||||
command: () => {
|
||||
setEditingTemplate(null);
|
||||
setFormData({
|
||||
...template,
|
||||
nom: `${template.nom} (Copie)`,
|
||||
id: undefined
|
||||
});
|
||||
setShowDialog(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: 'Supprimer',
|
||||
icon: 'pi pi-trash',
|
||||
className: 'text-red-500',
|
||||
command: () => {
|
||||
setSelectedTemplate(template);
|
||||
// TODO: Afficher dialog de confirmation
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const actionBodyTemplate = (rowData: DevisTemplate) => (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
className="p-button-text p-button-sm p-button-success"
|
||||
tooltip="Utiliser ce template"
|
||||
onClick={() => handleUseTemplate(rowData)}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-ellipsis-v"
|
||||
className="p-button-text p-button-sm"
|
||||
onClick={(e) => {
|
||||
setSelectedTemplate(rowData);
|
||||
menuRef.current?.toggle(e);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const categorieBodyTemplate = (rowData: DevisTemplate) => {
|
||||
const categorie = categorieOptions.find(opt => opt.value === rowData.categorie);
|
||||
return (
|
||||
<Tag
|
||||
value={categorie?.label || rowData.categorie}
|
||||
severity="info"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const statutBodyTemplate = (rowData: DevisTemplate) => (
|
||||
<Tag
|
||||
value={rowData.actif ? 'Actif' : 'Inactif'}
|
||||
severity={rowData.actif ? 'success' : 'danger'}
|
||||
/>
|
||||
);
|
||||
|
||||
const utilisationsBodyTemplate = (rowData: DevisTemplate) => (
|
||||
<Badge value={rowData.utilisations} severity="info" />
|
||||
);
|
||||
|
||||
const toolbarStartTemplate = () => (
|
||||
<div className="flex align-items-center gap-2">
|
||||
<h2 className="text-xl font-bold m-0">Templates de Devis</h2>
|
||||
</div>
|
||||
);
|
||||
|
||||
const toolbarEndTemplate = () => (
|
||||
<div className="flex align-items-center gap-2">
|
||||
<span className="p-input-icon-left">
|
||||
<i className="pi pi-search" />
|
||||
<InputText
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
placeholder="Rechercher..."
|
||||
/>
|
||||
</span>
|
||||
<Button
|
||||
label="Nouveau Template"
|
||||
icon="pi pi-plus"
|
||||
onClick={handleNew}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<Toast ref={toast} />
|
||||
<ConfirmDialog />
|
||||
<Menu ref={menuRef} model={selectedTemplate ? getMenuItems(selectedTemplate) : []} popup />
|
||||
|
||||
<div className="col-12">
|
||||
<Toolbar start={toolbarStartTemplate} end={toolbarEndTemplate} />
|
||||
</div>
|
||||
|
||||
{/* Statistiques rapides */}
|
||||
<div className="col-12 lg:col-3 md:col-6">
|
||||
<Card className="h-full">
|
||||
<div className="flex justify-content-between mb-3">
|
||||
<div>
|
||||
<span className="block text-500 font-medium mb-3">Total Templates</span>
|
||||
<div className="text-900 font-medium text-xl">{templates.length}</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-file-edit text-blue-500 text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="col-12 lg:col-3 md:col-6">
|
||||
<Card className="h-full">
|
||||
<div className="flex justify-content-between mb-3">
|
||||
<div>
|
||||
<span className="block text-500 font-medium mb-3">Templates Actifs</span>
|
||||
<div className="text-900 font-medium text-xl">
|
||||
{templates.filter(t => t.actif).length}
|
||||
</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-check-circle text-green-500 text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="col-12 lg:col-3 md:col-6">
|
||||
<Card className="h-full">
|
||||
<div className="flex justify-content-between mb-3">
|
||||
<div>
|
||||
<span className="block text-500 font-medium mb-3">Plus Utilisé</span>
|
||||
<div className="text-900 font-medium text-xl">
|
||||
{Math.max(...templates.map(t => t.utilisations), 0)} fois
|
||||
</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-star text-orange-500 text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="col-12 lg:col-3 md:col-6">
|
||||
<Card className="h-full">
|
||||
<div className="flex justify-content-between mb-3">
|
||||
<div>
|
||||
<span className="block text-500 font-medium mb-3">Catégories</span>
|
||||
<div className="text-900 font-medium text-xl">
|
||||
{new Set(templates.map(t => t.categorie)).size}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex align-items-center justify-content-center bg-purple-100 border-round" style={{ width: '2.5rem', height: '2.5rem' }}>
|
||||
<i className="pi pi-tags text-purple-500 text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Table des templates */}
|
||||
<div className="col-12">
|
||||
<Card>
|
||||
<DataTable
|
||||
value={templates}
|
||||
loading={loading}
|
||||
globalFilter={globalFilter}
|
||||
responsiveLayout="scroll"
|
||||
paginator
|
||||
rows={10}
|
||||
rowsPerPageOptions={[5, 10, 25]}
|
||||
emptyMessage="Aucun template trouvé"
|
||||
header={
|
||||
<div className="flex justify-content-between align-items-center">
|
||||
<span className="text-xl font-bold">Liste des Templates</span>
|
||||
<span className="text-sm text-600">{templates.length} template(s)</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Column field="nom" header="Nom" sortable />
|
||||
<Column field="description" header="Description" />
|
||||
<Column
|
||||
field="categorie"
|
||||
header="Catégorie"
|
||||
body={categorieBodyTemplate}
|
||||
sortable
|
||||
/>
|
||||
<Column
|
||||
field="lignes"
|
||||
header="Nb Lignes"
|
||||
body={(rowData) => rowData.lignes?.length || 0}
|
||||
style={{ width: '100px' }}
|
||||
/>
|
||||
<Column
|
||||
field="utilisations"
|
||||
header="Utilisations"
|
||||
body={utilisationsBodyTemplate}
|
||||
sortable
|
||||
style={{ width: '120px' }}
|
||||
/>
|
||||
<Column
|
||||
field="actif"
|
||||
header="Statut"
|
||||
body={statutBodyTemplate}
|
||||
style={{ width: '100px' }}
|
||||
/>
|
||||
<Column
|
||||
field="dateCreation"
|
||||
header="Créé le"
|
||||
body={(rowData) => formatDate(rowData.dateCreation)}
|
||||
sortable
|
||||
style={{ width: '120px' }}
|
||||
/>
|
||||
<Column
|
||||
header="Actions"
|
||||
body={actionBodyTemplate}
|
||||
style={{ width: '120px' }}
|
||||
/>
|
||||
</DataTable>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Dialog de création/modification */}
|
||||
<Dialog
|
||||
header={editingTemplate ? "Modifier le template" : "Nouveau template"}
|
||||
visible={showDialog}
|
||||
onHide={() => setShowDialog(false)}
|
||||
style={{ width: '800px' }}
|
||||
footer={
|
||||
<div className="flex justify-content-end gap-2">
|
||||
<Button
|
||||
label="Annuler"
|
||||
icon="pi pi-times"
|
||||
className="p-button-outlined"
|
||||
onClick={() => setShowDialog(false)}
|
||||
/>
|
||||
<Button
|
||||
label="Enregistrer"
|
||||
icon="pi pi-save"
|
||||
onClick={handleSave}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid">
|
||||
<div className="col-12 md:col-6">
|
||||
<div className="field">
|
||||
<label htmlFor="nom" className="font-semibold">Nom *</label>
|
||||
<InputText
|
||||
id="nom"
|
||||
value={formData.nom}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, nom: e.target.value }))}
|
||||
className="w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12 md:col-6">
|
||||
<div className="field">
|
||||
<label htmlFor="categorie" className="font-semibold">Catégorie *</label>
|
||||
<Dropdown
|
||||
id="categorie"
|
||||
value={formData.categorie}
|
||||
options={categorieOptions}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, categorie: e.value }))}
|
||||
className="w-full"
|
||||
placeholder="Sélectionner une catégorie"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12">
|
||||
<div className="field">
|
||||
<label htmlFor="description" className="font-semibold">Description</label>
|
||||
<InputTextarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="w-full"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12">
|
||||
<h6>Prestations du template</h6>
|
||||
<p className="text-sm text-600 mb-3">
|
||||
Les prestations seront automatiquement ajoutées lors de l'utilisation du template.
|
||||
</p>
|
||||
|
||||
{formData.lignes && formData.lignes.length > 0 ? (
|
||||
<DataTable value={formData.lignes} responsiveLayout="scroll">
|
||||
<Column field="designation" header="Désignation" />
|
||||
<Column field="quantite" header="Quantité" style={{ width: '100px' }} />
|
||||
<Column field="unite" header="Unité" style={{ width: '80px' }} />
|
||||
<Column
|
||||
field="prixUnitaire"
|
||||
header="Prix unitaire"
|
||||
style={{ width: '120px' }}
|
||||
body={(rowData) => `${rowData.prixUnitaire}€`}
|
||||
/>
|
||||
</DataTable>
|
||||
) : (
|
||||
<div className="text-center p-4 border-2 border-dashed border-300 border-round">
|
||||
<i className="pi pi-inbox text-4xl text-400 mb-3"></i>
|
||||
<p className="text-600">Aucune prestation définie</p>
|
||||
<Button
|
||||
label="Ajouter des prestations"
|
||||
icon="pi pi-plus"
|
||||
className="p-button-outlined p-button-sm"
|
||||
onClick={() => {
|
||||
// TODO: Ouvrir dialog pour ajouter des prestations
|
||||
toast.current?.show({
|
||||
severity: 'info',
|
||||
summary: 'Info',
|
||||
detail: 'Fonctionnalité en cours de développement'
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DevisTemplatesPage;
|
||||
Reference in New Issue
Block a user