Spaces:
Running
Running
| import { useState } from 'react'; | |
| import { useTranslation } from 'react-i18next'; | |
| import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; | |
| import { | |
| MapPin, | |
| Search, | |
| Plus, | |
| Pencil, | |
| Trash2, | |
| Check, | |
| X, | |
| ChevronLeft, | |
| ChevronRight, | |
| } from 'lucide-react'; | |
| import { Input } from '@/components/ui/input'; | |
| import { Button } from '@/components/ui/button'; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from '@/components/ui/select'; | |
| import { | |
| Table, | |
| TableBody, | |
| TableCell, | |
| TableHead, | |
| TableHeader, | |
| TableRow, | |
| } from '@/components/ui/table'; | |
| import { cn } from '@/lib/utils'; | |
| import { api } from '@/services/api'; | |
| import { toast } from 'sonner'; | |
| interface BranchData { | |
| id: number; | |
| email: string; | |
| branch: string; | |
| active: boolean; | |
| updatedAt: string; | |
| } | |
| const BRANCH_COLORS = [ | |
| 'bg-blue-100 text-blue-600', | |
| 'bg-indigo-100 text-indigo-600', | |
| 'bg-purple-100 text-purple-600', | |
| 'bg-teal-100 text-teal-600', | |
| 'bg-amber-100 text-amber-600', | |
| 'bg-pink-100 text-pink-600', | |
| 'bg-emerald-100 text-emerald-600', | |
| 'bg-cyan-100 text-cyan-600', | |
| ]; | |
| function getBranchColor(name: string): string { | |
| let hash = 0; | |
| for (let i = 0; i < name.length; i++) { | |
| hash = name.charCodeAt(i) + ((hash << 5) - hash); | |
| } | |
| return BRANCH_COLORS[Math.abs(hash) % BRANCH_COLORS.length]; | |
| } | |
| const PER_PAGE = 100; | |
| export default function BranchTable() { | |
| const { t } = useTranslation(); | |
| const queryClient = useQueryClient(); | |
| const [search, setSearch] = useState(''); | |
| const [statusFilter, setStatusFilter] = useState('all'); | |
| const [currentPage, setCurrentPage] = useState(1); | |
| // Add form state | |
| const [showAddForm, setShowAddForm] = useState(false); | |
| const [addEmail, setAddEmail] = useState(''); | |
| const [addBranch, setAddBranch] = useState(''); | |
| // Edit state | |
| const [editingId, setEditingId] = useState<number | null>(null); | |
| const [editEmail, setEditEmail] = useState(''); | |
| const [editBranch, setEditBranch] = useState(''); | |
| // Delete confirmation state | |
| const [deletingId, setDeletingId] = useState<number | null>(null); | |
| // Fetch branches | |
| const { data, isLoading, isError } = useQuery({ | |
| queryKey: ['branches'], | |
| queryFn: () => api.get<{ branches: BranchData[] }>('/settings/branches'), | |
| retry: false, | |
| }); | |
| const allBranches = data?.branches ?? []; | |
| // Filter branches | |
| const filtered = allBranches.filter((b) => { | |
| const matchesSearch = | |
| !search || | |
| b.email.toLowerCase().includes(search.toLowerCase()) || | |
| b.branch.toLowerCase().includes(search.toLowerCase()); | |
| const matchesStatus = | |
| statusFilter === 'all' || | |
| (statusFilter === 'active' && b.active) || | |
| (statusFilter === 'inactive' && !b.active); | |
| return matchesSearch && matchesStatus; | |
| }); | |
| const totalPages = Math.ceil(filtered.length / PER_PAGE); | |
| const paginated = filtered.slice( | |
| (currentPage - 1) * PER_PAGE, | |
| currentPage * PER_PAGE | |
| ); | |
| const startItem = filtered.length > 0 ? (currentPage - 1) * PER_PAGE + 1 : 0; | |
| const endItem = Math.min(currentPage * PER_PAGE, filtered.length); | |
| // Mutations | |
| const addMutation = useMutation({ | |
| mutationFn: (data: { email: string; branch: string }) => | |
| api.post('/settings/branches', data), | |
| onSuccess: () => { | |
| queryClient.invalidateQueries({ queryKey: ['branches'] }); | |
| setShowAddForm(false); | |
| setAddEmail(''); | |
| setAddBranch(''); | |
| toast.success('Succursale ajoutée'); | |
| }, | |
| onError: (err: Error) => { | |
| toast.error(err.message); | |
| }, | |
| }); | |
| const updateMutation = useMutation({ | |
| mutationFn: ({ id, ...data }: { id: number; email?: string; branch?: string; active?: boolean }) => | |
| api.put(`/settings/branches/${id}`, data), | |
| onSuccess: () => { | |
| queryClient.invalidateQueries({ queryKey: ['branches'] }); | |
| setEditingId(null); | |
| toast.success('Succursale mise à jour'); | |
| }, | |
| onError: (err: Error) => { | |
| toast.error(err.message); | |
| }, | |
| }); | |
| const deleteMutation = useMutation({ | |
| mutationFn: (id: number) => api.delete(`/settings/branches/${id}`), | |
| onSuccess: () => { | |
| queryClient.invalidateQueries({ queryKey: ['branches'] }); | |
| setDeletingId(null); | |
| toast.success('Succursale supprimée'); | |
| }, | |
| onError: (err: Error) => { | |
| toast.error(err.message); | |
| }, | |
| }); | |
| const handleAdd = () => { | |
| if (!addEmail || !addBranch) return; | |
| addMutation.mutate({ email: addEmail, branch: addBranch }); | |
| }; | |
| const startEdit = (b: BranchData) => { | |
| setEditingId(b.id); | |
| setEditEmail(b.email); | |
| setEditBranch(b.branch); | |
| }; | |
| const handleEdit = () => { | |
| if (editingId === null || !editEmail || !editBranch) return; | |
| updateMutation.mutate({ id: editingId, email: editEmail, branch: editBranch }); | |
| }; | |
| const toggleActive = (b: BranchData) => { | |
| updateMutation.mutate({ id: b.id, active: !b.active }); | |
| }; | |
| return ( | |
| <div className="flex flex-col gap-6"> | |
| {/* Search & Filters */} | |
| <div className="flex flex-col gap-4 rounded-xl bg-card p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between border border-border"> | |
| <div className="relative w-full max-w-md"> | |
| <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> | |
| <Search className="h-4 w-4 text-muted-foreground" /> | |
| </div> | |
| <Input | |
| className="pl-10 bg-slate-50 border-slate-300" | |
| placeholder={t('branches.searchPlaceholder')} | |
| value={search} | |
| onChange={(e) => { | |
| setSearch(e.target.value); | |
| setCurrentPage(1); | |
| }} | |
| /> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <div className="flex items-center gap-2"> | |
| <label className="text-sm font-medium text-slate-700" htmlFor="status-filter"> | |
| {t('branches.statusLabel')}: | |
| </label> | |
| <Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setCurrentPage(1); }}> | |
| <SelectTrigger className="w-[130px] bg-slate-50 border-slate-300"> | |
| <SelectValue /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="all">{t('branches.statusAll')}</SelectItem> | |
| <SelectItem value="active">{t('branches.statusActive')}</SelectItem> | |
| <SelectItem value="inactive">{t('branches.statusInactive')}</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| <Button | |
| size="sm" | |
| className="gap-2" | |
| onClick={() => setShowAddForm(!showAddForm)} | |
| > | |
| <Plus className="h-4 w-4" /> | |
| {t('branches.addBranch')} | |
| </Button> | |
| </div> | |
| </div> | |
| {/* Add form */} | |
| {showAddForm && ( | |
| <div className="flex items-end gap-3 rounded-xl bg-blue-50 border border-blue-200 p-4"> | |
| <div className="flex-1 space-y-1"> | |
| <label className="text-xs font-semibold text-slate-600 uppercase tracking-wide"> | |
| {t('branches.colEmail')} | |
| </label> | |
| <Input | |
| placeholder="email@iccameriques.org" | |
| value={addEmail} | |
| onChange={(e) => setAddEmail(e.target.value)} | |
| className="bg-white" | |
| /> | |
| </div> | |
| <div className="flex-1 space-y-1"> | |
| <label className="text-xs font-semibold text-slate-600 uppercase tracking-wide"> | |
| {t('branches.colBranch')} | |
| </label> | |
| <Input | |
| placeholder="Ville" | |
| value={addBranch} | |
| onChange={(e) => setAddBranch(e.target.value)} | |
| className="bg-white" | |
| /> | |
| </div> | |
| <Button onClick={handleAdd} disabled={addMutation.isPending} className="gap-2"> | |
| <Check className="h-4 w-4" /> | |
| {t('common.save')} | |
| </Button> | |
| <Button | |
| variant="outline" | |
| onClick={() => { setShowAddForm(false); setAddEmail(''); setAddBranch(''); }} | |
| > | |
| {t('common.cancel')} | |
| </Button> | |
| </div> | |
| )} | |
| {/* Table */} | |
| <div className="overflow-hidden rounded-xl border border-border bg-card shadow-sm"> | |
| <div className="overflow-x-auto"> | |
| <Table> | |
| <TableHeader> | |
| <TableRow className="bg-slate-50"> | |
| <TableHead className="font-semibold text-xs uppercase text-slate-700"> | |
| {t('branches.colEmail')} | |
| </TableHead> | |
| <TableHead className="font-semibold text-xs uppercase text-slate-700"> | |
| {t('branches.colBranch')} | |
| </TableHead> | |
| <TableHead className="font-semibold text-xs uppercase text-slate-700"> | |
| {t('branches.colStatus')} | |
| </TableHead> | |
| <TableHead className="font-semibold text-xs uppercase text-slate-700 text-right"> | |
| {t('branches.colActions')} | |
| </TableHead> | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {isLoading ? ( | |
| Array.from({ length: 5 }).map((_, i) => ( | |
| <TableRow key={i} className="animate-pulse"> | |
| {Array.from({ length: 4 }).map((_, j) => ( | |
| <TableCell key={j} className="py-4"> | |
| <div className="h-4 bg-slate-100 rounded w-32" /> | |
| </TableCell> | |
| ))} | |
| </TableRow> | |
| )) | |
| ) : isError ? ( | |
| <TableRow> | |
| <TableCell colSpan={4} className="py-12 text-center text-red-500"> | |
| Veuillez vous connecter pour voir les succursales. | |
| </TableCell> | |
| </TableRow> | |
| ) : paginated.length === 0 ? ( | |
| <TableRow> | |
| <TableCell colSpan={4} className="py-16 text-center"> | |
| <div className="flex flex-col items-center gap-3"> | |
| <div className="h-12 w-12 rounded-xl bg-slate-100 flex items-center justify-center"> | |
| <MapPin className="h-6 w-6 text-slate-400" /> | |
| </div> | |
| <div> | |
| <p className="text-sm font-medium text-slate-700"> | |
| {search ? t('transactions.noResults') : t('branches.emptyTitle', 'No branches configured yet')} | |
| </p> | |
| <p className="text-xs text-slate-500 mt-1"> | |
| {search ? '' : t('branches.emptyDesc', 'Add a branch to map recipient emails to branch names')} | |
| </p> | |
| </div> | |
| {!search && !showAddForm && ( | |
| <Button size="sm" className="gap-2 mt-2" onClick={() => setShowAddForm(true)}> | |
| <Plus className="h-4 w-4" /> | |
| {t('branches.addBranch')} | |
| </Button> | |
| )} | |
| </div> | |
| </TableCell> | |
| </TableRow> | |
| ) : ( | |
| paginated.map((branch) => ( | |
| <TableRow | |
| key={branch.id} | |
| className="bg-white hover:bg-slate-50 transition-colors" | |
| > | |
| {editingId === branch.id ? ( | |
| <> | |
| <TableCell> | |
| <Input | |
| value={editEmail} | |
| onChange={(e) => setEditEmail(e.target.value)} | |
| className="h-8 text-sm" | |
| /> | |
| </TableCell> | |
| <TableCell> | |
| <Input | |
| value={editBranch} | |
| onChange={(e) => setEditBranch(e.target.value)} | |
| className="h-8 text-sm" | |
| /> | |
| </TableCell> | |
| <TableCell> | |
| <button | |
| onClick={() => toggleActive(branch)} | |
| className="cursor-pointer" | |
| > | |
| {branch.active ? ( | |
| <span className="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800"> | |
| <span className="mr-1.5 h-1.5 w-1.5 rounded-full bg-emerald-500" /> | |
| {t('branches.statusActive')} | |
| </span> | |
| ) : ( | |
| <span className="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-600"> | |
| <span className="mr-1.5 h-1.5 w-1.5 rounded-full bg-slate-400" /> | |
| {t('branches.statusInactive')} | |
| </span> | |
| )} | |
| </button> | |
| </TableCell> | |
| <TableCell className="text-right"> | |
| <button | |
| onClick={handleEdit} | |
| className="rounded-lg p-2 text-emerald-600 hover:bg-emerald-50 transition-colors" | |
| > | |
| <Check className="h-4 w-4" /> | |
| </button> | |
| <button | |
| onClick={() => setEditingId(null)} | |
| className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 transition-colors" | |
| > | |
| <X className="h-4 w-4" /> | |
| </button> | |
| </TableCell> | |
| </> | |
| ) : ( | |
| <> | |
| <TableCell className="font-medium text-slate-900 whitespace-nowrap"> | |
| {branch.email} | |
| </TableCell> | |
| <TableCell> | |
| <div className="flex items-center gap-2"> | |
| <div | |
| className={cn( | |
| 'flex h-8 w-8 items-center justify-center rounded-full', | |
| getBranchColor(branch.branch) | |
| )} | |
| > | |
| <MapPin className="h-3.5 w-3.5" /> | |
| </div> | |
| <span className="text-slate-600">{branch.branch}</span> | |
| </div> | |
| </TableCell> | |
| <TableCell> | |
| <button | |
| onClick={() => toggleActive(branch)} | |
| className="cursor-pointer" | |
| > | |
| {branch.active ? ( | |
| <span className="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800 hover:bg-emerald-200 transition-colors"> | |
| <span className="mr-1.5 h-1.5 w-1.5 rounded-full bg-emerald-500" /> | |
| {t('branches.statusActive')} | |
| </span> | |
| ) : ( | |
| <span className="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-600 hover:bg-slate-200 transition-colors"> | |
| <span className="mr-1.5 h-1.5 w-1.5 rounded-full bg-slate-400" /> | |
| {t('branches.statusInactive')} | |
| </span> | |
| )} | |
| </button> | |
| </TableCell> | |
| <TableCell className="text-right"> | |
| {deletingId === branch.id ? ( | |
| <> | |
| <Button | |
| size="sm" | |
| variant="outline" | |
| className="h-8 text-xs border-red-300 text-red-700 hover:bg-red-50 mr-1" | |
| onClick={() => deleteMutation.mutate(branch.id)} | |
| disabled={deleteMutation.isPending} | |
| > | |
| Confirmer | |
| </Button> | |
| <Button | |
| size="sm" | |
| variant="outline" | |
| className="h-8 text-xs" | |
| onClick={() => setDeletingId(null)} | |
| > | |
| {t('common.cancel')} | |
| </Button> | |
| </> | |
| ) : ( | |
| <> | |
| <button | |
| onClick={() => startEdit(branch)} | |
| className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 hover:text-primary transition-colors" | |
| > | |
| <Pencil className="h-4 w-4" /> | |
| </button> | |
| <button | |
| onClick={() => setDeletingId(branch.id)} | |
| className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 hover:text-red-600 transition-colors" | |
| > | |
| <Trash2 className="h-4 w-4" /> | |
| </button> | |
| </> | |
| )} | |
| </TableCell> | |
| </> | |
| )} | |
| </TableRow> | |
| )) | |
| )} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| {/* Pagination */} | |
| {filtered.length > 0 && ( | |
| <div className="flex items-center justify-between border-t border-border bg-white px-4 py-3 sm:px-6"> | |
| <div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between"> | |
| <p className="text-sm text-slate-700"> | |
| {t('transactions.showing')}{' '} | |
| <span className="font-medium text-slate-900">{startItem}</span>{' '} | |
| {t('transactions.to')}{' '} | |
| <span className="font-medium text-slate-900">{endItem}</span>{' '} | |
| {t('transactions.of')}{' '} | |
| <span className="font-medium text-slate-900">{filtered.length}</span>{' '} | |
| {t('transactions.results')} | |
| </p> | |
| {totalPages > 1 && ( | |
| <nav className="isolate inline-flex -space-x-px rounded-md shadow-sm"> | |
| <button | |
| className="relative inline-flex items-center rounded-l-md px-2 py-2 text-slate-400 ring-1 ring-inset ring-slate-300 hover:bg-slate-50 disabled:opacity-50" | |
| disabled={currentPage === 1} | |
| onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} | |
| > | |
| <ChevronLeft className="h-4 w-4" /> | |
| </button> | |
| {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( | |
| <button | |
| key={page} | |
| className={cn( | |
| 'relative inline-flex items-center px-4 py-2 text-sm font-semibold', | |
| page === currentPage | |
| ? 'z-10 bg-primary text-white' | |
| : 'text-slate-900 ring-1 ring-inset ring-slate-300 hover:bg-slate-50' | |
| )} | |
| onClick={() => setCurrentPage(page)} | |
| > | |
| {page} | |
| </button> | |
| ))} | |
| <button | |
| className="relative inline-flex items-center rounded-r-md px-2 py-2 text-slate-400 ring-1 ring-inset ring-slate-300 hover:bg-slate-50 disabled:opacity-50" | |
| disabled={currentPage === totalPages} | |
| onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} | |
| > | |
| <ChevronRight className="h-4 w-4" /> | |
| </button> | |
| </nav> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |