From 467696e5b835d3e5586647b5fd69aaa3ed4b73e5 Mon Sep 17 00:00:00 2001 From: soufiane Date: Sun, 30 Nov 2025 23:39:01 +0100 Subject: [PATCH] refactor: add more shared utilities and reduce duplication further MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/employe/gains-client/page.tsx | 60 ++++----------- app/employe/historique/page.tsx | 40 +++------- components/ui/EmptyState.tsx | 41 ++++++++++ components/ui/Pagination.tsx | 121 ++++++++++++++++++++++++++++++ components/ui/index.ts | 2 + utils/export.ts | 80 ++++++++++++++++++++ 6 files changed, 267 insertions(+), 77 deletions(-) create mode 100644 components/ui/EmptyState.tsx create mode 100644 components/ui/Pagination.tsx create mode 100644 utils/export.ts diff --git a/app/employe/gains-client/page.tsx b/app/employe/gains-client/page.tsx index cfdb5a9..280d790 100644 --- a/app/employe/gains-client/page.tsx +++ b/app/employe/gains-client/page.tsx @@ -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() { {clientData.prizes.length === 0 ? ( -
- -

Ce client n'a pas encore gagné de lots

-
+ ) : (
{clientData.prizes.map((prize) => ( diff --git a/app/employe/historique/page.tsx b/app/employe/historique/page.tsx index 9ccce6e..46b5b8d 100644 --- a/app/employe/historique/page.tsx +++ b/app/employe/historique/page.tsx @@ -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 */} {filteredHistory.length === 0 ? ( -
-
📝
-

- Aucun ticket dans l'historique -

-

- {filter === 'ALL' - ? 'Vous n\'avez pas encore validé de tickets' - : `Aucun ticket ${filter === 'CLAIMED' ? 'validé' : 'rejeté'}`} -

-
+ ) : (
{filteredHistory.map((ticket) => ( diff --git a/components/ui/EmptyState.tsx b/components/ui/EmptyState.tsx new file mode 100644 index 0000000..ab6abb9 --- /dev/null +++ b/components/ui/EmptyState.tsx @@ -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 = ({ + icon = '📝', + title, + message, + action, +}) => { + return ( +
+
{icon}
+ {title &&

{title}

} +

{message}

+ {action && ( + + )} +
+ ); +}; + +export default EmptyState; diff --git a/components/ui/Pagination.tsx b/components/ui/Pagination.tsx new file mode 100644 index 0000000..fdf5d6b --- /dev/null +++ b/components/ui/Pagination.tsx @@ -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 = ({ + 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 ( +
+ + + {showPageNumbers && ( +
+ {getPageNumbers().map((page, index) => + page === '...' ? ( + + ... + + ) : ( + + ) + )} +
+ )} + + {!showPageNumbers && ( + + Page {currentPage} sur {totalPages} + + )} + + +
+ ); +}; + +export default Pagination; diff --git a/components/ui/index.ts b/components/ui/index.ts index 9bdb93c..b9502c8 100644 --- a/components/ui/index.ts +++ b/components/ui/index.ts @@ -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'; diff --git a/utils/export.ts b/utils/export.ts new file mode 100644 index 0000000..5ba6a99 --- /dev/null +++ b/utils/export.ts @@ -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 = (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', + }); +};