Initial commit
This commit is contained in:
137
components/dashboard/AlertsWidget.tsx
Normal file
137
components/dashboard/AlertsWidget.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Composant widget d'alertes (factures en retard, devis expirant)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Message } from 'primereact/message';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Skeleton } from 'primereact/skeleton';
|
||||
import { FactureEnRetard, DevisEnAttente } from '../../types/btp';
|
||||
|
||||
interface AlertsWidgetProps {
|
||||
facturesEnRetard: FactureEnRetard[];
|
||||
devisEnAttente: DevisEnAttente[];
|
||||
loading?: boolean;
|
||||
onViewFacture?: (id: string) => void;
|
||||
onViewDevis?: (id: string) => void;
|
||||
}
|
||||
|
||||
const AlertsWidget: React.FC<AlertsWidgetProps> = ({
|
||||
facturesEnRetard,
|
||||
devisEnAttente,
|
||||
loading = false,
|
||||
onViewFacture,
|
||||
onViewDevis
|
||||
}) => {
|
||||
const getAlertSeverity = (jours: number) => {
|
||||
if (jours <= 3) return 'warn';
|
||||
if (jours <= 7) return 'info';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const header = (
|
||||
<div className="flex align-items-center">
|
||||
<i className="pi pi-exclamation-triangle text-orange-500 mr-2" />
|
||||
<h5 className="m-0">Alertes</h5>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex align-items-center mb-3">
|
||||
<Skeleton width="1.5rem" height="1.5rem" className="mr-2" />
|
||||
<Skeleton width="4rem" height="1.5rem" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="p-3 border-1 border-200 border-round">
|
||||
<Skeleton width="100%" height="1rem" className="mb-2" />
|
||||
<Skeleton width="60%" height="0.8rem" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const hasAlerts = facturesEnRetard.length > 0 || devisEnAttente.length > 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex align-items-center mb-3">
|
||||
<i className="pi pi-exclamation-triangle text-orange-500 mr-2" />
|
||||
<h5 className="m-0">Alertes</h5>
|
||||
</div>
|
||||
|
||||
{!hasAlerts ? (
|
||||
<Message
|
||||
severity="success"
|
||||
text="Aucune alerte pour le moment"
|
||||
className="w-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Factures en retard */}
|
||||
{facturesEnRetard.map((facture) => (
|
||||
<div
|
||||
key={facture.id}
|
||||
className="flex align-items-center justify-content-between p-3 border-1 border-red-200 border-round bg-red-50"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-900 mb-1">
|
||||
Facture {facture.numero} en retard
|
||||
</div>
|
||||
<div className="text-500 text-sm">
|
||||
{facture.client} • {facture.montantTTC.toLocaleString()} € •
|
||||
{facture.joursRetard} jour{facture.joursRetard > 1 ? 's' : ''} de retard
|
||||
</div>
|
||||
</div>
|
||||
{onViewFacture && (
|
||||
<Button
|
||||
icon="pi pi-eye"
|
||||
className="p-button-text p-button-sm"
|
||||
onClick={() => onViewFacture(facture.id)}
|
||||
tooltip="Voir la facture"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Devis expirant bientôt */}
|
||||
{devisEnAttente.map((devis) => (
|
||||
<div
|
||||
key={devis.id}
|
||||
className={`flex align-items-center justify-content-between p-3 border-1 border-round ${
|
||||
devis.joursRestants <= 3
|
||||
? 'border-red-200 bg-red-50'
|
||||
: 'border-orange-200 bg-orange-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-900 mb-1">
|
||||
Devis {devis.numero} expire bientôt
|
||||
</div>
|
||||
<div className="text-500 text-sm">
|
||||
{devis.client} • {devis.montantTTC.toLocaleString()} € •
|
||||
{devis.joursRestants} jour{devis.joursRestants > 1 ? 's' : ''} restant{devis.joursRestants > 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
{onViewDevis && (
|
||||
<Button
|
||||
icon="pi pi-eye"
|
||||
className="p-button-text p-button-sm"
|
||||
onClick={() => onViewDevis(devis.id)}
|
||||
tooltip="Voir le devis"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertsWidget;
|
||||
158
components/dashboard/ChantiersList.tsx
Normal file
158
components/dashboard/ChantiersList.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Composant liste des chantiers récents
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Skeleton } from 'primereact/skeleton';
|
||||
import { ChantierRecent, StatutChantier } from '../../types/btp';
|
||||
|
||||
interface ChantiersListProps {
|
||||
chantiers: ChantierRecent[];
|
||||
loading?: boolean;
|
||||
onViewAll?: () => void;
|
||||
}
|
||||
|
||||
const ChantiersList: React.FC<ChantiersListProps> = ({
|
||||
chantiers,
|
||||
loading = false,
|
||||
onViewAll
|
||||
}) => {
|
||||
const getStatutSeverity = (statut: StatutChantier) => {
|
||||
switch (statut) {
|
||||
case StatutChantier.EN_COURS:
|
||||
return 'info';
|
||||
case StatutChantier.PLANIFIE:
|
||||
return 'warning';
|
||||
case StatutChantier.TERMINE:
|
||||
return 'success';
|
||||
case StatutChantier.ANNULE:
|
||||
return 'danger';
|
||||
case StatutChantier.SUSPENDU:
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatutLabel = (statut: StatutChantier) => {
|
||||
switch (statut) {
|
||||
case StatutChantier.EN_COURS:
|
||||
return 'En cours';
|
||||
case StatutChantier.PLANIFIE:
|
||||
return 'Planifié';
|
||||
case StatutChantier.TERMINE:
|
||||
return 'Terminé';
|
||||
case StatutChantier.ANNULE:
|
||||
return 'Annulé';
|
||||
case StatutChantier.SUSPENDU:
|
||||
return 'Suspendu';
|
||||
default:
|
||||
return statut;
|
||||
}
|
||||
};
|
||||
|
||||
const statutBodyTemplate = (rowData: ChantierRecent) => {
|
||||
return (
|
||||
<Badge
|
||||
value={getStatutLabel(rowData.statut)}
|
||||
severity={getStatutSeverity(rowData.statut)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const montantBodyTemplate = (rowData: ChantierRecent) => {
|
||||
return rowData.montantPrevu
|
||||
? `${rowData.montantPrevu.toLocaleString()} €`
|
||||
: '-';
|
||||
};
|
||||
|
||||
const dateBodyTemplate = (rowData: ChantierRecent) => {
|
||||
return new Date(rowData.dateDebut).toLocaleDateString('fr-FR');
|
||||
};
|
||||
|
||||
const header = (
|
||||
<div className="flex align-items-center justify-content-between">
|
||||
<h5 className="m-0">Chantiers récents</h5>
|
||||
{onViewAll && (
|
||||
<Button
|
||||
label="Voir tout"
|
||||
icon="pi pi-external-link"
|
||||
className="p-button-text p-button-sm"
|
||||
onClick={onViewAll}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex align-items-center justify-content-between mb-3">
|
||||
<Skeleton width="8rem" height="1.5rem" />
|
||||
<Skeleton width="5rem" height="2rem" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex align-items-center justify-content-between p-3 border-1 border-200 border-round">
|
||||
<div className="flex-1">
|
||||
<Skeleton width="60%" height="1rem" className="mb-2" />
|
||||
<Skeleton width="40%" height="0.8rem" />
|
||||
</div>
|
||||
<Skeleton width="5rem" height="1.5rem" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<DataTable
|
||||
value={chantiers}
|
||||
header={header}
|
||||
responsiveLayout="scroll"
|
||||
showHeaders={false}
|
||||
emptyMessage="Aucun chantier récent"
|
||||
className="p-datatable-sm"
|
||||
>
|
||||
<Column
|
||||
field="nom"
|
||||
header="Nom"
|
||||
style={{ width: '35%' }}
|
||||
body={(rowData: ChantierRecent) => (
|
||||
<div>
|
||||
<div className="font-medium text-900">{rowData.nom}</div>
|
||||
<div className="text-500 text-sm">{rowData.client}</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Column
|
||||
field="statut"
|
||||
header="Statut"
|
||||
style={{ width: '25%' }}
|
||||
body={statutBodyTemplate}
|
||||
/>
|
||||
<Column
|
||||
field="dateDebut"
|
||||
header="Date"
|
||||
style={{ width: '20%' }}
|
||||
body={dateBodyTemplate}
|
||||
/>
|
||||
<Column
|
||||
field="montantPrevu"
|
||||
header="Montant"
|
||||
style={{ width: '20%' }}
|
||||
body={montantBodyTemplate}
|
||||
/>
|
||||
</DataTable>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChantiersList;
|
||||
100
components/dashboard/StatsCard.tsx
Normal file
100
components/dashboard/StatsCard.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Composant carte de statistiques pour le dashboard
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Badge } from 'primereact/badge';
|
||||
import { Skeleton } from 'primereact/skeleton';
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string;
|
||||
value: number | string;
|
||||
icon: string;
|
||||
color: 'primary' | 'success' | 'info' | 'warning' | 'danger';
|
||||
loading?: boolean;
|
||||
subtitle?: string;
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const StatsCard: React.FC<StatsCardProps> = ({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
color,
|
||||
loading = false,
|
||||
subtitle,
|
||||
trend
|
||||
}) => {
|
||||
const getColorClass = (color: string) => {
|
||||
switch (color) {
|
||||
case 'primary': return 'text-blue-500';
|
||||
case 'success': return 'text-green-500';
|
||||
case 'info': return 'text-cyan-500';
|
||||
case 'warning': return 'text-yellow-500';
|
||||
case 'danger': return 'text-red-500';
|
||||
default: return 'text-blue-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getBgClass = (color: string) => {
|
||||
switch (color) {
|
||||
case 'primary': return 'bg-blue-100';
|
||||
case 'success': return 'bg-green-100';
|
||||
case 'info': return 'bg-cyan-100';
|
||||
case 'warning': return 'bg-yellow-100';
|
||||
case 'danger': return 'bg-red-100';
|
||||
default: return 'bg-blue-100';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<div className="flex align-items-center justify-content-between mb-3">
|
||||
<Skeleton width="60%" height="1rem" />
|
||||
<Skeleton width="2rem" height="2rem" borderRadius="50%" />
|
||||
</div>
|
||||
<Skeleton width="40%" height="2rem" className="mb-2" />
|
||||
<Skeleton width="80%" height="1rem" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<div className="flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<div className="text-500 font-medium text-xl mb-2">{title}</div>
|
||||
<div className="text-900 font-bold text-2xl">
|
||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div className="text-500 text-sm mt-1">{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${getBgClass(color)} border-round-lg p-3`}>
|
||||
<i className={`${icon} ${getColorClass(color)} text-2xl`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{trend && (
|
||||
<div className="flex align-items-center">
|
||||
<Badge
|
||||
value={`${trend.isPositive ? '+' : ''}${trend.value}%`}
|
||||
severity={trend.isPositive ? 'success' : 'danger'}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-500 text-sm">
|
||||
{trend.isPositive ? 'Augmentation' : 'Diminution'} ce mois
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsCard;
|
||||
212
components/dashboard/__tests__/ChantiersList.test.tsx
Normal file
212
components/dashboard/__tests__/ChantiersList.test.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '../../../test-utils'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ChantiersList from '../ChantiersList'
|
||||
import { ChantierRecent, StatutChantier } from '../../../types/btp'
|
||||
|
||||
const mockChantiers: ChantierRecent[] = [
|
||||
{
|
||||
id: '1',
|
||||
nom: 'Rénovation Bureau',
|
||||
client: 'Entreprise ABC',
|
||||
statut: StatutChantier.EN_COURS,
|
||||
dateDebut: '2024-01-15',
|
||||
montantPrevu: 45000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
nom: 'Construction Garage',
|
||||
client: 'Client XYZ',
|
||||
statut: StatutChantier.PLANIFIE,
|
||||
dateDebut: '2024-02-01',
|
||||
montantPrevu: 25000,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
nom: 'Réparation Toiture',
|
||||
client: 'Mairie',
|
||||
statut: StatutChantier.TERMINE,
|
||||
dateDebut: '2024-01-01',
|
||||
montantPrevu: 15000,
|
||||
},
|
||||
]
|
||||
|
||||
describe('Composant ChantiersList', () => {
|
||||
const mockOnViewAll = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('devrait afficher la liste des chantiers', () => {
|
||||
render(<ChantiersList chantiers={mockChantiers} onViewAll={mockOnViewAll} />)
|
||||
|
||||
expect(screen.getByText('Chantiers récents')).toBeInTheDocument()
|
||||
expect(screen.getByText('Rénovation Bureau')).toBeInTheDocument()
|
||||
expect(screen.getByText('Construction Garage')).toBeInTheDocument()
|
||||
expect(screen.getByText('Réparation Toiture')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait afficher les informations détaillées de chaque chantier', () => {
|
||||
render(<ChantiersList chantiers={mockChantiers} onViewAll={mockOnViewAll} />)
|
||||
|
||||
// Vérifier les noms des clients
|
||||
expect(screen.getByText('Entreprise ABC')).toBeInTheDocument()
|
||||
expect(screen.getByText('Client XYZ')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mairie')).toBeInTheDocument()
|
||||
|
||||
// Vérifier les montants formatés
|
||||
expect(screen.getByText('45 000 €')).toBeInTheDocument()
|
||||
expect(screen.getByText('25 000 €')).toBeInTheDocument()
|
||||
expect(screen.getByText('15 000 €')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait afficher les statuts corrects avec les bonnes couleurs', () => {
|
||||
render(<ChantiersList chantiers={mockChantiers} onViewAll={mockOnViewAll} />)
|
||||
|
||||
expect(screen.getByText('En cours')).toBeInTheDocument()
|
||||
expect(screen.getByText('Planifié')).toBeInTheDocument()
|
||||
expect(screen.getByText('Terminé')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait formater les dates correctement', () => {
|
||||
render(<ChantiersList chantiers={mockChantiers} onViewAll={mockOnViewAll} />)
|
||||
|
||||
// Vérifier que les dates sont au format français
|
||||
expect(screen.getByText('15/01/2024')).toBeInTheDocument()
|
||||
expect(screen.getByText('01/02/2024')).toBeInTheDocument()
|
||||
expect(screen.getByText('01/01/2024')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait afficher le bouton "Voir tout" et le rendre cliquable', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChantiersList chantiers={mockChantiers} onViewAll={mockOnViewAll} />)
|
||||
|
||||
const viewAllButton = screen.getByText('Voir tout')
|
||||
expect(viewAllButton).toBeInTheDocument()
|
||||
|
||||
await user.click(viewAllButton)
|
||||
expect(mockOnViewAll).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ne devrait pas afficher le bouton "Voir tout" si onViewAll n\'est pas fourni', () => {
|
||||
render(<ChantiersList chantiers={mockChantiers} />)
|
||||
|
||||
expect(screen.queryByText('Voir tout')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait afficher un message quand il n\'y a pas de chantiers', () => {
|
||||
render(<ChantiersList chantiers={[]} onViewAll={mockOnViewAll} />)
|
||||
|
||||
expect(screen.getByText('Aucun chantier récent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait afficher l\'état de chargement', () => {
|
||||
render(<ChantiersList chantiers={[]} loading={true} />)
|
||||
|
||||
// Vérifier que le titre n'est pas affiché en mode chargement
|
||||
expect(screen.queryByText('Chantiers récents')).not.toBeInTheDocument()
|
||||
|
||||
// Vérifier la présence des skeletons
|
||||
const skeletons = document.querySelectorAll('[data-pc-name="skeleton"]')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('devrait gérer tous les statuts de chantier', () => {
|
||||
const chantiersAvecTousStatuts: ChantierRecent[] = [
|
||||
{
|
||||
id: '1',
|
||||
nom: 'Chantier 1',
|
||||
client: 'Client 1',
|
||||
statut: StatutChantier.EN_COURS,
|
||||
dateDebut: '2024-01-01',
|
||||
montantPrevu: 1000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
nom: 'Chantier 2',
|
||||
client: 'Client 2',
|
||||
statut: StatutChantier.PLANIFIE,
|
||||
dateDebut: '2024-01-01',
|
||||
montantPrevu: 2000,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
nom: 'Chantier 3',
|
||||
client: 'Client 3',
|
||||
statut: StatutChantier.TERMINE,
|
||||
dateDebut: '2024-01-01',
|
||||
montantPrevu: 3000,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
nom: 'Chantier 4',
|
||||
client: 'Client 4',
|
||||
statut: StatutChantier.ANNULE,
|
||||
dateDebut: '2024-01-01',
|
||||
montantPrevu: 4000,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
nom: 'Chantier 5',
|
||||
client: 'Client 5',
|
||||
statut: StatutChantier.SUSPENDU,
|
||||
dateDebut: '2024-01-01',
|
||||
montantPrevu: 5000,
|
||||
},
|
||||
]
|
||||
|
||||
render(<ChantiersList chantiers={chantiersAvecTousStatuts} />)
|
||||
|
||||
expect(screen.getByText('En cours')).toBeInTheDocument()
|
||||
expect(screen.getByText('Planifié')).toBeInTheDocument()
|
||||
expect(screen.getByText('Terminé')).toBeInTheDocument()
|
||||
expect(screen.getByText('Annulé')).toBeInTheDocument()
|
||||
expect(screen.getByText('Suspendu')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait gérer les montants manquants', () => {
|
||||
const chantiersAvecMontantManquant: ChantierRecent[] = [
|
||||
{
|
||||
id: '1',
|
||||
nom: 'Chantier sans montant',
|
||||
client: 'Client Test',
|
||||
statut: StatutChantier.EN_COURS,
|
||||
dateDebut: '2024-01-01',
|
||||
montantPrevu: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
render(<ChantiersList chantiers={chantiersAvecMontantManquant} />)
|
||||
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait avoir une structure de table responsive', () => {
|
||||
const { container } = render(<ChantiersList chantiers={mockChantiers} />)
|
||||
|
||||
const dataTable = container.querySelector('[data-pc-name="datatable"]')
|
||||
expect(dataTable).toBeInTheDocument()
|
||||
|
||||
// Vérifier que les en-têtes sont cachés
|
||||
expect(dataTable).toHaveAttribute('data-pc-section', 'wrapper')
|
||||
})
|
||||
|
||||
it('devrait formater correctement les gros montants', () => {
|
||||
const chantiersAvecGrosMontants: ChantierRecent[] = [
|
||||
{
|
||||
id: '1',
|
||||
nom: 'Gros Chantier',
|
||||
client: 'Client VIP',
|
||||
statut: StatutChantier.EN_COURS,
|
||||
dateDebut: '2024-01-01',
|
||||
montantPrevu: 1234567,
|
||||
},
|
||||
]
|
||||
|
||||
render(<ChantiersList chantiers={chantiersAvecGrosMontants} />)
|
||||
|
||||
// Vérifier que le montant est formaté avec des séparateurs
|
||||
expect(screen.getByText(/1[,\s]?234[,\s]?567 €/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
132
components/dashboard/__tests__/StatsCard.test.tsx
Normal file
132
components/dashboard/__tests__/StatsCard.test.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '../../../test-utils'
|
||||
import StatsCard from '../StatsCard'
|
||||
|
||||
describe('Composant StatsCard', () => {
|
||||
const defaultProps = {
|
||||
title: 'Projets actifs',
|
||||
value: 42,
|
||||
icon: 'pi pi-building',
|
||||
color: 'primary' as const,
|
||||
}
|
||||
|
||||
it('devrait afficher les informations de base', () => {
|
||||
render(<StatsCard {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('Projets actifs')).toBeInTheDocument()
|
||||
expect(screen.getByText('42')).toBeInTheDocument()
|
||||
expect(document.querySelector('.pi-building')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait formater les nombres avec des séparateurs', () => {
|
||||
render(<StatsCard {...defaultProps} value={1234567} />)
|
||||
|
||||
// Le format peut varier selon la locale
|
||||
expect(screen.getByText(/1[,\s]?234[,\s]?567/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait afficher les valeurs string directement', () => {
|
||||
render(<StatsCard {...defaultProps} value="50 000 €" />)
|
||||
|
||||
expect(screen.getByText('50 000 €')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait afficher le sous-titre si fourni', () => {
|
||||
render(<StatsCard {...defaultProps} subtitle="En cours ce mois" />)
|
||||
|
||||
expect(screen.getByText('En cours ce mois')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait afficher la tendance positive', () => {
|
||||
render(
|
||||
<StatsCard
|
||||
{...defaultProps}
|
||||
trend={{ value: 15, isPositive: true }}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('+15%')).toBeInTheDocument()
|
||||
expect(screen.getByText('Augmentation ce mois')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait afficher la tendance négative', () => {
|
||||
render(
|
||||
<StatsCard
|
||||
{...defaultProps}
|
||||
trend={{ value: 5, isPositive: false }}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('-5%')).toBeInTheDocument()
|
||||
expect(screen.getByText('Diminution ce mois')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('devrait appliquer la bonne couleur', () => {
|
||||
const colors = [
|
||||
{ color: 'primary' as const, textClass: 'text-blue-500', bgClass: 'bg-blue-100' },
|
||||
{ color: 'success' as const, textClass: 'text-green-500', bgClass: 'bg-green-100' },
|
||||
{ color: 'info' as const, textClass: 'text-cyan-500', bgClass: 'bg-cyan-100' },
|
||||
{ color: 'warning' as const, textClass: 'text-yellow-500', bgClass: 'bg-yellow-100' },
|
||||
{ color: 'danger' as const, textClass: 'text-red-500', bgClass: 'bg-red-100' },
|
||||
]
|
||||
|
||||
colors.forEach(({ color, textClass, bgClass }) => {
|
||||
const { container } = render(<StatsCard {...defaultProps} color={color} />)
|
||||
|
||||
const icon = container.querySelector(`.${defaultProps.icon.replace(' ', '.')}`)
|
||||
const iconContainer = icon?.parentElement
|
||||
|
||||
expect(icon).toHaveClass(textClass)
|
||||
expect(iconContainer).toHaveClass(bgClass)
|
||||
})
|
||||
})
|
||||
|
||||
it('devrait afficher l\'état de chargement', () => {
|
||||
render(<StatsCard {...defaultProps} loading={true} />)
|
||||
|
||||
// Vérifier la présence des skeletons
|
||||
expect(screen.queryByText('Projets actifs')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('42')).not.toBeInTheDocument()
|
||||
|
||||
// PrimeReact Skeleton crée des éléments avec data-pc-name="skeleton"
|
||||
const skeletons = document.querySelectorAll('[data-pc-name="skeleton"]')
|
||||
expect(skeletons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('devrait avoir une hauteur complète', () => {
|
||||
const { container } = render(<StatsCard {...defaultProps} />)
|
||||
|
||||
const card = container.querySelector('[data-pc-name="card"]')
|
||||
expect(card).toHaveClass('h-full')
|
||||
})
|
||||
|
||||
it('devrait gérer tous les types d\'icônes PrimeIcons', () => {
|
||||
const icons = ['pi pi-users', 'pi pi-euro', 'pi pi-exclamation-triangle']
|
||||
|
||||
icons.forEach(icon => {
|
||||
const { container } = render(<StatsCard {...defaultProps} icon={icon} />)
|
||||
const iconElement = container.querySelector(`.${icon.split(' ').join('.')}`)
|
||||
expect(iconElement).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('devrait afficher correctement avec toutes les props', () => {
|
||||
render(
|
||||
<StatsCard
|
||||
title="Chiffre d'affaires"
|
||||
value="125 450 €"
|
||||
icon="pi pi-euro"
|
||||
color="success"
|
||||
subtitle="Total ce mois"
|
||||
trend={{ value: 8, isPositive: true }}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText("Chiffre d'affaires")).toBeInTheDocument()
|
||||
expect(screen.getByText("125 450 €")).toBeInTheDocument()
|
||||
expect(screen.getByText("Total ce mois")).toBeInTheDocument()
|
||||
expect(screen.getByText("+8%")).toBeInTheDocument()
|
||||
expect(screen.getByText("Augmentation ce mois")).toBeInTheDocument()
|
||||
expect(document.querySelector('.pi-euro')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user