Sage100/src/pages/crm/ClientsPage.tsx
2026-01-20 11:06:26 +03:00

613 lines
No EOL
22 KiB
TypeScript

import React, { useState, useMemo, useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { Plus, Eye, Mail, Users, Building2, UserCheck, UserX, X } from 'lucide-react';
import DataTable from '@/components/DataTable';
import KPIBar, { PeriodType } from '@/components/KPIBar';
import { toast } from '@/components/ui/use-toast';
import { CompanyInfo } from '@/data/mockData';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { getClients } from '@/store/features/client/thunk';
import { clientStatus, getAllClients } from '@/store/features/client/selectors';
import { Client, Contacts } from '@/types/clientType';
import { selectClient } from '@/store/features/client/slice';
import PrimaryButton_v2 from '@/components/PrimaryButton_v2';
import StatusBadgetLettre from '@/components/StatusBadgetLettre';
import ColumnSelector, { ColumnConfig } from '@/components/common/ColumnSelector';
import { Commercial } from '@/types/commercialType';
import { cn } from '@/lib/utils';
import { useDashboardData } from '@/store/hooks/useAppData';
// ============================================
// TYPES
// ============================================
type FilterType = 'all' | 'active' | 'with_contacts' | 'inactive';
interface KPIConfig {
id: FilterType;
title: string;
icon: React.ElementType;
color: string;
getValue: (clients: Client[]) => number;
getSubtitle: (clients: Client[], value: number) => string;
getChange: (clients: Client[], value: number) => string;
getTrend: (value: number) => 'up' | 'down' | 'neutral';
filter: (clients: Client[]) => Client[];
}
// ============================================
// CONFIGURATION DES COLONNES
// ============================================
const DEFAULT_COLUMNS: ColumnConfig[] = [
{ key: 'numero', label: 'Numéro', visible: true, locked: true },
{ key: 'intitule', label: 'Nom', visible: true },
{ key: 'email', label: 'Email', visible: true },
{ key: 'telephone', label: 'Téléphone', visible: true },
{ key: 'contacts', label: 'Contacts', visible: true },
{ key: 'commercial', label: 'Commercial', visible: true },
{ key: 'est_actif', label: 'Statut', visible: true },
];
// ============================================
// CONFIGURATION DES KPIs
// ============================================
const KPI_CONFIG: KPIConfig[] = [
{
id: 'all',
title: 'Total Clients',
icon: Users,
color: 'blue',
getValue: (clients) => clients.length,
getSubtitle: (clients) => {
const active = clients.filter(c => c.est_actif !== false).length;
return `${active} actif${active > 1 ? 's' : ''}`;
},
getChange: () => '',
getTrend: () => 'neutral',
filter: (clients) => clients,
},
{
id: 'active',
title: 'Clients Actifs',
icon: UserCheck,
color: 'green',
getValue: (clients) => clients.filter(c => c.est_actif !== false).length,
getSubtitle: () => 'du portefeuille',
getChange: (clients, value) => {
const total = clients.length;
return total > 0 ? `${((value / total) * 100).toFixed(1)}%` : '0%';
},
getTrend: () => 'up',
filter: (clients) => clients.filter(c => c.est_actif !== false),
},
{
id: 'with_contacts',
title: 'Avec Contacts',
icon: Building2,
color: 'orange',
getValue: (clients) => clients.filter(c => c.contacts && c.contacts.length > 0).length,
getSubtitle: () => 'ont des contacts',
getChange: (clients, value) => {
const total = clients.length;
return total > 0 ? `${((value / total) * 100).toFixed(0)}%` : '0%';
},
getTrend: () => 'neutral',
filter: (clients) => clients.filter(c => c.contacts && c.contacts.length > 0),
},
{
id: 'inactive',
title: 'Inactifs',
icon: UserX,
color: 'red',
getValue: (clients) => clients.filter(c => c.est_actif === false).length,
getSubtitle: (clients, value) => {
const total = clients.length;
return `${total > 0 ? ((value / total) * 100).toFixed(0) : 0}% du total`;
},
getChange: () => '',
getTrend: (value) => (value > 0 ? 'down' : 'up'),
filter: (clients) => clients.filter(c => c.est_actif === false),
},
];
// ============================================
// COMPOSANT PRINCIPAL
// ============================================
const ClientsPage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const clients = useAppSelector(getAllClients) as Client[];
const statusClient = useAppSelector(clientStatus);
const [period, setPeriod] = useState<PeriodType>('all');
const { refresh } = useDashboardData();
// État du filtre actif
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
// État des colonnes visibles
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(DEFAULT_COLUMNS);
const isLoading = statusClient === 'loading' && clients.length === 0;
useEffect(() => {
const load = async () => {
if (statusClient === 'idle' || statusClient === 'failed') {
await dispatch(getClients()).unwrap();
}
};
load();
}, [statusClient, dispatch]);
// Générer les KPIs avec onClick
const kpis = useMemo(() => {
return KPI_CONFIG.map(config => {
const value = config.getValue(clients);
return {
id: config.id,
title: config.title,
value,
change: config.getChange(clients, value),
trend: config.getTrend(value),
icon: config.icon,
subtitle: config.getSubtitle(clients, value),
color: config.color,
isActive: activeFilter === config.id,
onClick: () => {
// Toggle: si déjà actif, revenir à "all"
setActiveFilter(prev => (prev === config.id ? 'all' : config.id));
},
};
});
}, [clients, activeFilter]);
// Filtrer les clients selon le filtre actif
const filteredClients = useMemo(() => {
const config = KPI_CONFIG.find(k => k.id === activeFilter);
if (!config) return clients;
return config.filter(clients);
}, [clients, activeFilter]);
// Tri par intitulé (alphabétique)
const sortedClients = useMemo(() => {
return [...filteredClients].sort((a, b) =>
(a.intitule || '').localeCompare(b.intitule || '')
);
}, [filteredClients]);
// Label du filtre actif
const activeFilterLabel = useMemo(() => {
const config = KPI_CONFIG.find(k => k.id === activeFilter);
return config?.title || 'Tous';
}, [activeFilter]);
const handleCreate = () => {
navigate('/home/clients/create');
};
const handleView = (row: Client) => {
dispatch(selectClient(row));
navigate(`/home/clients/${row.numero}`);
};
// ============================================
// COLONNES DYNAMIQUES
// ============================================
const allColumnsDefinition = useMemo(() => {
return {
numero: { key: 'numero', label: 'Numéro', sortable: true },
intitule: { key: 'intitule', label: 'Nom', sortable: true },
email: { key: 'email', label: 'Email', sortable: true },
telephone: {
key: 'telephone',
label: 'Téléphone',
sortable: true,
render: (value: any) => <span>{value}</span>,
},
contacts: {
key: 'contacts',
label: 'Contacts',
sortable: false,
render: (row: Contacts[]) => {
const count = row?.length || 0;
const defaultContact = row?.find(c => c.est_defaut);
return (
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900 dark:text-white">
{count} contact{count !== 1 ? 's' : ''}
</span>
{defaultContact && (
<span className="text-xs text-gray-500">
{defaultContact.nom} {defaultContact.prenom} (défaut)
</span>
)}
</div>
);
},
},
est_actif: {
key: 'est_actif',
label: 'Statut',
sortable: true,
render: (isActive: any) => (
<StatusBadgetLettre status={isActive !== false ? 'actif' : 'inactif'} />
),
},
commercial: {
key: 'commercial',
label: 'Commercial',
sortable: true,
render: (value?: Commercial) => (
<span>
{value?.prenom && value?.nom ? `${value.prenom} ${value.nom}` : '_'}
</span>
),
},
};
}, []);
const visibleColumns = useMemo(() => {
return columnConfig
.filter(col => col.visible)
.map(col => allColumnsDefinition[col.key as keyof typeof allColumnsDefinition])
.filter(Boolean);
}, [columnConfig, allColumnsDefinition]);
const actions = (row: Client) => (
<>
<button
onClick={e => {
e.stopPropagation();
handleView(row);
}}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="Voir détails"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={e => {
e.stopPropagation();
if (row.email) {
toast({ title: 'Email envoyé', description: `À: ${row.email}` });
} else {
toast({
title: "Pas d'email",
description: "Ce client n'a pas d'adresse email",
variant: 'destructive',
});
}
}}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="Envoyer email"
>
<Mail className="w-4 h-4" />
</button>
</>
);
return (
<>
<Helmet>
<title>Clients - {CompanyInfo.name}</title>
<meta name="description" content="Gestion de vos clients" />
</Helmet>
<div className="space-y-6">
<KPIBar
kpis={kpis}
loading={statusClient}
onRefresh={refresh}
onPeriodChange={setPeriod}
period={period}
/>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Clients
</h1>
{/* Badge filtre actif */}
{activeFilter !== 'all' && (
<button
onClick={() => setActiveFilter('all')}
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium transition-colors",
"bg-[#007E45]/10 text-[#007E45] hover:bg-[#007E45]/20"
)}
>
{activeFilterLabel}
<X className="w-3 h-3" />
</button>
)}
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{activeFilter === 'all' ? (
<>
{clients.filter(c => c.est_actif !== false).length} clients actifs sur{' '}
{clients.length}
</>
) : (
<>
{filteredClients.length} client{filteredClients.length > 1 ? 's' : ''}{' '}
({activeFilterLabel.toLowerCase()})
</>
)}
</p>
</div>
<div className="flex gap-3">
<ColumnSelector columns={columnConfig} onChange={setColumnConfig} />
<PrimaryButton_v2 icon={Plus} onClick={handleCreate}>
Nouveau client
</PrimaryButton_v2>
</div>
</div>
<DataTable
columns={visibleColumns}
data={sortedClients}
onRowClick={(row: Client) => handleView(row)}
actions={actions}
status={isLoading}
/>
</div>
</>
);
};
export default ClientsPage;
// import React, { useState, useMemo, useEffect } from 'react';
// import { Helmet } from 'react-helmet';
// import { useNavigate } from 'react-router-dom';
// import { Plus, Eye, Mail, Users, Building2, UserCheck, UserX, UserPlus } from 'lucide-react';
// import DataTable from '@/components/DataTable';
// import KPIBar, { PeriodType } from '@/components/KPIBar';
// import { toast } from '@/components/ui/use-toast';
// import { CompanyInfo } from '@/data/mockData';
// import { useAppDispatch, useAppSelector } from '@/store/hooks';
// import { getClients } from '@/store/features/client/thunk';
// import { clientStatus, getAllClients } from '@/store/features/client/selectors';
// import { Client, Contacts } from '@/types/clientType';
// import { selectClient } from '@/store/features/client/slice';
// import PrimaryButton_v2 from '@/components/PrimaryButton_v2';
// import StatusBadgetLettre from '@/components/StatusBadgetLettre';
// import ColumnSelector, { ColumnConfig } from '@/components/common/ColumnSelector';
// import { Commercial } from '@/types/commercialType';
// // ============================================
// // CONFIGURATION DES COLONNES
// // ============================================
// const DEFAULT_COLUMNS: ColumnConfig[] = [
// { key: 'numero', label: 'Numéro', visible: true, locked: true },
// { key: 'intitule', label: 'Nom', visible: true },
// { key: 'email', label: 'Email', visible: true },
// { key: 'telephone', label: 'Téléphone', visible: true },
// { key: 'contacts', label: 'Contacts', visible: true },
// { key: 'commercial', label: 'Commercial', visible: true },
// { key: 'est_actif', label: 'Statut', visible: true },
// ];
// const ClientsPage = () => {
// const navigate = useNavigate();
// const dispatch = useAppDispatch();
// const clients = useAppSelector(getAllClients) as Client[];
// const statusClient = useAppSelector(clientStatus);
// const [period, setPeriod] = useState<PeriodType>('all');
// // État des colonnes visibles
// const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(DEFAULT_COLUMNS);
// const handleRefresh = async () => {
// await dispatch(getClients()).unwrap();
// };
// const isLoading = statusClient === 'loading' && clients.length === 0;
// useEffect(() => {
// const load = async () => {
// if (statusClient === 'idle' || statusClient === 'failed') {
// await dispatch(getClients()).unwrap();
// }
// };
// load();
// }, [statusClient, dispatch]);
// // Tri par intitulé (alphabétique)
// const sortedClients = useMemo(() => {
// return [...clients].sort((a, b) => (a.intitule || '').localeCompare(b.intitule || ''));
// }, [clients]);
// const kpis = useMemo(() => {
// const totalClients = clients.length;
// const activeClients = clients.filter(c => c.est_actif !== false);
// const inactiveClients = clients.filter(c => c.est_actif === false);
// const prospects = clients.filter(c => c.est_prospect === true);
// const withContacts = clients.filter(c => c.contacts && c.contacts.length > 0);
// const activityRate = totalClients > 0 ? ((activeClients.length / totalClients) * 100).toFixed(1) : '0';
// const contactRate = totalClients > 0 ? ((withContacts.length / totalClients) * 100).toFixed(0) : '0';
// return [
// {
// title: 'Total Clients',
// value: totalClients,
// change: '',
// trend: 'neutral',
// icon: Users,
// subtitle: `${activeClients.length} actif${activeClients.length > 1 ? 's' : ''}`,
// color: 'blue',
// },
// {
// title: 'Clients Actifs',
// value: activeClients.length,
// change: `${activityRate}%`,
// trend: 'up',
// icon: UserCheck,
// subtitle: 'du portefeuille',
// color: 'green',
// },
// {
// title: 'Avec Contacts',
// value: withContacts.length,
// change: `${contactRate}%`,
// trend: 'neutral',
// icon: Building2,
// subtitle: 'ont des contacts',
// color: 'orange',
// },
// {
// title: 'Inactifs',
// value: inactiveClients.length,
// change: '',
// trend: inactiveClients.length > 0 ? 'down' : 'up',
// icon: UserX,
// subtitle: `${totalClients > 0 ? ((inactiveClients.length / totalClients) * 100).toFixed(0) : 0}% du total`,
// color: 'red',
// },
// ];
// }, [clients]);
// const handleCreate = () => {
// navigate('/home/clients/create');
// };
// const handleView = (row: Client) => {
// dispatch(selectClient(row));
// navigate(`/home/clients/${row.numero}`);
// };
// // ============================================
// // COLONNES DYNAMIQUES
// // ============================================
// const allColumnsDefinition = useMemo(() => {
// return {
// numero: { key: 'numero', label: 'Numéro', sortable: true },
// intitule: { key: 'intitule', label: 'Nom', sortable: true },
// email: { key: 'email', label: 'Email', sortable: true },
// telephone: { key: 'telephone', label: 'Téléphone', sortable: true, render: (value: any) => <span>+ {value} </span>, },
// contacts: {
// key: 'contacts',
// label: 'Contacts',
// sortable: false,
// render: (row: Contacts[]) => {
// const count = row?.length || 0;
// const defaultContact = row.find(c => c.est_defaut);
// return (
// <div className="flex flex-col">
// <span className="text-sm font-medium text-gray-900 dark:text-white">
// {count} contact{count !== 1 ? 's' : ''}
// </span>
// {defaultContact && (
// <span className="text-xs text-gray-500">
// {defaultContact.nom} {defaultContact.prenom} (défaut)
// </span>
// )}
// </div>
// );
// },
// },
// est_actif: {
// key: 'est_actif',
// label: 'Statut',
// sortable: true,
// render: (isActive: any) => <StatusBadgetLettre status={isActive !== false ? 'actif' : 'inactif'} />,
// },
// commercial: {
// key: 'commercial',
// label: 'Commercial',
// sortable: true,
// render: (value?: Commercial) => (
// <span>{value?.prenom && value?.nom ? `${value.prenom} ${value.nom}` : '_'}</span>
// )
// },
// };
// }, []);
// const visibleColumns = useMemo(() => {
// return columnConfig
// .filter(col => col.visible)
// .map(col => allColumnsDefinition[col.key as keyof typeof allColumnsDefinition])
// .filter(Boolean);
// }, [columnConfig, allColumnsDefinition]);
// const actions = (row: Client) => (
// <>
// <button
// onClick={e => {
// e.stopPropagation();
// handleView(row);
// }}
// className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
// title="Voir détails"
// >
// <Eye className="w-4 h-4" />
// </button>
// <button
// onClick={e => {
// e.stopPropagation();
// if (row.email) {
// toast({ title: 'Email envoyé', description: `À: ${row.email}` });
// } else {
// toast({ title: "Pas d'email", description: "Ce client n'a pas d'adresse email", variant: 'destructive' });
// }
// }}
// className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
// title="Envoyer email"
// >
// <Mail className="w-4 h-4" />
// </button>
// </>
// );
// return (
// <>
// <Helmet>
// <title>Clients - {CompanyInfo.name}</title>
// <meta name="description" content="Gestion de vos clients" />
// </Helmet>
// <div className="space-y-6">
// <KPIBar kpis={kpis} loading={statusClient} onRefresh={handleRefresh} onPeriodChange={setPeriod} period={period} />
// <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
// <div>
// <h1 className="text-2xl font-bold text-gray-900 dark:text-white">Clients</h1>
// <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
// {clients.filter(c => c.est_actif !== false).length} clients actifs sur {clients.length}
// </p>
// </div>
// <div className="flex gap-3">
// <ColumnSelector columns={columnConfig} onChange={setColumnConfig} />
// <PrimaryButton_v2 icon={Plus} onClick={handleCreate}>
// Nouveau client
// </PrimaryButton_v2>
// </div>
// </div>
// <DataTable
// columns={visibleColumns}
// data={sortedClients}
// onRowClick={(row: Client) => handleView(row)}
// actions={actions}
// status={isLoading}
// />
// </div>
// </>
// );
// };
// export default ClientsPage;