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:
parent
c7c2a3f56c
commit
467696e5b8
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
import { useState } from 'react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import Button from '@/components/Button';
|
||||
import { API_BASE_URL } from '@/utils/constants';
|
||||
import { apiFetch } from '@/hooks/useApi';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
Search,
|
||||
|
|
@ -63,26 +64,10 @@ export default function GainsClientPage() {
|
|||
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
|
||||
const queryParam = searchType === 'email' ? `email=${encodeURIComponent(searchValue)}` : `phone=${encodeURIComponent(searchValue)}`;
|
||||
|
||||
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();
|
||||
const data = await apiFetch<{ data: ClientData }>(`/employee/client-prizes?${queryParam}`);
|
||||
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) {
|
||||
console.error('Error searching client:', error);
|
||||
toast.error(error.message || 'Erreur lors de la recherche');
|
||||
|
|
@ -99,30 +84,11 @@ export default function GainsClientPage() {
|
|||
|
||||
setValidatingTicketId(ticketId);
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/employee/validate-ticket`,
|
||||
{
|
||||
method: 'POST',
|
||||
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
|
||||
await apiFetch('/employee/validate-ticket', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ticketId, action: 'APPROVE' }),
|
||||
});
|
||||
toast.success('Lot marqué comme remis!');
|
||||
handleSearch();
|
||||
} catch (error: any) {
|
||||
console.error('Error validating prize:', error);
|
||||
|
|
@ -282,10 +248,10 @@ export default function GainsClientPage() {
|
|||
</h2>
|
||||
|
||||
{clientData.prizes.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
||||
<Gift className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600">Ce client n'a pas encore gagné de lots</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="🎁"
|
||||
message="Ce client n'a pas encore gagné de lots"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{clientData.prizes.map((prize) => (
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '@/hooks';
|
||||
import { Card } from '@/components/ui';
|
||||
import { Card, EmptyState } from '@/components/ui';
|
||||
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 {
|
||||
CheckCircle,
|
||||
|
|
@ -45,23 +45,7 @@ export default function EmployeeHistoryPage() {
|
|||
const loadHistory = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('auth_token') || localStorage.getItem('token');
|
||||
|
||||
// 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();
|
||||
const data = await apiFetch<{ data: HistoryTicket[] }>('/employee/history');
|
||||
setHistory(data.data || []);
|
||||
} catch (error: any) {
|
||||
console.error('Error loading history:', error);
|
||||
|
|
@ -209,17 +193,13 @@ export default function EmployeeHistoryPage() {
|
|||
{/* History List */}
|
||||
<Card className="p-6">
|
||||
{filteredHistory.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<div className="text-6xl mb-4">📝</div>
|
||||
<p className="text-gray-600 mb-2 font-medium">
|
||||
Aucun ticket dans l'historique
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{filter === 'ALL'
|
||||
? 'Vous n\'avez pas encore validé de tickets'
|
||||
: `Aucun ticket ${filter === 'CLAIMED' ? 'validé' : 'rejeté'}`}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="📝"
|
||||
title="Aucun ticket dans l'historique"
|
||||
message={filter === 'ALL'
|
||||
? 'Vous n\'avez pas encore validé de tickets'
|
||||
: `Aucun ticket ${filter === 'CLAIMED' ? 'validé' : 'rejeté'}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredHistory.map((ticket) => (
|
||||
|
|
|
|||
41
components/ui/EmptyState.tsx
Normal file
41
components/ui/EmptyState.tsx
Normal 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;
|
||||
121
components/ui/Pagination.tsx
Normal file
121
components/ui/Pagination.tsx
Normal 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;
|
||||
|
|
@ -7,3 +7,5 @@ export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '.
|
|||
export { LoadingState } from './LoadingState';
|
||||
export { ErrorState } from './ErrorState';
|
||||
export { StatusBadge, getRoleBadgeColor, getTicketStatusColor, getStatusColor } from './StatusBadge';
|
||||
export { EmptyState } from './EmptyState';
|
||||
export { Pagination } from './Pagination';
|
||||
|
|
|
|||
80
utils/export.ts
Normal file
80
utils/export.ts
Normal 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',
|
||||
});
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user