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 { 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) => (
|
||||||
|
|
|
||||||
|
|
@ -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) => (
|
||||||
|
|
|
||||||
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 { 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
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