refactor: add more shared utilities and reduce duplication further

- Add utils/export.ts for centralized CSV export functionality
- Add EmptyState component for consistent empty state UI
- Add Pagination component for reusable pagination controls
- Refactor employe/gains-client to use apiFetch and EmptyState
- Refactor employe/historique to use apiFetch and EmptyState
- Export new components from ui/index.ts

Target: reduce duplication from 13.93% to under 3%

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
soufiane 2025-11-30 23:39:01 +01:00
parent c7c2a3f56c
commit 467696e5b8
6 changed files with 267 additions and 77 deletions

View File

@ -2,8 +2,9 @@
import { useState } from 'react'; import { useState } from 'react';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { EmptyState } from '@/components/ui/EmptyState';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { API_BASE_URL } from '@/utils/constants'; import { apiFetch } from '@/hooks/useApi';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { import {
Search, Search,
@ -63,26 +64,10 @@ export default function GainsClientPage() {
setLoading(true); setLoading(true);
try { try {
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
const queryParam = searchType === 'email' ? `email=${encodeURIComponent(searchValue)}` : `phone=${encodeURIComponent(searchValue)}`; const queryParam = searchType === 'email' ? `email=${encodeURIComponent(searchValue)}` : `phone=${encodeURIComponent(searchValue)}`;
const data = await apiFetch<{ data: ClientData }>(`/employee/client-prizes?${queryParam}`);
const response = await fetch(
`${API_BASE_URL}/employee/client-prizes?${queryParam}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Client non trouvé');
}
const data = await response.json();
setClientData(data.data); setClientData(data.data);
toast.success(`Client trouvé: ${data.data.client.firstName} ${data.data.client.lastName}`); toast.success(`Client trouvé: ${data.data.client.firstName} ${data.data.client.lastName}`);
} catch (error: any) { } catch (error: any) {
console.error('Error searching client:', error); console.error('Error searching client:', error);
toast.error(error.message || 'Erreur lors de la recherche'); toast.error(error.message || 'Erreur lors de la recherche');
@ -99,30 +84,11 @@ export default function GainsClientPage() {
setValidatingTicketId(ticketId); setValidatingTicketId(ticketId);
try { try {
const token = localStorage.getItem('auth_token') || localStorage.getItem('token'); await apiFetch('/employee/validate-ticket', {
const response = await fetch( method: 'POST',
`${API_BASE_URL}/employee/validate-ticket`, body: JSON.stringify({ ticketId, action: 'APPROVE' }),
{ });
method: 'POST', toast.success('Lot marqué comme remis!');
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
ticketId,
action: 'APPROVE',
}),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erreur lors de la validation');
}
toast.success('✅ Lot marqué comme remis!');
// Recharger les données du client
handleSearch(); handleSearch();
} catch (error: any) { } catch (error: any) {
console.error('Error validating prize:', error); console.error('Error validating prize:', error);
@ -282,10 +248,10 @@ export default function GainsClientPage() {
</h2> </h2>
{clientData.prizes.length === 0 ? ( {clientData.prizes.length === 0 ? (
<div className="text-center py-12 bg-gray-50 rounded-lg"> <EmptyState
<Gift className="w-16 h-16 text-gray-400 mx-auto mb-4" /> icon="🎁"
<p className="text-gray-600">Ce client n'a pas encore gagné de lots</p> message="Ce client n'a pas encore gagné de lots"
</div> />
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{clientData.prizes.map((prize) => ( {clientData.prizes.map((prize) => (

View File

@ -2,9 +2,9 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useAuth } from '@/hooks'; import { useAuth } from '@/hooks';
import { Card } from '@/components/ui'; import { Card, EmptyState } from '@/components/ui';
import { Loading } from '@/components/ui/Loading'; import { Loading } from '@/components/ui/Loading';
import { API_BASE_URL } from '@/utils/constants'; import { apiFetch } from '@/hooks/useApi';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { import {
CheckCircle, CheckCircle,
@ -45,23 +45,7 @@ export default function EmployeeHistoryPage() {
const loadHistory = async () => { const loadHistory = async () => {
try { try {
setLoading(true); setLoading(true);
const token = localStorage.getItem('auth_token') || localStorage.getItem('token'); const data = await apiFetch<{ data: HistoryTicket[] }>('/employee/history');
// Charger tous les tickets validés par cet employé
const response = await fetch(
`${API_BASE_URL}/employee/history`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
throw new Error('Erreur lors du chargement de l\'historique');
}
const data = await response.json();
setHistory(data.data || []); setHistory(data.data || []);
} catch (error: any) { } catch (error: any) {
console.error('Error loading history:', error); console.error('Error loading history:', error);
@ -209,17 +193,13 @@ export default function EmployeeHistoryPage() {
{/* History List */} {/* History List */}
<Card className="p-6"> <Card className="p-6">
{filteredHistory.length === 0 ? ( {filteredHistory.length === 0 ? (
<div className="text-center py-16"> <EmptyState
<div className="text-6xl mb-4">📝</div> icon="📝"
<p className="text-gray-600 mb-2 font-medium"> title="Aucun ticket dans l'historique"
Aucun ticket dans l'historique message={filter === 'ALL'
</p> ? 'Vous n\'avez pas encore validé de tickets'
<p className="text-sm text-gray-500"> : `Aucun ticket ${filter === 'CLAIMED' ? 'validé' : 'rejeté'}`}
{filter === 'ALL' />
? 'Vous n\'avez pas encore validé de tickets'
: `Aucun ticket ${filter === 'CLAIMED' ? 'validé' : 'rejeté'}`}
</p>
</div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{filteredHistory.map((ticket) => ( {filteredHistory.map((ticket) => (

View File

@ -0,0 +1,41 @@
'use client';
import React from 'react';
interface EmptyStateProps {
icon?: string;
title?: string;
message: string;
action?: {
label: string;
onClick: () => void;
};
}
/**
* Reusable empty state component for lists and tables
*/
export const EmptyState: React.FC<EmptyStateProps> = ({
icon = '📝',
title,
message,
action,
}) => {
return (
<div className="text-center py-16">
<div className="text-6xl mb-4">{icon}</div>
{title && <h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>}
<p className="text-gray-600 mb-4">{message}</p>
{action && (
<button
onClick={action.onClick}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium px-4 py-2 rounded-lg transition"
>
{action.label}
</button>
)}
</div>
);
};
export default EmptyState;

View File

@ -0,0 +1,121 @@
'use client';
import React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
showPageNumbers?: boolean;
className?: string;
}
/**
* Reusable pagination component
*/
export const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange,
showPageNumbers = true,
className = '',
}) => {
if (totalPages <= 1) return null;
const handlePrevious = () => {
if (currentPage > 1) {
onPageChange(currentPage - 1);
}
};
const handleNext = () => {
if (currentPage < totalPages) {
onPageChange(currentPage + 1);
}
};
// Generate page numbers to show
const getPageNumbers = () => {
const pages: (number | string)[] = [];
const maxVisible = 5;
if (totalPages <= maxVisible) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage <= 3) {
for (let i = 1; i <= 4; i++) pages.push(i);
pages.push('...');
pages.push(totalPages);
} else if (currentPage >= totalPages - 2) {
pages.push(1);
pages.push('...');
for (let i = totalPages - 3; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
pages.push('...');
for (let i = currentPage - 1; i <= currentPage + 1; i++) pages.push(i);
pages.push('...');
pages.push(totalPages);
}
}
return pages;
};
return (
<div className={`flex items-center justify-center gap-2 ${className}`}>
<button
onClick={handlePrevious}
disabled={currentPage === 1}
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
Précédent
</button>
{showPageNumbers && (
<div className="flex items-center gap-1">
{getPageNumbers().map((page, index) =>
page === '...' ? (
<span key={`ellipsis-${index}`} className="px-2 text-gray-500">
...
</span>
) : (
<button
key={page}
onClick={() => onPageChange(page as number)}
className={`px-3 py-2 text-sm font-medium rounded-lg ${
currentPage === page
? 'bg-blue-600 text-white'
: 'text-gray-700 bg-white border border-gray-300 hover:bg-gray-50'
}`}
>
{page}
</button>
)
)}
</div>
)}
{!showPageNumbers && (
<span className="text-sm text-gray-600">
Page {currentPage} sur {totalPages}
</span>
)}
<button
onClick={handleNext}
disabled={currentPage === totalPages}
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Suivant
<ChevronRight className="w-4 h-4" />
</button>
</div>
);
};
export default Pagination;

View File

@ -7,3 +7,5 @@ export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '.
export { LoadingState } from './LoadingState'; export { LoadingState } from './LoadingState';
export { ErrorState } from './ErrorState'; export { ErrorState } from './ErrorState';
export { StatusBadge, getRoleBadgeColor, getTicketStatusColor, getStatusColor } from './StatusBadge'; export { StatusBadge, getRoleBadgeColor, getTicketStatusColor, getStatusColor } from './StatusBadge';
export { EmptyState } from './EmptyState';
export { Pagination } from './Pagination';

80
utils/export.ts Normal file
View File

@ -0,0 +1,80 @@
/**
* Utility functions for data export
*/
type CsvRow = (string | number | boolean | null | undefined)[];
/**
* Export data to CSV file
*/
export const exportToCSV = (
data: CsvRow[],
filename: string,
options?: { separator?: string; includeDate?: boolean }
): void => {
const { separator = ',', includeDate = true } = options || {};
const csvContent = data
.map((row) =>
row
.map((cell) => {
if (cell === null || cell === undefined) return '';
const cellStr = String(cell);
// Escape quotes and wrap in quotes if contains separator or quotes
if (cellStr.includes(separator) || cellStr.includes('"') || cellStr.includes('\n')) {
return `"${cellStr.replace(/"/g, '""')}"`;
}
return cellStr;
})
.join(separator)
)
.join('\n');
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
const date = includeDate ? `-${new Date().toISOString().split('T')[0]}` : '';
link.href = url;
link.download = `${filename}${date}.csv`;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
/**
* Export data to JSON file
*/
export const exportToJSON = <T>(data: T, filename: string): void => {
const jsonContent = JSON.stringify(data, null, 2);
const blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.href = url;
link.download = `${filename}-${new Date().toISOString().split('T')[0]}.json`;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
/**
* Format date for export
*/
export const formatDateForExport = (date: string | Date | null | undefined): string => {
if (!date) return '';
const d = new Date(date);
return d.toLocaleDateString('fr-FR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};