add pages

This commit is contained in:
mickael 2026-01-20 11:06:26 +03:00
parent 49949f1404
commit 13a4b3cd8a
59 changed files with 20180 additions and 0 deletions

442
src/pages/DashboardPage.tsx Normal file
View file

@ -0,0 +1,442 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { motion } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import { TrendingUp, TrendingDown, Euro, FileText, ShoppingCart, Receipt, Target, Activity, Users, CreditCard, Clock, RotateCcw } from 'lucide-react';
import SegmentedControl from '@/components/SegmentedControl';
import KPIBar, { PeriodType } from '@/components/KPIBar';
import ChartCard from '@/components/ChartCard';
import { mockStats, mockQuotes, mockInvoices, mockClients, calculateKPIs, CompanyInfo } from '@/data/mockData';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { factureStatus, getAllfacture } from '@/store/features/factures/selectors';
import { Facture } from '@/types/factureType';
import { getFactures } from '@/store/features/factures/thunk';
import { filterItemByPeriod, getPreviousPeriodItems } from '@/components/filter/ItemsFilter';
import { devisStatus, getAllDevis } from '@/store/features/devis/selectors';
import { DevisListItem } from '@/types/devisType';
import { getDevisList } from '@/store/features/devis/thunk';
import { clientStatus, getAllClients } from '@/store/features/client/selectors';
import { Client } from '@/types/clientType';
import { commandeStatus, getAllcommandes } from '@/store/features/commande/selectors';
import { Commande } from '@/types/commandeTypes';
import { getClients } from '@/store/features/client/thunk';
import { getCommandes } from '@/store/features/commande/thunk';
import { ChartByStatusChart, ChartDataven, TopClientsChart } from '@/components/chart/Chart';
import PeriodSelector from '@/components/common/PeriodSelector';
import { getArticles } from '@/store/features/article/thunk';
import { articleStatus } from '@/store/features/article/selectors';
import { avoirStatus, getAllavoir } from '@/store/features/avoir/selectors';
import { Avoir } from '@/types/avoirType';
import { getAvoirs } from '@/store/features/avoir/thunk';
import { gatewaysStatus } from '@/store/features/gateways/selectors';
import { getGateways } from '@/store/features/gateways/thunk';
import { commercialsStatus } from '@/store/features/commercial/selectors';
import { getCommercials } from '@/store/features/commercial/thunk';
import { universignStatus } from '@/store/features/universign/selectors';
import { getUniversigns } from '@/store/features/universign/thunk';
import { useDashboardData } from '@/store/hooks/useAppData';
import { getuserConnected } from '@/store/features/user/selectors';
const DashboardPage = () => {
const [activeSegment, setActiveSegment] = useState('ventes');
const [period, setPeriod] = useState<PeriodType>('all');
const navigate = useNavigate();
const dispatch = useAppDispatch()
const segments = [
{ id: 'ventes', label: 'Ventes & CRM' },
// { id: 'achats', label: 'Achats' },
// { id: 'sav', label: 'SAV & Qualité' },
];
// ✅ Récupération des données
const statusCommercial = useAppSelector(commercialsStatus) ;
const statusUniversign = useAppSelector(universignStatus) ;
const factures = useAppSelector(getAllfacture) as Facture[];
const statusFacture = useAppSelector(factureStatus);
const devis = useAppSelector(getAllDevis) as DevisListItem[];
const statusDevis = useAppSelector(devisStatus);
const clients = useAppSelector(getAllClients) as Client[];
const statusClient = useAppSelector(clientStatus);
const commandes = useAppSelector(getAllcommandes) as Commande[];
const statusCommande = useAppSelector(commandeStatus);
const avoirs = useAppSelector(getAllavoir) as Avoir[];
const statusAvoir = useAppSelector(avoirStatus);
const statusArticle = useAppSelector(articleStatus)
const userConnected = useAppSelector(getuserConnected);
const { refresh } = useDashboardData();
// ✅ Charger les données au montage
useEffect(() => {
const load = async () => {
if (statusDevis === "idle") await dispatch(getDevisList()).unwrap();
if (statusCommercial === "idle") await dispatch(getCommercials()).unwrap();
if (statusUniversign === "idle") await dispatch(getUniversigns()).unwrap();
if (statusArticle === "idle") await dispatch(getArticles()).unwrap();
if (statusFacture === "idle") await dispatch(getFactures()).unwrap();
if (statusClient === "idle") await dispatch(getClients()).unwrap();
if (statusCommande === "idle") await dispatch(getCommandes()).unwrap();
if (statusAvoir === "idle") await dispatch(getAvoirs()).unwrap();
};
load();
}, [statusFacture, statusDevis, statusArticle, statusAvoir, statusCommercial, statusUniversign, dispatch]);
// ✅ Filtrer par période
const filteredFactures = useMemo(() => {
return filterItemByPeriod(factures, period);
}, [factures, period]);
const filteredDevis = useMemo(() => {
return filterItemByPeriod(devis, period);
}, [devis, period]);
const filteredCommandes = useMemo(() => {
return filterItemByPeriod(commandes, period);
}, [commandes, period]);
const filteredAvoir = useMemo(() => {
return filterItemByPeriod(avoirs, period);
}, [avoirs, period]);
const isLoadingFacture = useMemo(() => {
return statusFacture === 'loading' || statusFacture === 'idle'
}, [statusFacture]);
const isLoadingDevis = useMemo(() => {
return statusDevis === 'loading' || statusDevis === 'idle'
}, [statusDevis]);
const isLoadingClient = useMemo(() => {
return statusClient === 'loading' || statusClient === 'idle'
}, [statusClient]);
const isLoadingCommande = useMemo(() => {
return statusCommande === 'loading' || statusCommande === 'idle'
}, [statusCommande]);
const isLoadingAvoir = useMemo(() => {
return statusAvoir === 'loading' || statusAvoir === 'idle'
}, [statusAvoir]);
// ✅ Calculer les KPIs
const kpis = useMemo(() => {
if (activeSegment === 'ventes') {
const caFacture = Math.round(
filteredFactures
.filter(f => f.statut === 2)
.reduce((sum, f) => sum + f.total_ht, 0)
);
const previousFactures = getPreviousPeriodItems(factures, period);
const previousCAFacture = previousFactures
.filter(f => f.statut === 2)
.reduce((sum, f) => sum + f.total_ht, 0);
const caChange = previousCAFacture > 0
? ((caFacture - previousCAFacture) / previousCAFacture * 100).toFixed(1)
: '0';
const caTrend = caFacture >= previousCAFacture ? 'up' : 'down';
// ✅ PIPELINE (Devis en attente ou acceptés)
const pipeline = Math.round(
filteredDevis
.filter(d => d.statut === 1 || d.statut === 2) // En attente ou Acceptés
.reduce((sum, d) => sum + d.total_ht, 0))
const previousDevis = getPreviousPeriodItems(devis, period);
const previousPipeline = previousDevis
.filter(d => d.statut === 1 || d.statut === 2)
.reduce((sum, d) => sum + d.total_ht, 0);
const pipelineChange = previousPipeline > 0
? ((pipeline - previousPipeline) / previousPipeline * 100).toFixed(1)
: '0';
const pipelineTrend = pipeline >= previousPipeline ? 'up' : 'down';
const nouveauxClients = clients.length;
const clientsChange = '+0';
const clientsTrend = 'neutral';
const nbCommandes = filteredCommandes.filter(c => c.statut === 2).length;
const commandesChange = '+0';
const commandesTrend = 'neutral';
const nbAvoirs = filteredAvoir.filter(c => c.statut === 2).length;
return [
{
title: 'CA Facturé',
value: isLoadingFacture ? '...' : `${caFacture.toLocaleString('fr-FR')}`,
change: isLoadingFacture ? '' : `${caTrend === 'up' ? '+' : ''}${caChange}%`,
trend: caTrend,
icon: Euro,
onClick: () => navigate('/home/factures'),
loading: isLoadingFacture,
tooltip: {
content: "Montant total des factures validées sur la période.",
calculation: "Σ(Factures HT) - Σ(Avoirs HT)",
source: "Module Ventes > Factures"
}
},
{
title: 'Pipeline',
value: isLoadingDevis ? '...' : `${pipeline.toLocaleString('fr-FR')}`,
change: isLoadingDevis ? '' : `${pipelineTrend === 'up' ? '+' : ''}${pipelineChange}%`,
trend: pipelineTrend,
icon: Target,
onClick: () => navigate('/home/devis'),
loading: isLoadingDevis,
tooltip: {
content: "Montant total des devis en attente et acceptés",
calculation: "Σ(Montant HT des devis en attente ou acceptés)",
source: "CRM > Devis"
}
},
{
title: 'Clients',
value: isLoadingClient ? '...' : nouveauxClients,
change: isLoadingClient ? '' : clientsChange,
trend: clientsTrend,
icon: Users,
onClick: () => navigate('/home/clients'),
loading: isLoadingClient,
tooltip: {
content: "Nombre de nouveaux comptes clients créés.",
source: "CRM > Clients"
}
},
{
title: 'Commandes',
value: isLoadingCommande ? '...' : nbCommandes,
change: isLoadingCommande ? '' : commandesChange,
trend: commandesTrend,
icon: ShoppingCart,
onClick: () => navigate('/home/commandes'),
loading: isLoadingCommande,
tooltip: {
content: "Nombre de commandes validées non livrées.",
source: "Ventes > Commandes"
}
},
{
title: 'Avoir',
value: isLoadingAvoir ? '...' : nbAvoirs,
change: isLoadingAvoir ? '' : commandesChange,
trend: commandesTrend,
icon: RotateCcw,
onClick: () => navigate('/home/avoirs'),
loading: isLoadingAvoir,
tooltip: {
content: "Nombre d'avoir validées non validés.",
source: "Ventes > Avoirs"
}
},
];
} else if (activeSegment === 'achats') {
// TODO: Implémenter les KPIs achats
return [
{
title: 'Dépenses',
value: '...',
change: '',
trend: 'neutral',
icon: Euro,
loading: true,
},
{
title: 'Commandes Four.',
value: '...',
change: '',
trend: 'neutral',
icon: ShoppingCart,
loading: true,
},
{
title: 'Factures à payer',
value: '...',
change: '',
trend: 'neutral',
icon: Receipt,
loading: true,
},
];
} else {
// TODO: Implémenter les KPIs support
return [
{
title: 'Tickets Ouverts',
value: '...',
change: '',
trend: 'neutral',
icon: Activity,
onClick: () => navigate('/home/tickets'),
loading: true,
},
{
title: 'SLA Global',
value: '...',
change: '',
trend: 'neutral',
icon: TrendingUp,
loading: true,
},
{
title: 'Satisfaction',
value: '...',
change: '',
trend: 'neutral',
icon: Users,
loading: true,
},
{
title: 'Temps Moyen',
value: '...',
change: '',
trend: 'neutral',
icon: Clock,
loading: true,
},
];
}
}, [
activeSegment,
period,
navigate,
isLoadingCommande,
isLoadingClient,
isLoadingDevis,
isLoadingFacture,
filteredFactures,
filteredDevis,
factures,
devis,
]);
return (
<>
<Helmet>
<title>Tableau de bord - {CompanyInfo.name}</title>
</Helmet>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className="space-y-6"
>
{/* <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Vue d'ensemble</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">Bienvenue, Jean Dupont</p>
</div>
<SegmentedControl
segments={segments}
active={activeSegment}
onChange={setActiveSegment}
/>
</div> */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Vue d'ensemble</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">Bienvenue, {userConnected?.nom} {userConnected?.prenom}</p>
</div>
<div className="flex items-center gap-4">
<SegmentedControl
segments={segments}
active={activeSegment}
onChange={setActiveSegment}
/>
<PeriodSelector value={period} onChange={setPeriod} />
</div>
</div>
<KPIBar kpis={kpis} period={period} loading={statusFacture} onRefresh={refresh}/>
<motion.div
key={activeSegment}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{activeSegment === 'ventes' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<ChartDataven
items={factures}
period={period}
className="lg:col-span-2"
type="area"
title="Evolution du CA"
/>
<ChartByStatusChart
items={devis}
period={period}
type='donut'
title="État des Devis"
/>
{/* <TopClientsChart
title="Ventes par Commercial"
type="bar"
period={period}
items={factures}
className="lg:col-span-2"
/> */}
{/* <ChartCard
title="Types de Clients"
type="donut"
data={[{name: 'VIP', value: 15}, {name: 'PME', value: 45}, {name: 'Startup', value: 25}, {name: 'Autres', value: 15}]}
/> */}
</div>
)}
{activeSegment === 'achats' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* <ChartCard
title="Dépenses Mensuelles"
type="line"
data={mockStats.expenses}
/>
<ChartCard
title="Achats par Catégorie"
type="bar"
data={[{name: 'Matériel', value: 45000}, {name: 'Licences', value: 25000}, {name: 'Services', value: 35000}, {name: 'Locaux', value: 40000}]}
/> */}
</div>
)}
{activeSegment === 'sav' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* <ChartCard
title="Tickets par Priorité"
type="bar"
data={mockStats.ticketPriority}
/>
<ChartCard
title="Tickets par Statut"
type="donut"
data={mockStats.ticketStatus}
/> */}
</div>
)}
</motion.div>
</motion.div>
</>
);
};
export default DashboardPage;

View file

@ -0,0 +1,25 @@
import React from 'react';
import { Helmet } from 'react-helmet';
const DocumentsPage = () => {
return (
<>
<Helmet>
<title>Documents - Bijou ERP</title>
<meta name="description" content="Gestion électronique des documents" />
</Helmet>
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">GED - Documents</h1>
</div>
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-12 text-center">
<p className="text-gray-600 dark:text-gray-400">Page Documents - Contenu à venir</p>
</div>
</div>
</>
);
};
export default DocumentsPage;

View file

@ -0,0 +1,91 @@
import { ModalLoading } from '@/components/modal/ModalLoading';
import { getSageBuilderHtmlContent, sageBuilderError, sageBuilderStatus } from '@/store/features/sage-builder/selectors';
import { getSageBuilderDashboard } from '@/store/features/sage-builder/thunk';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import React, { useEffect, useRef } from 'react';
const SageBuilderPage = () => {
const dispatch = useAppDispatch();
const iframeRef = useRef<HTMLIFrameElement>(null);
const htmlContent = useAppSelector(getSageBuilderHtmlContent);
const status = useAppSelector(sageBuilderStatus);
const error = useAppSelector(sageBuilderError);
useEffect(() => {
// Charger le dashboard au montage du composant
if (status === 'idle') {
dispatch(getSageBuilderDashboard());
}
}, [dispatch, status]);
useEffect(() => {
// Injecter le HTML dans l'iframe quand il est disponible
if (htmlContent && iframeRef.current && status === 'succeeded') {
const iframeDoc = iframeRef.current.contentDocument || iframeRef.current.contentWindow?.document;
if (iframeDoc) {
iframeDoc.open();
iframeDoc.write(htmlContent);
iframeDoc.close();
}
}
}, [htmlContent, status]);
const handleRetry = () => {
dispatch(getSageBuilderDashboard());
};
return (
<div className="h-screen w-full overflow-hidden bg-gray-50 relative">
{/* Loading Overlay */}
{status === 'loading' && <ModalLoading />}
{/* Error State */}
{status === 'failed' && error && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-gray-50">
<div className="text-center max-w-md px-4">
<div className="inline-flex items-center justify-center w-16 h-16 mb-4 bg-red-100 rounded-full">
<svg
className="w-8 h-8 text-red-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Erreur de chargement
</h3>
<p className="text-sm text-gray-600 mb-4">
{error}
</p>
<button
onClick={handleRetry}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
Réessayer
</button>
</div>
</div>
)}
{/* Iframe */}
<iframe
ref={iframeRef}
className={`w-full h-full border-0 transition-opacity duration-300 ${
status === 'succeeded' ? 'opacity-100' : 'opacity-0'
}`}
title="Sage Builder Dashboard"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
/>
</div>
);
};
export default SageBuilderPage;

154
src/pages/UIKitPage.jsx Normal file
View file

@ -0,0 +1,154 @@
import React, { useState } from 'react';
import { Helmet } from 'react-helmet';
import { Mail, Download, Loader2, Check } from 'lucide-react';
import PrimaryButton from '@/components/PrimaryButton';
import StatusBadge from '@/components/StatusBadget';
import Tabs from '@/components/Tabs';
import { toast } from '@/components/ui/use-toast';
const UIKitPage = () => {
const [activeTab, setActiveTab] = useState('buttons');
const tabs = [
{ id: 'buttons', label: 'Boutons' },
{ id: 'badges', label: 'Badges' },
{ id: 'inputs', label: 'Inputs' },
{ id: 'tables', label: 'Tables' },
];
const statuses = ['draft', 'sent', 'accepted', 'refused', 'pending', 'validated', 'paid', 'cancelled', 'open', 'in-progress', 'resolved', 'closed', 'low', 'normal', 'high', 'critical'];
return (
<>
<Helmet>
<title>UI Kit - Bijou ERP</title>
<meta name="description" content="Kit de composants UI de Bijou ERP" />
</Helmet>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">UI Kit</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">Composants de l'interface Bijou ERP</p>
</div>
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6">
{activeTab === 'buttons' && (
<div className="space-y-8">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Boutons primaires</h3>
<div className="flex flex-wrap gap-3">
<PrimaryButton onClick={() => toast({ title: "Bouton cliqué!" })}>
Bouton normal
</PrimaryButton>
<PrimaryButton icon={Mail} onClick={() => toast({ title: "Bouton cliqué!" })}>
Avec icône
</PrimaryButton>
<PrimaryButton loading>
Chargement
</PrimaryButton>
<PrimaryButton disabled>
Désactivé
</PrimaryButton>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Boutons secondaires</h3>
<div className="flex flex-wrap gap-3">
<button className="px-4 py-2.5 border border-gray-200 dark:border-gray-800 text-gray-900 dark:text-white rounded-xl text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors">
Secondaire
</button>
<button className="px-4 py-2.5 text-gray-600 dark:text-gray-400 rounded-xl text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
Ghost
</button>
</div>
</div>
</div>
)}
{activeTab === 'badges' && (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Badges de statut</h3>
<div className="flex flex-wrap gap-3">
{statuses.map(status => (
<StatusBadge key={status} status={status} />
))}
</div>
</div>
)}
{activeTab === 'inputs' && (
<div className="space-y-6 max-w-md">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Input texte
</label>
<input
type="text"
placeholder="Entrez du texte..."
className="w-full px-4 py-2.5 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#941403] text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select
</label>
<select className="w-full px-4 py-2.5 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#941403] text-gray-900 dark:text-white">
<option>Option 1</option>
<option>Option 2</option>
<option>Option 3</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Textarea
</label>
<textarea
rows={4}
placeholder="Entrez votre message..."
className="w-full px-4 py-2.5 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#941403] text-gray-900 dark:text-white resize-none"
/>
</div>
</div>
)}
{activeTab === 'tables' && (
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Exemple de table</h3>
<div className="border border-gray-200 dark:border-gray-800 rounded-xl overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400">Nom</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400">Email</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400">Statut</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
<tr>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white">Jean Dupont</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">jean@example.com</td>
<td className="px-4 py-3"><StatusBadge status="validated" /></td>
</tr>
<tr>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white">Marie Martin</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">marie@example.com</td>
<td className="px-4 py-3"><StatusBadge status="pending" /></td>
</tr>
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
</>
);
};
export default UIKitPage;

View file

@ -0,0 +1,112 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { User, Mail, Phone, Building, Camera, Save } from 'lucide-react';
import PrimaryButton from '@/components/PrimaryButton';
import SmartForm from '@/components/SmartForm';
import { z } from 'zod';
import { toast } from '@/components/ui/use-toast';
import { motion } from 'framer-motion';
const profileSchema = z.object({
firstName: z.string().min(2, "Le prénom est requis"),
lastName: z.string().min(2, "Le nom est requis"),
email: z.string().email("Email invalide"),
phone: z.string().optional(),
department: z.string().optional(),
signature: z.string().optional()
});
const UserProfilePage = () => {
const handleSave = (data) => {
console.log('Profile saved:', data);
toast({ title: "Profil mis à jour", description: "Vos modifications ont été enregistrées avec succès." });
};
return (
<>
<Helmet>
<title>Mon Profil - Bijou ERP</title>
</Helmet>
<div className="max-w-4xl mx-auto space-y-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Mon Profil</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Sidebar Card */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className="md:col-span-1 space-y-6"
>
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6 flex flex-col items-center text-center">
<div className="relative mb-4 group cursor-pointer">
<div className="w-32 h-32 rounded-full bg-[#941403] text-white flex items-center justify-center text-3xl font-bold overflow-hidden">
JD
</div>
<div className="absolute inset-0 bg-black/50 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Camera className="w-8 h-8 text-white" />
</div>
</div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Jean Dupont</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Administrateur</p>
<div className="w-full mt-6 pt-6 border-t border-gray-100 dark:border-gray-800 space-y-3 text-left">
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<Mail className="w-4 h-4" /> jean.dupont@bijou.com
</div>
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<Phone className="w-4 h-4" /> +33 6 12 34 56 78
</div>
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<Building className="w-4 h-4" /> Direction
</div>
</div>
</div>
</motion.div>
{/* Form Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="md:col-span-2"
>
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6">
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Informations personnelles</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">Mettez à jour vos coordonnées et préférences.</p>
</div>
<SmartForm
schema={profileSchema}
defaultValues={{
firstName: 'Jean',
lastName: 'Dupont',
email: 'jean.dupont@bijou.com',
phone: '+33 6 12 34 56 78',
department: 'Direction',
signature: 'Jean Dupont\nCEO - Bijou ERP'
}}
onSubmit={handleSave}
submitLabel="Enregistrer les modifications"
fields={[
{ title: 'Identité', type: 'section' },
{ name: 'firstName', label: 'Prénom' },
{ name: 'lastName', label: 'Nom' },
{ name: 'email', label: 'Email professionnel', type: 'email' },
{ name: 'phone', label: 'Téléphone mobile' },
{ title: 'Entreprise', type: 'section' },
{ name: 'department', label: 'Département', type: 'select', options: [{ value: 'Direction', label: 'Direction' }, { value: 'Ventes', label: 'Ventes' }, { value: 'SAV', label: 'SAV' }] },
{ name: 'signature', label: 'Signature email', type: 'textarea', rows: 3, fullWidth: true },
]}
/>
</div>
</motion.div>
</div>
</div>
</>
);
};
export default UserProfilePage;

View file

@ -0,0 +1,22 @@
import React from 'react';
import { Helmet } from 'react-helmet';
const ActivityLogPage = () => {
return (
<>
<Helmet>
<title>Journal d'activité - Bijou ERP</title>
<meta name="description" content="Journal des activités système" />
</Helmet>
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Journal d'activité</h1>
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-12 text-center">
<p className="text-gray-600 dark:text-gray-400">Journal d'activité - Contenu à venir</p>
</div>
</div>
</>
);
};
export default ActivityLogPage;

View file

@ -0,0 +1,22 @@
import React from 'react';
import { Helmet } from 'react-helmet';
const RolesPage = () => {
return (
<>
<Helmet>
<title>Rôles & Permissions - Bijou ERP</title>
<meta name="description" content="Gestion des rôles et permissions" />
</Helmet>
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Rôles & Permissions</h1>
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-12 text-center">
<p className="text-gray-600 dark:text-gray-400">Page Rôles & Permissions - Contenu à venir</p>
</div>
</div>
</>
);
};
export default RolesPage;

View file

@ -0,0 +1,22 @@
import React from 'react';
import { Helmet } from 'react-helmet';
const SettingsPage = () => {
return (
<>
<Helmet>
<title>Paramètres - Bijou ERP</title>
<meta name="description" content="Paramètres de l'application" />
</Helmet>
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Paramètres</h1>
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-12 text-center">
<p className="text-gray-600 dark:text-gray-400">Page Paramètres - Contenu à venir</p>
</div>
</div>
</>
);
};
export default SettingsPage;

View file

@ -0,0 +1,38 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { Eye } from 'lucide-react';
import DataTable from '@/components/DataTable';
import StatusBadge from '@/components/StatusBadget';
import { mockUsers } from '@/data/mockData';
const UsersPage = () => {
const columns = [
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'role', label: 'Rôle', sortable: true },
{
key: 'status',
label: 'Statut',
sortable: true,
render: (value) => <StatusBadge status={value === 'active' ? 'validated' : 'cancelled'} />
},
];
return (
<>
<Helmet>
<title>Utilisateurs - Bijou ERP</title>
<meta name="description" content="Gestion des utilisateurs" />
</Helmet>
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Utilisateurs</h1>
</div>
<DataTable columns={columns} data={mockUsers} />
</div>
</>
);
};
export default UsersPage;

272
src/pages/auth/Login.tsx Normal file
View file

@ -0,0 +1,272 @@
import AuthLayout from "@/components/layout/AuthLayout";
import { motion } from 'framer-motion';
import { useState } from 'react'
import { loginInterface } from '@/types/userInterface'
import { Link, useNavigate } from "react-router-dom";
import { sageService } from "@/service/sageService";
import { useToast } from "@/components/ui/use-toast";
import { Button } from '@/components/ui/buttonTsx';
import AuthInput from '@/components/ui/AuthInput';
import { Checkbox } from '@/components/ui/checkbox';
import { ArrowRight, Building2, Eye, EyeOff, Loader2, Lock, Mail, ShieldCheck } from "lucide-react";
import { useAppDispatch } from "@/store/hooks";
import { authMe } from "@/store/features/user/thunk";
export default function Login() {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [loginData, setLoginData] = useState<loginInterface>({ email: "", password: "", rememberMe: false });
const [errors, setErrors] = useState({}) as any;
const [errorFom, setErrorForm] = useState('')
const validateForm = () => {
const newErrors = {} as any;
if (!loginData.email) {
newErrors.email = "L'adresse email est requise";
} else if (!/\S+@\S+\.\S+/.test(loginData.email)) {
newErrors.email = "Format d'email invalide";
}
if (!loginData.password) {
newErrors.password = "Le mot de passe est requis";
} else if (loginData.password.length < 8) {
newErrors.password = "Le mot de passe doit contenir au moins 8 caractères";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
setIsLoading(true);
try {
const response = await sageService.login(loginData);
const access_token = response.access_token;
const refresh_token = response.refresh_token;
const expires_in = response.expires_in;
if (access_token) {
document.cookie = `access_token=${access_token}; path=/; max-age=${expires_in}`;
document.cookie = `refresh_token=${refresh_token}; path=/; max-age=${expires_in * 2}`;
try {
await dispatch(authMe()).unwrap();
navigate("/home");
} catch (error) {
setErrorForm("Impossible de vérifier votre accès. Veuillez réessayer.");
}
} else {
setErrors("Email ou mot de passe incorrect")
setErrorForm("Email ou mot de passe incorrect")
}
setIsLoading(false);
} catch (err: any) {
setErrors(`${err.response.data.detail || "Email ou mot de passe incorrect!"}`);
setErrorForm(`${err.response.data.detail || "Email ou mot de passe incorrect!"}`);
setIsLoading(false);
} finally {
setIsLoading(false);
}
};
const handleSSOLogin = (provider : any) => {
toast({
title: "Redirection SSO",
description: `Connexion avec ${provider} en cours...`,
});
// Implementation would go here
};
// Animation variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 24 } }
};
return (
<AuthLayout>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="w-full max-w-[420px]"
>
{/* Mobile Logo */}
<div className="lg:hidden flex justify-center mb-8">
<div className="w-12 h-12 rounded-xl bg-[#338660] flex items-center justify-center shadow-lg shadow-[#338660]/30">
<Building2 className="w-7 h-7 text-white" />
</div>
</div>
{/* Header */}
<motion.div variants={itemVariants} className="text-center mb-10">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">Bon retour</h2>
<p className="text-gray-500 dark:text-gray-400">
Connectez-vous pour accéder à votre espace
</p>
</motion.div>
{/* SSO Buttons */}
<motion.div variants={itemVariants} className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
<button
onClick={() => handleSSOLogin('Microsoft')}
className="flex items-center justify-center gap-3 px-4 py-3 bg-[#2F2F2F] hover:bg-[#1a1a1a] text-white rounded-xl font-medium transition-all hover:shadow-lg hover:-translate-y-0.5 duration-200 border border-transparent"
>
<svg className="w-5 h-5" viewBox="0 0 23 23">
<path fill="#f35325" d="M1 1h10v10H1z"/>
<path fill="#81bc06" d="M12 1h10v10H12z"/>
<path fill="#05a6f0" d="M1 12h10v10H1z"/>
<path fill="#ffba08" d="M12 12h10v10H12z"/>
</svg>
<span>Microsoft</span>
</button>
<button
onClick={() => handleSSOLogin('Google')}
className="flex items-center justify-center gap-3 px-4 py-3 bg-white hover:bg-gray-50 text-gray-700 rounded-xl font-medium transition-all hover:shadow-lg hover:-translate-y-0.5 duration-200 border border-gray-200"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.26.81-.58z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
<span>Google</span>
</button>
</motion.div>
<motion.div variants={itemVariants} className="relative mb-8">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-200 dark:border-gray-800" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-white dark:bg-gray-950 text-gray-500">ou avec email</span>
</div>
</motion.div>
{/* Login Form */}
{errorFom && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-lg">
{errorFom}
</div>
)}
<motion.form variants={itemVariants} onSubmit={handleLogin} className="space-y-6">
<AuthInput
icon={Mail}
type="email"
label="Adresse email"
placeholder="nom@entreprise.com"
value={loginData.email}
onChange={(e) => setLoginData({...loginData, email: e.target.value})}
error={errors.email}
/>
<AuthInput
icon={Lock}
type={showPassword ? "text" : "password"}
label="Mot de passe"
placeholder="••••••••"
value={loginData.password}
onChange={(e) => setLoginData({...loginData, password: e.target.value})}
error={errors.password}
rightElement={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-gray-400 hover:text-gray-600 focus:outline-none"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
/>
<div className="flex items-center justify-between pt-2">
<div className="flex items-center space-x-2">
<Checkbox
id="remember"
checked={loginData.rememberMe}
onCheckedChange={(checked) =>
setLoginData({
...loginData,
rememberMe: checked === true
})
}
/>
<label
htmlFor="remember"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-gray-600 dark:text-gray-400 cursor-pointer"
>
Se souvenir de moi
</label>
</div>
<Link
to="/forgot"
className="text-sm font-medium text-[#338660] hover:text-[#2A6F4F] hover:underline"
>
Mot de passe oublié ?
</Link>
</div>
<Button
type="submit"
disabled={isLoading}
className="w-full h-12 text-base font-bold bg-[#338660] hover:bg-[#2A6F4F] transition-all duration-300 shadow-[0_4px_14px_0_rgba(51,134,96,0.39)] hover:shadow-[0_6px_20px_rgba(51,134,96,0.23)] hover:-translate-y-0.5 rounded-xl"
>
{isLoading ? (
<span className="flex items-center gap-2">
<Loader2 className="w-5 h-5 animate-spin" />
Connexion en cours...
</span>
) : (
<span className="flex items-center gap-2">
Se connecter
<ArrowRight className="w-5 h-5" />
</span>
)}
</Button>
</motion.form>
{/* Footer Trust Elements */}
<motion.div variants={itemVariants} className="mt-12 text-center space-y-4">
<div className="flex items-center justify-center gap-2 text-xs text-gray-500 bg-gray-50 dark:bg-gray-900/50 py-2 px-4 rounded-full mx-auto">
<ShieldCheck className="w-3.5 h-3.5 text-[#338660]" />
<span>Connexion chiffrée SSL Données sécurisées</span>
</div>
<p className="text-xs text-gray-400">
En vous connectant, vous acceptez nos{' '}
<Link to="/" className="text-gray-500 hover:text-[#338660] underline">Conditions</Link>
{' '}et{' '}
<Link to="/privacy" className="text-gray-500 hover:text-[#338660] underline">Politique de confidentialité</Link>.
</p>
</motion.div>
</motion.div>
</AuthLayout>
)
}

203
src/pages/auth/forgot.tsx Normal file
View file

@ -0,0 +1,203 @@
import AuthLayout from "@/components/layout/AuthLayout";
import { motion } from 'framer-motion';
import { useState, useMemo } from 'react';
import { Link, useNavigate } from "react-router-dom";
import { sageService } from "@/service/sageService";
import { Button } from '@/components/ui/buttonTsx';
import AuthInput from '@/components/ui/AuthInput';
import { ArrowRight, ArrowLeft, Building2, Loader2, Mail, ShieldCheck, CheckCircle } from "lucide-react";
export default function Forgot() {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const canSave = useMemo(() => {
if (!email) return false;
return /\S+@\S+\.\S+/.test(email);
}, [email]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (loading || !canSave) return;
setError(null);
setMessage(null);
setLoading(true);
try {
const response = await sageService.forgotPass(email);
const success = response.success;
const responseMessage = response.message;
if (success) {
setMessage(responseMessage || "Un email de réinitialisation a été envoyé à votre adresse.");
} else {
setError("Email introuvable ou invalide.");
}
} catch (err) {
console.log("Erreur:", err);
setError("Une erreur est survenue. Veuillez réessayer.");
} finally {
setLoading(false);
}
};
// Animation variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 24 } }
};
return (
<AuthLayout>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="w-full max-w-[420px]"
>
{/* Mobile Logo */}
<div className="lg:hidden flex justify-center mb-8">
<div className="w-12 h-12 rounded-xl bg-[#338660] flex items-center justify-center shadow-lg shadow-[#338660]/30">
<Building2 className="w-7 h-7 text-white" />
</div>
</div>
{/* Header */}
<motion.div variants={itemVariants} className="text-center mb-10">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-[#338660]/10 flex items-center justify-center">
<Mail className="w-8 h-8 text-[#338660]" />
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Mot de passe oublié ?
</h2>
<p className="text-gray-500 dark:text-gray-400">
Entrez votre email pour recevoir un lien de réinitialisation
</p>
</motion.div>
{/* Success Message */}
{message && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl"
>
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-green-800 dark:text-green-300">
Email envoyé avec succès !
</p>
<p className="text-xs text-green-700 dark:text-green-400 mt-1">
{message}
</p>
</div>
</div>
</motion.div>
)}
{/* Error Message */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl"
>
<div className="flex items-start gap-3">
<div className="w-5 h-5 rounded-full bg-red-100 dark:bg-red-900/50 flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-red-600 dark:text-red-400 text-xs font-bold">!</span>
</div>
<div className="flex-1">
<p className="text-sm font-medium text-red-800 dark:text-red-300">
{error}
</p>
</div>
<button
onClick={() => setError(null)}
className="text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-300"
>
<span className="text-lg leading-none">×</span>
</button>
</div>
</motion.div>
)}
{/* Form */}
<motion.form
variants={itemVariants}
onSubmit={handleSubmit}
className="space-y-6"
>
<AuthInput
icon={Mail}
type="email"
label="Adresse email"
placeholder="nom@entreprise.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
error={!canSave && email ? "Format d'email invalide" : undefined}
autoFocus
/>
<Button
type="submit"
disabled={!canSave || loading}
className="w-full h-12 text-base font-bold bg-[#338660] hover:bg-[#2A6F4F] transition-all duration-300 shadow-[0_4px_14px_0_rgba(51,134,96,0.39)] hover:shadow-[0_6px_20px_rgba(51,134,96,0.23)] hover:-translate-y-0.5 rounded-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:transform-none"
>
{loading ? (
<span className="flex items-center gap-2">
<Loader2 className="w-5 h-5 animate-spin" />
Envoi en cours...
</span>
) : (
<span className="flex items-center gap-2">
Envoyer le lien
<ArrowRight className="w-5 h-5" />
</span>
)}
</Button>
{/* Back to Login */}
<button
type="button"
onClick={() => navigate("/login")}
className="w-full flex items-center justify-center gap-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-[#338660] dark:hover:text-[#338660] transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Retour à la connexion
</button>
</motion.form>
{/* Footer Trust Elements */}
<motion.div variants={itemVariants} className="mt-12 text-center space-y-4">
<div className="flex items-center justify-center gap-2 text-xs text-gray-500 bg-gray-50 dark:bg-gray-900/50 py-2 px-4 rounded-full mx-auto">
<ShieldCheck className="w-3.5 h-3.5 text-[#338660]" />
<span>Connexion chiffrée SSL Données sécurisées</span>
</div>
<p className="text-xs text-gray-400">
Besoin d'aide ?{' '}
<Link to="/support" className="text-gray-500 hover:text-[#338660] underline">
Contactez notre support
</Link>
</p>
</motion.div>
</motion.div>
</AuthLayout>
);
}

319
src/pages/auth/reset.tsx Normal file
View file

@ -0,0 +1,319 @@
import AuthLayout from "@/components/layout/AuthLayout";
import { motion } from 'framer-motion';
import { useEffect, useMemo, useState } from 'react';
import { resetPassInterface } from '@/types/userInterface';
import { useNavigate, useSearchParams } from "react-router-dom";
import { sageService } from "@/service/sageService";
import { Button } from '@/components/ui/buttonTsx';
import AuthInput from '@/components/ui/AuthInput';
import {
ArrowRight,
Building2,
Loader2,
Lock,
ShieldCheck,
CheckCircle,
Eye,
EyeOff,
AlertCircle
} from "lucide-react";
export default function Reset() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const token = searchParams.get("token");
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [resetData, setResetData] = useState<resetPassInterface>({
token: "",
new_password: "",
verify_password: ""
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (token) {
setResetData(prev => ({ ...prev, token }));
}
}, [token]);
const passwordError = resetData.verify_password!.length > 0 &&
resetData.new_password !== resetData.verify_password;
const passwordStrength = useMemo(() => {
const password = resetData.new_password;
if (!password) return { strength: 0, label: '', color: '' };
let strength = 0;
if (password.length >= 8) strength++;
if (password.length >= 12) strength++;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
if (/\d/.test(password)) strength++;
if (/[^a-zA-Z0-9]/.test(password)) strength++;
const levels = [
{ strength: 0, label: '', color: '' },
{ strength: 1, label: 'Très faible', color: 'bg-red-500' },
{ strength: 2, label: 'Faible', color: 'bg-orange-500' },
{ strength: 3, label: 'Moyen', color: 'bg-yellow-500' },
{ strength: 4, label: 'Fort', color: 'bg-green-500' },
{ strength: 5, label: 'Très fort', color: 'bg-green-600' },
];
return levels[strength];
}, [resetData.new_password]);
const canSave = useMemo(() => {
if (!resetData.new_password || resetData.new_password.length < 8) return false;
if (!resetData.verify_password) return false;
if (passwordError) return false;
return true;
}, [resetData.new_password, resetData.verify_password, passwordError]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (loading || !canSave) return;
setError(null);
setLoading(true);
try {
const data: resetPassInterface = {
token: resetData.token,
new_password: resetData.new_password
};
const response = await sageService.resetPass(data);
const success = response.success;
if (success) {
// Show success message briefly before redirect
setTimeout(() => {
navigate('/login');
}, 1500);
} else {
setError("Erreur lors de la mise à jour du mot de passe");
}
} catch (err) {
console.log("Erreur:", err);
setError("Une erreur est survenue. Veuillez réessayer.");
} finally {
setLoading(false);
}
};
// Animation variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 24 } }
};
return (
<AuthLayout>
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="w-full max-w-[420px]"
>
{/* Mobile Logo */}
<div className="lg:hidden flex justify-center mb-8">
<div className="w-12 h-12 rounded-xl bg-[#338660] flex items-center justify-center shadow-lg shadow-[#338660]/30">
<Building2 className="w-7 h-7 text-white" />
</div>
</div>
{/* Header */}
<motion.div variants={itemVariants} className="text-center mb-10">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-[#338660]/10 flex items-center justify-center">
<Lock className="w-8 h-8 text-[#338660]" />
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Nouveau mot de passe
</h2>
<p className="text-gray-500 dark:text-gray-400 text-sm">
Merci de renseigner un nouveau mot de passe afin de compléter la procédure de réinitialisation
</p>
</motion.div>
{/* Error Message */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl"
>
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-red-800 dark:text-red-300">
{error}
</p>
</div>
<button
onClick={() => setError(null)}
className="text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-300"
>
<span className="text-lg leading-none">×</span>
</button>
</div>
</motion.div>
)}
{/* Form */}
<motion.form
variants={itemVariants}
onSubmit={handleSubmit}
className="space-y-6"
>
{/* New Password */}
<div>
<AuthInput
icon={Lock}
type={showPassword ? "text" : "password"}
label="Nouveau mot de passe"
placeholder="••••••••"
value={resetData.new_password}
onChange={(e) => setResetData({ ...resetData, new_password: e.target.value })}
error={resetData.new_password.length > 0 && resetData.new_password.length < 8 ? "Le mot de passe doit contenir au moins 8 caractères" : undefined}
rightElement={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-gray-400 hover:text-gray-600 focus:outline-none"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
/>
{/* Password Strength Indicator */}
{resetData.new_password && (
<div className="mt-2">
<div className="flex gap-1 mb-1">
{[1, 2, 3, 4, 5].map((level) => (
<div
key={level}
className={`h-1 flex-1 rounded-full transition-all ${
level <= passwordStrength.strength
? passwordStrength.color
: 'bg-gray-200 dark:bg-gray-700'
}`}
/>
))}
</div>
{passwordStrength.label && (
<p className="text-xs text-gray-500 dark:text-gray-400">
Force: {passwordStrength.label}
</p>
)}
</div>
)}
</div>
{/* Confirm Password */}
<div>
<AuthInput
icon={Lock}
type={showConfirmPassword ? "text" : "password"}
label="Confirmer le mot de passe"
placeholder="••••••••"
value={resetData.verify_password}
onChange={(e) => setResetData({ ...resetData, verify_password: e.target.value })}
error={passwordError ? "Les mots de passe ne correspondent pas" : undefined}
rightElement={
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="text-gray-400 hover:text-gray-600 focus:outline-none"
>
{showConfirmPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
/>
{/* Password Match Indicator */}
{resetData.verify_password!.length > 0 && !passwordError && resetData.new_password === resetData.verify_password && (
<div className="flex items-center gap-2 mt-2 text-green-600 dark:text-green-400">
<CheckCircle className="w-4 h-4" />
<p className="text-xs">Les mots de passe correspondent</p>
</div>
)}
</div>
{/* Password Requirements */}
<div className="p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Le mot de passe doit contenir :
</p>
<ul className="space-y-1 text-xs text-gray-600 dark:text-gray-400">
<li className="flex items-center gap-2">
<span className={resetData.new_password.length >= 8 ? "text-green-600" : "text-gray-400"}>
{resetData.new_password.length >= 8 ? "✓" : "○"}
</span>
Au moins 8 caractères
</li>
<li className="flex items-center gap-2">
<span className={/[A-Z]/.test(resetData.new_password) ? "text-green-600" : "text-gray-400"}>
{/[A-Z]/.test(resetData.new_password) ? "✓" : "○"}
</span>
Une lettre majuscule
</li>
<li className="flex items-center gap-2">
<span className={/[a-z]/.test(resetData.new_password) ? "text-green-600" : "text-gray-400"}>
{/[a-z]/.test(resetData.new_password) ? "✓" : "○"}
</span>
Une lettre minuscule
</li>
<li className="flex items-center gap-2">
<span className={/\d/.test(resetData.new_password) ? "text-green-600" : "text-gray-400"}>
{/\d/.test(resetData.new_password) ? "✓" : "○"}
</span>
Un chiffre
</li>
</ul>
</div>
<Button
type="submit"
disabled={!canSave || loading}
className="w-full h-12 text-base font-bold bg-[#338660] hover:bg-[#2A6F4F] transition-all duration-300 shadow-[0_4px_14px_0_rgba(51,134,96,0.39)] hover:shadow-[0_6px_20px_rgba(51,134,96,0.23)] hover:-translate-y-0.5 rounded-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:transform-none"
>
{loading ? (
<span className="flex items-center gap-2">
<Loader2 className="w-5 h-5 animate-spin" />
Mise à jour en cours...
</span>
) : (
<span className="flex items-center gap-2">
Mettre à jour le mot de passe
<ArrowRight className="w-5 h-5" />
</span>
)}
</Button>
</motion.form>
{/* Footer Trust Elements */}
<motion.div variants={itemVariants} className="mt-12 text-center space-y-4">
<div className="flex items-center justify-center gap-2 text-xs text-gray-500 bg-gray-50 dark:bg-gray-900/50 py-2 px-4 rounded-full mx-auto">
<ShieldCheck className="w-3.5 h-3.5 text-[#338660]" />
<span>Connexion chiffrée SSL Données sécurisées</span>
</div>
</motion.div>
</motion.div>
</AuthLayout>
);
}

View file

@ -0,0 +1,46 @@
import React, { useMemo, useState } from 'react';
import { Helmet } from 'react-helmet';
import { CheckCircle, Clock, Phone, Mail } from 'lucide-react';
import KPIBar from '@/components/KPIBar';
import { mockTimeline, calculateKPIs } from '@/data/mockData'; // Reusing timeline as activities
const ActivitiesPage = () => {
const [period, setPeriod] = useState('month');
const kpis = useMemo(() => {
// Fake amount field to satisfy calculator
const activitiesWithAmt = mockTimeline.map(t => ({ ...t, amount: 1 }));
const stats = calculateKPIs(activitiesWithAmt, period, { dateField: 'date', amountField: 'amount' });
const calls = stats.items.filter(a => a.type === 'call');
const emails = stats.items.filter(a => a.type === 'email');
return [
{ title: 'Activités Créées', value: stats.filteredCount, change: '+15', trend: 'up', icon: Clock },
{ title: 'Complétées', value: Math.round(stats.filteredCount * 0.8), change: '+12', trend: 'up', icon: CheckCircle },
{ title: 'Appels', value: calls.length, change: '', trend: 'neutral', icon: Phone },
{ title: 'Emails', value: emails.length, change: '', trend: 'neutral', icon: Mail },
];
}, [period]);
return (
<>
<Helmet>
<title>Activités - Bijou ERP</title>
<meta name="description" content="Journal des activités" />
</Helmet>
<div className="space-y-6">
<KPIBar kpis={kpis} period={period} onPeriodChange={setPeriod} />
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Activités</h1>
</div>
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-12 text-center">
<p className="text-gray-600 dark:text-gray-400">Page Activités - Contenu à venir</p>
</div>
</div>
</>
);
};
export default ActivitiesPage;

View file

@ -0,0 +1,25 @@
import React from 'react';
import { Helmet } from 'react-helmet';
const ArticlesPage = () => {
return (
<>
<Helmet>
<title>Articles - Bijou ERP</title>
<meta name="description" content="Catalogue d'articles" />
</Helmet>
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Articles</h1>
</div>
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-12 text-center">
<p className="text-gray-600 dark:text-gray-400">Page Articles - Contenu à venir</p>
</div>
</div>
</>
);
};
export default ArticlesPage;

View file

@ -0,0 +1,585 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import {
ArrowLeft, FileText, Building2, MapPin,
CreditCard, Edit, MoreVertical,
Package, Truck,
Plus,
Trash2,
File,
Paperclip,
Upload,
HardDrive,
Bell
} from 'lucide-react';
import { motion } from 'framer-motion';
import Tabs from '@/components/Tabs';
import Timeline from '@/components/Timeline';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { getClientSelected } from '@/store/features/client/selectors';
import { Client, ClientRequest, Contacts } from '@/types/clientType';
import PrimaryButton_v2 from '@/components/PrimaryButton_v2';
import { devisStatus, getAllDevis, getDevisSelected } from '@/store/features/devis/selectors';
import { DevisListItem } from '@/types/devisType';
import { getDevisList, selectDevisAsync } from '@/store/features/devis/thunk';
import { commandeStatus, getAllcommandes } from '@/store/features/commande/selectors';
import { Commande } from '@/types/commandeTypes';
import { getCommandes } from '@/store/features/commande/thunk';
import { ModalQuote } from '@/components/modal/ModalQuote';
import StatusBadgetLettre from '@/components/StatusBadgetLettre';
import { toast } from '@/components/ui/use-toast';
import ToggleSwitch from '@/components/ui/ToggleSwitch';
import ModalContact from '@/components/modal/ModalContact';
import { formatDateFRCourt } from '@/lib/utils';
import { UniversignType } from '@/types/sageTypes';
import { getAlluniversign } from '@/store/features/universign/selectors';
import ClientDetailsGrid from '@/components/page/client/ClientDetailsGrid';
import ClientContactsList from '@/components/page/client/ClientContactsList';
import { updateClient, updateStatus } from '@/store/features/client/thunk';
import { selectClient } from '@/store/features/client/slice';
import { ModalLoading } from '@/components/modal/ModalLoading';
import PDFPreview, { DocumentData } from '@/components/modal/PDFPreview';
import { usePDFPreview } from '@/components/ui/PDFActionButtons';
import { CompanyInfo } from '@/data/mockData';
import { downloadSignedDocument } from '@/store/features/universign/thunk';
import ClientInfoVerifier from '@/components/page/client/ClientInfoVerifier';
const ClientDetailPage = () => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const dispatch = useAppDispatch();
const [activeTab, setActiveTab] = useState('info');
const client = useAppSelector(getClientSelected) as Client;
const devis = useAppSelector(getAllDevis) as DevisListItem[];
const universign = useAppSelector(getAlluniversign) as UniversignType[];
const statusDevis = useAppSelector(devisStatus);
const commandes = useAppSelector(getAllcommandes) as Commande[];
const statusCommande = useAppSelector(commandeStatus);
const [isActive, setIsActive] = useState(client?.est_actif ?? true);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const { showPreview, openPreview, closePreview } = usePDFPreview();
const [isCreateModalContactOpen, setIsCreateModalContactOpen] = useState(false);
const [editing, setEditing] = useState<Contacts | null>(null);
useEffect(() => {
const load = async () => {
if (statusDevis === "idle") await dispatch(getDevisList()).unwrap();
if (statusCommande === "idle") await dispatch(getCommandes()).unwrap();
};
load();
}, [statusDevis, statusCommande, dispatch]);
if (!client) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-gray-500">Client non trouvé</p>
</div>
);
}
const devisSelected = useAppSelector(getDevisSelected) as DevisListItem;
const devisClient = devis.filter((item) => item.client_code === client.numero);
const commandeClient = commandes.filter((item) => item.client_code === client.numero);
const devisSignedClient = universign.filter(
(item) => item.local_status === "SIGNE" && item.sage_document_id && devisClient.some((d) => d.numero === item.sage_document_id)
);
console.log("devisSignedClient : ",devisSignedClient);
const timelineEventsDevis = devisClient
.map(d => ({
type: 'quote' as const,
title: 'Devis créé',
description: `${d.numero} pour ${d.total_ttc.toLocaleString('fr-FR')}`,
date: d.date,
user: d.statut,
link: `/home/devis/${d.numero}`,
item: d
}))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const timelineEventsCommande = commandeClient
.map(d => ({
type: 'commande' as const,
title: 'Commande créée',
description: `${d.numero} pour ${d.total_ttc.toLocaleString('fr-FR')}`,
date: d.date,
user: d.statut,
link: `/home/commandes/${d.numero}`,
item: d
}))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const timelineEvents = [
...timelineEventsDevis,
...timelineEventsCommande,
].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
const handleToggleActive = async (newStatus: boolean) => {
setIsActive(newStatus);
setIsLoading(true);
try {
const rep = await dispatch(updateStatus({
numero: client.numero,
est_actif: newStatus
})).unwrap() as any;
const dataResponse = rep.client as Client;
toast({
title: "Client mis à jour !",
description: `Le client ${dataResponse.numero} a été mis à jour avec succès.`,
className: "bg-green-500 text-white border-green-600"
});
dispatch(selectClient(dataResponse));
navigate(`/home/clients/${dataResponse.numero}`);
} catch (e: any) {
console.error("Erreur lors de la sauvegarde:", e);
toast({
title: "Erreur",
description: e.message || "Une erreur est survenue.",
variant: "destructive"
});
} finally {
setIsLoading(false);
}
};
// Fonction pour obtenir le libellé du type de tiers
const getTypeTiersLabel = (type: number): string => {
const types: Record<number, string> = {
0: 'Client',
1: 'Fournisseur',
2: 'Client & Fournisseur',
3: 'Autre'
};
return types[type] || 'Non défini';
};
const getBadges = () => {
const badges = [];
if (client.type_tiers === 1 || client.type_tiers === 2) {
badges.push({ label: 'Fournisseur', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300' });
}
if (!client.est_actif) {
badges.push({ label: 'Inactif', color: 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-300' });
}
if (client.est_prospect) {
badges.push({ label: 'Prospect', color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-300' });
}
return badges;
};
const tabs = [
{ id: 'info', label: 'Informations' },
{ id: 'contacts', label: 'Contacts', count: client.contacts?.length || 0 },
{ id: 'relances', label: 'Relances & Relevés' },
{ id: 'timeline', label: 'Timeline', count: timelineEvents.length || 0 },
{ id: 'documents', label: 'Documents', count: devisSignedClient.length || 0 },
{ id: 'pieces', label: 'Pièces', count:1 },
// { id: 'tickets', label: 'Tickets' },
];
const handleEdit = () => {
navigate(`/home/clients/${client.numero}/edit`);
};
const handleCreateContact = () => {
setEditing(null);
setIsCreateModalContactOpen(true);
};
const handleEditContact = (row: Contacts) => {
setEditing(row);
setIsCreateModalContactOpen(true);
};
const openPDF = async (row: DevisListItem) => {
await dispatch(selectDevisAsync(row)).unwrap();
openPreview();
};
const downloadPDF = async (transactionId: string) => {
try {
const blob = await dispatch(downloadSignedDocument(transactionId)).unwrap();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `document-${transactionId}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Erreur lors du téléchargement:', error);
}
};
const pdfData = useMemo(() => {
if (!devisSelected) return null;
return {
numero: devisSelected.numero,
type: 'devis' as const,
date: devisSelected.date,
client: {
code: devisSelected.client_code,
nom: devisSelected.client_intitule,
adresse: devisSelected.client_adresse,
code_postal: devisSelected.client_code_postal,
ville: devisSelected.client_ville,
email: devisSelected.client_email,
telephone: devisSelected.client_telephone,
},
reference_externe: devisSelected.reference,
lignes: (devisSelected.lignes ?? []).map(l => ({
article: l.article_code,
designation: l.designation,
quantite: l.quantite,
prix_unitaire: l.prix_unitaire_ht ?? 0,
tva: 20,
total_ht: l.quantite * (l.prix_unitaire_ht ?? 0),
})),
total_ht: devisSelected.total_ht_calcule,
total_tva: devisSelected.total_taxes_calcule,
total_ttc: devisSelected.total_ttc_calcule,
};
}, [devisSelected]) as DocumentData;
return (
<>
<Helmet>
<title>{client.intitule} - Clients - Dataven</title>
<meta name="description" content={`Fiche client ${client.intitule}`} />
</Helmet>
<div className="space-y-6">
<button
onClick={() => navigate('/home/clients')}
className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Retour aux clients
</button>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col gap-4"
>
{/* Carte principale du client */}
<div className="flex flex-row xl:flex-row xl:items-center justify-between gap-4 bg-white dark:bg-gray-950 p-6 rounded-2xl border border-gray-200 dark:border-gray-800 shadow-sm">
<div className="flex items-start gap-4">
<div
style={{ width: "7vh", height: "7vh" }}
className="w-16 h-16 rounded-2xl bg-[#007E45] text-white flex items-center justify-center text-2xl font-bold shadow-lg shadow-red-900/20"
>
{client.intitule?.split(' ').map(n => n[0]).join('').slice(0, 2)}
</div>
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{client.intitule}</h1>
<StatusBadgetLettre status={isActive ? 'actif' : 'inactif'} />
<ToggleSwitch isActive={isActive} onChange={handleToggleActive} />
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span className="font-mono">#{client.numero}</span>
{client.ville && (
<span className="flex items-center gap-1">
<MapPin className="w-3.5 h-3.5" /> {client.ville}, {client.pays || 'France'}
</span>
)}
{client.forme_juridique && (
<span className="flex items-center gap-1">
<Building2 className="w-3.5 h-3.5" /> {client.forme_juridique}
</span>
)}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2 h-10">
<PrimaryButton_v2 icon={Edit} onClick={handleEdit}>
Modifier
</PrimaryButton_v2>
<button
className="p-2.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400"
title="Plus d'actions"
>
<MoreVertical className="w-5 h-5" />
</button>
</div>
</div>
{/* Composant de vérification des informations */}
<ClientInfoVerifier client={client} />
</motion.div>
{/* Quick Actions */}
<div className="flex gap-2 overflow-x-auto pb-2">
<button onClick={() => navigate('/home/devis')} className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-900 whitespace-nowrap transition-colors">
<FileText className="w-4 h-4 text-blue-600" /> Devis
</button>
<button onClick={() => navigate('/home/commandes')} className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-900 whitespace-nowrap transition-colors">
<Package className="w-4 h-4 text-blue-600" /> Commander
</button>
<button onClick={() => navigate('/home/bons-livraison')} className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-900 whitespace-nowrap transition-colors">
<Truck className="w-4 h-4 text-orange-600" /> Livraison
</button>
<button onClick={() => navigate('/home/factures')} className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-900 whitespace-nowrap transition-colors">
<CreditCard className="w-4 h-4 text-purple-600" /> Enregistrer facture
</button>
</div>
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
<div className="mt-6">
{activeTab === 'info' && (
<ClientDetailsGrid client={client} />
)}
{activeTab === 'contacts' && (
<ClientContactsList
contacts={client.contacts!}
onCreateContact={handleCreateContact}
onEditContact={handleEditContact}
showDeleteButton={false}
/>
)}
{activeTab === 'relances' && (
<div className="space-y-6 animate-in fade-in">
<div className="flex justify-between items-center bg-red-50 dark:bg-red-900/20 p-4 rounded-xl border border-red-100 dark:border-red-800">
<div>
<h3 className="font-bold text-red-800 dark:text-red-200">Factures en retard : 0</h3>
<p className="text-sm text-red-600 dark:text-red-300 mt-1">
Montant total : <span className="font-bold">0 </span>
</p>
</div>
<div className="flex gap-2">
<button className="px-4 py-2 bg-white text-gray-700 border border-gray-200 rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors">
Envoyer Relevé
</button>
<PrimaryButton_v2 icon={Bell}>
Relancer maintenant
</PrimaryButton_v2>
</div>
</div>
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-sm text-left">
<thead className="bg-gray-50 dark:bg-gray-900 font-medium text-gray-500">
<tr>
<th className="px-6 py-3">N° Facture</th>
<th className="px-6 py-3">Date</th>
<th className="px-6 py-3">Échéance</th>
<th className="px-6 py-3">Montant TTC</th>
<th className="px-6 py-3">Reste </th>
<th className="px-6 py-3">Retard (j)</th>
<th className="px-6 py-3">Dernière Relance</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
Aucune facture en retard. Bon payeur ! 🎉
</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
{activeTab === 'timeline' && (
timelineEvents.length > 0 ? (
<Timeline events={timelineEvents} />
) : (
<div className="text-center py-12">
<FileText className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500">Aucun événement dans l'historique</p>
</div>
)
)}
{activeTab === 'documents' && (
<div className="space-y-6">
{/* <div className="bg-white dark:bg-gray-950 border border-dashed border-gray-200 dark:border-gray-800 rounded-2xl p-12 text-center hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors cursor-pointer">
<File className="w-12 h-12 mx-auto mb-4 text-gray-300" />
<p className="text-gray-600 dark:text-gray-400 font-medium text-sm">
Glissez-déposez vos fichiers ici
</p>
</div> */}
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl overflow-hidden shadow-sm">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800">
<tr className="text-left text-xs font-semibold text-gray-500 uppercase">
<th className="px-6 py-3">Fichier</th>
<th className="px-6 py-3">Type</th>
<th className="px-6 py-3">Date</th>
<th className="px-6 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{devisSignedClient.length > 0 ? (
devisSignedClient.map((item) => {
const devis = devisClient.find((dev) => dev.numero === item.sage_document_id) as DevisListItem
return (
<tr key={item.id} className="hover:bg-gray-50 dark:hover:bg-gray-900/50">
<td className="px-6 py-4 flex items-center gap-3 text-sm">
<FileText className="w-5 h-5 text-[#007E45]" />
<span className="font-medium text-gray-900 dark:text-white">
DEVIS_{item.sage_document_id}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500">PDF</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatDateFRCourt(item.created_at)}
</td>
<td className="px-6 py-4 text-right">
<button
// onClick={async () => openPDF(devis)}
onClick={async () => downloadPDF(item.transaction_id)}
className="text-blue-600 hover:underline text-sm font-medium mr-4"
>
Télécharger
</button>
<button className="text-[#007E45] hover:text-red-700">
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
)
})
) : (
<tr>
<td colSpan={4} className="px-6 py-8 text-center text-sm text-gray-500">
Aucun document signé pour ce client
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{activeTab === 'pieces' && (
<div className="space-y-6">
{/* Zone d'upload */}
<div className="relative">
<input
type="file"
multiple
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
onChange={(e) => console.log(e.target.files)}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
/>
<div className="border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-2xl p-8 text-center hover:border-[#007E45] hover:bg-green-50/50 dark:hover:bg-green-900/10 transition-all duration-300">
<div className="flex flex-col items-center gap-4">
<div className="p-4 bg-green-100 dark:bg-green-900/30 rounded-full">
<Upload className="w-8 h-8 text-[#007E45]" />
</div>
<div>
<p className="text-base font-semibold text-gray-900 dark:text-white">
Glissez-déposez vos fichiers ici
</p>
<p className="text-sm text-gray-500 mt-1">
ou <span className="text-[#007E45] font-medium hover:underline">parcourir</span> depuis votre ordinateur
</p>
</div>
<div className="flex flex-wrap justify-center gap-2 mt-2">
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded text-xs text-gray-500">PDF</span>
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded text-xs text-gray-500">DOC</span>
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded text-xs text-gray-500">XLS</span>
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded text-xs text-gray-500">PNG</span>
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded text-xs text-gray-500">JPG</span>
</div>
<p className="text-xs text-gray-400">Taille max: 10 Mo par fichier</p>
</div>
</div>
</div>
{/* Liste des fichiers */}
<div className="bg-white dark:bg-gray-950 rounded-2xl border border-gray-200 dark:border-gray-800 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-800 flex items-center justify-between">
<div className="flex items-center gap-3">
<Paperclip className="w-5 h-5 text-gray-400" />
<h3 className="font-semibold text-gray-900 dark:text-white">Pièces jointes</h3>
<span className="px-2 py-0.5 bg-gray-100 dark:bg-gray-800 rounded-full text-xs text-gray-500">
0 fichier
</span>
</div>
<button className="text-sm text-[#007E45] font-medium hover:underline flex items-center gap-1">
<Plus className="w-4 h-4" />
Ajouter
</button>
</div>
</div>
{/* Résumé stockage */}
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-xl p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<HardDrive className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">Espace utilisé</p>
<p className="text-xs text-gray-500">4.5 Mo sur 50 Mo disponibles</p>
</div>
</div>
<div className="w-32 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div className="h-full bg-[#007E45] rounded-full" style={{ width: '9%' }} />
</div>
</div>
</div>
)}
<PDFPreview open={showPreview} onClose={closePreview} data={pdfData} entreprise={CompanyInfo} />
</div>
</div>
{isLoading && <ModalLoading />}
<ModalQuote
open={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
title={"Créer un devis"}
client={client}
/>
<ModalContact
open={isCreateModalContactOpen}
onClose={() => setIsCreateModalContactOpen(false)}
title={editing ? `Mettre à jour le contact ${editing.nom}` : 'Créer un contact'}
editing={editing}
entityType='client'
/>
</>
);
};
export default ClientDetailPage;

View file

@ -0,0 +1,613 @@
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;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,652 @@
import React, { useState, useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { motion, AnimatePresence } from 'framer-motion';
import {
ChevronDown, ChevronUp, Info, CheckCircle2, AlertCircle,
Save, X, Building2, User, MapPin, Wallet, Tag,
FileText, Zap, Shield, ArrowLeft, UploadCloud
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { toast } from '@/components/ui/use-toast';
import { Switch } from '@/components/ui/switch';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
import { mockUsers } from '@/data/mockData';
// --- Schema Validation ---
const prospectSchema = z.object({
// 1. General
type: z.enum(['entreprise', 'particulier']),
name: z.string().min(2, "Le nom est requis"),
companyName: z.string().optional(),
siret: z.string().optional(),
category: z.string().optional(),
origin: z.string().optional(),
status: z.string().optional(),
assignedTo: z.string().optional(),
// 2. Coordinates
address1: z.string().min(5, "L'adresse est requise"),
address2: z.string().optional(),
zipCode: z.string().min(4, "Code postal requis"),
city: z.string().min(2, "Ville requise"),
country: z.string().default('France'),
// Contact
civility: z.string().optional(),
contactName: z.string().min(2, "Nom requis"),
contactFirstName: z.string().min(2, "Prénom requis"),
position: z.string().optional(),
email: z.string().email("Email invalide"),
phone: z.string().min(10, "Numéro invalide"),
phoneSecondary: z.string().optional(),
contactPref: z.enum(['email', 'phone', 'sms', 'whatsapp']).default('email'),
// 3. Company Info (Conditional in UI logic, permissive in schema)
legalForm: z.string().optional(),
employees: z.string().optional(),
revenue: z.string().optional(),
sector: z.string().optional(),
website: z.string().url("URL invalide").optional().or(z.literal('')),
linkedin: z.string().url("URL invalide").optional().or(z.literal('')),
// 4. Commercial
priceFamily: z.string().optional(),
accountingCategory: z.string().optional(),
accountingAccount: z.string().optional(),
paymentMethod: z.string().optional(),
paymentTerm: z.string().optional(),
billingConditions: z.string().optional(),
discountCommercial: z.string().optional(),
discountFinancial: z.string().optional(),
// 5. Technical
needsDescription: z.string().optional(),
products: z.array(z.string()).optional(),
interestLevel: z.enum(['low', 'medium', 'high']).default('medium'),
urgency: z.string().optional(),
budget: z.string().optional(),
followUpDate: z.string().optional(),
tags: z.string().optional(), // Simplification for tags as comma string
// 6. Notes
notes: z.string().optional(),
// 7. Automation
autoOpportunity: z.boolean().default(false),
autoTask: z.boolean().default(false),
sendWelcome: z.boolean().default(false),
autoAssign: z.boolean().default(false),
// 8. Permissions
visibility: z.enum(['me', 'team', 'all']).default('team'),
role: z.string().default('commercial'),
}).refine((data) => {
if (data.type === 'entreprise' && !data.companyName) {
return false;
}
return true;
}, {
message: "La raison sociale est requise pour une entreprise",
path: ["companyName"],
});
// --- Components ---
const Section = ({ title, icon: Icon, children, defaultOpen = true, progress = 100 }) => {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl overflow-hidden shadow-sm transition-all duration-200 hover:shadow-md">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between p-4 bg-gray-50/50 dark:bg-gray-900/50 hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors"
>
<div className="flex items-center gap-3">
<div className={cn("p-2 rounded-lg bg-red-50 dark:bg-red-900/20 text-[#007E45]")}>
<Icon className="w-5 h-5" />
</div>
<div className="text-left">
<h3 className="font-semibold text-gray-900 dark:text-white">{title}</h3>
</div>
</div>
<div className="flex items-center gap-3">
{progress < 100 && (
<span className="text-xs font-medium text-amber-600 bg-amber-50 px-2 py-1 rounded-full">
Incomplet
</span>
)}
{isOpen ? <ChevronUp className="w-5 h-5 text-gray-400" /> : <ChevronDown className="w-5 h-5 text-gray-400" />}
</div>
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="p-6 border-t border-gray-100 dark:border-gray-800">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
const InputGroup = ({ label, error, required, tooltip, children, className }) => (
<div className={cn("space-y-1.5", className)}>
<div className="flex items-center justify-between">
<label className="flex items-center gap-1 text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
{required && <span className="text-[#007E45]">*</span>}
{tooltip && (
<div className="group relative ml-1 cursor-help">
<Info className="w-3.5 h-3.5 text-gray-400" />
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-48 p-2 bg-gray-900 text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
{tooltip}
</div>
</div>
)}
</label>
</div>
<div className="relative">
{children}
{error ? (
<AlertCircle className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-red-500 pointer-events-none" />
) : !error && required && children?.props?.value ? (
<CheckCircle2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-green-500 pointer-events-none" />
) : null}
</div>
{error && <p className="text-xs text-red-500 font-medium animate-in slide-in-from-top-1">{error.message}</p>}
</div>
);
const CreateProspectPage = () => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [completion, setCompletion] = useState(0);
const { register, handleSubmit, watch, control, formState: { errors, isValid, touchedFields }, reset } = useForm({
resolver: zodResolver(prospectSchema),
mode: "onChange",
defaultValues: {
type: 'entreprise',
contactPref: 'email',
interestLevel: 'medium',
country: 'France',
autoOpportunity: false,
visibility: 'team'
}
});
const watchAllFields = watch();
const type = watch('type');
// Calculate completion percentage
useEffect(() => {
const requiredFields = ['name', 'address1', 'zipCode', 'city', 'contactName', 'contactFirstName', 'email', 'phone'];
if (type === 'entreprise') requiredFields.push('companyName');
const filled = requiredFields.filter(field => !!watchAllFields[field]);
setCompletion(Math.round((filled.length / requiredFields.length) * 100));
}, [watchAllFields, type]);
const onSubmit = async (data) => {
setIsLoading(true);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1500));
console.log(data);
toast({
title: "Prospect créé avec succès",
description: `${data.name} a été ajouté à votre base CRM.`,
variant: "success"
});
setIsLoading(false);
navigate('/prospects');
};
const inputClass = (error, success) => cn(
"w-full px-3 py-2 bg-white dark:bg-gray-950 border rounded-xl text-sm shadow-sm transition-all focus:outline-none focus:ring-2 pr-10",
error
? "border-red-300 focus:border-red-500 focus:ring-red-200"
: success
? "border-green-300 focus:border-green-500 focus:ring-green-200"
: "border-gray-200 dark:border-gray-800 focus:border-[#007E45] focus:ring-red-100/50"
);
return (
<>
<Helmet>
<title>Créer un prospect - Bijou ERP</title>
</Helmet>
<div className="max-w-5xl mx-auto pb-24 space-y-6">
{/* Header */}
<div className="space-y-4">
<nav className="flex items-center gap-2 text-sm text-gray-500">
<button onClick={() => navigate('/home')} className="hover:text-gray-900 dark:hover:text-white">Accueil</button>
<span>/</span>
<button onClick={() => navigate('/home/prospects')} className="hover:text-gray-900 dark:hover:text-white">CRM</button>
<span>/</span>
<span className="font-medium text-gray-900 dark:text-white">Créer un prospect</span>
</nav>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/home/prospects')}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors"
>
<ArrowLeft className="w-6 h-6 text-gray-500" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Nouveau Prospect</h1>
<p className="text-sm text-gray-500">Remplissez les informations pour créer une fiche prospect complète.</p>
</div>
</div>
<div className="hidden md:block w-64">
<div className="flex justify-between text-xs mb-1">
<span className="text-gray-500">Complétion de la fiche</span>
<span className="font-medium text-[#007E45]">{completion}%</span>
</div>
<Progress value={completion} className="h-2" />
</div>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* SECTION 1: INFORMATIONS GÉNÉRALES */}
<Section title="Informations Générales" icon={Building2}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<InputGroup label="Type de prospect" className="col-span-1 md:col-span-2">
<div className="flex gap-4">
{['entreprise', 'particulier'].map((val) => (
<label key={val} className={cn(
"flex-1 flex items-center justify-center gap-2 p-3 rounded-xl border cursor-pointer transition-all",
type === val
? "bg-red-50 border-[#007E45] text-[#007E45]"
: "bg-white border-gray-200 hover:bg-gray-50 text-gray-600"
)}>
<input
type="radio"
value={val}
{...register('type')}
className="hidden"
/>
<span className="capitalize font-medium">{val}</span>
</label>
))}
</div>
</InputGroup>
<InputGroup label="Nom du prospect" error={errors.name} required>
<input
{...register('name')}
placeholder="Nom d'affichage (ex: ACME Corp)"
className={inputClass(errors.name, touchedFields.name && !errors.name)}
/>
</InputGroup>
{type === 'entreprise' && (
<InputGroup label="Raison sociale" error={errors.companyName} required>
<input
{...register('companyName')}
placeholder="Raison sociale officielle"
className={inputClass(errors.companyName, touchedFields.companyName && !errors.companyName)}
/>
</InputGroup>
)}
<InputGroup label="SIRET / TVA">
<input
{...register('siret')}
placeholder="123 456 789 00012"
className={inputClass(errors.siret)}
/>
</InputGroup>
<InputGroup label="Catégorie">
<select {...register('category')} className={inputClass()}>
<option value="">Sélectionner...</option>
<option value="standard">Standard</option>
<option value="premium">Premium</option>
<option value="revendeur">Revendeur</option>
<option value="vip">VIP</option>
</select>
</InputGroup>
<InputGroup label="Origine">
<select {...register('origin')} className={inputClass()}>
<option value="">Sélectionner...</option>
<option value="web">Site Web</option>
<option value="phone">Téléphone</option>
<option value="email">Email</option>
<option value="social">Réseaux Sociaux</option>
<option value="partner">Partenaire</option>
</select>
</InputGroup>
<InputGroup label="Commercial attribué">
<select {...register('assignedTo')} className={inputClass()}>
<option value="">Sélectionner...</option>
{mockUsers.map(user => (
<option key={user.id} value={user.id}>{user.name}</option>
))}
</select>
</InputGroup>
</div>
</Section>
{/* SECTION 2: COORDONNÉES */}
<Section title="Coordonnées & Contact" icon={MapPin}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4">
<h4 className="font-medium text-gray-900 border-b pb-2">Adresse</h4>
<InputGroup label="Adresse" error={errors.address1} required>
<input {...register('address1')} placeholder="123 Rue de la Paix" className={inputClass(errors.address1)} />
</InputGroup>
<InputGroup label="Complément">
<input {...register('address2')} placeholder="Bâtiment B" className={inputClass()} />
</InputGroup>
<div className="grid grid-cols-2 gap-4">
<InputGroup label="Code Postal" error={errors.zipCode} required>
<input {...register('zipCode')} placeholder="75001" className={inputClass(errors.zipCode)} />
</InputGroup>
<InputGroup label="Ville" error={errors.city} required>
<input {...register('city')} placeholder="Paris" className={inputClass(errors.city)} />
</InputGroup>
</div>
<InputGroup label="Pays">
<select {...register('country')} className={inputClass()}>
<option value="France">France</option>
<option value="Belgium">Belgique</option>
<option value="Switzerland">Suisse</option>
<option value="Germany">Allemagne</option>
<option value="Spain">Espagne</option>
</select>
</InputGroup>
</div>
<div className="space-y-4">
<h4 className="font-medium text-gray-900 border-b pb-2">Contact Principal</h4>
<div className="grid grid-cols-3 gap-4">
<InputGroup label="Civilité">
<select {...register('civility')} className={inputClass()}>
<option value="M.">M.</option>
<option value="Mme">Mme</option>
</select>
</InputGroup>
<InputGroup label="Prénom" className="col-span-2" error={errors.contactFirstName} required>
<input {...register('contactFirstName')} className={inputClass(errors.contactFirstName)} />
</InputGroup>
</div>
<InputGroup label="Nom" error={errors.contactName} required>
<input {...register('contactName')} className={inputClass(errors.contactName)} />
</InputGroup>
<InputGroup label="Email" error={errors.email} required>
<input type="email" {...register('email')} className={inputClass(errors.email)} />
</InputGroup>
<InputGroup label="Téléphone" error={errors.phone} required>
<input type="tel" {...register('phone')} className={inputClass(errors.phone)} />
</InputGroup>
<InputGroup label="Préférence de contact">
<div className="flex gap-2 mt-2">
{['email', 'phone', 'sms', 'whatsapp'].map(pref => (
<label key={pref} className={cn(
"px-3 py-1.5 rounded-lg text-xs font-medium border cursor-pointer transition-colors",
watchAllFields.contactPref === pref
? "bg-gray-900 text-white border-gray-900"
: "bg-white text-gray-600 border-gray-200 hover:bg-gray-50"
)}>
<input type="radio" value={pref} {...register('contactPref')} className="hidden" />
<span className="capitalize">{pref}</span>
</label>
))}
</div>
</InputGroup>
</div>
</div>
</Section>
{/* SECTION 3: COMPANY INFO (Conditional) */}
{type === 'entreprise' && (
<Section title="Informations Entreprise" icon={Building2}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<InputGroup label="Forme Juridique">
<select {...register('legalForm')} className={inputClass()}>
<option value="">Sélectionner...</option>
<option value="SARL">SARL</option>
<option value="SAS">SAS</option>
<option value="SA">SA</option>
<option value="EURL">EURL</option>
<option value="Auto">Auto-entrepreneur</option>
</select>
</InputGroup>
<InputGroup label="Effectif">
<select {...register('employees')} className={inputClass()}>
<option value="1-10">1-10</option>
<option value="11-50">11-50</option>
<option value="51-200">51-200</option>
<option value="200+">200+</option>
</select>
</InputGroup>
<InputGroup label="CA Annuel (€)">
<input {...register('revenue')} placeholder="ex: 150 000" className={inputClass()} />
</InputGroup>
<InputGroup label="Secteur d'activité" className="md:col-span-2">
<select {...register('sector')} className={inputClass()}>
<option value="">Sélectionner...</option>
<option value="IT">Informatique & Télécoms</option>
<option value="Retail">Commerce & Distribution</option>
<option value="Industry">Industrie</option>
<option value="Services">Services</option>
<option value="Construction">BTP</option>
</select>
</InputGroup>
<InputGroup label="Site Web" error={errors.website}>
<input {...register('website')} placeholder="https://" className={inputClass(errors.website)} />
</InputGroup>
<InputGroup label="LinkedIn" className="md:col-span-3" error={errors.linkedin}>
<input {...register('linkedin')} placeholder="https://linkedin.com/company/..." className={inputClass(errors.linkedin)} />
</InputGroup>
</div>
</Section>
)}
{/* SECTION 4: COMMERCIAL & COMPTA */}
<Section title="Commercial & Comptabilité" icon={Wallet} defaultOpen={false}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<InputGroup label="Mode de règlement">
<select {...register('paymentMethod')} className={inputClass()}>
<option value="virement">Virement</option>
<option value="cheque">Chèque</option>
<option value="cb">Carte Bancaire</option>
<option value="prelevement">Prélèvement</option>
</select>
</InputGroup>
<InputGroup label="Délai de paiement">
<select {...register('paymentTerm')} className={inputClass()}>
<option value="30">30 jours</option>
<option value="45">45 jours</option>
<option value="60">60 jours</option>
<option value="immediate">Réception de facture</option>
</select>
</InputGroup>
<InputGroup label="Compte comptable">
<input {...register('accountingAccount')} placeholder="411..." className={inputClass()} />
</InputGroup>
<div className="grid grid-cols-2 gap-4">
<InputGroup label="Remise Com. (%)">
<input type="number" {...register('discountCommercial')} placeholder="0" className={inputClass()} />
</InputGroup>
<InputGroup label="Remise Fin. (%)">
<input type="number" {...register('discountFinancial')} placeholder="0" className={inputClass()} />
</InputGroup>
</div>
<InputGroup label="Conditions de facturation" className="md:col-span-2">
<textarea {...register('billingConditions')} rows={3} className={inputClass()} placeholder="Conditions particulières..." />
</InputGroup>
</div>
</Section>
{/* SECTION 5: BESOINS TECHNIQUES */}
<Section title="Données Techniques & Besoins" icon={Zap}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<InputGroup label="Niveau d'intérêt">
<div className="flex gap-4">
{[
{ val: 'low', label: 'Faible', color: 'bg-gray-100 text-gray-600' },
{ val: 'medium', label: 'Moyen', color: 'bg-orange-100 text-orange-600' },
{ val: 'high', label: 'Fort', color: 'bg-green-100 text-green-600' }
].map(opt => (
<label key={opt.val} className={cn(
"flex-1 p-3 text-center rounded-xl border cursor-pointer transition-all font-medium",
watchAllFields.interestLevel === opt.val ? `border-current ${opt.color}` : "border-gray-200 hover:bg-gray-50"
)}>
<input type="radio" value={opt.val} {...register('interestLevel')} className="hidden" />
{opt.label}
</label>
))}
</div>
</InputGroup>
<InputGroup label="Urgence">
<select {...register('urgency')} className={inputClass()}>
<option value="now">Immédiat</option>
<option value="1month">Dans le mois</option>
<option value="3months">3 mois</option>
<option value="later">Plus tard</option>
</select>
</InputGroup>
<InputGroup label="Description du besoin" className="md:col-span-2">
<textarea
{...register('needsDescription')}
rows={4}
className={inputClass()}
placeholder="Détaillez le besoin du client..."
/>
<p className="text-xs text-gray-500 text-right mt-1">
{watchAllFields.needsDescription?.length || 0} caractères
</p>
</InputGroup>
<InputGroup label="Budget Estimé (€)">
<input {...register('budget')} type="number" className={inputClass()} />
</InputGroup>
<InputGroup label="Tags">
<input {...register('tags')} placeholder="vip, chaud, tech..." className={inputClass()} />
</InputGroup>
</div>
</Section>
{/* SECTION 6: NOTES & HISTORIQUE */}
<Section title="Notes & Documents" icon={FileText} defaultOpen={false}>
<div className="space-y-6">
<InputGroup label="Commentaire interne">
<textarea {...register('notes')} rows={4} className={inputClass()} placeholder="Notes réservées à l'équipe..." />
</InputGroup>
<div className="border-2 border-dashed border-gray-200 dark:border-gray-800 rounded-xl p-8 text-center hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors cursor-pointer">
<UploadCloud className="w-10 h-10 text-gray-400 mx-auto mb-3" />
<p className="text-sm text-gray-600 dark:text-gray-400">Glissez-déposez des fichiers ici ou cliquez pour uploader</p>
<p className="text-xs text-gray-400 mt-1">(PDF, DOCX, JPG - Max 10Mo)</p>
</div>
</div>
</Section>
{/* SECTION 7: ACTIONS AUTOMATIQUES */}
<Section title="Actions Automatisées" icon={Zap} defaultOpen={false}>
<div className="space-y-4">
{[
{ id: 'autoOpportunity', label: 'Créer automatiquement une opportunité', desc: 'Ajoute une opportunité dans le pipeline par défaut' },
{ id: 'autoTask', label: 'Créer une tâche de suivi', desc: 'Rappel automatique dans 3 jours' },
{ id: 'sendWelcome', label: 'Envoyer un email de bienvenue', desc: 'Template "Nouveau prospect standard"' },
{ id: 'autoAssign', label: 'Assignation intelligente', desc: 'Attribuer au commercial le plus disponible' }
].map((action) => (
<div key={action.id} className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
<div>
<p className="font-medium text-gray-900 dark:text-white">{action.label}</p>
<p className="text-xs text-gray-500">{action.desc}</p>
</div>
<Controller
name={action.id}
control={control}
render={({ field }) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
</div>
))}
</div>
</Section>
{/* SECTION 8: PERMISSIONS */}
<Section title="Permissions & Visibilité" icon={Shield} defaultOpen={false}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<InputGroup label="Visible par">
<div className="space-y-2">
{[
{ val: 'me', label: 'Moi uniquement' },
{ val: 'team', label: 'Mon équipe commerciale' },
{ val: 'all', label: 'Toute l\'entreprise' }
].map(opt => (
<label key={opt.val} className="flex items-center gap-3 p-3 border rounded-xl cursor-pointer hover:bg-gray-50">
<input type="radio" value={opt.val} {...register('visibility')} className="w-4 h-4 text-[#007E45]" />
<span className="text-sm font-medium">{opt.label}</span>
</label>
))}
</div>
</InputGroup>
</div>
</Section>
{/* STICKY ACTIONS */}
<div className="sticky bottom-4 z-10 bg-white/90 dark:bg-gray-950/90 backdrop-blur-md border border-gray-200 dark:border-gray-800 p-4 rounded-2xl shadow-2xl flex items-center justify-between">
<button
type="button"
onClick={() => navigate('/prospects')}
className="px-6 py-2.5 text-sm font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 rounded-xl transition-colors"
>
Annuler
</button>
<div className="flex items-center gap-3">
<button
type="button"
className="px-6 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 rounded-xl transition-colors"
>
Enregistrer comme brouillon
</button>
<button
type="submit"
disabled={isLoading || !isValid}
className="inline-flex items-center gap-2 px-8 py-2.5 bg-[#007E45] text-white text-sm font-medium rounded-xl hover:bg-[#7a1002] disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-red-900/20"
>
{isLoading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Créer le prospect
</button>
</div>
</div>
</form>
</div>
</>
);
};
export default CreateProspectPage;

View file

@ -0,0 +1,115 @@
import React, { useState } from 'react';
import { Helmet } from 'react-helmet';
import { Plus } from 'lucide-react';
import KanbanBoard from '@/components/KanbanBoard';
import PrimaryButton from '@/components/PrimaryButton';
import SmartForm from '@/components/SmartForm';
import FormModal from '@/components/FormModal';
import { mockOpportunities } from '@/data/mockData';
import { z } from 'zod';
import { toast } from '@/components/ui/use-toast';
const opportunitySchema = z.object({
name: z.string().min(3, "Le nom est requis"),
clientId: z.string().min(1, "Le client est requis"),
amount: z.string().min(1, "Le montant est requis"),
stage: z.string().min(1, "L'étape est requise"),
probability: z.string().optional(),
closeDate: z.string().min(1, "Date requise"),
description: z.string().optional()
});
const OpportunitiesPage = () => {
const [items, setItems] = useState(mockOpportunities);
const [isModalOpen, setIsModalOpen] = useState(false);
const columns = [
{ id: 'new', title: 'Nouveau' },
{ id: 'qualification', title: 'Qualification' },
{ id: 'proposal', title: 'Proposition' },
{ id: 'negotiation', title: 'Négociation' },
{ id: 'won', title: 'Gagné' },
{ id: 'lost', title: 'Perdu' }
];
const handleDragEnd = (result) => {
if (!result.destination) return;
const { source, destination, draggableId } = result;
if (source.droppableId === destination.droppableId) return;
const newItems = items.map(item =>
item.id.toString() === draggableId
? { ...item, stage: destination.droppableId }
: item
);
setItems(newItems);
toast({
title: "Étape mise à jour",
description: "L'opportunité a changé d'étape avec succès.",
});
};
const handleCreate = (data) => {
// Simulation
const newOpp = {
id: Math.random(),
...data,
amount: parseFloat(data.amount),
probability: parseInt(data.probability) || 0,
client: "Client Simulé", // In real app, lookup client name
owner: "Jean Dupont"
};
setItems([...items, newOpp]);
setIsModalOpen(false);
toast({ title: "Opportunité créée", description: `${data.name} a été ajouté au pipeline.` });
};
return (
<>
<Helmet>
<title>Pipeline - Bijou ERP</title>
</Helmet>
<div className="h-[calc(100vh-120px)] flex flex-col">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Pipeline</h1>
<PrimaryButton icon={Plus} onClick={() => setIsModalOpen(true)}>Nouvelle opportunité</PrimaryButton>
</div>
<div className="flex-1 overflow-hidden">
<KanbanBoard
columns={columns}
items={items}
onDragEnd={handleDragEnd}
onItemClick={(item) => toast({ title: "Détail", description: `Ouverture de ${item.name}` })}
/>
</div>
</div>
<FormModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Nouvelle opportunité"
>
<SmartForm
schema={opportunitySchema}
onSubmit={handleCreate}
onCancel={() => setIsModalOpen(false)}
fields={[
{ name: 'name', label: 'Nom de l\'opportunité', placeholder: 'Ex: Contrat annuel 2025' },
{ name: 'clientId', label: 'Client', type: 'select', options: [{ value: '1', label: 'ACME Corp' }, { value: '2', label: 'TechStart' }] },
{ name: 'amount', label: 'Montant (€)', type: 'number' },
{ name: 'stage', label: 'Étape', type: 'select', options: columns.map(c => ({ value: c.id, label: c.title })) },
{ name: 'probability', label: 'Probabilité (%)', type: 'number' },
{ name: 'closeDate', label: 'Date de clôture estimée', type: 'date' },
{ name: 'description', label: 'Description', type: 'textarea', fullWidth: true },
]}
/>
</FormModal>
</>
);
};
export default OpportunitiesPage;

View file

@ -0,0 +1,133 @@
import React, { useState, useMemo } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { Plus, Search, Filter, Target, TrendingUp, XCircle, Euro } from 'lucide-react';
import KanbanBoard from '@/components/KanbanBoard';
import PrimaryButton from '@/components/PrimaryButton';
import KPIBar from '@/components/KPIBar';
import { mockOpportunities, calculateKPIs } from '@/data/mockData';
import { toast } from '@/components/ui/use-toast';
const OpportunitiesPipelinePage = () => {
const navigate = useNavigate();
const [opportunities, setOpportunities] = useState(mockOpportunities);
const [searchTerm, setSearchTerm] = useState('');
const [period, setPeriod] = useState('month');
const kpis = useMemo(() => {
const stats = calculateKPIs(opportunities, period, { dateField: 'createdAt', amountField: 'amount' });
const won = stats.items.filter(o => o.stage === 'won');
const lost = stats.items.filter(o => o.stage === 'lost');
// Total pipeline value for ALL opportunities (not just filtered period for context)
const totalPipeline = opportunities.reduce((sum, o) => sum + o.amount, 0);
return [
{ title: 'Total Pipeline', value: `${totalPipeline.toLocaleString()}`, change: '', trend: 'neutral', icon: Euro },
{ title: 'Opps Créées', value: stats.filteredCount, change: '+5', trend: 'up', icon: Target },
{ title: 'Gagnées', value: won.length, change: '+2', trend: 'up', icon: TrendingUp },
{ title: 'Montant Gagné', value: `${won.reduce((acc, o) => acc + o.amount, 0).toLocaleString()}`, change: '+8%', trend: 'up', icon: Euro },
{ title: 'Perdues', value: lost.length, change: '', trend: 'neutral', icon: XCircle },
];
}, [period, opportunities]);
const columns = [
{ id: 'new', title: 'Nouveau' },
{ id: 'qualification', title: 'Qualification' },
{ id: 'discovery', title: 'Découverte/RDV' },
{ id: 'proposal', title: 'Proposition' },
{ id: 'negotiation', title: 'Négociation' },
{ id: 'won', title: 'Gagné' },
{ id: 'lost', title: 'Perdu' },
];
const handleDragEnd = (result) => {
if (!result.destination) return;
const { source, destination, draggableId } = result;
if (source.droppableId === destination.droppableId) return;
const updatedOpportunities = opportunities.map(opp => {
if (opp.id.toString() === draggableId) {
return { ...opp, stage: destination.droppableId };
}
return opp;
});
setOpportunities(updatedOpportunities);
toast({
title: "Opportunité mise à jour",
description: "Le statut de l'opportunité a été modifié.",
});
};
const handleCardAction = (action, item) => {
if (action === 'quote') {
navigate('/devis', { state: { opportunityId: item.id } });
toast({ title: "Nouveau devis", description: `Création d'un devis pour ${item.name}` });
} else if (action === 'task') {
toast({ title: "Nouvelle tâche", description: "Ajouter une tâche de rappel" });
} else {
toast({ title: "Note ajoutée", description: "Note rapide ajoutée" });
}
};
const filteredOpportunities = opportunities.filter(opp =>
opp.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
opp.client.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<>
<Helmet>
<title>Pipeline Opportunités - Bijou ERP</title>
</Helmet>
<div className="h-[calc(100vh-6rem)] flex flex-col space-y-6">
<KPIBar kpis={kpis} period={period} onPeriodChange={setPeriod} />
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 flex-shrink-0">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Pipeline des ventes</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{filteredOpportunities.length} opportunités actives {filteredOpportunities.reduce((sum, o) => sum + o.amount, 0).toLocaleString()}
</p>
</div>
<div className="flex gap-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Rechercher..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 pr-4 py-2 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#007E45]"
/>
</div>
<button className="p-2 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-900 text-gray-600 dark:text-gray-400">
<Filter className="w-5 h-5" />
</button>
<PrimaryButton icon={Plus} onClick={() => toast({ title: "Non implémenté", description: "Utilisez le bouton Nouveau Prospect pour l'instant" })}>
Nouvelle opportunité
</PrimaryButton>
</div>
</div>
{/* Kanban */}
<div className="flex-1 overflow-hidden min-h-0">
<KanbanBoard
columns={columns}
items={filteredOpportunities}
onDragEnd={handleDragEnd}
onItemClick={(item) => navigate(`/opportunites/${item.id}`)}
onActionItem={handleCardAction}
/>
</div>
</div>
</>
);
};
export default OpportunitiesPipelinePage;

View file

@ -0,0 +1,213 @@
import React, { useState } from 'react';
import { Helmet } from 'react-helmet';
import { useParams, useNavigate } from 'react-router-dom';
import {
ArrowLeft, Calendar, User, DollarSign, FileText, Plus,
Building2, Phone, Mail, Trash2, Edit, Paperclip, Link, Send
} from 'lucide-react';
import { motion } from 'framer-motion';
import Tabs from '@/components/Tabs';
import StatusBadge from '@/components/StatusBadget';
import PrimaryButton from '@/components/PrimaryButton';
import Timeline from '@/components/Timeline';
import DataTable from '@/components/DataTable';
import { mockOpportunities, mockTimeline, mockClients, mockContacts, mockQuotes } from '@/data/mockData';
import { toast } from '@/components/ui/use-toast';
import FormModal from '@/components/FormModal';
import SmartForm from '@/components/SmartForm';
import { z } from 'zod';
const OpportunityDetailPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState('details');
const opportunity = mockOpportunities.find(o => o.id === parseInt(id));
if (!opportunity) return <div className="p-8 text-center">Opportunité introuvable</div>;
const client = mockClients.find(c => c.id === opportunity.clientId) || {};
const contact = mockContacts.find(c => c.id === opportunity.contactId) || {};
const relatedQuotes = mockQuotes.filter(q => q.opportunityId === opportunity.id);
const tabs = [
{ id: 'details', label: 'Détails' },
{ id: 'contacts', label: 'Contacts associés' },
{ id: 'company', label: 'Entreprise associée' },
{ id: 'quotes', label: 'Pièces commerciales' },
{ id: 'timeline', label: 'Timeline' },
{ id: 'documents', label: 'Documents' }
];
return (
<>
<Helmet>
<title>{opportunity.name} - Opportunité - Bijou ERP</title>
</Helmet>
<div className="space-y-6 max-w-7xl mx-auto">
{/* Navigation */}
<div className="flex items-center justify-between">
<nav className="flex items-center gap-2 text-sm text-gray-500">
<button onClick={() => navigate('/opportunites')} className="hover:text-gray-900 dark:hover:text-white flex items-center gap-1">
<ArrowLeft className="w-4 h-4" />
Pipeline
</button>
<span className="text-gray-300">/</span>
<span className="font-medium text-gray-900 dark:text-white">{opportunity.name}</span>
</nav>
</div>
{/* Header */}
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6 shadow-sm">
<div className="flex flex-col md:flex-row md:items-start justify-between gap-6">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{opportunity.name}</h1>
<StatusBadge status={opportunity.stage} />
</div>
<div className="flex flex-wrap gap-4 text-sm text-gray-500">
<span className="flex items-center gap-1"><Building2 className="w-4 h-4" /> {client.company || opportunity.client}</span>
<span className="flex items-center gap-1"><User className="w-4 h-4" /> {contact.firstName} {contact.lastName}</span>
<span className="flex items-center gap-1 text-[#941403] font-semibold"><DollarSign className="w-4 h-4" /> {opportunity.amount.toLocaleString()}</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button className="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl" title="Modifier">
<Edit className="w-5 h-5" />
</button>
<button className="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-xl" title="Supprimer">
<Trash2 className="w-5 h-5" />
</button>
<PrimaryButton icon={Link} onClick={() => toast({ title: "Associer un devis", description: "Sélectionnez un devis existant" })} className="bg-gray-900 hover:bg-gray-800">
Lier Devis
</PrimaryButton>
<PrimaryButton icon={FileText} onClick={() => navigate('/devis')}>
Créer Devis
</PrimaryButton>
</div>
</div>
{/* Progress Bar */}
<div className="mt-6">
<div className="flex justify-between text-xs text-gray-500 mb-2">
<span>Probabilité de succès</span>
<span>{opportunity.probability}%</span>
</div>
<div className="h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-orange-500 to-[#941403] transition-all duration-1000"
style={{ width: `${opportunity.probability}%` }}
/>
</div>
</div>
</div>
{/* Content */}
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6 shadow-sm min-h-[500px]">
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
<div className="mt-6">
{activeTab === 'details' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 animate-in fade-in slide-in-from-bottom-2">
<div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wider">Général</h3>
<dl className="space-y-3 text-sm">
<div className="grid grid-cols-3"><dt className="text-gray-500">Propriétaire</dt><dd className="col-span-2 font-medium">{opportunity.owner}</dd></div>
<div className="grid grid-cols-3"><dt className="text-gray-500">Priorité</dt><dd className="col-span-2"><StatusBadge status={opportunity.priority || 'normal'} /></dd></div>
<div className="grid grid-cols-3"><dt className="text-gray-500">Date clôture</dt><dd className="col-span-2 font-medium">{new Date(opportunity.closeDate).toLocaleDateString()}</dd></div>
<div className="grid grid-cols-3"><dt className="text-gray-500">Créé le</dt><dd className="col-span-2">{new Date(opportunity.createdAt).toLocaleDateString()}</dd></div>
</dl>
</div>
<div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wider">Description & Besoins</h3>
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-xl text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
{opportunity.description}
</div>
</div>
</div>
)}
{activeTab === 'contacts' && (
<div className="space-y-4 animate-in fade-in">
{contact.id ? (
<div className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-800 rounded-xl">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 font-bold">
{contact.firstName[0]}{contact.lastName[0]}
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">{contact.firstName} {contact.lastName}</p>
<p className="text-xs text-gray-500">{contact.role}</p>
</div>
</div>
<div className="flex gap-2">
<button className="p-2 hover:bg-gray-100 rounded-full"><Mail className="w-4 h-4 text-gray-500" /></button>
<button className="p-2 hover:bg-gray-100 rounded-full"><Phone className="w-4 h-4 text-gray-500" /></button>
</div>
</div>
) : <div className="text-center py-8 text-gray-500">Aucun contact associé</div>}
<button className="w-full py-3 border-2 border-dashed border-gray-200 dark:border-gray-800 rounded-xl text-sm text-gray-500 hover:border-[#941403] hover:text-[#941403] transition-colors">
+ Ajouter un contact
</button>
</div>
)}
{activeTab === 'company' && (
<div className="p-6 border border-gray-200 dark:border-gray-800 rounded-xl animate-in fade-in">
<div className="flex items-center gap-4 mb-6">
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center"><Building2 className="w-6 h-6 text-gray-600" /></div>
<div>
<h3 className="text-lg font-bold">{client.company}</h3>
<p className="text-sm text-gray-500">{client.industry} {client.city}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div><label className="text-xs text-gray-500">Email</label><p className="font-medium">{client.email}</p></div>
<div><label className="text-xs text-gray-500">Téléphone</label><p className="font-medium">{client.phone}</p></div>
</div>
</div>
)}
{activeTab === 'quotes' && (
<div className="space-y-4 animate-in fade-in">
{relatedQuotes.length > 0 ? (
<DataTable
columns={[
{ key: 'number', label: 'Numéro' },
{ key: 'date', label: 'Date' },
{ key: 'amountHT', label: 'Montant HT', render: (v) => `${v.toLocaleString()}` },
{ key: 'status', label: 'Statut', render: (v) => <StatusBadge status={v} /> }
]}
data={relatedQuotes}
actions={(row) => (
<button onClick={() => navigate(`/devis/${row.id}`)} className="p-2 hover:bg-gray-100 rounded"><ArrowLeft className="w-4 h-4 rotate-180" /></button>
)}
/>
) : (
<div className="text-center py-12">
<FileText className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500">Aucune pièce commerciale associée</p>
</div>
)}
</div>
)}
{activeTab === 'timeline' && <Timeline events={mockTimeline.slice(0, 8)} />}
{activeTab === 'documents' && (
<div className="text-center py-12 border-2 border-dashed border-gray-200 rounded-xl animate-in fade-in">
<Paperclip className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 mb-4">Aucun document pour l'instant</p>
<PrimaryButton>Téléverser un fichier</PrimaryButton>
</div>
)}
</div>
</div>
</div>
</>
);
};
export default OpportunityDetailPage;

View file

@ -0,0 +1,81 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { motion } from 'framer-motion';
import { mockOpportunities } from '@/data/mockData';
import { Euro } from 'lucide-react';
const PipelinePage = () => {
const stages = ['new', 'qualification', 'proposal', 'negotiation', 'won', 'lost'];
const stageLabels = {
new: 'Nouveau',
qualification: 'Qualification',
proposal: 'Proposition',
negotiation: 'Négociation',
won: 'Gagné',
lost: 'Perdu'
};
const getOpportunitiesByStage = (stage) => {
return mockOpportunities.filter(opp => opp.stage === stage);
};
return (
<>
<Helmet>
<title>Pipeline - Bijou ERP</title>
<meta name="description" content="Pipeline des opportunités" />
</Helmet>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Pipeline des ventes</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">Vue Kanban de vos opportunités</p>
</div>
<div className="flex gap-4 overflow-x-auto pb-4">
{stages.map((stage) => {
const opportunities = getOpportunitiesByStage(stage);
const totalValue = opportunities.reduce((sum, opp) => sum + opp.value, 0);
return (
<div key={stage} className="flex-shrink-0 w-80">
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-900 dark:text-white">{stageLabels[stage]}</h3>
<span className="text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded-lg">
{opportunities.length}
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">{totalValue.toLocaleString()}</p>
<div className="space-y-3">
{opportunities.map((opp) => (
<motion.div
key={opp.id}
whileHover={{ scale: 1.02 }}
className="p-4 bg-gray-50 dark:bg-gray-900 rounded-xl cursor-pointer hover:shadow-md transition-all"
>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-1">{opp.name}</h4>
<p className="text-xs text-gray-600 dark:text-gray-400 mb-2">{opp.client}</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-[#941403]">
<Euro className="w-4 h-4" />
<span className="text-sm font-semibold">{opp.value.toLocaleString()}</span>
</div>
<span className="text-xs text-gray-600 dark:text-gray-400">{opp.probability}%</span>
</div>
</motion.div>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
</>
);
};
export default PipelinePage;

View file

@ -0,0 +1,274 @@
import React, { useState } from 'react';
import { Helmet } from 'react-helmet';
import { useParams, useNavigate } from 'react-router-dom';
import {
ArrowLeft, Mail, Phone, MapPin, Tag, FileText, Clock,
Ticket, Plus, Edit, Trash2, Lightbulb, Building2, Wallet
} from 'lucide-react';
import { motion } from 'framer-motion';
import Tabs from '@/components/Tabs';
import StatusBadge from '@/components/StatusBadget';
import PrimaryButton from '@/components/PrimaryButton';
import Timeline from '@/components/Timeline';
import { toast } from '@/components/ui/use-toast';
import { mockClients, mockTimeline, mockOpportunities } from '@/data/mockData';
import ProspectFormModal from '@/components/forms/ProspectFormModal';
import { cn } from '@/lib/utils';
const ProspectDetailPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState('info');
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
// Simulate fetching prospect from mockClients (assuming filtered by ID)
const prospect = mockClients.find(c => c.id === parseInt(id));
if (!prospect) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px]">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Prospect non trouvé</h2>
<button onClick={() => navigate('/prospects')} className="mt-4 text-[#007E45] hover:underline">Retour à la liste</button>
</div>
);
}
const tabs = [
{ id: 'info', label: 'Informations' },
{ id: 'timeline', label: 'Historique' },
{ id: 'opportunities', label: 'Opportunités' },
{ id: 'documents', label: 'Documents' },
];
// Filter timeline and opportunities for this mock prospect
// In a real app, this would be an API call filtering by prospect_id
const prospectTimeline = mockTimeline.slice(0, 5);
const prospectOpportunities = mockOpportunities.slice(0, 2).map(o => ({ ...o, client: prospect.company }));
return (
<>
<Helmet>
<title>{prospect.name} - Prospects - Bijou ERP</title>
<meta name="description" content={`Fiche prospect ${prospect.name}`} />
</Helmet>
<div className="space-y-6">
{/* Breadcrumbs & Back */}
<div className="flex items-center justify-between">
<nav className="flex items-center gap-2 text-sm text-gray-500">
<button onClick={() => navigate('/home/prospects')} className="hover:text-gray-900 dark:hover:text-white flex items-center gap-1">
<ArrowLeft className="w-4 h-4" />
Prospects
</button>
<span className="text-gray-300">/</span>
<span className="font-medium text-gray-900 dark:text-white">{prospect.name}</span>
</nav>
</div>
{/* Header Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6 shadow-sm"
>
<div className="flex flex-col md:flex-row md:items-start justify-between gap-6">
<div className="flex items-start gap-5">
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-[#007E45] to-green-700 text-white flex items-center justify-center text-3xl font-bold shadow-lg shadow-red-900/20">
{prospect.name.charAt(0)}
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{prospect.name}</h1>
<div className="flex items-center gap-2 mt-1 text-gray-500">
<Building2 className="w-4 h-4" />
<span>{prospect.company}</span>
</div>
<div className="flex items-center gap-3 mt-4">
<div className={cn(
"px-3 py-1 rounded-full text-xs font-medium border",
prospect.status === 'active' ? "bg-green-50 text-green-700 border-green-200" : "bg-blue-50 text-blue-700 border-blue-200"
)}>
{prospect.status === 'active' ? 'Converti' : 'Nouveau Prospect'}
</div>
<span className="flex items-center gap-1 text-xs text-gray-500">
<Clock className="w-3 h-3" /> Dernière activité: {prospect.lastActivity}
</span>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => setIsEditModalOpen(true)}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<Edit className="w-4 h-4" />
Modifier
</button>
<PrimaryButton
icon={Lightbulb}
onClick={() => toast({ title: "Opportunité créée", description: "Nouvelle opportunité ajoutée au pipeline" })}
>
Créer opportunité
</PrimaryButton>
<button className="p-2 text-green-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-xl transition-colors">
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
{/* Quick Info Bar */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-8 pt-6 border-t border-gray-100 dark:border-gray-800">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-50 dark:bg-gray-900 rounded-lg">
<Mail className="w-4 h-4 text-gray-400" />
</div>
<div className="truncate">
<p className="text-xs text-gray-500">Email</p>
<p className="text-sm font-medium text-gray-900 dark:text-white truncate" title={prospect.email}>{prospect.email}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-50 dark:bg-gray-900 rounded-lg">
<Phone className="w-4 h-4 text-gray-400" />
</div>
<div>
<p className="text-xs text-gray-500">Téléphone</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{prospect.phone}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-50 dark:bg-gray-900 rounded-lg">
<Tag className="w-4 h-4 text-gray-400" />
</div>
<div>
<p className="text-xs text-gray-500">Secteur</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{prospect.industry}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-50 dark:bg-gray-900 rounded-lg">
<MapPin className="w-4 h-4 text-gray-400" />
</div>
<div>
<p className="text-xs text-gray-500">Ville</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{prospect.city}</p>
</div>
</div>
</div>
</motion.div>
{/* Tabs Content */}
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6 shadow-sm min-h-[500px]">
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
<div className="mt-8">
{activeTab === 'info' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="space-y-6">
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Building2 className="w-4 h-4 text-[#007E45]" />
Informations Société
</h3>
<dl className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<dt className="text-sm text-gray-500">Raison sociale</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-white col-span-2">{prospect.company}</dd>
</div>
<div className="grid grid-cols-3 gap-4">
<dt className="text-sm text-gray-500">Secteur</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-white col-span-2">{prospect.industry}</dd>
</div>
<div className="grid grid-cols-3 gap-4">
<dt className="text-sm text-gray-500">Site Web</dt>
<dd className="text-sm font-medium text-[#007E45] col-span-2 cursor-pointer hover:underline">www.example.com</dd>
</div>
</dl>
</div>
<div className="space-y-6">
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Wallet className="w-4 h-4 text-[#007E45]" />
Potentiel & Budget
</h3>
<dl className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<dt className="text-sm text-gray-500">Intérêt</dt>
<dd className="col-span-2"><span className="px-2 py-1 bg-orange-100 text-orange-700 rounded text-xs font-bold">Moyen</span></dd>
</div>
<div className="grid grid-cols-3 gap-4">
<dt className="text-sm text-gray-500">Budget Est.</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-white col-span-2">15 000 - 25 000 </dd>
</div>
<div className="grid grid-cols-3 gap-4">
<dt className="text-sm text-gray-500">Échéance</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-white col-span-2">Q4 2025</dd>
</div>
</dl>
</div>
</div>
)}
{activeTab === 'timeline' && (
<Timeline events={prospectTimeline} />
)}
{activeTab === 'opportunities' && (
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-4">
{prospectOpportunities.length > 0 ? prospectOpportunities.map(opp => (
<div key={opp.id} className="flex items-center justify-between p-4 border border-gray-100 dark:border-gray-800 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors">
<div className="flex items-center gap-4">
<div className="p-2 bg-amber-50 text-amber-600 rounded-lg">
<Lightbulb className="w-5 h-5" />
</div>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">{opp.name}</h4>
<p className="text-xs text-gray-500">{opp.amount} {opp.probability}% de chance</p>
</div>
</div>
<StatusBadge status={opp.stage} />
</div>
)) : (
<div className="text-center py-12">
<Lightbulb className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500">Aucune opportunité détectée</p>
<button className="mt-2 text-sm text-[#007E45] font-medium hover:underline">Créer une opportunité</button>
</div>
)}
</div>
)}
{activeTab === 'documents' && (
<div className="text-center py-12 animate-in fade-in">
<FileText className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500">Aucun document associé</p>
<p className="text-xs text-gray-400">Glissez-déposez des fichiers pour les attacher à ce prospect</p>
</div>
)}
</div>
</div>
</div>
{/* Edit Modal */}
<ProspectFormModal
isOpen={isEditModalOpen}
onClose={() => setIsEditModalOpen(false)}
initialData={{
type: 'entreprise',
name: prospect.name,
companyName: prospect.company,
email: prospect.email,
phone: prospect.phone.replace(/\s/g, ''),
address1: '123 Rue Example',
zipCode: '75000',
city: prospect.city,
country: 'France',
contactName: prospect.name.split(' ')[1] || '',
contactFirstName: prospect.name.split(' ')[0] || '',
}}
/>
</>
);
};
export default ProspectDetailPage;

View file

@ -0,0 +1,156 @@
import React, { useState, useMemo } from 'react';
import { Helmet } from 'react-helmet';
import { Plus, Filter, Search, MoreHorizontal, Phone, Mail, UserPlus, TrendingUp, Award, Users } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import PrimaryButton from '@/components/PrimaryButton';
import KPIBar from '@/components/KPIBar';
import { mockClients, calculateKPIs } from '@/data/mockData.js';
import { cn } from '@/lib/utils.js';
const ProspectsPage = () => {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState('');
const [period, setPeriod] = useState('month');
// Using clients with specific status as proxy for prospects
const allProspects = mockClients.filter(c => c.status === 'prospect' || c.status === 'inactive' || c.status === 'lead');
const kpis = useMemo(() => {
const stats = calculateKPIs(allProspects, period, { dateField: 'createdAt', amountField: 'id' }); // Mock dateField
const converted = stats.items.filter(p => p.status === 'active');
const leads = stats.items.filter(p => p.status === 'lead' || p.status === 'prospect');
return [
{ title: 'Nouveaux Prospects', value: stats.filteredCount, change: '+12%', trend: 'up', icon: UserPlus },
{ title: 'En Qualification', value: leads.length, change: '+5', trend: 'up', icon: Users },
{ title: 'Taux Conversion', value: '15%', change: '+2%', trend: 'up', icon: TrendingUp },
{ title: 'Gagnés', value: converted.length, change: '', trend: 'neutral', icon: Award },
];
}, [period, allProspects]);
const filteredProspects = allProspects.filter(
p => p.company.toLowerCase().includes(searchTerm.toLowerCase()) ||
p.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleRowClick = (prospect) => {
navigate(`/home/prospects/${prospect.id}`);
};
return (
<>
<Helmet>
<title>Prospects - Bijou ERP</title>
<meta name="description" content="Gestion de vos prospects" />
</Helmet>
<div className="space-y-6">
<KPIBar kpis={kpis} period={period} onPeriodChange={setPeriod} />
{/* Header */}
<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">Prospects</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Gérez votre pipeline de prospection et convertissez vos leads.
</p>
</div>
<div className="flex gap-3">
<PrimaryButton icon={Plus} onClick={() => navigate('/home/prospects/create')}>
Nouveau prospect
</PrimaryButton>
</div>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4 p-4 bg-white dark:bg-gray-950 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Rechercher un prospect..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#338660]/20 focus:border-[#338660]"
/>
</div>
<button className="flex items-center gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
<Filter className="w-4 h-4" />
Filtres
</button>
</div>
{/* List */}
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800">
<tr>
<th className="px-6 py-4 font-medium text-gray-500">Société / Nom</th>
<th className="px-6 py-4 font-medium text-gray-500">Contact</th>
<th className="px-6 py-4 font-medium text-gray-500">Ville</th>
<th className="px-6 py-4 font-medium text-gray-500">Statut</th>
<th className="px-6 py-4 text-right font-medium text-gray-500">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{filteredProspects.map((prospect) => (
<tr
key={prospect.id}
onClick={() => handleRowClick(prospect)}
className="group hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors cursor-pointer"
>
<td className="px-6 py-4">
<div className="font-medium text-gray-900 dark:text-white hover:text-[#338660] transition-colors">
{prospect.company}
</div>
<div className="text-xs text-gray-500">{prospect.industry}</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center text-xs font-medium text-gray-600">
{prospect.name.charAt(0)}
</div>
<div>
<div className="text-gray-900 dark:text-white">{prospect.name}</div>
<div className="text-xs text-gray-500">{prospect.email}</div>
</div>
</div>
</td>
<td className="px-6 py-4 text-gray-600 dark:text-gray-400">
{prospect.city}
</td>
<td className="px-6 py-4">
<span className={cn(
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium",
prospect.status === 'active' ? "bg-green-100 text-green-800" : "bg-blue-100 text-blue-800"
)}>
{prospect.status === 'active' ? 'Converti' : 'Nouveau'}
</span>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-lg text-gray-500">
<Phone className="w-4 h-4" />
</button>
<button className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-lg text-gray-500">
<Mail className="w-4 h-4" />
</button>
<button
className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-lg text-gray-500"
>
<MoreHorizontal className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
};
export default ProspectsPage;

View file

@ -0,0 +1,535 @@
import React, { useState } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import {
ArrowLeft, Mail, Phone, MapPin, Building2, Globe, CreditCard,
User, Package, Truck, FileText, History, Plus, Edit, Trash2,
Star, MoreVertical, Calendar, Briefcase, Banknote, Hash
} from 'lucide-react';
import { motion } from 'framer-motion';
import { useAppSelector } from '@/store/hooks';
import { getfournisseurSelected } from '@/store/features/fournisseur/selectors';
import { Fournisseur } from '@/types/fournisseurType';
import Tabs from '@/components/Tabs';
import StatusBadge from '@/components/StatusBadget';
import Timeline from '@/components/Timeline';
import PrimaryButton_v2 from '@/components/PrimaryButton_v2';
import { toast } from '@/components/ui/use-toast';
import ToggleSwitch from '@/components/ui/ToggleSwitch';
import { ModalFournisseur } from '@/components/modal/ModalFournisseur';
import StatusBadgetLettre from '@/components/StatusBadgetLettre';
import { Contacts } from '@/types/clientType';
import ModalContact from '@/components/modal/ModalContact';
// Composants réutilisables
const InfoCard = ({ icon: Icon, label, value }: { icon: any; label: string; value?: string | number | null }) => {
if (!value) return null;
return (
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
<Icon className="w-5 h-5 text-gray-400 flex-shrink-0" />
<div className="min-w-0">
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase">{label}</p>
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">{value}</p>
</div>
</div>
);
};
const InfoRow = ({ label, value }: { label: string; value?: string | number | null }) => {
if (!value) return null;
return (
<div>
<p className="text-xs text-gray-500 uppercase">{label}</p>
<p className="font-medium text-gray-900 dark:text-white mt-1">{value}</p>
</div>
);
};
const SectionCard = ({ icon: Icon, title, children }: { icon: any; title: string; children: React.ReactNode }) => (
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6">
<h3 className="text-lg font-bold mb-4 flex items-center gap-2 text-gray-900 dark:text-white">
<Icon className="w-5 h-5 text-[#007E45]" /> {title}
</h3>
{children}
</div>
);
const ContactCard = ({ contact, onEdit, onDelete, onSetDefault }: any) => (
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl p-5 hover:shadow-md transition-shadow">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-gray-600 dark:text-gray-300 font-bold">
{contact.nom?.[0] || contact.prenom?.[0] || 'C'}
</div>
<div>
<h4 className="font-bold text-gray-900 dark:text-white">
{contact.prenom} {contact.nom}
</h4>
<p className="text-xs text-gray-500">{contact.fonction || 'Pas de fonction définie'}</p>
</div>
</div>
{contact.est_principal && (
<span className="px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-[10px] font-bold uppercase tracking-wider rounded-full border border-green-200 dark:border-green-800">
Principal
</span>
)}
</div>
<div className="space-y-2 mb-4">
{contact.email && (
<a href={`mailto:${contact.email}`} className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-[#941403] transition-colors">
<Mail className="w-4 h-4" /> {contact.email}
</a>
)}
{contact.telephone && (
<a href={`tel:${contact.telephone}`} className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-[#941403] transition-colors">
<Phone className="w-4 h-4" /> {contact.telephone}
</a>
)}
</div>
<div className="pt-4 border-t border-gray-100 dark:border-gray-800 flex justify-end gap-2">
{!contact.est_principal && (
<button onClick={() => onSetDefault?.(contact)} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-400 hover:text-yellow-500 transition-colors" title="Définir comme principal">
<Star className="w-4 h-4" />
</button>
)}
<button onClick={() => onEdit?.(contact)} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-400 hover:text-blue-500 transition-colors" title="Modifier">
<Edit className="w-4 h-4" />
</button>
<button onClick={() => onDelete?.(contact)} className="p-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg text-gray-400 hover:text-red-500 transition-colors" title="Supprimer">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
const FournisseurDetailPage = () => {
const navigate = useNavigate();
const fournisseur = useAppSelector(getfournisseurSelected) as Fournisseur;
const [activeTab, setActiveTab] = useState('identification');
const [isActive, setIsActive] = useState(fournisseur?.est_actif ?? true);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editing, setEditing] = useState<Fournisseur | null>(null);
const [editingContact, setEditingContact] = useState<Contacts | null>(null);
const [isCreateModalContactOpen, setIsCreateModalContactOpen] = useState(false);
if (!fournisseur) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-gray-500">Fournisseur non trouvé</p>
</div>
);
}
const tabs = [
{ id: 'identification', label: 'Identification' },
{ id: 'contacts', label: 'Contacts', count: fournisseur.nb_contacts || fournisseur.contacts?.length || 0 },
{ id: 'bancaire', label: 'Coordonnées bancaires' },
{ id: 'commercial', label: 'Pièces d\'achat' },
{ id: 'timeline', label: 'Timeline' },
];
const getBadges = () => {
const badges = [];
if (fournisseur.est_fournisseur) badges.push({ label: 'Fournisseur', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300' });
if (fournisseur.en_sommeil) badges.push({ label: 'En sommeil', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/20 dark:text-orange-300' });
if (!fournisseur.est_actif) badges.push({ label: 'Inactif', color: 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-300' });
return badges;
};
const handleToggleActive = (newStatus: boolean) => {
setIsActive(newStatus);
toast({
title: "Statut mis à jour",
description: `Le fournisseur est maintenant ${newStatus ? 'Actif' : 'Inactif'}`,
variant: "success"
});
};
const handleEdit = (row: Fournisseur) => {
setEditing(row);
setIsCreateModalOpen(true);
};
const handleCreateContact = () => {
setEditingContact(null);
setIsCreateModalContactOpen(true);
};
const handleEditContact = (row: Contacts) => {
setEditingContact(row);
setIsCreateModalContactOpen(true);
};
return (
<>
<Helmet>
<title>{fournisseur.intitule} - Fournisseurs - Dataven</title>
<meta name="description" content={`Fiche fournisseur ${fournisseur.intitule}`} />
</Helmet>
<div className="space-y-6">
{/* Navigation */}
<button
onClick={() => navigate('/home/fournisseurs')}
className="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white transition-colors w-fit"
>
<ArrowLeft className="w-4 h-4" />
Retour aux fournisseurs
</button>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-row xl:flex-row xl:items-center justify-between gap-4 bg-white dark:bg-gray-950 p-6 rounded-2xl border border-gray-200 dark:border-gray-800 shadow-sm"
>
<div className="flex items-start gap-4">
<div style={{ width: "7vh", height: "7vh" }} className="w-16 h-16 rounded-2xl bg-[#007E45] text-white flex items-center justify-center text-2xl font-bold shadow-lg shadow-red-900/20">
{fournisseur.intitule?.split(' ').map(n => n[0]).join('').slice(0, 2)}
</div>
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{fournisseur.intitule}</h1>
<StatusBadgetLettre status={isActive ? 'actif' : 'inactif'} />
<ToggleSwitch isActive={isActive} onChange={handleToggleActive} />
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span className="font-mono">#{fournisseur.numero}</span>
{fournisseur.ville && (
<span className="flex items-center gap-1">
<MapPin className="w-3.5 h-3.5" /> {fournisseur.ville}, {fournisseur.pays || 'France'}
</span>
)}
{fournisseur.forme_juridique && (
<span className="flex items-center gap-1">
<Building2 className="w-3.5 h-3.5" /> {fournisseur.forme_juridique}
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2 mt-3">
{getBadges().map((badge, i) => (
<span key={i} className={`px-2.5 py-0.5 rounded-lg text-xs font-medium ${badge.color}`}>
{badge.label}
</span>
))}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2 h-10">
<PrimaryButton_v2 icon={Edit} onClick={() => handleEdit(fournisseur)}>
Modifier
</PrimaryButton_v2>
<button className="p-2.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400" title="Plus d'actions">
<MoreVertical className="w-5 h-5" />
</button>
</div>
</motion.div>
{/* Quick Actions */}
<div className="flex gap-2 overflow-x-auto pb-2">
<button onClick={() => navigate('/home/devis')} className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-900 whitespace-nowrap transition-colors">
<FileText className="w-4 h-4 text-blue-600" /> Devis
</button>
<button onClick={() => navigate('/home/commandes')} className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-900 whitespace-nowrap transition-colors">
<Package className="w-4 h-4 text-blue-600" /> Commander
</button>
<button onClick={() => navigate('/home/bons-livraison')} className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-900 whitespace-nowrap transition-colors">
<Truck className="w-4 h-4 text-orange-600" /> Livraison
</button>
<button onClick={() => navigate('/home/factures')} className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-900 whitespace-nowrap transition-colors">
<CreditCard className="w-4 h-4 text-purple-600" /> Enregistrer facture
</button>
</div>
{/* Tabs */}
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
{/* Content */}
<div className="mt-6">
{activeTab === 'identification' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Colonne gauche */}
<div className="space-y-6">
<SectionCard icon={Building2} title="Identification">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Compte Tiers" value={fournisseur.numero} />
<InfoRow label="Type" value={fournisseur.type === 1 ? 'Fournisseur' : 'Autre'} />
</div>
<InfoRow label="SIRET" value={fournisseur.siret} />
<InfoRow label="SIREN" value={fournisseur.siren} />
<InfoRow label="TVA Intracommunautaire" value={fournisseur.tva_intra} />
<InfoRow label="Code NAF" value={fournisseur.code_naf} />
<InfoRow label="Forme juridique" value={fournisseur.forme_juridique} />
</div>
</SectionCard>
<SectionCard icon={Phone} title="Télécommunication">
<div className="space-y-4">
{fournisseur.telephone && (
<div className="flex items-center gap-3">
<Phone className="w-4 h-4 text-gray-400" />
<span className="text-gray-900 dark:text-white">{fournisseur.telephone}</span>
</div>
)}
{fournisseur.portable && (
<div className="flex items-center gap-3">
<Phone className="w-4 h-4 text-gray-400" />
<span className="text-gray-900 dark:text-white">{fournisseur.portable}</span>
<span className="text-xs text-gray-500">(Mobile)</span>
</div>
)}
{fournisseur.telecopie && (
<div className="flex items-center gap-3">
<FileText className="w-4 h-4 text-gray-400" />
<span className="text-gray-900 dark:text-white">{fournisseur.telecopie}</span>
<span className="text-xs text-gray-500">(Fax)</span>
</div>
)}
{fournisseur.email && (
<div className="flex items-center gap-3">
<Mail className="w-4 h-4 text-gray-400" />
<a href={`mailto:${fournisseur.email}`} className="text-blue-600 hover:underline">{fournisseur.email}</a>
</div>
)}
{fournisseur.site_web && (
<div className="flex items-center gap-3">
<Globe className="w-4 h-4 text-gray-400" />
<a href={fournisseur.site_web} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">{fournisseur.site_web}</a>
</div>
)}
</div>
</SectionCard>
</div>
{/* Colonne droite */}
<div className="space-y-6">
<SectionCard icon={MapPin} title="Adresse">
<div className="space-y-4">
{fournisseur.adresse_complete ? (
<p className="text-gray-900 dark:text-white whitespace-pre-line">{fournisseur.adresse_complete}</p>
) : (
<>
<InfoRow label="Adresse" value={fournisseur.adresse} />
<InfoRow label="Complément" value={fournisseur.complement} />
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Code postal" value={fournisseur.code_postal} />
<InfoRow label="Ville" value={fournisseur.ville} />
</div>
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Région" value={fournisseur.region} />
<InfoRow label="Pays" value={fournisseur.pays} />
</div>
</>
)}
</div>
</SectionCard>
<SectionCard icon={CreditCard} title="Données Financières">
<div className="space-y-4">
<InfoRow label="Conditions de règlement" value={fournisseur.conditions_reglement_libelle || fournisseur.conditions_reglement_code} />
<InfoRow label="Mode de règlement" value={fournisseur.mode_reglement_libelle || fournisseur.mode_reglement_code} />
<InfoRow label="Encours autorisé" value={fournisseur.encours_autorise ? `${fournisseur.encours_autorise.toLocaleString('fr-FR')}` : null} />
<InfoRow label="CA annuel" value={fournisseur.ca_annuel ? `${fournisseur.ca_annuel.toLocaleString('fr-FR')}` : null} />
<InfoRow label="Compte général" value={fournisseur.compte_general} />
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Catégorie tarifaire" value={fournisseur.categorie_tarifaire?.toString()} />
<InfoRow label="Catégorie comptable" value={fournisseur.categorie_comptable?.toString()} />
</div>
</div>
</SectionCard>
{(fournisseur.date_creation || fournisseur.date_modification) && (
<SectionCard icon={Calendar} title="Historique">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Date de création" value={fournisseur.date_creation ? new Date(fournisseur.date_creation).toLocaleDateString('fr-FR') : null} />
<InfoRow label="Dernière modification" value={fournisseur.date_modification ? new Date(fournisseur.date_modification).toLocaleDateString('fr-FR') : null} />
</div>
</SectionCard>
)}
</div>
</div>
)}
{activeTab === 'contacts' && (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h3 className="font-bold text-gray-900 dark:text-white">Liste des contacts ({fournisseur.contacts?.length})</h3>
<PrimaryButton_v2
icon={Plus}
onClick={handleCreateContact}
>Ajouter un contact</PrimaryButton_v2>
</div>
{fournisseur.contacts?.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-950 rounded-2xl border border-dashed border-gray-200 dark:border-gray-800">
<User className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 font-medium">Aucun contact enregistré</p>
<p className="text-sm text-gray-400 mt-1">Cliquez sur "Ajouter un contact" pour commencer</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{fournisseur.contacts?.map((contact,index) => (
<div key={index} className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl p-5 hover:shadow-md transition-shadow relative group">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-gray-600 dark:text-gray-300 font-bold">
{contact.nom[0]}{contact.prenom[0]}
</div>
<div>
<h4 className="font-bold text-gray-900 dark:text-white">{contact.prenom} {contact.nom}</h4>
<p className="text-xs text-gray-500">{contact.fonction || 'Pas de poste défini'}</p>
</div>
</div>
{contact.est_defaut && (
<span className="px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-[10px] font-bold uppercase tracking-wider rounded-full border border-green-200 dark:border-green-800">
Défaut
</span>
)}
</div>
<div className="space-y-2 mb-4">
<a href={`mailto:${contact.email}`} className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-[#941403] transition-colors">
<Mail className="w-4 h-4" />
{contact.email}
</a>
{contact.telephone && (
<a href={`tel:${contact.telephone}`} className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-[#941403] transition-colors">
<Phone className="w-4 h-4" />
{contact.telephone}
</a>
)}
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<Building2 className="w-4 h-4" />
{contact.fonction || 'Non spécifié'}
</div>
</div>
<div className="pt-4 border-t border-gray-100 dark:border-gray-800 flex justify-end gap-2">
<button
onClick={() => handleEditContact(contact)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-400 hover:text-blue-500 transition-colors"
title="Modifier"
>
<Edit className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'bancaire' && (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h3 className="font-bold text-gray-900 dark:text-white">Coordonnées bancaires</h3>
{/* <PrimaryButton_v2 icon={Plus} onClick={() => handleAction('Ajouter RIB')}>
Ajouter un RIB
</PrimaryButton_v2> */}
</div>
{/* RIB Principal */}
{/* {(fournisseur.iban_principal || fournisseur.bic_principal) && (
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-4">
<Banknote className="w-5 h-5 text-[#941403]" />
<h4 className="font-bold text-gray-900 dark:text-white">RIB Principal</h4>
<span className="px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs font-medium rounded-full">Par défaut</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-xs text-gray-500 uppercase">IBAN</p>
<p className="font-mono font-medium text-gray-900 dark:text-white mt-1">{fournisseur.iban_principal || '-'}</p>
</div>
<div>
<p className="text-xs text-gray-500 uppercase">BIC</p>
<p className="font-mono font-medium text-gray-900 dark:text-white mt-1">{fournisseur.bic_principal || '-'}</p>
</div>
</div>
</div>
)} */}
{/* Autres coordonnées bancaires */}
{fournisseur.coordonnees_bancaires && fournisseur.coordonnees_bancaires.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{fournisseur.coordonnees_bancaires.map((cb, idx) => (
<div key={idx} className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl p-4">
<div className="flex justify-between items-start mb-3">
<p className="font-medium text-gray-900 dark:text-white">{cb.banque || `Compte ${idx + 1}`}</p>
<div className="flex gap-1">
<button className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg" title="Edit">
<Edit className="w-4 h-4 text-gray-400" />
</button>
<button className="p-1.5 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg" title="Delete">
<Trash2 className="w-4 h-4 text-gray-400 hover:text-red-500" />
</button>
</div>
</div>
<div className="space-y-2 text-sm">
<p className="text-gray-600 dark:text-gray-400">
<span className="text-gray-500">IBAN:</span> <span className="font-mono">{cb.iban}</span>
</p>
<p className="text-gray-600 dark:text-gray-400">
<span className="text-gray-500">BIC:</span> <span className="font-mono">{cb.bic}</span>
</p>
</div>
</div>
))}
</div>
) : !fournisseur.iban_principal && (
<div className="text-center py-12 bg-white dark:bg-gray-950 rounded-2xl border border-dashed border-gray-200 dark:border-gray-800">
<CreditCard className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 font-medium">Aucune coordonnée bancaire</p>
<p className="text-sm text-gray-400 mt-1">Ajoutez un RIB pour ce fournisseur</p>
</div>
)}
</div>
)}
{activeTab === 'commercial' && (
<div className="space-y-8">
<div className="text-center py-12 bg-white dark:bg-gray-950 rounded-2xl border border-dashed border-gray-200 dark:border-gray-800">
<Package className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 font-medium">Aucune pièce d'achat</p>
<p className="text-sm text-gray-400 mt-1">Les commandes, réceptions et factures apparaîtront ici</p>
</div>
</div>
)}
{activeTab === 'timeline' && (
<div className="text-center py-12 bg-white dark:bg-gray-950 rounded-2xl border border-dashed border-gray-200 dark:border-gray-800">
<History className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 font-medium">Aucun événement</p>
<p className="text-sm text-gray-400 mt-1">L'historique des actions apparaîtra ici</p>
</div>
)}
</div>
</div>
<ModalFournisseur
open={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
title={editing ? `Mettre à jour le fournisseur ${editing.numero}` : "Crée un fournisseur"}
editing={editing}
/>
<ModalContact
open={isCreateModalContactOpen}
onClose={() => setIsCreateModalContactOpen(false)}
title={editingContact ? `Mettre à jour le contact ${editingContact.nom}` : 'Créer un contact'}
editing={editingContact}
entityType= 'fournisseur'
/>
</>
);
};
export default FournisseurDetailPage;

View file

@ -0,0 +1,294 @@
import DataTable from '@/components/DataTable';
import { filterItemByPeriod } from '@/components/filter/ItemsFilter';
import KPIBar, { PeriodType } from '@/components/KPIBar';
import { ModalFournisseur } from '@/components/modal/ModalFournisseur';
import PrimaryButton_v2 from '@/components/PrimaryButton_v2';
import StatusBadgetLettre from '@/components/StatusBadgetLettre';
import { toast } from '@/components/ui/use-toast';
import { CompanyInfo } from '@/data/mockData';
import { fournisseurStatus, getAllfournisseurs } from '@/store/features/fournisseur/selectors';
import { selectfournisseur } from '@/store/features/fournisseur/slice';
import { getFournisseurs } from '@/store/features/fournisseur/thunk';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { Fournisseur } from '@/types/fournisseurType';
import { CheckCircle, Edit, Eye, Mail, MapPin, Phone, Plus, Users } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import ColumnSelector, { ColumnConfig } from '@/components/common/ColumnSelector';
import PeriodSelector from '@/components/common/PeriodSelector';
import { Contacts } from '@/types/clientType';
import { Commercial } from '@/types/commercialType';
import { useDashboardData } from '@/store/hooks/useAppData';
// ============================================
// 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: 'est_actif', label: 'Statut', visible: true },
{ key: 'commercial', label: 'Commercial', visible: true },
];
const SuppliersPage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [period, setPeriod] = useState<PeriodType>('all');
// État des colonnes visibles
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(DEFAULT_COLUMNS);
const fournisseurs = useAppSelector(getAllfournisseurs) as Fournisseur[];
const statusFournisseur = useAppSelector(fournisseurStatus);
const [editing, setEditing] = useState<Fournisseur | null>(null);
const isLoading = statusFournisseur === 'loading' && fournisseurs.length === 0;
const { refresh } = useDashboardData();
useEffect(() => {
const load = async () => {
if (statusFournisseur === 'idle' || statusFournisseur === 'failed') await dispatch(getFournisseurs()).unwrap();
};
load();
}, [statusFournisseur, dispatch]);
const filteredFournisseur = useMemo(() => {
return [...filterItemByPeriod(fournisseurs, period, 'date_creation')].sort(
(a, b) => new Date(b.date_creation!).getTime() - new Date(a.date_creation!).getTime()
);
}, [fournisseurs, period]);
const kpis = useMemo(() => {
const total = fournisseurs.length;
const withEmail = filteredFournisseur.filter(f => f.email && f.email.trim() !== '').length;
const withPhone = filteredFournisseur.filter(f => f.telephone && f.telephone.trim() !== '').length;
const withAddress = filteredFournisseur.filter(
f => f.adresse && f.code_postal && f.ville && f.adresse.trim() !== '' && f.code_postal.trim() !== '' && f.ville.trim() !== ''
).length;
const completeProfile = filteredFournisseur.filter(
f =>
f.intitule &&
f.adresse &&
f.code_postal &&
f.ville &&
f.email &&
f.telephone &&
f.adresse.trim() !== '' &&
f.email.trim() !== '' &&
f.telephone.trim() !== ''
).length;
const completionRate = total > 0 ? ((completeProfile / total) * 100).toFixed(1) : '0';
const emailRate = total > 0 ? ((withEmail / total) * 100).toFixed(1) : '0';
const phoneRate = total > 0 ? ((withPhone / total) * 100).toFixed(1) : '0';
const addressRate = total > 0 ? ((withAddress / total) * 100).toFixed(1) : '0';
return [
{
title: 'Total Fournisseurs',
value: total,
change: `${completeProfile} complets`,
trend: 'neutral',
icon: Users,
subtitle: `${completionRate}% profils complets`,
},
{
title: 'Avec Email',
value: withEmail,
change: `${emailRate}%`,
trend: withEmail >= total * 0.7 ? 'up' : 'down',
icon: Mail,
subtitle: `${total - withEmail} sans email`,
},
{
title: 'Avec Téléphone',
value: withPhone,
change: `${phoneRate}%`,
trend: withPhone >= total * 0.7 ? 'up' : 'down',
icon: Phone,
subtitle: `${total - withPhone} sans téléphone`,
},
{
title: 'Avec Adresse',
value: withAddress,
change: `${addressRate}%`,
trend: withAddress >= total * 0.7 ? 'up' : 'down',
icon: MapPin,
subtitle: `${total - withAddress} adresse incomplète`,
},
{
title: 'Profils Complets',
value: completeProfile,
change: `${completionRate}%`,
trend: completeProfile >= total * 0.5 ? 'up' : 'down',
icon: CheckCircle,
subtitle: `${total - completeProfile} incomplets`,
},
];
}, [filteredFournisseur, fournisseurs]);
const handleCreate = () => {
setEditing(null);
setIsCreateModalOpen(true);
};
const handleEdit = (row: Fournisseur) => {
setEditing(row);
setIsCreateModalOpen(true);
};
// ============================================
// 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 ? '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: Fournisseur) => (
<>
<button
onClick={e => {
e.stopPropagation();
dispatch(selectfournisseur(row));
navigate(`/home/fournisseurs/${row.numero}`);
}}
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();
handleEdit(row);
}}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="Modifier"
>
<Edit 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 fournisseur 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>Fournisseurs - {CompanyInfo.name}</title>
<meta name="description" content="Gestion de vos fournisseurs" />
</Helmet>
<div className="space-y-6">
<KPIBar kpis={kpis} period={period} loading={statusFournisseur} onRefresh={refresh} />
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Fournisseurs</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{filteredFournisseur.length} fournisseurs</p>
</div>
<PeriodSelector value={period} onChange={setPeriod} />
</div>
<div className="flex gap-3">
<ColumnSelector columns={columnConfig} onChange={setColumnConfig} />
<PrimaryButton_v2 icon={Plus} onClick={handleCreate}>
Nouveau fournisseur
</PrimaryButton_v2>
</div>
</div>
<DataTable
columns={visibleColumns}
data={filteredFournisseur}
onRowClick={(row: Fournisseur) => {
dispatch(selectfournisseur(row));
navigate(`/home/fournisseurs/${row.numero}`);
}}
actions={actions}
status={isLoading}
/>
</div>
<ModalFournisseur
open={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
title={editing ? `Mettre à jour le fournisseur ${editing.numero}` : 'Créer un fournisseur'}
editing={editing}
/>
</>
);
};
export default SuppliersPage;

View file

@ -0,0 +1,43 @@
import React, { useMemo, useState } from 'react';
import { Helmet } from 'react-helmet';
import { CheckSquare, AlertCircle, Flag } from 'lucide-react';
import KPIBar from '@/components/KPIBar';
// Using mockTimeline as proxy again for now
import { mockTimeline, calculateKPIs } from '@/data/mockData';
const TasksPage = () => {
const [period, setPeriod] = useState('month');
const kpis = useMemo(() => {
const stats = calculateKPIs(mockTimeline, period, { dateField: 'date', amountField: 'id' });
return [
{ title: 'Tâches Créées', value: stats.filteredCount, change: '+10', trend: 'up', icon: CheckSquare },
{ title: 'Complétées', value: Math.round(stats.filteredCount * 0.6), change: '+5', trend: 'up', icon: CheckSquare },
{ title: 'En Retard', value: 3, change: '-1', trend: 'down', icon: AlertCircle },
{ title: 'Haute Priorité', value: 5, change: '', trend: 'neutral', icon: Flag },
];
}, [period]);
return (
<>
<Helmet>
<title>Tâches - Bijou ERP</title>
<meta name="description" content="Gestion des tâches" />
</Helmet>
<div className="space-y-6">
<KPIBar kpis={kpis} period={period} onPeriodChange={setPeriod} />
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Tâches</h1>
</div>
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-12 text-center">
<p className="text-gray-600 dark:text-gray-400">Page Tâches - Contenu à venir</p>
</div>
</div>
</>
);
};
export default TasksPage;

View file

@ -0,0 +1,74 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Printer, CreditCard } from 'lucide-react';
import { mockPurchaseInvoices } from '@/data/mockData';
const PurchaseInvoiceDetailPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const inv = mockPurchaseInvoices.find(i => i.id === parseInt(id));
if (!inv) return <div>Facture introuvable</div>;
return (
<>
<Helmet>
<title>{inv.number} - Facture Achat - Bijou ERP</title>
</Helmet>
<div className="max-w-5xl mx-auto space-y-6">
<div className="flex items-center gap-4">
<button onClick={() => navigate('/factures-achat')} className="p-2 hover:bg-white rounded-lg"><ArrowLeft className="w-5 h-5" /></button>
<div className="flex-1">
<h1 className="text-2xl font-bold">{inv.number}</h1>
<p className="text-sm text-gray-500">{inv.supplier}</p>
</div>
<button className="px-4 py-2 bg-[#941403] text-white rounded-lg flex items-center gap-2">
<CreditCard className="w-4 h-4" /> Payer
</button>
</div>
<div className="bg-white p-8 rounded-xl shadow-sm border border-gray-200">
<div className="flex justify-between mb-8">
<div>
<h2 className="text-xl font-bold text-gray-900">FACTURE</h2>
<p className="text-gray-500">{inv.number}</p>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-[#941403]">{inv.amountTTC.toLocaleString()} </div>
<p className="text-sm text-gray-500">Montant TTC</p>
</div>
</div>
<div className="grid grid-cols-2 gap-8 mb-8">
<div>
<h4 className="font-medium text-gray-900 mb-2">Fournisseur</h4>
<div className="text-gray-600 bg-gray-50 p-4 rounded-lg">
{inv.supplier}
</div>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-2">Détails</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between border-b border-gray-100 pb-2">
<span className="text-gray-500">Date de facturation</span>
<span className="font-medium">{inv.date}</span>
</div>
<div className="flex justify-between border-b border-gray-100 pb-2">
<span className="text-gray-500">Échéance</span>
<span className="font-medium text-red-600">{inv.dueDate}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Bon de réception</span>
<span className="font-medium">{inv.receptionNote}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
};
export default PurchaseInvoiceDetailPage;

View file

@ -0,0 +1,134 @@
import React, { useState, useMemo } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Search, Filter, MoreHorizontal, Receipt, CheckCircle, AlertTriangle, Euro, FileText } from 'lucide-react';
import { mockPurchaseInvoices, calculateKPIs } from '@/data/mockData';
import { cn } from '@/lib/utils';
import KPIBar from '@/components/KPIBar';
const PurchaseInvoicesPage = () => {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState('');
const [period, setPeriod] = useState('month');
const kpis = useMemo(() => {
const stats = calculateKPIs(mockPurchaseInvoices, period, { dateField: 'date', amountField: 'amountTTC' });
const paid = stats.items.filter(i => i.status === 'paid');
const overdue = stats.items.filter(i => i.status === 'overdue');
return [
{ title: 'Total Facturé', value: `${stats.totalAmount.toLocaleString()}`, change: '+2%', trend: 'up', icon: Euro },
{ title: 'Nombre Factures', value: stats.filteredCount, change: '+1', trend: 'up', icon: FileText },
{ title: 'Payées', value: paid.length, change: '+3', trend: 'up', icon: CheckCircle },
{ title: 'En Retard', value: overdue.length, change: overdue.length > 0 ? '+1' : '', trend: overdue.length > 0 ? 'down' : 'neutral', icon: AlertTriangle },
];
}, [period]);
const getStatusColor = (status) => {
switch (status) {
case 'paid': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
case 'pending': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400';
case 'overdue': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
default: return 'bg-gray-100 text-gray-800';
}
};
const filteredInvoices = mockPurchaseInvoices.filter(
inv =>
inv.number.toLowerCase().includes(searchTerm.toLowerCase()) ||
inv.supplier.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<>
<Helmet>
<title>Factures Fournisseurs - Bijou ERP</title>
</Helmet>
<div className="space-y-6">
<KPIBar kpis={kpis} period={period} onPeriodChange={setPeriod} />
<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 flex items-center gap-2">
<Receipt className="w-6 h-6 text-[#941403]" />
Factures Fournisseurs
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Suivi de la facturation et des paiements fournisseurs.
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4 p-4 bg-white dark:bg-gray-950 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Rechercher une facture..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#941403]/20 focus:border-[#941403]"
/>
</div>
</div>
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800">
<tr>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400">Numéro</th>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400">Fournisseur</th>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400">Date Échéance</th>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400 text-right">Montant TTC</th>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400">Statut</th>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{filteredInvoices.map((inv) => (
<motion.tr
key={inv.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
whileHover={{ backgroundColor: "rgba(0,0,0,0.02)" }}
onClick={() => navigate(`/factures-achat/${inv.id}`)}
className="cursor-pointer group"
>
<td className="px-6 py-4 font-medium text-gray-900 dark:text-white">
{inv.number}
</td>
<td className="px-6 py-4 text-gray-600 dark:text-gray-400">
{inv.supplier}
</td>
<td className="px-6 py-4 text-gray-600 dark:text-gray-400">
{inv.dueDate}
</td>
<td className="px-6 py-4 text-right font-medium text-gray-900 dark:text-white">
{inv.amountTTC.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })}
</td>
<td className="px-6 py-4">
<span className={cn("inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium", getStatusColor(inv.status))}>
{inv.status === 'paid' ? 'Payée' : inv.status === 'pending' ? 'En attente' : 'En retard'}
</span>
</td>
<td className="px-6 py-4 text-right">
<button className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<MoreHorizontal className="w-4 h-4" />
</button>
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</>
);
};
export default PurchaseInvoicesPage;

View file

@ -0,0 +1,140 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { useParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { ArrowLeft, FileText, CheckSquare, Printer, Download, Mail, Calendar, User, Building2, DollarSign } from 'lucide-react';
import { mockPurchaseOrders } from '@/data/mockData';
import { cn } from '@/lib/utils';
const PurchaseOrderDetailPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const order = mockPurchaseOrders.find(o => o.id === parseInt(id)) || mockPurchaseOrders[0];
if (!order) return <div>Commande introuvable</div>;
return (
<>
<Helmet>
<title>{order.number} - Commande - Bijou ERP</title>
</Helmet>
<div className="max-w-5xl mx-auto space-y-6">
{/* Header Actions */}
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/bons-commande')}
className="p-2 hover:bg-white dark:hover:bg-gray-900 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5 text-gray-500" />
</button>
<div className="flex-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{order.number}</h1>
<span className={cn("px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800")}>
{order.status}
</span>
</div>
</div>
<div className="flex gap-2">
<button className="p-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800">
<Printer className="w-4 h-4 text-gray-600" />
</button>
<button className="p-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800">
<Download className="w-4 h-4 text-gray-600" />
</button>
<button className="flex items-center gap-2 px-4 py-2 bg-[#941403] text-white rounded-lg text-sm font-medium hover:bg-[#7a1002] transition-colors">
<CheckSquare className="w-4 h-4" />
Réceptionner
</button>
</div>
</div>
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Details */}
<div className="lg:col-span-2 space-y-6">
{/* Order Items */}
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl p-6 shadow-sm">
<h3 className="font-semibold text-gray-900 dark:text-white mb-4">Articles commandés</h3>
<div className="overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-500">Description</th>
<th className="px-4 py-2 text-right font-medium text-gray-500">Qté</th>
<th className="px-4 py-2 text-right font-medium text-gray-500">P.U.</th>
<th className="px-4 py-2 text-right font-medium text-gray-500">Total HT</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{order.items.map((item, idx) => (
<tr key={idx}>
<td className="px-4 py-3 text-gray-900 dark:text-white">{item.description}</td>
<td className="px-4 py-3 text-right text-gray-600 dark:text-gray-400">{item.quantity}</td>
<td className="px-4 py-3 text-right text-gray-600 dark:text-gray-400">{item.unitPrice} </td>
<td className="px-4 py-3 text-right font-medium text-gray-900 dark:text-white">{(item.quantity * item.unitPrice).toLocaleString()} </td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-6 flex justify-end">
<div className="w-64 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-500">Total HT</span>
<span className="font-medium">{order.amountHT.toLocaleString()} </span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">TVA (20%)</span>
<span className="font-medium">{(order.amountHT * 0.2).toLocaleString()} </span>
</div>
<div className="flex justify-between text-base font-bold border-t pt-2 mt-2 border-gray-100 dark:border-gray-800">
<span>Total TTC</span>
<span className="text-[#941403]">{order.amountTTC.toLocaleString()} </span>
</div>
</div>
</div>
</div>
</div>
{/* Right Column - Info */}
<div className="space-y-6">
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl p-6 shadow-sm">
<h3 className="font-semibold text-gray-900 dark:text-white mb-4">Informations</h3>
<div className="space-y-4">
<div className="flex items-start gap-3">
<Building2 className="w-5 h-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500 uppercase">Fournisseur</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{order.supplier}</p>
</div>
</div>
<div className="flex items-start gap-3">
<Calendar className="w-5 h-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500 uppercase">Date de commande</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{order.date}</p>
</div>
</div>
<div className="flex items-start gap-3">
<Calendar className="w-5 h-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500 uppercase">Livraison prévue</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{order.deliveryDate}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
};
export default PurchaseOrderDetailPage;

View file

@ -0,0 +1,154 @@
import React, { useState, useMemo } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Search, Filter, MoreHorizontal, Package, ShoppingCart, CheckCircle, Euro } from 'lucide-react';
import { mockPurchaseOrders, calculateKPIs } from '@/data/mockData';
import { cn } from '@/lib/utils';
import KPIBar from '@/components/KPIBar';
const PurchaseOrdersPage = () => {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState('');
const [period, setPeriod] = useState('month');
const kpis = useMemo(() => {
const stats = calculateKPIs(mockPurchaseOrders, period, { dateField: 'date', amountField: 'amountHT' });
const validated = stats.items.filter(o => o.status !== 'draft');
return [
{ title: 'Montant Engagé', value: `${stats.totalAmount.toLocaleString()}`, change: '+5%', trend: 'up', icon: Euro },
{ title: 'Nombre de BC', value: stats.filteredCount, change: '+2', trend: 'up', icon: ShoppingCart },
{ title: 'BC Validés', value: validated.length, change: '+3', trend: 'up', icon: CheckCircle },
{ title: 'Livraisons en Attente', value: stats.items.filter(o => o.status === 'sent').length, change: '', trend: 'neutral', icon: Package },
];
}, [period]);
const getStatusColor = (status) => {
switch (status) {
case 'received': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
case 'sent': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
case 'draft': return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
case 'invoiced': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400';
default: return 'bg-gray-100 text-gray-800';
}
};
const getStatusLabel = (status) => {
switch (status) {
case 'received': return 'Reçu';
case 'sent': return 'Envoyé';
case 'draft': return 'Brouillon';
case 'invoiced': return 'Facturé';
default: return status;
}
};
const filteredOrders = mockPurchaseOrders.filter(
order =>
order.number.toLowerCase().includes(searchTerm.toLowerCase()) ||
order.supplier.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<>
<Helmet>
<title>Commandes Fournisseurs - Bijou ERP</title>
</Helmet>
<div className="space-y-6">
<KPIBar kpis={kpis} period={period} onPeriodChange={setPeriod} />
<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 flex items-center gap-2">
<Package className="w-6 h-6 text-[#941403]" />
Commandes Fournisseurs
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Gérez vos approvisionnements et commandes d'achat.
</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4 p-4 bg-white dark:bg-gray-950 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Rechercher une commande..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#941403]/20 focus:border-[#941403]"
/>
</div>
<button className="flex items-center gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
<Filter className="w-4 h-4" />
Filtres
</button>
</div>
{/* Table */}
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800">
<tr>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400">Numéro</th>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400">Fournisseur</th>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400">Date</th>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400">Livraison Prévue</th>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400 text-right">Montant TTC</th>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400">Statut</th>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{filteredOrders.map((order) => (
<motion.tr
key={order.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
whileHover={{ backgroundColor: "rgba(0,0,0,0.02)" }}
onClick={() => navigate(`/bons-commande/${order.id}`)}
className="cursor-pointer group"
>
<td className="px-6 py-4 font-medium text-gray-900 dark:text-white">
{order.number}
</td>
<td className="px-6 py-4 text-gray-600 dark:text-gray-400">
{order.supplier}
</td>
<td className="px-6 py-4 text-gray-600 dark:text-gray-400">
{order.date}
</td>
<td className="px-6 py-4 text-gray-600 dark:text-gray-400">
{order.deliveryDate}
</td>
<td className="px-6 py-4 text-right font-medium text-gray-900 dark:text-white">
{order.amountTTC.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR' })}
</td>
<td className="px-6 py-4">
<span className={cn("inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium", getStatusColor(order.status))}>
{getStatusLabel(order.status)}
</span>
</td>
<td className="px-6 py-4 text-right">
<button className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<MoreHorizontal className="w-4 h-4" />
</button>
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</>
);
};
export default PurchaseOrdersPage;

View file

@ -0,0 +1,38 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Printer, CheckSquare } from 'lucide-react';
import { mockReceptionNotes } from '@/data/mockData';
const ReceptionNoteDetailPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const note = mockReceptionNotes.find(n => n.id === parseInt(id));
if (!note) return <div>Bon de réception introuvable</div>;
return (
<>
<Helmet>
<title>{note.number} - Réception - Bijou ERP</title>
</Helmet>
<div className="max-w-5xl mx-auto space-y-6">
<div className="flex items-center gap-4">
<button onClick={() => navigate('/bons-reception')} className="p-2 hover:bg-white rounded-lg"><ArrowLeft className="w-5 h-5" /></button>
<h1 className="text-2xl font-bold flex-1">{note.number}</h1>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
<h3 className="font-semibold mb-4">Articles Réceptionnés</h3>
{note.items.map((item, idx) => (
<div key={idx} className="flex justify-between py-2 border-b last:border-0">
<span>{item.description}</span>
<span className="font-medium">{item.quantity} unités</span>
</div>
))}
</div>
</div>
</>
);
};
export default ReceptionNoteDetailPage;

View file

@ -0,0 +1,129 @@
import React, { useState, useMemo } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Search, Filter, MoreHorizontal, CheckSquare, Truck, Box } from 'lucide-react';
import { mockReceptionNotes, calculateKPIs } from '@/data/mockData';
import { cn } from '@/lib/utils';
import KPIBar from '@/components/KPIBar';
const ReceptionNotesPage = () => {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState('');
const [period, setPeriod] = useState('month');
const kpis = useMemo(() => {
// We can assume each reception note has a value (sum of items) for KPI purposes
// mockReceptionNotes in mockData might not have amount field directly, so we rely on item count or fake amount
const notesWithAmounts = mockReceptionNotes.map(n => ({...n, amount: 1000})); // Mock amount
const stats = calculateKPIs(notesWithAmounts, period, { dateField: 'date', amountField: 'amount' });
return [
{ title: 'Bons Réception', value: stats.filteredCount, change: '+4', trend: 'up', icon: CheckSquare },
{ title: 'Validés', value: stats.items.filter(n => n.status === 'validated').length, change: '+4', trend: 'up', icon: CheckSquare },
{ title: 'En Attente', value: stats.items.filter(n => n.status !== 'validated').length, change: '', trend: 'neutral', icon: Truck },
];
}, [period]);
const filteredNotes = mockReceptionNotes.filter(
note =>
note.number.toLowerCase().includes(searchTerm.toLowerCase()) ||
note.supplier.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<>
<Helmet>
<title>Bons de Réception - Bijou ERP</title>
</Helmet>
<div className="space-y-6">
<KPIBar kpis={kpis} period={period} onPeriodChange={setPeriod} />
<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 flex items-center gap-2">
<CheckSquare className="w-6 h-6 text-[#941403]" />
Bons de Réception
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Historique des réceptions de marchandises.
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4 p-4 bg-white dark:bg-gray-950 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Rechercher un bon..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#941403]/20 focus:border-[#941403]"
/>
</div>
<button className="flex items-center gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
<Filter className="w-4 h-4" />
Filtres
</button>
</div>
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800">
<tr>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400">Numéro</th>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400">Commande Liée</th>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400">Fournisseur</th>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400">Date Réception</th>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400">Statut</th>
<th className="px-6 py-4 font-medium text-gray-500 dark:text-gray-400 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{filteredNotes.map((note) => (
<motion.tr
key={note.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
whileHover={{ backgroundColor: "rgba(0,0,0,0.02)" }}
onClick={() => navigate(`/bons-reception/${note.id}`)}
className="cursor-pointer group"
>
<td className="px-6 py-4 font-medium text-gray-900 dark:text-white">
{note.number}
</td>
<td className="px-6 py-4 text-[#941403] font-medium">
{note.purchaseOrder}
</td>
<td className="px-6 py-4 text-gray-600 dark:text-gray-400">
{note.supplier}
</td>
<td className="px-6 py-4 text-gray-600 dark:text-gray-400">
{note.date}
</td>
<td className="px-6 py-4">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
Validé
</span>
</td>
<td className="px-6 py-4 text-right">
<button className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<MoreHorizontal className="w-4 h-4" />
</button>
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</>
);
};
export default ReceptionNotesPage;

View file

@ -0,0 +1,829 @@
import { useState, useMemo, useEffect } from "react";
import { Helmet } from "react-helmet";
import { useNavigate } from "react-router-dom";
import {
ArrowLeft,
Download,
Eye,
File,
Trash2,
FileText,
Plus,
X,
Check,
Loader2,
} from "lucide-react";
import StatusBadge from "@/components/StatusBadget";
import Tabs from "@/components/Tabs";
import { toast } from "@/components/ui/use-toast";
import {
cn,
formatDateFR,
formatDateFRCourt,
formatForDateInput,
} from "@/lib/utils";
import { useAppDispatch, useAppSelector } from "@/store/hooks";
import {
getavoirSelected,
avoirStatus,
} from "@/store/features/avoir/selectors";
import { Avoir } from "@/types/avoirType";
import { FormField, Input, RepresentantInput } from "@/components/ui/FormModal";
import Timeline from "@/components/Timeline";
import { getAllClients } from "@/store/features/client/selectors";
import { Client } from "@/types/clientType";
import { Article } from "@/types/articleType";
import { getAvoir, updateAvoir } from "@/store/features/avoir/thunk";
import { selectavoir } from "@/store/features/avoir/slice";
import ClientAutocomplete from "@/components/molecules/ClientAutocomplete";
import ArticleAutocomplete from "@/components/molecules/ArticleAutocomplete";
import { ModalLoading } from "@/components/modal/ModalLoading";
export interface LigneForm {
article_code: string;
quantite: number;
prix_unitaire_ht: number;
taux_taxe1: number;
montant_ligne_ht: number;
remise_pourcentage: number;
designation: string;
articles: Article | null;
}
const CreditNotesDetailPage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const clients = useAppSelector(getAllClients) as Client[];
const statusAvoir = useAppSelector(avoirStatus);
// États UI
const [activeTab, setActiveTab] = useState("identification");
// États pour l'édition inline
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// États des champs éditables
const [editDateAvoir, setEditDateAvoir] = useState("");
const [editDateLivraison, setEditDateLivraison] = useState("");
const [editReference, setEditReference] = useState("");
const [editClient, setEditClient] = useState<Client | null>(null);
const [editLignes, setEditLignes] = useState<LigneForm[]>([]);
const avoir = useAppSelector(getavoirSelected) as Avoir;
// Initialiser les valeurs d'édition quand l'avoir change ou quand on entre en mode édition
useEffect(() => {
if (avoir && isEditing) {
setEditDateAvoir(avoir.date || "");
setEditDateLivraison(avoir.date_livraison || "");
setEditReference(avoir.reference || "");
const clientFound = clients.find((c) => c.numero === avoir.client_code);
setEditClient(
clientFound ||
({
numero: avoir.client_code,
intitule: avoir.client_intitule,
compte_collectif: "",
adresse: "",
code_postal: "",
ville: "",
email: "",
telephone: "",
} as Client)
);
const lignesInitiales: LigneForm[] =
avoir.lignes?.map((ligne) => ({
article_code: ligne.article_code,
quantite: ligne.quantite,
prix_unitaire_ht: ligne.prix_unitaire_ht ?? 0,
designation: ligne.designation ?? "",
total_taxes: ligne.total_taxes ?? 0,
taux_taxe1: ligne.taux_taxe1 ?? 0,
montant_ligne_ht: ligne.montant_ligne_ht ?? 0,
remise_pourcentage: ligne.remise_pourcentage ?? 0,
articles: {
reference: ligne.article_code,
designation: ligne.designation ?? "",
prix_vente: ligne.prix_unitaire_ht ?? 0,
stock_reel: 0,
} as Article,
})) ?? [];
setEditLignes(
lignesInitiales.length > 0
? lignesInitiales
: [
{
article_code: "",
quantite: 1,
prix_unitaire_ht: 0,
designation: "",
taux_taxe1: 0,
montant_ligne_ht: 0,
remise_pourcentage: 0,
articles: null,
},
]
);
}
}, [avoir, isEditing, clients]);
if (!avoir) return <div className="p-8 text-center">Avoir introuvable</div>;
const calculerTotalLigne = (ligne: LigneForm) => {
const prix = ligne.prix_unitaire_ht || ligne.articles?.prix_vente || 0;
const remise = ligne.remise_pourcentage ?? 0;
const prixRemise = prix * (1 - remise / 100);
return prixRemise * ligne.quantite;
};
const calculerTotalTva = () => {
const taxesParLignes = editLignes.reduce((total, ligne) => {
const totalHtLigne = calculerTotalLigne(ligne);
const tva = totalHtLigne * (ligne.taux_taxe1 / 100);
return total + tva;
}, 0);
const valeur_frais = avoir.valeur_frais;
return taxesParLignes + valeur_frais * (avoir.taxes1 ?? 0.2);
};
const calculerTotalHT = () => {
const total_ligne = editLignes.map((ligne) => calculerTotalLigne(ligne));
const totalHTLigne = total_ligne.reduce((acc, ligne) => acc + ligne, 0);
const totalHtNet = totalHTLigne + avoir.valeur_frais;
return totalHtNet;
};
const editTotalHT = calculerTotalHT();
const editTotalTVA = calculerTotalTva();
const editTotalTTC = editTotalHT + editTotalTVA;
// Gestion des lignes
const ajouterLigne = () => {
setEditLignes([
...editLignes,
{
article_code: "",
quantite: 1,
prix_unitaire_ht: 0,
designation: "",
taux_taxe1: 0,
montant_ligne_ht: 0,
remise_pourcentage: 0,
articles: null,
},
]);
};
const supprimerLigne = (index: number) => {
if (editLignes.length > 1) {
setEditLignes(editLignes.filter((_, i) => i !== index));
}
};
const updateLigne = (index: number, field: keyof LigneForm, value: any) => {
const nouvelles = [...editLignes];
if (field === "articles" && value) {
const article = value as Article;
nouvelles[index] = {
...nouvelles[index],
articles: article,
article_code: article.reference,
designation: article.designation,
prix_unitaire_ht: article.prix_vente,
};
} else if (field === "articles" && !value) {
nouvelles[index] = {
...nouvelles[index],
articles: null,
article_code: "",
designation: "",
prix_unitaire_ht: 0,
};
} else {
(nouvelles[index] as any)[field] = value;
}
setEditLignes(nouvelles);
};
// Validation
const canSave = useMemo(() => {
if (!editClient) return false;
const lignesValides = editLignes.filter((l) => l.article_code);
return lignesValides.length > 0;
}, [editClient, editLignes]);
// Gestion de l'édition
const handleStartEdit = () => {
setIsEditing(true);
};
const handleCancelEdit = () => {
setIsEditing(false);
setError(null);
};
const handleSaveEdit = async () => {
if (!editClient) {
setError("Veuillez sélectionner un client");
return;
}
const lignesValides = editLignes.filter((l) => l.article_code);
if (lignesValides.length === 0) {
setError("Veuillez ajouter au moins un article");
return;
}
try {
setIsSaving(true);
setError(null);
const payloadUpdate = {
client_id: editClient.numero,
date_avoir: editDateAvoir,
date_livraison: editDateLivraison,
reference: editReference,
lignes: lignesValides.map((l) => ({
article_code: l.article_code,
quantite: l.quantite,
})),
};
await dispatch(
updateAvoir({
numero: avoir.numero,
data: payloadUpdate,
})
).unwrap();
toast({
title: "Avoir mis à jour !",
description: `L'avoir ${avoir.numero} a été mis à jour avec succès.`,
className: "bg-green-500 text-white border-green-600",
});
// Recharger l'avoir
const itemUpdated = (await dispatch(
getAvoir(avoir.numero)
).unwrap()) as Avoir;
dispatch(selectavoir(itemUpdated));
setIsEditing(false);
} catch (err: any) {
setError(err.message || "Erreur lors de la mise à jour");
toast({
title: "Erreur",
description: "Impossible de mettre à jour l'avoir.",
className: "bg-red-500 text-white border-red-600",
});
} finally {
setIsSaving(false);
}
};
const tabs = [
{ id: "identification", label: "Identification" },
{ id: "lines", label: "Lignes de l'avoir" },
{ id: "totals", label: "Totaux" },
{ id: "documents", label: "Documents", count: avoir.lignes?.length },
{ id: "historique", label: "Historique", count: avoir.lignes?.length },
];
const timeline = [
{
type: "avoir" as const,
title: "Avoir créé",
description: `${avoir.numero} pour ${avoir.total_ttc.toLocaleString(
"fr-FR"
)}`,
date: avoir.date,
user: avoir.statut,
link: `/home/avoirs/${avoir.numero}`,
item: avoir,
},
];
return (
<>
<Helmet>
<title>{avoir.numero} - Avoir</title>
</Helmet>
<div className="space-y-6">
{/* Header */}
<div className="sticky top-0 z-30 bg-white border-b border-gray-200 shadow-sm dark:bg-gray-950 dark:border-gray-800">
<div className="px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex gap-4 items-center">
<button
onClick={() => navigate("/home/avoirs")}
className="p-2 text-gray-500 rounded-full transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
aria-label="Retour à la liste des avoirs"
disabled={isEditing}
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<div className="flex gap-3 items-center">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
{avoir.numero}
</h1>
<StatusBadge status={avoir.statut} />
{isEditing && (
<span className="px-2 py-1 text-xs font-medium text-amber-800 bg-amber-100 rounded-full">
Mode édition
</span>
)}
</div>
<div className="flex gap-2 items-center text-sm text-gray-500">
{avoir.client_intitule ? (
<span className="font-medium text-gray-900 dark:text-gray-300">
{avoir.client_intitule}
</span>
) : (
<span className="italic text-gray-400">
Client non défini
</span>
)}
<span></span>
<span>{formatDateFR(avoir.date)}</span>
</div>
</div>
</div>
<div className="flex gap-2 items-center">
{isEditing ? (
<>
<button
onClick={handleCancelEdit}
disabled={isSaving}
className="flex gap-2 items-center px-4 py-2 text-sm font-medium text-gray-600 rounded-xl border border-gray-200 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-white dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 disabled:opacity-50"
>
<X className="w-4 h-4" />
Annuler
</button>
<button
onClick={handleSaveEdit}
disabled={!canSave || isSaving}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-[#007E45] hover:bg-[#007E45] rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Check className="w-4 h-4" />
)}
Enregistrer
</button>
</>
) : (
<>
<button className="flex gap-2 items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white rounded-xl border border-gray-200 transition-colors hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-700">
<Eye className="w-4 h-4" />
<span className="hidden sm:inline">Aperçu</span>
</button>
<button
className="p-2 text-gray-600 rounded-xl border border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900 dark:text-gray-400"
title="Download"
>
<Download className="w-5 h-5" />
</button>
<button
onClick={handleStartEdit}
className="px-4 py-2 text-sm font-medium text-gray-600 rounded-xl border border-gray-200 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-white dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
>
Modifier
</button>
</>
)}
</div>
</div>
</div>
</div>
{/* Tabs & Content */}
<div className="px-4 mx-auto mt-6 w-full max-w-7xl sm:px-6 lg:px-8">
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
<div className="mt-6">
{/* --- IDENTIFICATION TAB --- */}
{activeTab === "identification" && (
<div className="space-y-6">
<div className="overflow-hidden relative p-6 bg-white rounded-2xl border border-gray-200 dark:bg-gray-950 dark:border-gray-800">
<h3 className="mb-4 text-lg font-bold text-gray-900 dark:text-white">
Informations Générales
</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<FormField label="Client" required>
{isEditing ? (
<ClientAutocomplete
value={editClient}
onChange={setEditClient}
required
/>
) : (
<Input
type="text"
value={avoir.client_intitule}
disabled
className="bg-gray-50 opacity-50 cursor-not-allowed dark:bg-gray-900"
/>
)}
</FormField>
<div className="grid grid-cols-2 gap-4">
<FormField label="Date d'émission" required>
<Input
type="date"
value={
isEditing
? formatForDateInput(editDateAvoir)
: formatForDateInput(avoir.date)
}
onChange={(e) =>
isEditing && setEditDateAvoir(e.target.value)
}
disabled={!isEditing}
className={cn(
!isEditing &&
"opacity-50 cursor-not-allowed bg-gray-50 dark:bg-gray-900"
)}
/>
</FormField>
<FormField label="Dû le" required>
<Input
type="date"
value={
isEditing
? formatForDateInput(editDateLivraison)
: formatForDateInput(avoir.date_livraison)
}
onChange={(e) =>
isEditing && setEditDateLivraison(e.target.value)
}
disabled={!isEditing}
className={cn(
!isEditing &&
"opacity-50 cursor-not-allowed bg-gray-50 dark:bg-gray-900"
)}
/>
</FormField>
</div>
<FormField label="Référence externe">
<Input
value={
isEditing ? editReference : avoir.reference || ""
}
onChange={(e) =>
isEditing && setEditReference(e.target.value)
}
disabled={!isEditing}
placeholder="Référence..."
className={cn(
!isEditing &&
"opacity-50 cursor-not-allowed bg-gray-50 dark:bg-gray-900"
)}
/>
</FormField>
<FormField label="Affaire / Deal">
<Input
value="__"
disabled
className="bg-gray-50 opacity-50 cursor-not-allowed dark:bg-gray-900"
/>
</FormField>
<RepresentantInput />
</div>
</div>
</div>
)}
{/* --- LINES TAB --- */}
{activeTab === "lines" && (
<div className="p-6 bg-white rounded-2xl border border-gray-200 shadow-sm dark:bg-gray-950 dark:border-gray-800">
<div className="overflow-x-auto min-h-[200px] mb-8">
<table className="w-full">
<thead className="text-xs font-semibold tracking-wider text-left text-gray-500 uppercase bg-gray-50 dark:bg-gray-900/50">
<tr>
<th className="px-4 py-3 rounded-l-lg w-[35%]">
Article / Description
</th>
<th className="px-4 py-3 text-right w-[10%]">Qté</th>
<th className="px-4 py-3 text-right w-[12%]">
P.U. HT
</th>
<th className="px-4 py-3 text-right w-[10%]">Remise</th>
<th className="px-4 py-3 text-right w-[8%]">TVA</th>
<th className="px-4 py-3 text-right w-[15%] rounded-r-lg">
Total HT
</th>
{isEditing && <th className="w-[5%]"></th>}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{isEditing ? (
editLignes.map((item, i) => (
<tr
key={i}
className="group hover:bg-gray-50/50 dark:hover:bg-gray-900/20"
>
<td
className="px-4 py-3"
style={{ minWidth: "300px" }}
>
<ArticleAutocomplete
value={item.articles}
onChange={(article) =>
updateLigne(i, "articles", article)
}
required
/>
</td>
<td
className="px-4 py-3"
style={{ width: "100px" }}
>
<input
type="number"
value={item.quantite}
onChange={(e) =>
updateLigne(
i,
"quantite",
parseFloat(e.target.value) || 0
)
}
min={0}
step={1}
className="w-full text-center px-2 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-[#007E45] focus:border-transparent"
/>
</td>
<td
className="px-4 py-3"
style={{ width: "120px" }}
>
<input
type="number"
value={
item.prix_unitaire_ht ||
item.articles?.prix_vente ||
0
}
onChange={(e) =>
updateLigne(
i,
"prix_unitaire_ht",
parseFloat(e.target.value) || 0
)
}
min={0}
step={0.01}
className="w-full text-right px-2 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-[#007E45] focus:border-transparent"
/>
</td>
<td className="px-4 py-3 text-sm text-right text-gray-600">
0%
</td>
<td className="px-4 py-3 text-sm text-right text-gray-600">
20%
</td>
<td className="px-4 py-3 font-mono font-bold text-right text-red-600">
- {calculerTotalLigne(item).toFixed(2)}
</td>
<td className="px-1 py-3 text-center">
<button
onClick={() => supprimerLigne(i)}
disabled={editLignes.length === 1}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))
) : avoir.lignes ? (
avoir.lignes.map((item, i) => (
<tr
key={i}
className="group hover:bg-gray-50/50 dark:hover:bg-gray-900/20"
>
<td className="px-4 py-3 pt-5 font-mono text-sm font-bold align-top">
{item.article_code}
{item.designation && (
<div className="mt-1 text-xs text-gray-400">
{item.designation}
</div>
)}
</td>
<td
className="px-4 py-3 pt-5 font-mono text-sm font-bold text-right align-top"
style={{ width: "15vh" }}
>
<Input
type="number"
value={item.quantite}
disabled
className="font-mono text-right bg-gray-50 opacity-50 cursor-not-allowed dark:bg-gray-900"
/>
</td>
<td
className="px-4 py-3 pt-5 font-mono text-sm font-bold text-right align-top"
style={{ width: "15vh" }}
>
<Input
type="number"
value={item.prix_unitaire_ht}
disabled
className="font-mono text-right bg-gray-50 opacity-50 cursor-not-allowed dark:bg-gray-900"
/>
</td>
<td
className="px-4 py-3 pt-5 font-mono text-sm font-bold text-right align-top"
style={{ width: "15vh" }}
>
<Input
type="text"
value="0%"
disabled
className="font-mono text-right bg-gray-50 opacity-50 cursor-not-allowed dark:bg-gray-900"
/>
</td>
<td className="px-4 py-3 pt-5 text-sm text-right text-gray-600 align-top">
20%
</td>
<td className="px-4 py-3 pt-5 font-mono font-bold text-right text-red-600 align-top">
- {item.montant_ligne_ht}
</td>
</tr>
))
) : (
<tr>
<td
colSpan={6}
className="py-4 text-center text-gray-500"
>
Aucune ligne
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Bouton ajouter ligne en mode édition */}
{isEditing && (
<div className="mb-6">
<button
onClick={ajouterLigne}
className="text-sm text-[#941403] font-medium hover:underline flex items-center gap-1"
>
<Plus className="w-4 h-4" /> Ajouter une ligne
</button>
</div>
)}
{/* Totals Block */}
<div className="flex justify-end">
<div
style={{ width: "42vh" }}
className="p-6 space-y-3 bg-red-50 rounded-xl border border-red-100 lg:w-96 dark:bg-red-900/20 dark:border-red-800"
>
<div className="flex justify-between text-sm">
<span className="text-gray-500 dark:text-gray-400">
Total HT
</span>
<span className="font-mono font-semibold text-red-600 dark:text-red-400">
-{" "}
{isEditing
? editTotalHT.toLocaleString()
: avoir.total_ht_calcule.toLocaleString()}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500 dark:text-gray-400">
Total TVA
</span>
<span className="font-mono font-semibold text-red-600 dark:text-red-400">
-{" "}
{isEditing
? editTotalTVA.toLocaleString()
: avoir.total_taxes_calcule.toLocaleString()}
</span>
</div>
<div className="my-2 h-px bg-red-200 dark:bg-red-700" />
<div className="flex justify-between text-lg font-bold">
<span className="text-gray-900 dark:text-white">
Total à rembourser
</span>
<span className="text-[#941403] font-mono">
-{" "}
{isEditing
? editTotalTTC.toLocaleString()
: avoir.total_ttc_calcule.toLocaleString()}
</span>
</div>
</div>
</div>
{/* Message d'erreur */}
{error && isEditing && (
<div className="p-4 mt-4 text-sm text-red-800 bg-red-50 rounded-xl border border-red-200">
{error}
</div>
)}
</div>
)}
{/* --- TOTALS TAB --- */}
{activeTab === "totals" && (
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="p-6 bg-white rounded-2xl border border-gray-200 shadow-sm dark:bg-gray-950 dark:border-gray-800 h-fit">
<h3 className="mb-4 text-lg font-bold text-gray-900 dark:text-white">
Détails
</h3>
<div className="space-y-4">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
Nombre de lignes
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{avoir.lignes?.length || 0}
</span>
</div>
</div>
</div>
</div>
)}
{/* --- HISTORIQUE TAB --- */}
{activeTab === "historique" && <Timeline events={timeline} />}
{/* --- DOCUMENTS TAB --- */}
{activeTab === "documents" && (
<div className="space-y-6">
<div className="p-12 text-center bg-white rounded-2xl border border-gray-200 border-dashed transition-colors cursor-pointer dark:bg-gray-950 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900">
<File className="mx-auto mb-4 w-12 h-12 text-gray-300" />
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">
Glissez-déposez vos fichiers ici
</p>
</div>
<div className="overflow-hidden bg-white rounded-2xl border border-gray-200 shadow-sm dark:bg-gray-950 dark:border-gray-800">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200 dark:bg-gray-900 dark:border-gray-800">
<tr className="text-xs font-semibold text-left text-gray-500 uppercase">
<th className="px-6 py-3">Fichier</th>
<th className="px-6 py-3">Type</th>
<th className="px-6 py-3">Date</th>
<th className="px-6 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
<tr className="hover:bg-gray-50 dark:hover:bg-gray-900/50">
<td className="flex gap-3 items-center px-6 py-4 text-sm">
<FileText className="w-5 h-5 text-[#941403]" />
<span className="font-medium text-gray-900 dark:text-white">
AV_{avoir.numero}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500">PDF</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatDateFRCourt(avoir.date)}
</td>
<td className="px-6 py-4 text-right">
<button className="mr-4 text-sm font-medium text-blue-600 hover:underline">
Télécharger
</button>
<button className="text-[#941403] hover:text-green-700">
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
</div>
{isSaving && <ModalLoading />}
</>
);
};
export default CreditNotesDetailPage;

View file

@ -0,0 +1,548 @@
import { useEffect, useMemo, useState } from 'react';
import { Helmet } from 'react-helmet';
import { Plus, Euro, FileText, TrendingDown, AlertCircle, Users, Eye, Edit, X } from 'lucide-react';
import KPIBar, { PeriodType } from '@/components/KPIBar';
import PrimaryButton_v2 from '@/components/PrimaryButton_v2';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { avoirStatus, getAllavoir } from '@/store/features/avoir/selectors';
import { Avoir } from '@/types/avoirType';
import { getAvoirs, selectAvoirAsync } from '@/store/features/avoir/thunk';
import { filterItemByPeriod, getPreviousPeriodItems } from '@/components/filter/ItemsFilter';
import { cn, formatDateFRCourt } from '@/lib/utils';
import DataTable from '@/components/ui/DataTable';
import { ModalAvoir } from '@/components/modal/ModalAvoir';
import { clientStatus, getAllClients } from '@/store/features/client/selectors';
import { articleStatus } from '@/store/features/article/selectors';
import { getArticles } from '@/store/features/article/thunk';
import { getClients } from '@/store/features/client/thunk';
import { Client } from '@/types/clientType';
import StatusBadge, { STATUS_LABELS } from '@/components/ui/StatusBadge';
import { SageDocumentType } from '@/types/sageTypes';
import ColumnSelector, { ColumnConfig } from '@/components/common/ColumnSelector';
import PeriodSelector from '@/components/common/PeriodSelector';
import ExportDropdown from '@/components/common/ExportDropdown';
import { useDashboardData } from '@/store/hooks/useAppData';
import AdvancedFilters from '@/components/common/AdvancedFilters';
import { Commercial } from '@/types/commercialType';
import { commercialsStatus, getAllcommercials } from '@/store/features/commercial/selectors';
import { getCommercials } from '@/store/features/commercial/thunk';
// ============================================
// CONSTANTES
// ============================================
const COLORS = {
gray: 'bg-gray-400',
yellow: 'bg-yellow-400',
blue: 'bg-blue-400',
green: 'bg-green-400',
};
// Labels de statut pour les avoirs
const AVOIR_STATUS_LABELS = {
0: { label: 'Saisi', color: COLORS.gray },
1: { label: 'Confirmé', color: COLORS.yellow },
2: { label: 'A Facturer', color: COLORS.blue },
3: { label: 'Facturé', color: COLORS.green },
} as const;
export type StatusCode = keyof typeof AVOIR_STATUS_LABELS;
type FilterType = 'all' | 'validated' | 'pending' | 'accounted';
interface KPIConfig {
id: FilterType;
title: string;
icon: React.ElementType;
color: string;
getValue: (avoirs: Avoir[]) => number | string;
getSubtitle: (avoirs: Avoir[], value: number | string) => string;
getChange: (avoirs: Avoir[], value: number | string, period: PeriodType, allAvoirs: Avoir[]) => string;
getTrend: (avoirs: Avoir[], value: number | string, period: PeriodType, allAvoirs: Avoir[]) => 'up' | 'down' | 'neutral';
filter: (avoirs: Avoir[]) => Avoir[];
}
// ============================================
// CONFIGURATION DES COLONNES
// ============================================
const DEFAULT_COLUMNS: ColumnConfig[] = [
{ key: 'numero', label: 'N° Pièce', visible: true, locked: true },
{ key: 'client_code', label: 'Client', visible: true },
{ key: 'date', label: 'Date', visible: true },
{ key: 'total_ht_calcule', label: 'Montant HT', visible: true },
{ key: 'total_taxes_calcule', label: 'Montant TVA', visible: true },
{ key: 'total_ttc_calcule', label: 'Montant TTC', visible: true },
{ key: 'statut', label: 'Statut', visible: true },
];
// ============================================
// CONFIGURATION DES KPIs
// ============================================
const KPI_CONFIG: KPIConfig[] = [
{
id: 'all',
title: 'Total Avoirs HT',
icon: Euro,
color: 'blue',
getValue: (avoirs) => avoirs.reduce((sum, item) => sum + item.total_ht, 0),
getSubtitle: (avoirs) => {
const totalTTC = avoirs.reduce((sum, item) => sum + item.total_ttc, 0);
return `${totalTTC.toLocaleString('fr-FR', { minimumFractionDigits: 2 })}€ TTC`;
},
getChange: (avoirs, value, period, allAvoirs) => {
const previousPeriodAvoirs = getPreviousPeriodItems(allAvoirs, period);
const previousTotalHT = previousPeriodAvoirs.reduce((sum, item) => sum + item.total_ht, 0);
return previousTotalHT > 0
? Math.abs(((Number(value) - previousTotalHT) / previousTotalHT) * 100).toFixed(1)
: '0';
},
getTrend: (avoirs, value, period, allAvoirs) => {
const previousPeriodAvoirs = getPreviousPeriodItems(allAvoirs, period);
const previousTotalHT = previousPeriodAvoirs.reduce((sum, item) => sum + item.total_ht, 0);
return Number(value) <= previousTotalHT ? 'up' : 'down';
},
filter: (avoirs) => avoirs,
},
{
id: 'pending',
title: "Nombre d'Avoirs",
icon: FileText,
color: 'orange',
getValue: (avoirs) => avoirs.length,
getSubtitle: (avoirs) => {
const avgAvoir = avoirs.length > 0 ? avoirs.reduce((sum, item) => sum + item.total_ht, 0) / avoirs.length : 0;
return `${avgAvoir.toLocaleString('fr-FR', { maximumFractionDigits: 0 })}€ moyen`;
},
getChange: (avoirs, value, period, allAvoirs) => {
const previousPeriodAvoirs = getPreviousPeriodItems(allAvoirs, period);
const countChange = Number(value) - previousPeriodAvoirs.length;
return countChange !== 0 ? `${countChange > 0 ? '+' : ''}${countChange}` : '0';
},
getTrend: (avoirs, value, period, allAvoirs) => {
const previousPeriodAvoirs = getPreviousPeriodItems(allAvoirs, period);
return Number(value) <= previousPeriodAvoirs.length ? 'up' : 'down';
},
filter: (avoirs) => avoirs.filter(a => a.statut === 1),
},
{
id: 'validated',
title: 'Avoirs Validés',
icon: TrendingDown,
color: 'green',
getValue: (avoirs) => avoirs.filter(a => a.statut === 2).length,
getSubtitle: (avoirs) => {
const validated = avoirs.filter(a => a.statut === 2);
const validatedAmount = validated.reduce((sum, item) => sum + item.total_ht, 0);
return `${validatedAmount.toLocaleString('fr-FR', { maximumFractionDigits: 0 })}€`;
},
getChange: (avoirs, value, period, allAvoirs) => {
const previousPeriodAvoirs = getPreviousPeriodItems(allAvoirs, period);
const previousValidated = previousPeriodAvoirs.filter(a => a.statut === 2);
const validatedCountChange = Number(value) - previousValidated.length;
return validatedCountChange !== 0 ? `${validatedCountChange > 0 ? '+' : ''}${validatedCountChange}` : '';
},
getTrend: (avoirs, value, period, allAvoirs) => {
const previousPeriodAvoirs = getPreviousPeriodItems(allAvoirs, period);
const previousValidated = previousPeriodAvoirs.filter(a => a.statut === 2);
return Number(value) <= previousValidated.length ? 'up' : 'down';
},
filter: (avoirs) => avoirs.filter(a => a.statut === 2),
},
{
id: 'accounted',
title: 'Avoirs Facturés',
icon: AlertCircle,
color: 'purple',
getValue: (avoirs) => avoirs.filter(a => a.statut === 3).length,
getSubtitle: (avoirs) => {
const accounted = avoirs.filter(a => a.statut === 3);
const accountedAmount = accounted.reduce((sum, item) => sum + item.total_ht, 0);
return `${accountedAmount.toLocaleString('fr-FR', { maximumFractionDigits: 0 })}€`;
},
getChange: (avoirs) => {
const validationRate =
avoirs.length > 0 ? ((avoirs.filter(a => a.statut === 2).length / avoirs.length) * 100).toFixed(1) : '0';
return `${validationRate}% validés`;
},
getTrend: (avoirs) => {
const pending = avoirs.filter(a => a.statut === 1);
return pending.length > 0 ? 'neutral' : 'up';
},
filter: (avoirs) => avoirs.filter(a => a.statut === 3),
},
];
// ============================================
// COMPOSANT PRINCIPAL
// ============================================
const CreditNotesPage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [period, setPeriod] = useState<PeriodType>('all');
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
const [activeFilters, setActiveFilters] = useState<Record<string, string[] | undefined>>({});
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
// État des colonnes visibles
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(DEFAULT_COLUMNS);
const clients = useAppSelector(getAllClients) as Client[];
const commercials = useAppSelector(getAllcommercials) as Commercial[];
const avoirs = useAppSelector(getAllavoir) as Avoir[];
const statusAvoir = useAppSelector(avoirStatus);
const [editing, setEditing] = useState<Avoir | null>(null);
const isLoading = statusAvoir === 'loading' && avoirs.length === 0;
const statusClient = useAppSelector(clientStatus);
const statusArticle = useAppSelector(articleStatus);
const statusCommercial = useAppSelector(commercialsStatus);
useEffect(() => {
const load = async () => {
try {
if (statusArticle === 'idle') await dispatch(getArticles()).unwrap();
if (statusClient === 'idle' || statusClient === 'failed') await dispatch(getClients()).unwrap();
if (statusCommercial === 'idle') await dispatch(getCommercials()).unwrap();
} catch (error) {
console.error(error);
}
};
load();
}, [statusArticle, statusClient, statusCommercial, dispatch]);
const { refresh } = useDashboardData();
useEffect(() => {
const load = async () => {
if (statusAvoir === 'idle' || statusAvoir === 'failed') await dispatch(getAvoirs()).unwrap();
};
load();
}, [statusAvoir, dispatch]);
// ============================================
// OPTIONS POUR LES FILTRES
// ============================================
const commercialOptions = useMemo(() => {
return commercials.map(c => ({
value: c.numero.toString(),
label: `${c.prenom || ''} ${c.nom || ''}`.trim() || `Commercial ${c.numero}`,
}));
}, [commercials]);
// Map pour retrouver le commercial d'un client rapidement
const clientCommercialMap = useMemo(() => {
const map = new Map<string, string>();
clients.forEach(client => {
if (client.commercial?.numero) {
map.set(client.numero, client.commercial.numero.toString());
}
});
return map;
}, [clients]);
const filterDefinitions = [
{
key: 'status',
label: 'Statut',
options: (Object.entries(AVOIR_STATUS_LABELS) as [string, typeof AVOIR_STATUS_LABELS[StatusCode]][]).map(
([value, { label, color }]) => ({
value: value.toString(),
label,
color,
})
),
},
{
key: 'rep',
label: 'Commercial',
options: commercialOptions,
},
];
// ============================================
// KPIs avec onClick
// ============================================
const kpis = useMemo(() => {
const periodFilteredAvoirs = filterItemByPeriod(avoirs, period, 'date');
return KPI_CONFIG.map(config => {
const value = config.getValue(periodFilteredAvoirs);
return {
id: config.id,
title: config.title,
value: config.id === 'all' ? `${Number(value).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}€` : value,
change: config.getChange(periodFilteredAvoirs, value, period, avoirs),
trend: config.getTrend(periodFilteredAvoirs, value, period, avoirs),
icon: config.icon,
subtitle: config.getSubtitle(periodFilteredAvoirs, value),
color: config.color,
isActive: activeFilter === config.id,
onClick: () => setActiveFilter(prev => (prev === config.id ? 'all' : config.id)),
};
});
}, [avoirs, period, activeFilter]);
// ============================================
// Filtrage combiné : Période + KPI + Filtres avancés
// ============================================
const filteredAvoirs = useMemo(() => {
// 1. Filtrer par période
let result = filterItemByPeriod(avoirs, period, 'date');
// 2. Filtrer par KPI actif
const kpiConfig = KPI_CONFIG.find(k => k.id === activeFilter);
if (kpiConfig && activeFilter !== 'all') {
result = kpiConfig.filter(result);
}
// 3. Filtres avancés (statut)
if (activeFilters.status && activeFilters.status.length > 0) {
result = result.filter(item => activeFilters.status!.includes(item.statut.toString()));
}
// 4. Filtre par commercial
if (activeFilters.rep && activeFilters.rep.length > 0) {
result = result.filter(item => {
const commercialCode = clientCommercialMap.get(item.client_code);
return commercialCode && activeFilters.rep!.includes(commercialCode);
});
}
// 5. Tri par date décroissante
return [...result].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}, [avoirs, period, activeFilter, activeFilters, clientCommercialMap]);
// ============================================
// Label du filtre actif
// ============================================
const activeFilterLabel = useMemo(() => {
const config = KPI_CONFIG.find(k => k.id === activeFilter);
return config?.title || 'Tous';
}, [activeFilter]);
// ============================================
// COLONNES DYNAMIQUES
// ============================================
const allColumnsDefinition = useMemo(() => {
const clientsMap = new Map(clients.map(c => [c.numero, c]));
return {
numero: {
key: 'numero',
label: 'N° Pièce',
sortable: true,
render: (value: string) => <span className="font-bold">{value}</span>,
},
client_code: {
key: 'client_code',
label: 'Client',
sortable: true,
render: (clientCode: string) => {
const client = clientsMap.get(clientCode);
if (!client) return <span className="text-gray-400">{clientCode}</span>;
const avatar = client.intitule?.charAt(0).toUpperCase() || '?';
return (
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-xs font-bold text-gray-600 dark:text-gray-300">
{avatar}
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{client.intitule}</p>
<p className="text-xs text-gray-500">{client.email || client.telephone || clientCode}</p>
</div>
</div>
);
},
},
date: {
key: 'date',
label: 'Date',
sortable: true,
render: (date: string) => <span>{formatDateFRCourt(date)}</span>,
},
total_ht_calcule: {
key: 'total_ht_calcule',
label: 'Montant HT',
sortable: true,
render: (v: number) => `${v.toLocaleString()}`,
},
total_taxes_calcule: {
key: 'total_taxes_calcule',
label: 'Montant TVA',
sortable: true,
render: (value: number) => `${value.toLocaleString()}`,
},
total_ttc_calcule: {
key: 'total_ttc_calcule',
label: 'Montant TTC',
sortable: true,
render: (v: number) => <span className="font-bold">{v.toLocaleString()}</span>,
},
statut: {
key: 'statut',
label: 'Statut',
sortable: true,
render: (v: number) => <StatusBadge status={v} type_doc={SageDocumentType.BON_AVOIR} />,
},
};
}, [clients]);
const visibleColumns = useMemo(() => {
return columnConfig
.filter(col => col.visible)
.map(col => allColumnsDefinition[col.key as keyof typeof allColumnsDefinition])
.filter(Boolean);
}, [columnConfig, allColumnsDefinition]);
// Formateurs personnalisés pour transformer les valeurs à l'export
const columnFormatters: Record<string, (value: any, row: any) => string> = {
date: value => {
if (!value) return '';
return formatDateFRCourt(value);
},
total_ht_calcule: value => {
if (value === null || value === undefined) return '0 €';
return `${value.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €`;
},
total_taxes_calcule: value => {
if (value === null || value === undefined) return '0 €';
return `${value.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €`;
},
total_ttc_calcule: value => {
if (value === null || value === undefined) return '0 €';
return `${value.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €`;
},
statut: value => {
return AVOIR_STATUS_LABELS[value as StatusCode]?.label || 'Inconnu';
},
client_intitule: (value, row) => {
return value || row.client_code || '';
},
};
const actions = (row: Avoir) => (
<>
<button
onClick={async () => {
await dispatch(selectAvoirAsync(row)).unwrap();
navigate(`/home/avoirs/${row.numero}`);
}}
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={async () => {
const rep = (await dispatch(selectAvoirAsync(row)).unwrap()) as Avoir;
handleEdit(rep);
}}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="Modifier"
>
<Edit className="w-4 h-4" />
</button>
</>
);
const handleCreate = () => {
setEditing(null);
setIsCreateModalOpen(true);
};
const handleEdit = (row: Avoir) => {
setEditing(row);
setIsCreateModalOpen(true);
};
return (
<>
<Helmet>
<title>Avoirs - Dataven</title>
<meta name="description" content="Gestion des avoirs" />
</Helmet>
<div className="space-y-6">
<KPIBar kpis={kpis} period={period} loading={statusAvoir} onRefresh={refresh} />
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Avoirs</h1>
{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">
{filteredAvoirs.length} avoir{filteredAvoirs.length > 1 ? 's' : ''}
{activeFilter !== 'all' && ` (${activeFilterLabel.toLowerCase()})`}
</p>
</div>
<PeriodSelector value={period} onChange={setPeriod} />
</div>
<div className="flex gap-3 flex-wrap items-center">
<ColumnSelector columns={columnConfig} onChange={setColumnConfig} />
<ExportDropdown
data={filteredAvoirs}
columns={columnConfig}
columnFormatters={columnFormatters}
filename="Avoir"
/>
<AdvancedFilters
filters={filterDefinitions}
activeFilters={activeFilters}
onFilterChange={(key, values) => {
setActiveFilters(prev => ({
...prev,
[key]: values,
}));
}}
onReset={() => setActiveFilters({})}
/>
<PrimaryButton_v2 icon={Plus} onClick={handleCreate}>
Nouvel avoir
</PrimaryButton_v2>
</div>
</div>
<DataTable
columns={visibleColumns}
data={filteredAvoirs}
actions={actions}
status={isLoading}
onRowClick={async (row: Avoir) => {
await dispatch(selectAvoirAsync(row)).unwrap();
navigate(`/home/avoirs/${row.numero}`);
}}
/>
</div>
<ModalAvoir
open={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
title={editing ? `Mettre à jour l'avoir ${editing.numero}` : 'Créer un avoir'}
editing={editing}
/>
</>
);
};
export default CreditNotesPage;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,736 @@
import { CheckCircle, Clock, Copy, Euro, Eye, FileText, Plus, TrendingUp, X } from 'lucide-react';
import { toast } from '@/components/ui/use-toast';
import { CompanyInfo } from '@/data/mockData';
import PrimaryButton_v2 from '@/components/PrimaryButton_v2';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { useEffect, useMemo, useState } from 'react';
import { BLStatus, getAllBLs, getBLSelected } from '@/store/features/bl/selectors';
import { BL, BLRequest, BLResponse } from '@/types/BL_Types';
import { createBL, getAllBL, getBL, selectBLAsync } from '@/store/features/bl/thunk';
import { filterItemByPeriod, getPreviousPeriodItems } from '@/components/filter/ItemsFilter';
import { cn, formatDateFRCourt } from '@/lib/utils';
import { Helmet } from 'react-helmet';
import KPIBar, { PeriodType } from '@/components/KPIBar';
import DataTable from '@/components/DataTable';
import { ModalBL } from '@/components/modal/ModalBL';
import { DropdownMenuTable } from '@/components/DropdownMenu';
import { Client } from '@/types/clientType';
import { clientStatus, getAllClients } from '@/store/features/client/selectors';
import { articleStatus } from '@/store/features/article/selectors';
import { getArticles } from '@/store/features/article/thunk';
import { getClients } from '@/store/features/client/thunk';
import PDFPreview, { DocumentData } from '@/components/modal/PDFPreview';
import { usePDFPreview } from '@/components/ui/PDFActionButtons';
import { selectBL } from '@/store/features/bl/slice';
import { ModalLoading } from '@/components/modal/ModalLoading';
import FormModal from '@/components/ui/FormModal';
import ModalStatus from '@/components/modal/ModalStatus';
import StatusBadge, { STATUS_LABELS } from '@/components/ui/StatusBadge';
import { SageDocumentType } from '@/types/sageTypes';
import ColumnSelector, { ColumnConfig } from '@/components/common/ColumnSelector';
import PeriodSelector from '@/components/common/PeriodSelector';
import ExportDropdown from '@/components/common/ExportDropdown';
import { useDashboardData } from '@/store/hooks/useAppData';
import AdvancedFilters from '@/components/common/AdvancedFilters';
import { Commercial } from '@/types/commercialType';
import { commercialsStatus, getAllcommercials } from '@/store/features/commercial/selectors';
import { getCommercials } from '@/store/features/commercial/thunk';
// ============================================
// CONSTANTES
// ============================================
const COLORS = {
gray: 'bg-gray-400',
yellow: 'bg-yellow-400',
orange: 'bg-orange-400',
green: 'bg-green-400',
purple: 'bg-purple-400',
blue: 'bg-blue-400',
};
// Labels de statut pour les BL
const BL_STATUS_LABELS = {
0: { label: 'Saisi', color: COLORS.gray },
1: { label: 'Confirmé', color: COLORS.yellow },
2: { label: 'A préparer', color: COLORS.blue },
3: { label: 'Préparé', color: COLORS.green },
4: { label: 'Soldé', color: COLORS.purple },
} as const;
export type StatusCode = keyof typeof BL_STATUS_LABELS;
type FilterType = 'all' | 'delivered' | 'pending' | 'toInvoice' | 'invoiced';
interface KPIConfig {
id: FilterType;
title: string;
icon: React.ElementType;
color: string;
getValue: (bls: BL[]) => number | string;
getSubtitle: (bls: BL[], value: number | string) => string;
getChange: (bls: BL[], value: number | string, period: PeriodType, allBLs: BL[]) => string;
getTrend: (bls: BL[], value: number | string, period: PeriodType, allBLs: BL[]) => 'up' | 'down' | 'neutral';
filter: (bls: BL[]) => BL[];
tooltip?: { content: string; source: string };
}
// ============================================
// CONFIGURATION DES COLONNES
// ============================================
const DEFAULT_COLUMNS: ColumnConfig[] = [
{ key: 'numero', label: 'Numéro', visible: true, locked: true },
{ key: 'client_code', label: 'Client', visible: true },
{ key: 'date', label: 'Date', visible: true },
{ key: 'total_ht_calcule', label: 'Montant HT', visible: true },
{ key: 'total_taxes_calcule', label: 'Montant TVA', visible: true },
{ key: 'total_ttc_calcule', label: 'Montant TTC', visible: true },
{ key: 'statut', label: 'Statut', visible: true },
];
// ============================================
// CONFIGURATION DES KPIs
// ============================================
const KPI_CONFIG: KPIConfig[] = [
{
id: 'all',
title: 'Total HT',
icon: Euro,
color: 'blue',
getValue: (bls) => Math.round(bls.reduce((sum, item) => sum + item.total_ht, 0)),
getSubtitle: (bls) => {
const totalTTC = bls.reduce((sum, item) => sum + item.total_ttc, 0);
return `${totalTTC.toLocaleString('fr-FR', { minimumFractionDigits: 2 })}€ TTC`;
},
getChange: (bls, value, period, allBLs) => {
const previousPeriodBL = getPreviousPeriodItems(allBLs, period);
const previousTotalHT = previousPeriodBL.reduce((sum, item) => sum + item.total_ht, 0);
return previousTotalHT > 0 ? (((Number(value) - previousTotalHT) / previousTotalHT) * 100).toFixed(1) : '0';
},
getTrend: (bls, value, period, allBLs) => {
const previousPeriodBL = getPreviousPeriodItems(allBLs, period);
const previousTotalHT = previousPeriodBL.reduce((sum, item) => sum + item.total_ht, 0);
return Number(value) >= previousTotalHT ? 'up' : 'down';
},
filter: (bls) => bls,
tooltip: { content: 'Total des BL sur la période.', source: 'Ventes > Bon de livraisons' },
},
{
id: 'pending',
title: 'Nombre de BL',
icon: FileText,
color: 'orange',
getValue: (bls) => bls.length,
getSubtitle: (bls) => {
const delivered = bls.filter(b => b.statut === 3 || b.statut === 4);
const deliveryRate = bls.length > 0 ? ((delivered.length / bls.length) * 100).toFixed(1) : '0';
return `${deliveryRate}% livrés`;
},
getChange: (bls, value, period, allBLs) => {
const previousPeriodBL = getPreviousPeriodItems(allBLs, period);
const countChange = Number(value) - previousPeriodBL.length;
return countChange !== 0 ? `${countChange > 0 ? '+' : ''}${countChange}` : '0';
},
getTrend: (bls, value, period, allBLs) => {
const previousPeriodBL = getPreviousPeriodItems(allBLs, period);
return Number(value) >= previousPeriodBL.length ? 'up' : 'down';
},
filter: (bls) => bls.filter(b => b.statut === 1),
},
{
id: 'delivered',
title: 'BL Livrés',
icon: CheckCircle,
color: 'green',
getValue: (bls) => bls.filter(b => b.statut === 3 || b.statut === 4).length,
getSubtitle: (bls) => {
const delivered = bls.filter(b => b.statut === 3 || b.statut === 4);
const deliveredAmount = delivered.reduce((sum, item) => sum + item.total_ht, 0);
return `${deliveredAmount.toLocaleString('fr-FR', { maximumFractionDigits: 0 })}€`;
},
getChange: (bls, value, period, allBLs) => {
const previousPeriodBL = getPreviousPeriodItems(allBLs, period);
const previousDelivered = previousPeriodBL.filter(b => b.statut === 3 || b.statut === 4);
const deliveredChange = Number(value) - previousDelivered.length;
return deliveredChange !== 0 ? `${deliveredChange > 0 ? '+' : ''}${deliveredChange}` : '';
},
getTrend: (bls, value, period, allBLs) => {
const previousPeriodBL = getPreviousPeriodItems(allBLs, period);
const previousDelivered = previousPeriodBL.filter(b => b.statut === 3 || b.statut === 4);
return Number(value) >= previousDelivered.length ? 'up' : 'down';
},
filter: (bls) => bls.filter(b => b.statut === 3 || b.statut === 4),
},
{
id: 'toInvoice',
title: 'À Facturer',
icon: Clock,
color: 'purple',
getValue: (bls) => bls.filter(b => b.statut === 3).length,
getSubtitle: (bls) => {
const toInvoice = bls.filter(b => b.statut === 3);
const toInvoiceAmount = toInvoice.reduce((sum, item) => sum + item.total_ht, 0);
return `${toInvoiceAmount.toLocaleString('fr-FR', { maximumFractionDigits: 0 })}€`;
},
getChange: (bls) => {
const toInvoice = bls.filter(b => b.statut === 3);
const toInvoiceAmount = toInvoice.reduce((sum, item) => sum + item.total_ht, 0);
return `${toInvoiceAmount.toLocaleString('fr-FR', { maximumFractionDigits: 0 })}€`;
},
getTrend: (bls) => {
const toInvoice = bls.filter(b => b.statut === 3);
return toInvoice.length > 0 ? 'neutral' : 'up';
},
filter: (bls) => bls.filter(b => b.statut === 3),
},
{
id: 'invoiced',
title: 'Montant Facturé',
icon: TrendingUp,
color: 'blue',
getValue: (bls) => {
const invoiced = bls.filter(b => b.statut === 4);
return Math.round(invoiced.reduce((sum, item) => sum + item.total_ht, 0));
},
getSubtitle: (bls) => {
const invoiced = bls.filter(b => b.statut === 4);
return `${invoiced.length} BL facturé${invoiced.length > 1 ? 's' : ''}`;
},
getChange: (bls, value, period, allBLs) => {
const previousPeriodBL = getPreviousPeriodItems(allBLs, period);
const previousInvoiced = previousPeriodBL.filter(b => b.statut === 4);
const previousInvoicedAmount = previousInvoiced.reduce((sum, item) => sum + item.total_ht, 0);
return previousInvoicedAmount > 0
? (((Number(value) - previousInvoicedAmount) / previousInvoicedAmount) * 100).toFixed(1)
: '0';
},
getTrend: (bls, value, period, allBLs) => {
const previousPeriodBL = getPreviousPeriodItems(allBLs, period);
const previousInvoiced = previousPeriodBL.filter(b => b.statut === 4);
const previousInvoicedAmount = previousInvoiced.reduce((sum, item) => sum + item.total_ht, 0);
return Number(value) >= previousInvoicedAmount ? 'up' : 'down';
},
filter: (bls) => bls.filter(b => b.statut === 4),
tooltip: { content: 'Total des BL facturés sur la période.', source: 'Ventes > Bon de livraisons' },
},
];
// ============================================
// COMPOSANT PRINCIPAL
// ============================================
const DeliveryNotesPage = () => {
const navigate = useNavigate();
const { showPreview, openPreview, closePreview } = usePDFPreview();
const dispatch = useAppDispatch();
const [period, setPeriod] = useState<PeriodType>('all');
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
const [activeFilters, setActiveFilters] = useState<Record<string, string[] | undefined>>({});
// État des colonnes visibles
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(DEFAULT_COLUMNS);
const BLs = useAppSelector(getAllBLs) as BL[];
const clients = useAppSelector(getAllClients) as Client[];
const commercials = useAppSelector(getAllcommercials) as Commercial[];
const [openStatus, setOpenStatus] = useState(false);
const bl = useAppSelector(getBLSelected) as BL;
const statusBL = useAppSelector(BLStatus);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editing, setEditing] = useState<BL | null>(null);
const isLoading = statusBL === 'loading' && BLs.length === 0;
const [isDuplicate, setIsDuplicate] = useState(false);
const [loading, setLoading] = useState(false);
const statusClient = useAppSelector(clientStatus);
const statusArticle = useAppSelector(articleStatus);
const statusCommercial = useAppSelector(commercialsStatus);
useEffect(() => {
const load = async () => {
try {
if (statusArticle === 'idle') await dispatch(getArticles()).unwrap();
if (statusClient === 'idle' || statusClient === 'failed') await dispatch(getClients()).unwrap();
if (statusCommercial === 'idle') await dispatch(getCommercials()).unwrap();
} catch (error) {
console.error(error);
}
};
load();
}, [statusArticle, statusClient, statusCommercial, dispatch]);
const { refresh } = useDashboardData();
useEffect(() => {
const load = async () => {
if (statusBL === 'idle') await dispatch(getAllBL()).unwrap();
};
load();
}, [statusBL, dispatch]);
// ============================================
// OPTIONS POUR LES FILTRES
// ============================================
const commercialOptions = useMemo(() => {
return commercials.map(c => ({
value: c.numero.toString(),
label: `${c.prenom || ''} ${c.nom || ''}`.trim() || `Commercial ${c.numero}`,
}));
}, [commercials]);
// Map pour retrouver le commercial d'un client rapidement
const clientCommercialMap = useMemo(() => {
const map = new Map<string, string>();
clients.forEach(client => {
if (client.commercial?.numero) {
map.set(client.numero, client.commercial.numero.toString());
}
});
return map;
}, [clients]);
const filterDefinitions = [
{
key: 'status',
label: 'Statut',
options: (Object.entries(BL_STATUS_LABELS) as [string, typeof BL_STATUS_LABELS[StatusCode]][]).map(
([value, { label, color }]) => ({
value: value.toString(),
label,
color,
})
),
},
{
key: 'rep',
label: 'Commercial',
options: commercialOptions,
},
];
// ============================================
// KPIs avec onClick
// ============================================
const kpis = useMemo(() => {
const periodFilteredBL = filterItemByPeriod(BLs, period, 'date');
return KPI_CONFIG.map(config => {
const value = config.getValue(periodFilteredBL);
return {
id: config.id,
title: config.title,
value: config.id === 'all' || config.id === 'invoiced' ? `${value.toLocaleString('fr-FR')}` : value,
change: config.getChange(periodFilteredBL, value, period, BLs),
trend: config.getTrend(periodFilteredBL, value, period, BLs),
icon: config.icon,
subtitle: config.getSubtitle(periodFilteredBL, value),
color: config.color,
tooltip: config.tooltip,
isActive: activeFilter === config.id,
onClick: () => setActiveFilter(prev => (prev === config.id ? 'all' : config.id)),
};
});
}, [BLs, period, activeFilter]);
// ============================================
// Filtrage combiné : Période + KPI + Filtres avancés
// ============================================
const filteredBL = useMemo(() => {
// 1. Filtrer par période
let result = filterItemByPeriod(BLs, period, 'date');
// 2. Filtrer par KPI actif
const kpiConfig = KPI_CONFIG.find(k => k.id === activeFilter);
if (kpiConfig && activeFilter !== 'all') {
result = kpiConfig.filter(result);
}
// 3. Filtres avancés (statut)
if (activeFilters.status && activeFilters.status.length > 0) {
result = result.filter(item => activeFilters.status!.includes(item.statut.toString()));
}
// 4. Filtre par commercial
if (activeFilters.rep && activeFilters.rep.length > 0) {
result = result.filter(item => {
const commercialCode = clientCommercialMap.get(item.client_code);
return commercialCode && activeFilters.rep!.includes(commercialCode);
});
}
// 5. Tri par date décroissante
return [...result].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}, [BLs, period, activeFilter, activeFilters, clientCommercialMap]);
// ============================================
// Label du filtre actif
// ============================================
const activeFilterLabel = useMemo(() => {
const config = KPI_CONFIG.find(k => k.id === activeFilter);
return config?.title || 'Tous';
}, [activeFilter]);
const pdfData = useMemo(() => {
if (!bl) return null;
return {
numero: bl.numero,
type: 'bl' as const,
date: bl.date,
client: {
code: bl.client_code,
nom: bl.client_intitule,
adresse: bl.client_adresse,
code_postal: bl.client_code_postal,
ville: bl.client_ville,
email: bl.client_email,
telephone: bl.client_telephone,
},
reference_externe: bl.reference,
lignes: (bl.lignes ?? []).map(l => ({
article: l.article_code,
designation: l.designation,
quantite: l.quantite,
prix_unitaire: l.prix_unitaire_ht ?? 0,
tva: 20,
total_ht: l.quantite * (l.prix_unitaire_ht ?? 0),
})),
total_ht: bl.total_ht_calcule,
total_tva: bl.total_taxes_calcule,
total_ttc: bl.total_ttc_calcule,
};
}, [bl]) as DocumentData;
// ============================================
// COLONNES DYNAMIQUES
// ============================================
const allColumnsDefinition = useMemo(() => {
const clientsMap = new Map(clients.map(c => [c.numero, c]));
return {
numero: { key: 'numero', label: 'Numéro', sortable: true },
client_code: {
key: 'client_code',
label: 'Client',
sortable: true,
render: (clientCode: string) => {
const client = clientsMap.get(clientCode);
if (!client) return <span className="text-gray-400">{clientCode}</span>;
const avatar = client.intitule?.charAt(0).toUpperCase() || '?';
return (
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-xs font-bold text-gray-600 dark:text-gray-300">
{avatar}
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{client.intitule}</p>
<p className="text-xs text-gray-500">{client.email || client.telephone || clientCode}</p>
</div>
</div>
);
},
},
date: {
key: 'date',
label: 'Date',
sortable: true,
render: (date: string) => <span>{formatDateFRCourt(date)}</span>,
},
total_ht_calcule: {
key: 'total_ht_calcule',
label: 'Montant HT',
sortable: true,
render: (value: number) => `${value.toLocaleString()}`,
},
total_taxes_calcule: {
key: 'total_taxes_calcule',
label: 'Montant TVA',
sortable: true,
render: (value: number) => `${value.toLocaleString()}`,
},
total_ttc_calcule: {
key: 'total_ttc_calcule',
label: 'Montant TTC',
sortable: true,
render: (value: number) => <span className="font-medium">{value.toLocaleString()}</span>,
},
statut: {
key: 'statut',
label: 'Statut',
sortable: true,
render: (value: number) => <StatusBadge status={value} type_doc={SageDocumentType.BON_LIVRAISON} />,
},
};
}, [clients]);
const visibleColumns = useMemo(() => {
return columnConfig
.filter(col => col.visible)
.map(col => allColumnsDefinition[col.key as keyof typeof allColumnsDefinition])
.filter(Boolean);
}, [columnConfig, allColumnsDefinition]);
// Formateurs personnalisés pour transformer les valeurs à l'export
const columnFormatters: Record<string, (value: any, row: any) => string> = {
date: value => {
if (!value) return '';
return formatDateFRCourt(value);
},
total_ht_calcule: value => {
if (value === null || value === undefined) return '0 €';
return `${value.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €`;
},
total_taxes_calcule: value => {
if (value === null || value === undefined) return '0 €';
return `${value.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €`;
},
total_ttc_calcule: value => {
if (value === null || value === undefined) return '0 €';
return `${value.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €`;
},
statut: value => {
return BL_STATUS_LABELS[value as StatusCode]?.label || 'Inconnu';
},
client_intitule: (value, row) => {
return value || row.client_code || '';
},
};
const handleCreate = () => {
setEditing(null);
setIsCreateModalOpen(true);
};
const handleEdit = (row: BL) => {
setEditing(row);
setIsCreateModalOpen(true);
};
const openPDF = async (row: BL) => {
await dispatch(selectBLAsync(row)).unwrap();
openPreview();
};
const onDuplicate = async () => {
try {
setLoading(true);
const payloadCreate: BLRequest = {
client_id: bl.client_code,
date_livraison: (() => {
const d = new Date();
d.setDate(d.getDate() + 1);
return d.toISOString().split('T')[0];
})(),
reference: bl.reference,
lignes: bl.lignes!.map(ligne => ({
article_code: ligne.article_code,
quantite: ligne.quantite,
})),
};
const result = (await dispatch(createBL(payloadCreate)).unwrap()) as BLResponse;
const data = result.data;
await new Promise(resolve => setTimeout(resolve, 1500));
const itemCreated = (await dispatch(getBL(data.numero_livraison)).unwrap()) as any;
const res = itemCreated as BL;
dispatch(selectBL(res));
toast({
title: 'BL dupliqué avec succès !',
description: `Un nouveau BL ${itemCreated.numero_livraison} a été créé avec succès.`,
className: 'bg-green-500 text-white border-green-600',
});
setLoading(false);
setIsDuplicate(false);
navigate(`/home/bons-livraison/${data.numero_livraison}`);
} catch (err: any) {
setLoading(false);
setIsDuplicate(false);
}
};
const actions = (row: BL) => {
const handleStatus =
row.statut !== 2 && row.statut !== 3 && row.statut !== 4
? async () => {
await dispatch(selectBLAsync(row)).unwrap();
setOpenStatus(true);
}
: undefined;
return (
<>
<button
onClick={() => openPDF(row)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-gray-600 dark:text-gray-400"
title="Voir"
>
<Eye className="w-4 h-4" />
</button>
<DropdownMenuTable
row={row}
onEdit={() => handleEdit(row)}
onStatus={handleStatus}
onDulipcate={async () => {
await dispatch(selectBLAsync(row)).unwrap();
setIsDuplicate(true);
}}
/>
</>
);
};
return (
<>
<Helmet>
<title>Bons de livraison - {CompanyInfo.name}</title>
<meta name="description" content="Gestion des bons de livraison" />
</Helmet>
<div className="space-y-6">
<KPIBar kpis={kpis} period={period} loading={statusBL} onRefresh={refresh} />
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Bons de livraison</h1>
{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">
{filteredBL.length} bon{filteredBL.length > 1 ? 's' : ''} de livraison
{activeFilter !== 'all' && ` (${activeFilterLabel.toLowerCase()})`}
</p>
</div>
<PeriodSelector value={period} onChange={setPeriod} />
</div>
<div className="flex gap-3 flex-wrap items-center">
<ColumnSelector columns={columnConfig} onChange={setColumnConfig} />
<ExportDropdown data={filteredBL} columns={columnConfig} columnFormatters={columnFormatters} filename="BL" />
<AdvancedFilters
filters={filterDefinitions}
activeFilters={activeFilters}
onFilterChange={(key, values) => {
setActiveFilters(prev => ({
...prev,
[key]: values,
}));
}}
onReset={() => setActiveFilters({})}
/>
<PrimaryButton_v2 icon={Plus} onClick={handleCreate}>
Nouveau bon de livraison
</PrimaryButton_v2>
</div>
</div>
<DataTable
columns={visibleColumns}
data={filteredBL}
onRowClick={async (row: BL) => {
await dispatch(selectBLAsync(row)).unwrap();
navigate(`/home/bons-livraison/${row.numero}`);
}}
actions={actions}
status={isLoading}
/>
</div>
<ModalBL
open={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
title={editing ? `Mettre à jour le bon de livraison ${editing.numero}` : 'Créer un bon de livraison'}
editing={editing}
/>
<PDFPreview open={showPreview} onClose={closePreview} data={pdfData} entreprise={CompanyInfo} />
<ModalStatus open={openStatus} onClose={() => setOpenStatus(false)} type_doc={SageDocumentType.BON_LIVRAISON} />
<FormModal
isOpen={isDuplicate}
onClose={() => setIsDuplicate(false)}
title={`Dupliquer le BL ${bl?.numero}`}
onSubmit={onDuplicate}
submitLabel="Dupliquer"
>
<div className="mb-6 p-4 bg-teal-50 text-teal-800 rounded-xl text-sm flex gap-3 border border-teal-200">
<Copy className="w-5 h-5 shrink-0" />
<p>Vous allez dupliquer ce bon de livraison</p>
</div>
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl overflow-hidden mb-6">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800">
<tr>
<th className="px-4 py-3 text-left font-semibold text-gray-600">Article</th>
<th className="px-4 py-3 text-right font-semibold text-gray-600">Qté BL</th>
<th className="px-4 py-3 text-right font-semibold text-gray-600">TVA</th>
<th className="px-4 py-3 text-right font-semibold text-gray-600">Total HT</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{bl?.lignes ? (
bl?.lignes.map((item, i) => (
<tr key={i} className="hover:bg-gray-50/50">
<td className="px-4 py-3">
<div className="font-medium text-gray-900 dark:text-white">{item.designation}</div>
<div className="text-xs text-gray-500">{item.article_code}</div>
</td>
<td className="px-4 py-3 text-right text-gray-600">{item.quantite}</td>
<td className="px-4 py-3 text-right font-medium text-gray-900">20%</td>
<td className="px-4 py-3 text-right font-medium text-gray-900 dark:text-white">
{item.montant_ligne_ht!.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="py-4 text-center text-gray-500">
Aucune ligne
</td>
</tr>
)}
</tbody>
<tfoot className="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800">
<tr>
<td colSpan={3} className="px-4 py-3 text-right font-bold text-gray-900 dark:text-white">
Total HT à générer
</td>
<td className="px-4 py-3 text-right font-bold text-[#007E45]">{bl?.total_ht_calcule?.toFixed(2)} </td>
</tr>
</tfoot>
</table>
</div>
</FormModal>
{loading && <ModalLoading />}
</>
);
};
export default DeliveryNotesPage;

View file

@ -0,0 +1,774 @@
import React, { useState, useMemo, useCallback } from "react";
import { Helmet } from "react-helmet";
import { useNavigate } from "react-router-dom";
import {
ArrowLeft,
Save,
Loader2,
Plus,
Trash2,
PenLine,
Package,
Lock,
EyeOff,
AlertTriangle,
} from "lucide-react";
import { toast } from "@/components/ui/use-toast";
import { useAppDispatch, useAppSelector } from "@/store/hooks";
import { FactureRequest } from "@/types/factureType";
import { createFacture, getFacture } from "@/store/features/factures/thunk";
import { ModalLoading } from "@/components/modal/ModalLoading";
import { cn, formatForDateInput } from "@/lib/utils";
import { getAllClients } from "@/store/features/client/selectors";
import { Client } from "@/types/clientType";
import { Article } from "@/types/articleType";
import { selectfacture } from "@/store/features/factures/slice";
import { ModalArticle } from "@/components/modal/ModalArticle";
import { getuserConnected } from "@/store/features/user/selectors";
import { UserInterface } from "@/types/userInterface";
import ClientAutocomplete from "@/components/molecules/ClientAutocomplete";
import ArticleAutocomplete from "@/components/molecules/ArticleAutocomplete";
import StatusBadge from "@/components/ui/StatusBadge";
import { Input, Textarea } from "@/components/ui/FormModal";
import { Tooltip, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
import StickyTotals from "@/components/document-entry/StickyTotals";
// ============================================
// TYPES
// ============================================
export interface LigneForm {
id: string;
article_code: string;
quantite: number;
prix_unitaire_ht: number;
total_taxes: number;
taux_taxe1: number;
montant_ligne_ht: number;
remise_pourcentage: number;
designation: string;
articles: Article | null;
isManual: boolean;
}
export interface Note {
publique: string;
prive: string;
}
// Générer un ID unique
const generateLineId = () =>
`line_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Créer une ligne vide
const createEmptyLine = (): LigneForm => ({
id: generateLineId(),
article_code: "",
quantite: 1,
prix_unitaire_ht: 0,
total_taxes: 0,
taux_taxe1: 20,
montant_ligne_ht: 0,
remise_pourcentage: 0,
designation: "",
articles: null,
isManual: false,
});
// ============================================
// COMPOSANT PRINCIPAL
// ============================================
const InvoiceCreatePage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const clients = useAppSelector(getAllClients) as Client[];
const userConnected = useAppSelector(getuserConnected) as UserInterface;
// États UI
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// États pour le modal article
const [isArticleModalOpen, setIsArticleModalOpen] = useState(false);
const [currentLineId, setCurrentLineId] = useState<string | null>(null);
const [activeLineId, setActiveLineId] = useState<string | null>(null);
// États des champs éditables
const [editDateFacture, setEditDateFacture] = useState(() => {
const d = new Date();
d.setDate(d.getDate() + 1);
return d.toISOString().split("T")[0];
});
const [editDateEcheance, setEditDateEcheance] = useState(() => {
const d = new Date();
d.setDate(d.getDate() + 31); // Échéance à 30 jours
return d.toISOString().split("T")[0];
});
const [editReference, setEditReference] = useState("");
const [editClient, setEditClient] = useState<Client | null>(null);
const [editLignes, setEditLignes] = useState<LigneForm[]>([createEmptyLine()]);
// Notes
const [note, setNote] = useState<Note>({
publique: "Paiement à 30 jours fin de mois",
prive: "",
});
const [description, setDescription] = useState<string | null>(null);
// ============================================
// VÉRIFIER SI LA DERNIÈRE LIGNE EST VIDE
// ============================================
const isLastLineEmpty = useCallback((lignes: LigneForm[]) => {
if (lignes.length === 0) return false;
const lastLine = lignes[lignes.length - 1];
if (lastLine.isManual) {
return !lastLine.designation || lastLine.designation.trim() === "";
}
return !lastLine.article_code;
}, []);
// ============================================
// AJOUTER UNE LIGNE SI NÉCESSAIRE
// ============================================
const addLineIfNeeded = useCallback((currentLignes: LigneForm[], updatedLineId: string) => {
const lastLine = currentLignes[currentLignes.length - 1];
if (lastLine && lastLine.id === updatedLineId) {
const isLineFilled = lastLine.isManual
? lastLine.designation && lastLine.designation.trim() !== ""
: lastLine.article_code !== "";
if (isLineFilled) {
return [...currentLignes, createEmptyLine()];
}
}
return currentLignes;
}, []);
// ============================================
// CALCULS
// ============================================
const calculerTotalLigne = (ligne: LigneForm) => {
const prix = ligne.prix_unitaire_ht || ligne.articles?.prix_vente || 0;
const remise = ligne.remise_pourcentage ?? 0;
const prixRemise = prix * (1 - remise / 100);
return prixRemise * ligne.quantite;
};
const calculerTotalHT = () => {
return editLignes.reduce((acc, ligne) => acc + calculerTotalLigne(ligne), 0);
};
const calculerTotalTva = () => {
return editLignes.reduce((total, ligne) => {
const totalHtLigne = calculerTotalLigne(ligne);
const tva = totalHtLigne * (ligne.taux_taxe1 / 100);
return total + tva;
}, 0);
};
const editTotalHT = calculerTotalHT();
const editTotalTVA = calculerTotalTva();
const editTotalTTC = editTotalHT + editTotalTVA;
// ============================================
// GESTION DES LIGNES
// ============================================
const toggleLineMode = (lineId: string) => {
setEditLignes((prev) =>
prev.map((ligne) => {
if (ligne.id === lineId) {
const newIsManual = !ligne.isManual;
return {
...ligne,
isManual: newIsManual,
article_code: newIsManual ? "" : ligne.article_code,
articles: newIsManual ? null : ligne.articles,
designation: "",
prix_unitaire_ht: newIsManual ? 0 : ligne.prix_unitaire_ht,
};
}
return ligne;
})
);
};
const ajouterLigne = () => {
if (isLastLineEmpty(editLignes)) return;
setEditLignes([...editLignes, createEmptyLine()]);
};
const supprimerLigne = (lineId: string) => {
if (editLignes.length > 1) {
const newLignes = editLignes.filter((l) => l.id !== lineId);
if (!isLastLineEmpty(newLignes)) {
setEditLignes([...newLignes, createEmptyLine()]);
} else {
setEditLignes(newLignes);
}
}
};
const updateLigne = (lineId: string, field: keyof LigneForm, value: any) => {
setEditLignes((prev) => {
let updatedLignes = prev.map((ligne) => {
if (ligne.id !== lineId) return ligne;
if (field === "articles" && value) {
const article = value as Article;
return {
...ligne,
articles: article,
article_code: article.reference,
designation: article.designation,
prix_unitaire_ht: article.prix_vente,
};
} else if (field === "articles" && !value) {
return {
...ligne,
articles: null,
article_code: "",
designation: "",
prix_unitaire_ht: 0,
};
} else {
return { ...ligne, [field]: value };
}
});
if (field === "articles" && value) {
updatedLignes = addLineIfNeeded(updatedLignes, lineId);
}
return updatedLignes;
});
};
const handleManualDesignationBlur = (lineId: string, designation: string) => {
if (designation && designation.trim() !== "") {
setEditLignes((prev) => {
const lastLine = prev[prev.length - 1];
if (lastLine && lastLine.id === lineId && lastLine.isManual) {
if (!isLastLineEmpty(prev)) {
return prev;
}
return [...prev, createEmptyLine()];
}
return prev;
});
}
};
// ============================================
// VALIDATION
// ============================================
const canSave = useMemo(() => {
if (!editClient) return false;
const lignesValides = editLignes.filter(
(l) => l.article_code || (l.isManual && l.designation && l.designation.trim() !== "")
);
return lignesValides.length > 0;
}, [editClient, editLignes]);
// ============================================
// HANDLERS
// ============================================
const handleCancelEdit = () => {
navigate("/home/factures");
};
const handleSaveEdit = async () => {
if (!editClient) {
setError("Veuillez sélectionner un client");
return;
}
const lignesValides = editLignes.filter(
(l) => l.article_code || (l.isManual && l.designation && l.designation.trim() !== "")
);
if (lignesValides.length === 0) {
setError("Veuillez ajouter au moins un article ou une ligne");
return;
}
try {
setIsSaving(true);
setError(null);
const payloadCreate: FactureRequest = {
client_id: editClient.numero,
reference: editReference,
date_facture: editDateFacture,
date_livraison: editDateEcheance,
lignes: lignesValides.map((l) => ({
article_code: l.article_code || "DIVERS",
quantite: l.quantite,
prix_unitaire_ht: l.prix_unitaire_ht,
remise_pourcentage: l.remise_pourcentage,
...(l.isManual && l.designation && { designation: l.designation }),
})),
};
const result = await dispatch(createFacture(payloadCreate)).unwrap();
await new Promise((resolve) => setTimeout(resolve, 1500));
const factureCreated = await dispatch(getFacture(result.data.numero_facture)).unwrap();
dispatch(selectfacture(factureCreated as any));
toast({
title: "Facture créée avec succès !",
description: `La facture ${result.data.numero_facture} a été créée.`,
className: "bg-green-500 text-white border-green-600",
});
navigate(`/home/factures/${result.data.numero_facture}`);
} catch (err: any) {
setError(err.message || "Erreur lors de la création de la facture");
toast({
title: "Erreur",
description: "Impossible de créer la facture.",
className: "bg-red-500 text-white border-red-600",
});
} finally {
setIsSaving(false);
}
};
const handleOpenArticleModal = (lineId: string) => {
setCurrentLineId(lineId);
setIsArticleModalOpen(true);
setActiveLineId(null);
};
// ============================================
// RENDER
// ============================================
return (
<>
<Helmet>
<title>Nouvelle facture - Dataven</title>
</Helmet>
{/* Structure principale avec h-screen */}
<div className="flex flex-col h-[80vh] overflow-hidden">
{/* HEADER - Reste fixe */}
<div className="flex-none bg-white dark:bg-gray-950 border-b border-gray-200 dark:border-gray-800 z-30 shadow-[0_1px_3px_rgba(0,0,0,0.05)] py-2">
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col gap-4 justify-between items-start lg:flex-row lg:items-center">
{/* Left Section */}
<div className="flex flex-col flex-1 gap-3 w-full min-w-0 lg:w-auto">
{/* Row 1 */}
<div className="flex flex-wrap gap-4 items-center">
{/* Back + Title */}
<div className="flex gap-3 items-center mr-2 shrink-0">
<button
onClick={handleCancelEdit}
className="p-1.5 -ml-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors text-gray-500"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="flex flex-col">
<div className="flex gap-2 items-center">
<h1 className="text-xl font-bold tracking-tight text-gray-900 dark:text-white">
NOUVELLE
</h1>
<StatusBadge status={0} type_doc={60} />
<span className="px-2 py-1 text-xs font-medium text-blue-800 bg-blue-100 rounded-full">
Brouillon
</span>
</div>
</div>
</div>
<div className="hidden mx-2 w-px h-8 bg-gray-200 dark:bg-gray-800 sm:block" />
{/* Client */}
<div className="flex-1 min-w-[250px] max-w-[400px] relative z-20">
<ClientAutocomplete
value={editClient}
onChange={setEditClient}
required
placeholder="Client..."
/>
</div>
{/* Date + User */}
<div className="flex gap-4 shrink-0">
<div className="w-auto">
<Input
type="date"
value={formatForDateInput(editDateFacture)}
onChange={(e) => setEditDateFacture(e.target.value)}
className="h-10 text-xs"
/>
</div>
<div className="w-[140px]">
<div className="flex flex-row gap-2 items-center px-1 py-1 w-full text-sm bg-gray-50 rounded-xl border shadow-sm opacity-80 cursor-not-allowed dark:bg-gray-900 border-gray-200 dark:border-gray-800">
<div
className="w-6 h-6 rounded-full bg-[#007E45] text-white flex items-center justify-center text-[10px] font-semibold"
style={{ width: "3vh", height: "3vh" }}
>
{userConnected
? `${userConnected.prenom?.[0] || ""}${userConnected.nom?.[0] || ""}`
: "JD"}
</div>
<span className="text-sm text-gray-900 dark:text-white">
{userConnected ? `${userConnected.prenom}` : "_"}
</span>
</div>
</div>
</div>
</div>
{/* Row 2 - Référence + Date échéance */}
<div className="flex flex-wrap gap-4 items-center pt-1">
<div className="w-[250px]">
<Input
value={editReference}
onChange={(e) => setEditReference(e.target.value)}
placeholder="Référence..."
className="h-9 text-xs"
/>
</div>
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-red-500" />
<span className="text-xs text-gray-500">Date d'échéance:</span>
<Input
type="date"
value={formatForDateInput(editDateEcheance)}
onChange={(e) => setEditDateEcheance(e.target.value)}
className="h-9 text-xs w-[140px]"
/>
</div>
</div>
</div>
{/* Right Section - Actions */}
<div className="flex gap-3 items-center self-start mt-2 ml-auto shrink-0 lg:self-center lg:mt-0">
<button
onClick={handleCancelEdit}
disabled={isSaving}
className="px-4 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 disabled:opacity-50"
>
Annuler
</button>
<button
onClick={handleSaveEdit}
disabled={!canSave || isSaving}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-[#007E45] hover:bg-[#006837] rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Enregistrer
</button>
</div>
</div>
</div>
</div>
{/* CONTENT - Zone scrollable */}
<div className="flex-1 overflow-y-auto scroll-smooth">
<div className="px-4 mx-auto w-full sm:px-6 lg:px-8 py-6">
<div className="w-full">
{/* Tableau des lignes */}
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm dark:bg-gray-950 dark:border-gray-800">
<div className="overflow-x-auto mb-2">
<table className="w-full">
<thead className="text-xs font-semibold tracking-wider text-gray-500 uppercase bg-gray-50 border-b dark:bg-gray-900/50">
<tr className="text-[10px]">
<th className="px-2 py-3 w-5 text-center">Mode</th>
<th className="px-4 py-3 text-left w-[25%]">Désignation</th>
<th className="px-4 py-3 text-right w-[20%]">Description détaillée</th>
<th className="px-4 py-3 text-right w-[8%]">Qté</th>
<th className="px-4 py-3 text-right w-[15%]">P.U. HT</th>
<th className="px-4 py-3 text-right w-[8%]">Rem. %</th>
<th className="px-4 py-3 text-right w-[8%]">TVA</th>
<th className="px-4 py-3 text-right w-[15%]">Total HT</th>
<th className="px-2 py-3 w-12"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{editLignes.map((item, index) => {
const isLastEmptyLine = index === editLignes.length - 1 && isLastLineEmpty(editLignes);
return (
<tr
key={item.id}
className={cn(
"group hover:bg-gray-50/50 dark:hover:bg-gray-900/20",
isLastEmptyLine && "bg-gray-50/30"
)}
>
{/* ... tout le contenu du tr reste identique ... */}
{/* Mode Toggle */}
<td className="px-2 py-3">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => toggleLineMode(item.id)}
className={cn(
"p-1.5 rounded-md transition-all duration-200",
item.isManual
? "text-amber-600 bg-amber-50 hover:bg-amber-100"
: "text-[#007E45] bg-green-50 hover:bg-green-100"
)}
>
{item.isManual ? (
<PenLine className="w-4 h-4" />
) : (
<Package className="w-4 h-4" />
)}
</button>
</TooltipTrigger>
</Tooltip>
</TooltipProvider>
</td>
{/* Article / Désignation */}
<td className="px-4 py-3 min-w-[280px]">
{item.isManual ? (
<div className="relative">
<input
type="text"
value={item.designation}
onChange={(e) => {
updateLigne(item.id, "designation", e.target.value);
}}
onFocus={() => setActiveLineId(item.id)}
onBlur={(e) => {
setTimeout(() => setActiveLineId(null), 150);
handleManualDesignationBlur(item.id, e.target.value);
}}
className="w-full border-0 text-sm focus:outline-none bg-transparent text-gray-900"
placeholder={isLastEmptyLine ? "Saisir une désignation..." : "Désignation..."}
/>
{activeLineId === item.id && item.designation && (
<div className="overflow-hidden mt-1 w-full bg-white rounded-xl border border-gray-200 shadow-lg absolute z-10">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleOpenArticleModal(item.id)}
className="flex gap-3 items-center px-4 py-3 w-full text-left transition-colors hover:bg-green-50"
>
<div className="p-1.5 bg-[#007E45] rounded-lg text-white">
<Plus className="w-4 h-4" />
</div>
<span className="text-sm font-medium text-gray-900">
Créer l'article "<span className="text-[#007E45]">{item.designation}</span>"
</span>
</button>
<div className="border-t border-gray-100" />
<div className="flex flex-row gap-3 justify-center items-center py-2 w-full text-xs text-center text-gray-400">
<PenLine className="w-4 h-4" />
<span className="text-xs font-medium">Texte libre accepté</span>
</div>
</div>
)}
</div>
) : (
<ArticleAutocomplete
value={item.articles}
onChange={(article) => updateLigne(item.id, "articles", article)}
required
className="text-sm"
placeholder={isLastEmptyLine ? "Rechercher un article..." : "Article..."}
/>
)}
</td>
{/* Description */}
<td className="px-1">
<input
type="text"
value={item.isManual ? description || "" : item.designation || ""}
onChange={(e) =>
item.isManual
? setDescription(e.target.value)
: updateLigne(item.id, "designation", e.target.value)
}
placeholder="Description détaillée..."
className="w-full border-0 text-sm focus:outline-none bg-transparent text-gray-900 text-right"
/>
</td>
{/* Quantité */}
<td className="px-1" style={{ width: "100px" }}>
<input
type="number"
value={item.quantite}
onChange={(e) =>
updateLigne(item.id, "quantite", parseFloat(e.target.value) || 0)
}
min={0}
className="w-full border-0 text-sm focus:outline-none bg-transparent text-gray-900 text-right"
/>
</td>
{/* Prix Unitaire */}
<td className="px-1 text-center" style={{ width: "60px" }}>
<input
type="number"
value={item.prix_unitaire_ht || item.articles?.prix_vente || 0}
onChange={(e) =>
updateLigne(item.id, "prix_unitaire_ht", parseFloat(e.target.value) || 0)
}
min={0}
step={0.01}
className="w-full border-0 text-sm focus:outline-none bg-transparent text-gray-900 text-right"
/>
</td>
{/* Remise */}
<td className="px-1 text-center" style={{ width: "60px" }}>
<input
type="number"
value={item.remise_pourcentage || 0}
onChange={(e) =>
updateLigne(item.id, "remise_pourcentage", parseFloat(e.target.value) || 0)
}
min={0}
step={1}
className="w-full border-0 text-sm focus:outline-none bg-transparent text-gray-900 text-right"
/>
</td>
{/* TVA */}
<td className="px-1 text-center text-gray-500 text-right text-sm">
{item.taux_taxe1}%
</td>
{/* Total */}
<td className="px-1 text-right">
<span className={cn(
"font-bold font-mono text-sm",
isLastEmptyLine ? "text-gray-300" : "text-gray-900"
)}>
{calculerTotalLigne(item).toFixed(2)}
</span>
</td>
{/* Delete */}
<td className="px-1 text-center">
<button
onClick={() => supprimerLigne(item.id)}
disabled={editLignes.length <= 1}
className={cn(
"p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-30",
isLastEmptyLine && "invisible"
)}
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Bouton ajouter ligne */}
{!isLastLineEmpty(editLignes) && (
<div className="m-6">
<button
onClick={ajouterLigne}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-[#2A6F4F] bg-white border border-[#2A6F4F] rounded-lg hover:bg-green-50 transition-all shadow-sm"
>
<Plus className="w-4 h-4" /> Ajouter une ligne
</button>
</div>
)}
{/* Message d'erreur */}
{error && (
<div className="p-4 m-4 text-sm text-red-800 bg-red-50 rounded-xl border border-red-200">
{error}
</div>
)}
</div>
{/* Section Notes */}
<div className="grid grid-cols-1 gap-6 p-6 mt-8 bg-gray-50 rounded-2xl border border-gray-100 md:grid-cols-2 dark:bg-gray-900/30 dark:border-gray-800">
{/* Notes publiques */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<label className="flex gap-2 items-center text-sm font-semibold text-gray-900 dark:text-white">
Notes publiques <Lock className="w-3 h-3 text-gray-400" />
</label>
<span className="text-xs text-gray-500">Visible sur le PDF</span>
</div>
<Textarea
rows={4}
placeholder="Conditions de paiement, délais de livraison, modalités particulières..."
className="bg-white resize-none dark:bg-gray-950"
value={note.publique}
onChange={(e) => setNote({ ...note, publique: e.target.value })}
/>
</div>
{/* Notes privées */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<label className="flex gap-2 items-center text-sm font-semibold text-gray-900 dark:text-white">
Notes privées <EyeOff className="w-3 h-3 text-gray-400" />
</label>
<span className="text-xs text-gray-500">Interne uniquement</span>
</div>
<Textarea
rows={4}
placeholder="Notes internes, marge de négociation, contexte client..."
className="bg-white border-yellow-200 resize-none dark:bg-gray-950 focus:border-yellow-400"
value={note.prive}
onChange={(e) => setNote({ ...note, prive: e.target.value })}
/>
</div>
</div>
</div>
</div>
</div>
{/* Footer Totaux - Reste fixe en bas */}
<div className="flex-none bg-white dark:bg-gray-950 border-t border-gray-200 dark:border-gray-800 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] z-20">
<div className="px-4 mx-auto w-full sm:px-6 lg:px-8">
<StickyTotals
total_ht_calcule={editTotalHT}
total_taxes_calcule={editTotalTVA}
total_ttc_calcule={editTotalTTC}
/>
</div>
</div>
</div>
{/* Modal création article */}
<ModalArticle
open={isArticleModalOpen}
onClose={() => setIsArticleModalOpen(false)}
/>
{isSaving && <ModalLoading />}
</>
);
};
export default InvoiceCreatePage;

View file

@ -0,0 +1,530 @@
import React, { useState, useMemo, useEffect } from "react";
import { Helmet } from "react-helmet";
import { useNavigate } from "react-router-dom";
import { toast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import { useAppDispatch, useAppSelector } from "@/store/hooks";
import { getfactureSelected } from "@/store/features/factures/selectors";
import { Facture } from "@/types/factureType";
import { getAllClients } from "@/store/features/client/selectors";
import { Client } from "@/types/clientType";
import { Article } from "@/types/articleType";
import { selectClient } from "@/store/features/client/slice";
import { ModalAvoir } from "@/components/modal/ModalAvoir";
import ModalStatus from "@/components/modal/ModalStatus";
import { getFacture, updateFacture, validerFacture } from "@/store/features/factures/thunk";
import { selectfacture } from "@/store/features/factures/slice";
import { ModalLoading } from "@/components/modal/ModalLoading";
import { ModalArticle } from "@/components/modal/ModalArticle";
import PDFPreviewPanel from "@/components/panels/PDFPreviewPanel";
import { useDisplayMode } from "@/context/DisplayModeContext";
import FactureContent, { LigneForm, Note } from "@/components/page/facture/FactureContent";
import FactureHeader from "@/components/page/facture/FactureHeader";
import { UserInterface } from "@/types/userInterface";
import { getuserConnected } from "@/store/features/user/selectors";
import { CheckCircle, Lock } from "lucide-react";
import FormModal from "@/components/ui/FormModal";
import { ValiderFacture } from "@/components/page/facture/ValiderFacture";
// Générer un ID unique
const generateLineId = () =>
`line_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const InvoiceDetailPage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const clients = useAppSelector(getAllClients) as Client[];
const facture = useAppSelector(getfactureSelected) as Facture;
// États UI
const [openStatus, setOpenStatus] = useState(false);
const [isCreateAvoir, setIsCreateAvoir] = useState(false);
const [isArticleModalOpen, setIsArticleModalOpen] = useState(false);
const [isValid, setIsValid] = useState(false)
const [currentLineId, setCurrentLineId] = useState<string | null>(null);
const userConnected = useAppSelector(getuserConnected) as UserInterface
// États pour l'édition inline
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// États des champs éditables
const [editDateFacture, setEditDateFacture] = useState("");
const [editDateLivraison, setEditDateLivraison] = useState("");
const [editReference, setEditReference] = useState("");
const [editClient, setEditClient] = useState<Client | null>(null);
const [editLignes, setEditLignes] = useState<LigneForm[]>([]);
const { isPdfPreviewVisible, togglePdfPreview } = useDisplayMode();
const [description, setDescription] = useState<string | null>(null);
const [activeLineId, setActiveLineId] = useState<string | null>(null);
const [note, setNote] = useState<Note>({
publique: "Paiement à 30 jours",
prive: "",
});
const editNotLignes: LigneForm[] = facture?.lignes?.map((line, index) => ({
id: `${index}`,
article_code: line.article_code,
quantite: line.quantite,
prix_unitaire_ht: line.prix_unitaire_ht ?? 0,
total_taxes: line.total_taxes ?? 0,
taux_taxe1: line.taux_taxe1 ?? 0,
montant_ligne_ht: line.montant_ligne_ht ?? 0,
designation: line.designation ?? "",
remise_pourcentage: line.remise_pourcentage ?? 0,
articles: null,
isManual: false,
})) || [];
// Initialiser les valeurs d'édition
useEffect(() => {
if (facture && isEditing) {
setEditDateFacture(facture.date || "");
setEditDateLivraison(facture.date_livraison || "");
setEditReference(facture.reference || "");
const clientFound = clients.find((c) => c.numero === facture.client_code);
setEditClient(
clientFound ||
({
numero: facture.client_code,
intitule: facture.client_intitule,
compte_collectif: "",
adresse: "",
code_postal: "",
ville: "",
email: "",
telephone: "",
} as Client)
);
const lignesInitiales: LigneForm[] =
facture.lignes?.map((ligne) => ({
id: generateLineId(),
article_code: ligne.article_code,
quantite: ligne.quantite,
prix_unitaire_ht: ligne.prix_unitaire_ht ?? 0,
designation: ligne.designation ?? "",
remise_pourcentage: ligne.remise_valeur1 ?? 0,
total_taxes: ligne.total_taxes ?? 0,
taux_taxe1: ligne.taux_taxe1 ?? 0,
montant_ligne_ht: ligne.montant_ligne_ht ?? 0,
articles: ligne.article_code
? ({
reference: ligne.article_code,
designation: ligne.designation ?? "",
prix_vente: ligne.prix_unitaire_ht ?? 0,
stock_reel: 0,
} as Article)
: null,
isManual: !ligne.article_code,
})) ?? [];
setEditLignes(
lignesInitiales.length > 0
? lignesInitiales
: [
{
id: generateLineId(),
article_code: "",
quantite: 1,
prix_unitaire_ht: 0,
total_taxes: 0,
taux_taxe1: 0,
montant_ligne_ht: 0,
remise_pourcentage: 0,
designation: "",
articles: null,
isManual: false,
},
]
);
}
}, [facture, isEditing, clients]);
if (!facture)
return <div className="p-8 text-center">Facture introuvable</div>;
const client = clients.find(
(item: Client) => item.numero === facture.client_code
) as Client;
const calculerTotalLigne = (ligne: LigneForm) => {
const prix = ligne.prix_unitaire_ht || ligne.articles?.prix_vente || 0;
const remise = ligne.remise_pourcentage ?? 0;
const prixRemise = prix * (1 - remise / 100);
return prixRemise * ligne.quantite;
};
const calculerTotalTva = () => {
const taxesParLignes = editLignes.reduce((total, ligne) => {
const totalHtLigne = calculerTotalLigne(ligne);
const tva = totalHtLigne * (ligne.taux_taxe1 / 100);
return total + tva;
}, 0);
const valeur_frais = facture.valeur_frais;
return taxesParLignes + valeur_frais * (facture.taxes1 ?? 0.2);
};
const calculerTotalHT = () => {
const total_ligne = editLignes.map((ligne) => calculerTotalLigne(ligne));
const totalHTLigne = total_ligne.reduce((acc, ligne) => acc + ligne, 0);
const totalHtNet = totalHTLigne + facture.valeur_frais;
return totalHtNet;
};
const editTotalHT = calculerTotalHT();
const editTotalTVA = calculerTotalTva();
const editTotalTTC = editTotalHT + editTotalTVA;
// Toggle mode Article/Libre
const toggleLineMode = (lineId: string) => {
setEditLignes((prev) =>
prev.map((ligne) => {
if (ligne.id === lineId) {
const newIsManual = !ligne.isManual;
return {
...ligne,
isManual: newIsManual,
article_code: newIsManual ? "" : ligne.article_code,
articles: newIsManual ? null : ligne.articles,
designation: "",
prix_unitaire_ht: newIsManual ? 0 : ligne.prix_unitaire_ht,
};
}
return ligne;
})
);
};
// Gestion des lignes
const ajouterLigne = () => {
setEditLignes([
...editLignes,
{
id: generateLineId(),
article_code: "",
quantite: 1,
prix_unitaire_ht: 0,
total_taxes: 0,
taux_taxe1: 0,
montant_ligne_ht: 0,
remise_pourcentage: 0,
designation: "",
articles: null,
isManual: false,
},
]);
};
const supprimerLigne = (lineId: string) => {
if (editLignes.length > 1) {
setEditLignes(editLignes.filter((l) => l.id !== lineId));
}
};
const updateLigne = (lineId: string, field: keyof LigneForm, value: any) => {
setEditLignes((prev) =>
prev.map((ligne) => {
if (ligne.id !== lineId) return ligne;
if (field === "articles" && value) {
const article = value as Article;
return {
...ligne,
articles: article,
article_code: article.reference,
designation: article.designation,
prix_unitaire_ht: article.prix_vente,
};
} else if (field === "articles" && !value) {
return {
...ligne,
articles: null,
article_code: "",
designation: "",
prix_unitaire_ht: 0,
};
} else {
return { ...ligne, [field]: value };
}
})
);
};
// Validation
const canSave = useMemo(() => {
if (!editClient) return false;
const lignesValides = editLignes.filter(
(l) => l.article_code || (l.isManual && l.designation)
);
return lignesValides.length > 0;
}, [editClient, editLignes]);
// Gestion de l'édition
const handleStartEdit = () => setIsEditing(true);
const handleCancelEdit = () => {
setIsEditing(false);
setError(null);
};
const handleSaveEdit = async () => {
if (!editClient) {
setError("Veuillez sélectionner un client");
return;
}
const lignesValides = editLignes.filter(
(l) => l.article_code || (l.isManual && l.designation)
);
if (lignesValides.length === 0) {
setError("Veuillez ajouter au moins un article ou une ligne");
return;
}
try {
setIsSaving(true);
setError(null);
const payloadUpdate = {
client_id: editClient.numero,
date_facture: editDateFacture,
date_livraison: editDateLivraison,
reference: editReference,
lignes: lignesValides.map((l) => ({
article_code: l.article_code || "DIVERS",
quantite: l.quantite,
remise_pourcentage: l.remise_pourcentage,
})),
};
await dispatch(
updateFacture({ numero: facture.numero, data: payloadUpdate })
).unwrap();
toast({
title: "Facture mise à jour !",
description: `La facture ${facture.numero} a été mise à jour avec succès.`,
className: "bg-green-500 text-white border-green-600",
});
const itemUpdated = (await dispatch(
getFacture(facture.numero)
).unwrap()) as Facture;
dispatch(selectfacture(itemUpdated));
setIsEditing(false);
} catch (err: any) {
setError(err.message || "Erreur lors de la mise à jour");
toast({
title: "Erreur",
description: "Impossible de mettre à jour la facture.",
className: "bg-red-500 text-white border-red-600",
});
} finally {
setIsSaving(false);
}
};
const handleValiderFacture = async () => {
try {
setIsSaving(true);
setError(null);
await dispatch(validerFacture(facture.numero)).unwrap();
toast({
title: "Facture validé !",
description: `La facture ${facture.numero} a été bien validé avec succès.`,
className: "bg-green-500 text-white border-green-600",
});
const itemUpdated = (await dispatch(
getFacture(facture.numero)
).unwrap()) as Facture;
dispatch(selectfacture(itemUpdated));
setIsEditing(false);
setIsValid(false)
} catch (err: any) {
setError(err.message || "Erreur lors de la validation du facture");
toast({
title: "Erreur",
description: "Impossible de mettre à jour la facture.",
className: "bg-red-500 text-white border-red-600",
});
} finally {
setIsSaving(false);
}
};
const handleCreateAvoir = () => {
setIsCreateAvoir(true);
};
return (
<>
<Helmet>
<title>{facture.numero} - Facture</title>
</Helmet>
{/* Structure principale avec h-screen */}
<div className="flex flex-col h-[80vh] overflow-hidden">
{/* TOP HEADER - Reste fixe */}
<div className="flex-none">
<FactureHeader
// ... tous les props
userConnected={userConnected}
facture={facture}
client={client}
isEditing={isEditing}
isSaving={isSaving}
canSave={canSave}
editClient={editClient}
editDateFacture={editDateFacture}
editDateLivraison={editDateLivraison}
editReference={editReference}
isPdfPreviewVisible={isPdfPreviewVisible}
setIsValid={setIsValid}
onSetEditClient={setEditClient}
onSetEditDateFacture={setEditDateFacture}
onSetEditDateLivraison={setEditDateLivraison}
onSetEditReference={setEditReference}
onStartEdit={handleStartEdit}
onCancelEdit={handleCancelEdit}
onSaveEdit={handleSaveEdit}
onTogglePdfPreview={togglePdfPreview}
onOpenStatusModal={() => setOpenStatus(true)}
onOpenCreateAvoir={handleCreateAvoir}
onNavigateToClient={(clientCode) => {
const clientSelected = clients.find(
(item: Client) => item.numero === clientCode
);
if (clientSelected) {
dispatch(selectClient(clientSelected));
navigate(`/home/clients/${clientCode}`);
}
}}
/>
</div>
{/* LOCKED BANNER */}
{facture.valide === 1 && (
<div className="bg-blue-50 dark:bg-blue-900/20 border-b border-blue-100 dark:border-blue-800 px-4 py-3 text-center">
<p className="text-sm text-blue-800 dark:text-blue-200 font-medium flex items-center justify-center gap-2">
<Lock className="w-4 h-4" />
Cette facture est validée et n'est plus modifiable.
</p>
</div>
)}
{/* Zone de contenu avec scroll */}
<div className="flex flex-1 overflow-hidden">
{/* Left/Center Panel - Form/Table */}
<div
className={cn(
"flex flex-col flex-1 min-w-0 transition-all duration-300",
isPdfPreviewVisible
? "w-1/2 border-r border-gray-200 dark:border-gray-800"
: "w-full"
)}
>
{/* Content avec scroll */}
<FactureContent
facture={facture}
editLignes={editLignes}
note={note}
isEditing={isEditing}
isPdfPreviewVisible={isPdfPreviewVisible}
activeLineId={activeLineId}
description={description}
error={error}
editTotalHT={editTotalHT}
editTotalTVA={editTotalTVA}
editTotalTTC={editTotalTTC}
onToggleLineMode={toggleLineMode}
onUpdateLigne={updateLigne}
onAjouterLigne={ajouterLigne}
onSupprimerLigne={supprimerLigne}
onSetActiveLineId={setActiveLineId}
onSetDescription={setDescription}
onSetNote={setNote}
onOpenArticleModal={(lineId) => {
setCurrentLineId(lineId);
setIsArticleModalOpen(true);
}}
calculerTotalLigne={calculerTotalLigne}
/>
</div>
{/* Right Panel - Live PDF Preview */}
{isPdfPreviewVisible && (
<div className="w-[45%] hidden lg:block bg-[#525659] shadow-inner relative z-10 animate-in slide-in-from-right duration-300">
<PDFPreviewPanel
isOpen={true}
variant="inline"
documentType="facture"
document={facture}
notes={note}
data={isEditing ? editLignes : editNotLignes}
total_ht={isEditing ? editTotalHT : facture.total_ht_calcule}
total_taxes_calcule={
isEditing ? editTotalTVA : facture.total_taxes_calcule
}
total_ttc_calcule={
isEditing ? editTotalTTC : facture.total_ttc_calcule
}
onClose={togglePdfPreview}
isEdit={isEditing}
/>
</div>
)}
</div>
</div>
{/* Modals */}
<ModalStatus
open={openStatus}
onClose={() => setOpenStatus(false)}
type_doc={60}
/>
<ModalAvoir
open={isCreateAvoir}
onClose={() => setIsCreateAvoir(false)}
title="Créer un avoir"
editing={null}
/>
<ModalArticle
open={isArticleModalOpen}
onClose={() => {
setIsArticleModalOpen(false);
}}
/>
<FormModal
isOpen={isValid}
onClose={() => setIsValid(false)}
title="Validé la facture"
>
<div className="p-6 space-y-4">
<ValiderFacture
icon={CheckCircle}
title="Validé la facture"
description="Valide la facture afin de permettre son règlement et la création éventuelle dun avoir."
colorClass="bg-blue-500"
onClick={handleValiderFacture}
/>
</div>
</FormModal>
{isSaving && <ModalLoading />}
</>
);
};
export default InvoiceDetailPage;

View file

@ -0,0 +1,954 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { Plus, Eye, Download, CheckCircle, FileText, Euro, AlertTriangle, X, Wallet, Info } from 'lucide-react';
import KPIBar, { PeriodType } from '@/components/KPIBar';
import { toast } from '@/components/ui/use-toast';
import { cn, formatDateFRCourt } from '@/lib/utils';
import { Facture, FactureAction } from '@/types/factureType';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { factureStatus, getAllfacture, getfactureSelected } from '@/store/features/factures/selectors';
import { getFactures, selectFactureAsync, validerFacture, getFacture } from '@/store/features/factures/thunk';
import PrimaryButton_v2 from '@/components/PrimaryButton_v2';
import { filterItemByPeriod, getPreviousPeriodItems } from '@/components/filter/ItemsFilter';
import { ModalFacture } from '@/components/modal/ModalFacture';
import { DropdownMenuTable } from '@/components/DropdownMenu';
import { clientStatus, getAllClients } from '@/store/features/client/selectors';
import { articleStatus } from '@/store/features/article/selectors';
import { getArticles } from '@/store/features/article/thunk';
import { getClients } from '@/store/features/client/thunk';
import { Client } from '@/types/clientType';
import ModalStatus from '@/components/modal/ModalStatus';
import StatusBadge, { STATUS_LABELS } from '@/components/ui/StatusBadge';
import { SageDocumentType } from '@/types/sageTypes';
import ColumnSelector, { ColumnConfig } from '@/components/common/ColumnSelector';
import PeriodSelector from '@/components/common/PeriodSelector';
import { CompanyInfo } from '@/data/mockData';
import PDFPreview, { DocumentData } from '@/components/modal/PDFPreview';
import { usePDFPreview } from '@/components/ui/PDFActionButtons';
import ExportDropdown from '@/components/common/ExportDropdown';
import { useDashboardData } from '@/store/hooks/useAppData';
import DataTable from '@/components/ui/DataTable';
import { getAllReglements, reglementStatus } from '@/store/features/reglement/selectors';
import { loadAllReglementData } from '@/store/features/reglement/thunk';
import ModalPaymentPanel from '@/components/modal/ModalPaymentPanel';
import { FacturesReglement } from '@/types/reglementType';
import { selectfacture } from '@/store/features/factures/slice';
import FormModal from '@/components/ui/FormModal';
import ModalValidationWarningCard from '@/components/modal/ModalValidationWarningCard';
import { ModalLoading } from '@/components/modal/ModalLoading';
import AdvancedFilters from '@/components/common/AdvancedFilters';
import { Commercial } from '@/types/commercialType';
import { commercialsStatus, getAllcommercials } from '@/store/features/commercial/selectors';
import { getCommercials } from '@/store/features/commercial/thunk';
// ============================================
// CONSTANTES
// ============================================
const STATUT_REGLEMENT = {
SOLDE: 'Soldé',
PARTIEL: 'Partiellement réglé',
NON_REGLE: 'Non réglé',
} as const;
const COLORS = {
gray: 'bg-gray-400',
yellow: 'bg-yellow-400',
orange: 'bg-orange-400',
green: 'bg-green-400',
purple: 'bg-purple-400',
blue: 'bg-blue-400',
};
// Labels de statut pour les factures
const FACTURE_STATUS_LABELS = {
0: { label: 'Saisi', color: COLORS.gray },
1: { label: 'Confirmé', color: COLORS.yellow },
2: { label: 'A comptabiliser', color: COLORS.orange },
3: { label: 'Comptabilisé', color: COLORS.green },
// 4: { label: 'Payé', color: COLORS.purple },
5: { label: 'Validé', color: COLORS.blue },
6: { label: 'Payé', color: COLORS.green },
7: { label: 'Partiellement payé', color: COLORS.orange },
} as const;
export type StatusCode = keyof typeof FACTURE_STATUS_LABELS;
// ============================================
// TYPES
// ============================================
type FilterType = 'all' | 'paid' | 'pending' | 'validated';
interface FactureWithReglement extends Facture {
statut_reglement: string;
montant_regle: number;
reste_a_regler: number;
statut_display: number; // Statut à afficher (peut être 6 ou 7 selon règlement)
}
interface KPIConfig {
id: FilterType;
title: string;
icon: React.ElementType;
color: string;
getValue: (factures: Facture[]) => number | string;
getSubtitle: (factures: Facture[], value: number | string) => string;
getChange: (factures: Facture[], value: number | string, period: PeriodType, allFactures: Facture[]) => string;
getTrend: (factures: Facture[], value: number | string, period: PeriodType, allFactures: Facture[]) => 'up' | 'down' | 'neutral';
filter: (factures: Facture[]) => Facture[];
tooltip?: { content: string; source: string };
}
// ============================================
// CONFIGURATION DES COLONNES
// ============================================
const DEFAULT_COLUMNS: ColumnConfig[] = [
{ key: 'numero', label: 'N° Pièce', visible: true, locked: true },
{ key: 'client_code', label: 'Client', visible: true },
{ key: 'date', label: 'Date', visible: true },
{ key: 'total_ht_calcule', label: 'Montant HT', visible: false },
{ key: 'total_ttc_calcule', label: 'Montant TTC', visible: true },
{ key: 'reste_a_regler', label: 'Solde dû', visible: true },
{ key: 'statut', label: 'Statut', visible: true },
];
// ============================================
// CONFIGURATION DES KPIs
// ============================================
const KPI_CONFIG: KPIConfig[] = [
{
id: 'all',
title: 'Total HT',
icon: Euro,
color: 'blue',
getValue: (factures) => Math.round(factures.reduce((sum, item) => sum + item.total_ht, 0)),
getSubtitle: (factures) => {
const totalTTC = factures.reduce((sum, item) => sum + item.total_ttc, 0);
return `${totalTTC.toLocaleString('fr-FR')}€ TTC`;
},
getChange: (factures, value, period, allFactures) => {
const previousPeriodFactures = getPreviousPeriodItems(allFactures, period);
const previousTotalHT = previousPeriodFactures.reduce((sum, item) => sum + item.total_ht, 0);
return previousTotalHT > 0 ? (((Number(value) - previousTotalHT) / previousTotalHT) * 100).toFixed(1) : '0';
},
getTrend: (factures, value, period, allFactures) => {
const previousPeriodFactures = getPreviousPeriodItems(allFactures, period);
const previousTotalHT = previousPeriodFactures.reduce((sum, item) => sum + item.total_ht, 0);
return Number(value) >= previousTotalHT ? 'up' : 'down';
},
filter: (factures) => factures,
tooltip: { content: "Total des factures sur la période.", source: "Ventes > Factures" }
},
{
id: 'pending',
title: 'Nombre de Factures',
icon: FileText,
color: 'orange',
getValue: (factures) => factures.length,
getSubtitle: (factures) => {
const paid = factures.filter(f => f.statut === 4);
const paymentRate = factures.length > 0 ? ((paid.length / factures.length) * 100).toFixed(1) : '0';
return `${paymentRate}% payées`;
},
getChange: (factures, value, period, allFactures) => {
const previousPeriodFactures = getPreviousPeriodItems(allFactures, period);
const countChange = Number(value) - previousPeriodFactures.length;
return countChange !== 0 ? `${countChange > 0 ? '+' : ''}${countChange}` : '0';
},
getTrend: (factures, value, period, allFactures) => {
const previousPeriodFactures = getPreviousPeriodItems(allFactures, period);
return Number(value) >= previousPeriodFactures.length ? 'up' : 'down';
},
filter: (factures) => factures.filter(f => f.statut === 1),
},
{
id: 'paid',
title: 'Montant Payé',
icon: CheckCircle,
color: 'green',
getValue: (factures) => {
const paid = factures.filter(f => f.statut === 4);
return Math.round(paid.reduce((sum, item) => sum + item.total_ht, 0));
},
getSubtitle: (factures) => {
const paid = factures.filter(f => f.statut === 4);
return `${paid.length} facture${paid.length > 1 ? 's' : ''}`;
},
getChange: (factures) => {
const paid = factures.filter(f => f.statut === 4);
return `${paid.length}/${factures.length}`;
},
getTrend: (factures, value, period, allFactures) => {
const previousPeriodFactures = getPreviousPeriodItems(allFactures, period);
const previousPaid = previousPeriodFactures.filter(f => f.statut === 4);
const previousPaidAmount = previousPaid.reduce((sum, item) => sum + item.total_ht, 0);
return Number(value) >= previousPaidAmount ? 'up' : 'down';
},
filter: (factures) => factures.filter(f => f.statut === 4),
tooltip: { content: "Montant des factures payées sur la période.", source: "Ventes > Factures" }
},
{
id: 'validated',
title: 'Factures Validées',
icon: CheckCircle,
color: 'purple',
getValue: (factures) => factures.filter(f => f.valide === 1).length,
getSubtitle: (factures) => {
const pending = factures.filter(f => f.statut === 1);
return `${pending.length} en attente`;
},
getChange: (factures) => {
const validated = factures.filter(f => f.valide === 1);
return `${validated.length}/${factures.length}`;
},
getTrend: (factures, value, period, allFactures) => {
const previousPeriodFactures = getPreviousPeriodItems(allFactures, period);
const previousValidated = previousPeriodFactures.filter(f => f.valide === 1);
return Number(value) >= previousValidated.length ? 'up' : 'down';
},
filter: (factures) => factures.filter(f => f.valide === 1),
},
];
// ============================================
// COMPOSANT PRINCIPAL
// ============================================
const InvoicesPage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [period, setPeriod] = useState<PeriodType>('all');
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(DEFAULT_COLUMNS);
const [selectedInvoiceIds, setSelectedInvoiceIds] = useState<string[]>([]);
const [isPaymentPanelOpen, setIsPaymentPanelOpen] = useState(false);
const [isValidationDialogOpen, setIsValidationDialogOpen] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [facturesToValidate, setFacturesToValidate] = useState<FactureWithReglement[]>([]);
const [activeFilters, setActiveFilters] = useState<Record<string, string[] | undefined>>({});
const { showPreview, openPreview, closePreview } = usePDFPreview();
const clients = useAppSelector(getAllClients) as Client[];
const commercials = useAppSelector(getAllcommercials) as Commercial[];
const factures = useAppSelector(getAllfacture) as Facture[];
const facturesSelected = useAppSelector(getfactureSelected) as Facture;
const statusFacture = useAppSelector(factureStatus);
const reglements = useAppSelector(getAllReglements) as FacturesReglement[];
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [openStatus, setOpenStatus] = useState(false);
const [editing, setEditing] = useState<Facture | null>(null);
const isLoading = statusFacture === 'loading' && factures.length === 0;
const statusClient = useAppSelector(clientStatus);
const statusArticle = useAppSelector(articleStatus);
const statusReglement = useAppSelector(reglementStatus);
const statusCommercial = useAppSelector(commercialsStatus);
useEffect(() => {
const load = async () => {
try {
if (statusArticle === 'idle') await dispatch(getArticles()).unwrap();
if (statusClient === 'idle' || statusClient === 'failed') await dispatch(getClients()).unwrap();
if (statusFacture === 'idle' || statusFacture === 'failed') await dispatch(getFactures()).unwrap();
if (statusReglement === 'idle' || statusReglement === 'failed') await dispatch(loadAllReglementData()).unwrap();
if (statusCommercial === 'idle') await dispatch(getCommercials()).unwrap();
} catch (error) {
console.error(error);
}
};
load();
}, [statusArticle, statusClient, statusFacture, statusReglement, statusCommercial, dispatch]);
const { refresh } = useDashboardData();
// ============================================
// OPTIONS POUR LES FILTRES
// ============================================
const commercialOptions = useMemo(() => {
return commercials.map(c => ({
value: c.numero.toString(),
label: `${c.prenom || ''} ${c.nom || ''}`.trim() || `Commercial ${c.numero}`,
}));
}, [commercials]);
// Map pour retrouver le commercial d'un client rapidement
const clientCommercialMap = useMemo(() => {
const map = new Map<string, string>();
clients.forEach(client => {
if (client.commercial?.numero) {
map.set(client.numero, client.commercial.numero.toString());
}
});
return map;
}, [clients]);
const filterDefinitions = [
{
key: 'status',
label: 'Statut',
options: (Object.entries(FACTURE_STATUS_LABELS) as [string, typeof FACTURE_STATUS_LABELS[StatusCode]][]).map(
([value, { label, color }]) => ({
value: value.toString(),
label,
color,
})
),
},
{
key: 'rep',
label: 'Commercial',
options: commercialOptions,
},
];
// ============================================
// Map des règlements par numéro de facture
// ============================================
const reglementsMap = useMemo(() => {
return new Map(reglements.map(r => [r.numero, r]));
}, [reglements]);
// ============================================
// Factures enrichies avec statut règlement
// ============================================
const facturesWithReglement = useMemo((): FactureWithReglement[] => {
return factures.map(f => {
const reglement = reglementsMap.get(f.numero);
let resteARegler = f.total_ttc_calcule;
let montantRegle = 0;
let statutReglement: string = STATUT_REGLEMENT.NON_REGLE;
let statutDisplay = f.statut; // Par défaut, le statut original
if (reglement) {
statutReglement = reglement.statut_reglement || STATUT_REGLEMENT.NON_REGLE;
montantRegle = reglement.montants?.montant_regle || 0;
// Déterminer le statut_display selon le règlement
if (statutReglement === STATUT_REGLEMENT.SOLDE) {
resteARegler = 0;
statutDisplay = 6; // Payé (via règlement)
} else if (statutReglement === STATUT_REGLEMENT.PARTIEL) {
resteARegler = reglement.montants?.reste_a_regler || 0;
statutDisplay = 7; // Partiellement payé
} else {
resteARegler = reglement.montants?.reste_a_regler || f.total_ttc_calcule;
}
}
return {
...f,
statut_reglement: statutReglement,
montant_regle: montantRegle,
reste_a_regler: resteARegler,
statut_display: statutDisplay,
};
});
}, [factures, reglementsMap]);
// ============================================
// Client actuellement sélectionné
// ============================================
const selectedClientCode = useMemo(() => {
if (selectedInvoiceIds.length === 0) return null;
const firstSelectedFacture = facturesWithReglement.find(f => f.numero === selectedInvoiceIds[0]);
return firstSelectedFacture?.client_code || null;
}, [selectedInvoiceIds, facturesWithReglement]);
const selectedClient = useMemo(() => {
if (!selectedClientCode) return null;
return clients.find(c => c.numero === selectedClientCode);
}, [selectedClientCode, clients]);
// ============================================
// KPIs
// ============================================
const kpis = useMemo(() => {
const periodFilteredFactures = filterItemByPeriod(factures, period, 'date');
return KPI_CONFIG.map(config => {
const value = config.getValue(periodFilteredFactures);
return {
id: config.id,
title: config.title,
value: config.id === 'all' || config.id === 'paid' ? `${value.toLocaleString('fr-FR')}` : value,
change: config.getChange(periodFilteredFactures, value, period, factures),
trend: config.getTrend(periodFilteredFactures, value, period, factures),
icon: config.icon,
subtitle: config.getSubtitle(periodFilteredFactures, value),
color: config.color,
tooltip: config.tooltip,
isActive: activeFilter === config.id,
onClick: () => setActiveFilter(prev => (prev === config.id ? 'all' : config.id)),
};
});
}, [factures, period, activeFilter]);
// ============================================
// Filtrage combiné : Période + KPI + Filtres avancés
// ============================================
const filteredFactures = useMemo(() => {
// 1. Filtrer par période
let result: FactureWithReglement[] = filterItemByPeriod(facturesWithReglement, period, 'date');
// 2. Filtrer par KPI actif
const kpiConfig = KPI_CONFIG.find(k => k.id === activeFilter);
if (kpiConfig && activeFilter !== 'all') {
result = kpiConfig.filter(result) as FactureWithReglement[];
}
// 3. Filtres avancés (statut)
if (activeFilters.status && activeFilters.status.length > 0) {
result = result.filter(item =>
activeFilters.status!.includes(item.statut_display.toString())
);
}
// 4. Filtre par commercial
if (activeFilters.rep && activeFilters.rep.length > 0) {
result = result.filter(item => {
const commercialCode = clientCommercialMap.get(item.client_code);
return commercialCode && activeFilters.rep!.includes(commercialCode);
});
}
// 5. Tri par date décroissante
return [...result].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}, [facturesWithReglement, period, activeFilter, activeFilters, clientCommercialMap]);
// ============================================
// Factures sélectionnables
// ============================================
const selectableFactures = useMemo(() => {
return filteredFactures.filter(f => {
const isNotSolde = f.statut_reglement !== STATUT_REGLEMENT.SOLDE;
const isSameClient = !selectedClientCode || f.client_code === selectedClientCode;
return isNotSolde && isSameClient;
});
}, [filteredFactures, selectedClientCode]);
// ============================================
// Factures sélectionnées (avec données de règlement)
// ============================================
const selectedFactures = useMemo(() => {
return facturesWithReglement.filter(f => selectedInvoiceIds.includes(f.numero));
}, [selectedInvoiceIds, facturesWithReglement]);
// ============================================
// Total sélectionné = somme des reste_a_regler
// ============================================
const totalSelectedBalance = useMemo(() => {
return selectedFactures.reduce((sum, f) => sum + f.reste_a_regler, 0);
}, [selectedFactures]);
const formatCurrency = (amount: number) => {
return `${amount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })}€`;
};
// ============================================
// Label du filtre actif
// ============================================
const activeFilterLabel = useMemo(() => {
const config = KPI_CONFIG.find(k => k.id === activeFilter);
return config?.title || 'Tous';
}, [activeFilter]);
// ============================================
// Handlers
// ============================================
const handleSelectInvoice = (numero: string) => {
const facture = facturesWithReglement.find(f => f.numero === numero);
if (!facture) return;
if (facture.statut_reglement === STATUT_REGLEMENT.SOLDE) {
toast({
title: 'Facture déjà réglée',
description: 'Cette facture est déjà soldée.',
variant: 'destructive',
});
return;
}
if (selectedClientCode && facture.client_code !== selectedClientCode) {
toast({
title: 'Client différent',
description: 'Vous ne pouvez sélectionner que des factures du même client.',
variant: 'destructive',
});
return;
}
setSelectedInvoiceIds(prev => (prev.includes(numero) ? prev.filter(id => id !== numero) : [...prev, numero]));
};
const handleSelectAll = (checked: boolean) => {
if (checked && selectableFactures.length > 0) {
const firstClientCode = selectableFactures[0].client_code;
const sameClientFactures = selectableFactures.filter(f => f.client_code === firstClientCode);
setSelectedInvoiceIds(sameClientFactures.map(f => f.numero));
} else {
setSelectedInvoiceIds([]);
}
};
// ============================================
// Validation automatique des factures
// ============================================
const validateFacturesSequentially = useCallback(
async (facturesToValidate: FactureWithReglement[]) => {
setIsValidating(true);
try {
for (const facture of facturesToValidate) {
await dispatch(validerFacture(facture.numero)).unwrap();
const itemUpdated = (await dispatch(getFacture(facture.numero)).unwrap()) as Facture;
dispatch(selectfacture(itemUpdated));
}
toast({
title: 'Factures validées',
description: `${facturesToValidate.length} facture(s) validée(s) avec succès.`,
className: 'bg-green-500 text-white border-green-600',
});
await dispatch(getFactures()).unwrap();
return true;
} catch (err: any) {
toast({
title: 'Erreur de validation',
description: err.message || 'Impossible de valider les factures.',
variant: 'destructive',
});
return false;
} finally {
setIsValidating(false);
}
},
[dispatch]
);
const handlePayment = useCallback(() => {
if (selectedInvoiceIds.length === 0) return;
const nonValidatedFactures = selectedFactures.filter(f => f.valide !== 1);
if (nonValidatedFactures.length > 0) {
setFacturesToValidate(nonValidatedFactures);
setIsValidationDialogOpen(true);
} else {
setIsPaymentPanelOpen(true);
}
}, [selectedInvoiceIds, selectedFactures]);
const handleConfirmValidationAndPayment = useCallback(async () => {
setIsValidationDialogOpen(false);
setIsSaving(true);
const success = await validateFacturesSequentially(facturesToValidate);
if (success) {
setIsPaymentPanelOpen(true);
}
setIsSaving(false);
setFacturesToValidate([]);
}, [facturesToValidate, validateFacturesSequentially]);
const handleCancelValidation = useCallback(() => {
setIsValidationDialogOpen(false);
setFacturesToValidate([]);
}, []);
const handlePaymentSuccess = useCallback(() => {
setSelectedInvoiceIds([]);
setIsPaymentPanelOpen(false);
toast({
title: 'Règlement effectué',
description: 'Les factures ont été réglées avec succès.',
className: 'bg-green-500 text-white border-green-600',
});
dispatch(loadAllReglementData());
dispatch(getFactures());
}, [dispatch]);
// ============================================
// PDF Data
// ============================================
const pdfData = useMemo(() => {
if (!facturesSelected) return null;
return {
numero: facturesSelected.numero,
type: 'facture' as const,
date: facturesSelected.date,
client: {
code: facturesSelected.client_code,
nom: facturesSelected.client_intitule,
adresse: facturesSelected.client_adresse,
code_postal: facturesSelected.client_code_postal,
ville: facturesSelected.client_ville,
email: facturesSelected.client_email,
telephone: facturesSelected.client_telephone,
},
reference_externe: facturesSelected.reference,
lignes: (facturesSelected.lignes ?? []).map(l => ({
article: l.article_code,
designation: l.designation,
quantite: l.quantite,
prix_unitaire: l.prix_unitaire_ht ?? 0,
tva: 20,
total_ht: l.quantite * (l.prix_unitaire_ht ?? 0),
})),
total_ht: facturesSelected.total_ht_calcule,
total_tva: facturesSelected.total_taxes_calcule,
total_ttc: facturesSelected.total_ttc_calcule,
};
}, [facturesSelected]) as DocumentData;
const handleCreate = () => navigate('/home/factures/nouveau');
const handleEdit = (row: Facture) => {
setEditing(row);
setIsCreateModalOpen(true);
};
const handleAction = (action: FactureAction, row: Facture) => {
const messages: Record<FactureAction, { title: string; description: string }> = {
send: { title: 'Facture envoyée', description: `Email envoyé à ${row.client_intitule}` },
download: { title: 'Téléchargement', description: 'Génération du PDF en cours...' },
remind: { title: 'Relance envoyée', description: 'Le client a été notifié.' },
paid: { title: 'Facture payée', description: 'Statut mis à jour avec succès.' },
};
toast(messages[action]);
};
const openPDF = async (row: Facture) => {
await dispatch(selectFactureAsync(row)).unwrap();
openPreview();
};
// ============================================
// COLONNES
// ============================================
const allColumnsDefinition = useMemo(() => {
const clientsMap = new Map(clients.map(c => [c.numero, c]));
return {
numero: {
key: 'numero',
label: 'N° Pièce',
sortable: true,
render: (value: string) => <span className="font-bold">{value}</span>,
},
client_code: {
key: 'client_code',
label: 'Client',
sortable: true,
render: (clientCode: string) => {
const client = clientsMap.get(clientCode);
if (!client) return <span className="text-gray-400">{clientCode}</span>;
const avatar = client.intitule?.charAt(0).toUpperCase() || '?';
return (
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-xs font-bold text-gray-600 dark:text-gray-300">
{avatar}
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{client.intitule}</p>
<p className="text-xs text-gray-500">{client.email || client.telephone || clientCode}</p>
</div>
</div>
);
},
},
date: {
key: 'date',
label: 'Date',
sortable: true,
render: (date: string) => <span>{formatDateFRCourt(date)}</span>,
},
total_ht_calcule: {
key: 'total_ht_calcule',
label: 'Montant HT',
sortable: true,
render: (v: number) => `${v.toLocaleString()}`,
},
total_ttc_calcule: {
key: 'total_ttc_calcule',
label: 'Montant TTC',
sortable: true,
render: (v: number) => <span className="font-semibold">{formatCurrency(v)}</span>,
},
reste_a_regler: {
key: 'reste_a_regler',
label: 'Solde dû',
sortable: true,
render: (value: number, row: FactureWithReglement) => {
const isSolde = row.statut_reglement === STATUT_REGLEMENT.SOLDE;
const isPartiel = row.statut_reglement === STATUT_REGLEMENT.PARTIEL;
return (
<div className="flex flex-col">
<span
className={cn(
'font-bold',
isSolde
? 'text-green-600 dark:text-green-400'
: isPartiel
? 'text-amber-600 dark:text-amber-400'
: 'text-red-600 dark:text-red-400'
)}
>
{formatCurrency(value)}
</span>
{isPartiel && row.montant_regle > 0 && (
<span className="text-xs text-gray-500">(déjà réglé: {formatCurrency(row.montant_regle)})</span>
)}
</div>
);
},
},
statut: {
key: 'statut',
label: 'Statut',
sortable: true,
render: (v: number, row: FactureWithReglement) => (
<StatusBadge status={row.statut_display} type_doc={SageDocumentType.FACTURE} />
),
},
};
}, [clients]);
const visibleColumns = useMemo(() => {
return columnConfig
.filter(col => col.visible)
.map(col => allColumnsDefinition[col.key as keyof typeof allColumnsDefinition])
.filter(Boolean);
}, [columnConfig, allColumnsDefinition]);
const columnFormatters: Record<string, (value: any, row: any) => string> = {
date: value => (value ? formatDateFRCourt(value) : ''),
total_ht_calcule: value => formatCurrency(value || 0),
total_ttc_calcule: value => formatCurrency(value || 0),
reste_a_regler: value => formatCurrency(value || 0),
statut: (value, row) =>
row.statut_reglement || FACTURE_STATUS_LABELS[value as StatusCode]?.label || 'Inconnu',
client_intitule: (value, row) => value || row.client_code || '',
};
const actions = (row: FactureWithReglement) => {
const handleStatus =
row.statut !== 2 && row.statut !== 3 && row.statut !== 4
? async () => {
await dispatch(selectFactureAsync(row)).unwrap();
setOpenStatus(true);
}
: undefined;
return (
<>
<button
onClick={() => openPDF(row)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-500"
title="Voir"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleAction('download', row)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-500"
title="Télécharger"
>
<Download className="w-4 h-4" />
</button>
<DropdownMenuTable
row={row}
onStatus={handleStatus}
onEdit={async () => {
const rep = (await dispatch(selectFactureAsync(row)).unwrap()) as Facture;
handleEdit(rep);
}}
onDownload={() => openPDF(row)}
/>
</>
);
};
// ============================================
// Conversion pour le modal de paiement
// ============================================
const selectedFacturesForModal = useMemo((): Facture[] => {
return selectedFactures.map(f => ({
...f,
total_ttc_calcule: f.reste_a_regler,
}));
}, [selectedFactures]);
return (
<>
<Helmet>
<title>Factures - Dataven</title>
</Helmet>
<div className="space-y-6">
<KPIBar kpis={kpis} period={period} loading={statusFacture} onRefresh={refresh} />
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Factures</h1>
{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">
{filteredFactures.length} facture{filteredFactures.length > 1 ? 's' : ''}
{activeFilter !== 'all' && ` (${activeFilterLabel.toLowerCase()})`}
</p>
</div>
<PeriodSelector value={period} onChange={setPeriod} />
</div>
<div className="flex gap-3 flex-wrap items-center">
<ColumnSelector columns={columnConfig} onChange={setColumnConfig} />
<ExportDropdown
data={filteredFactures}
columns={columnConfig}
columnFormatters={columnFormatters}
filename="Facture"
/>
<AdvancedFilters
filters={filterDefinitions}
activeFilters={activeFilters}
onFilterChange={(key, values) => {
setActiveFilters(prev => ({
...prev,
[key]: values,
}));
}}
onReset={() => setActiveFilters({})}
/>
{selectedInvoiceIds.length > 0 && (
<div className="flex items-center gap-2">
{selectedClient && (
<span className="text-xs text-gray-500 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">
{selectedClient.intitule}
</span>
)}
<PrimaryButton_v2
onClick={handlePayment}
className="bg-[#338660] hover:bg-[#2A6F4F] shadow-lg shadow-green-900/20 animate-in fade-in zoom-in duration-200"
>
<Wallet className="w-4 h-4 mr-2" />
Régler ({selectedInvoiceIds.length}) · {formatCurrency(totalSelectedBalance)}
</PrimaryButton_v2>
</div>
)}
<PrimaryButton_v2 icon={Plus} onClick={handleCreate}>
Nouvelle facture
</PrimaryButton_v2>
</div>
</div>
{selectedClientCode && (
<div className="flex items-center gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 text-sm rounded-lg border border-blue-100 dark:border-blue-900/50">
<Info className="w-4 h-4 flex-shrink-0" />
<span>
Sélection limitée au client <strong>{selectedClient?.intitule}</strong>.
<button onClick={() => setSelectedInvoiceIds([])} className="ml-2 underline hover:no-underline">
Réinitialiser
</button>
</span>
</div>
)}
<DataTable
columns={visibleColumns}
data={filteredFactures}
actions={actions}
status={isLoading}
selectable={true}
selectableFactures={selectableFactures}
selectedIds={selectedInvoiceIds}
onSelectRow={handleSelectInvoice}
onSelectAll={handleSelectAll}
onRowClick={async (row: Facture) => {
await dispatch(selectFactureAsync(row)).unwrap();
navigate(`/home/factures/${row.numero}`);
}}
/>
</div>
<ModalStatus open={openStatus} onClose={() => setOpenStatus(false)} type_doc={SageDocumentType.FACTURE} />
<ModalFacture
open={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
title={editing ? `Mettre à jour la facture ${editing.numero}` : 'Créer une facture'}
editing={editing}
/>
<FormModal
isOpen={isValidationDialogOpen}
onClose={() => setIsValidationDialogOpen(false)}
title="Valider la facture"
>
<div className="p-6 space-y-4">
<ModalValidationWarningCard
icon={AlertTriangle}
title="Validation automatique"
description="En procédant au règlement, les factures sélectionnées seront automatiquement validées. Cette action est irréversible."
onClick={handleConfirmValidationAndPayment}
/>
</div>
</FormModal>
<ModalPaymentPanel
isOpen={isPaymentPanelOpen}
onClose={() => setIsPaymentPanelOpen(false)}
selectedInvoices={selectedFacturesForModal}
totalAmount={totalSelectedBalance}
/>
{isSaving && <ModalLoading />}
<PDFPreview open={showPreview} onClose={closePreview} data={pdfData} entreprise={CompanyInfo} />
</>
);
};
export default InvoicesPage;

View file

@ -0,0 +1,875 @@
import { useEffect, useMemo, useState } from "react";
import { Helmet } from "react-helmet";
import { useNavigate } from "react-router-dom";
import {
ArrowLeft,
CircleDot,
File,
FileText,
Hash,
Plus,
Save,
Settings,
ShoppingCart,
Trash2,
X,
} from "lucide-react";
import FormModal, {
FormField,
Input,
RepresentantInput,
Textarea,
} from "@/components/ui/FormModal";
import { toast } from "@/components/ui/use-toast";
import { useAppDispatch, useAppSelector } from "@/store/hooks";
import {
commandeStatus,
getcommandeSelected,
} from "@/store/features/commande/selectors";
import { Commande, LigneCommande } from "@/types/commandeTypes";
import {
formatDateFR,
formatDateFRCourt,
formatForDateInput,
} from "@/lib/utils";
import {
commandeToFacture,
getCommande,
updateCommande,
} from "@/store/features/commande/thunk";
import { getFacture } from "@/store/features/factures/thunk";
import { ModalLoading } from "@/components/modal/ModalLoading";
import { usePDFPreview } from "@/components/modal/ModalPDFPreview";
import Tabs from "@/components/Tabs";
import Timeline from "@/components/Timeline";
import { Section } from "@/components/ui/Section";
import { getAllClients } from "@/store/features/client/selectors";
import { Client } from "@/types/clientType";
import { selectClient } from "@/store/features/client/slice";
import { selectfacture } from "@/store/features/factures/slice";
import ModalStatus from "@/components/modal/ModalStatus";
import { ActionButton } from "@/components/ribbons/ActionButton";
import ClientAutocomplete from "@/components/molecules/ClientAutocomplete";
import ArticleAutocomplete from "@/components/molecules/ArticleAutocomplete";
import { Article } from "@/types/articleType";
import { selectcommande } from "@/store/features/commande/slice";
import { SageDocumentType } from "@/types/sageTypes";
import StatusBadge from "@/components/ui/StatusBadge";
import ModalWorkflowToBL from "@/components/modal/ModalWorkflowToBL";
import ModalCommandetoFacture from "@/components/modal/ModalCommandetoFacture";
interface LigneForm extends LigneCommande {
articles?: Article | null;
}
const OrderDetailPage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [error, setError] = useState<string | null>(null);
const [loadingTransform, setLoadingTransform] = useState(false);
const [activeTab, setActiveTab] = useState("identification");
const clients = useAppSelector(getAllClients) as Client[];
const commande = useAppSelector(getcommandeSelected) as Commande;
const statusCommande = useAppSelector(commandeStatus);
const [openStatus, setOpenStatus] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isOpenWorkflowToBL, setIsOpenWorkflowToBL] = useState(false);
const [isOpenBLToFacture, setIsOpenBLToFacture] = useState(false);
// États pour l'édition
const [editClient, setEditClient] = useState<Client | null>(null);
const [editDateCommande, setEditDateCommande] = useState("");
const [editDateLivraison, setEditDateLivraison] = useState("");
const [editReference, setEditReference] = useState("");
const [editCommentaire, setEditCommentaire] = useState("");
const [editLignes, setEditLignes] = useState<LigneForm[]>([]);
const {
openPreview,
closePreview,
isOpen,
pdfUrl,
fileName,
PDFPreviewModal,
} = usePDFPreview();
useEffect(() => {
if (isEditing && commande) {
const client = clients.find((c) => c.numero === commande.client_code);
setEditClient(client || null);
setEditDateCommande(commande.date || "");
setEditDateLivraison(commande.date_livraison || "");
setEditReference(commande.reference || "");
setEditCommentaire("");
const lignesInitiales: LigneForm[] =
commande.lignes?.map((ligne) => ({
...ligne,
article: {
reference: ligne.article_code,
designation: ligne.designation,
prix_vente: ligne.prix_unitaire_ht,
} as Article,
})) || [];
setEditLignes(
lignesInitiales.length > 0
? lignesInitiales
: [{ article_code: "", quantite: 1, articles: null }]
);
}
}, [isEditing, commande, clients]);
if (!commande) return <div>Commande introuvable</div>;
const totalPoidsNet =
commande.lignes?.reduce(
(total, ligne) => total + (ligne.poids_net ?? 0),
0
) ?? 0;
const totalPoidsBrut =
commande.lignes?.reduce(
(total, ligne) => total + (ligne.poids_brut ?? 0),
0
) ?? 0;
const ajouterLigne = () => {
setEditLignes([
...editLignes,
{ article_code: "", quantite: 1, articles: null },
]);
};
const supprimerLigne = (index: number) => {
if (editLignes.length > 1) {
setEditLignes(editLignes.filter((_, i) => i !== index));
}
};
const updateLigne = (index: number, field: keyof LigneForm, value: any) => {
const nouvelles = [...editLignes];
if (field === "articles" && value) {
nouvelles[index].articles = value;
nouvelles[index].article_code = value.reference;
nouvelles[index].prix_unitaire_ht = value.prix_vente;
nouvelles[index].designation = value.designation;
} else {
(nouvelles[index] as any)[field] = value;
}
setEditLignes(nouvelles);
};
const calculerTotalLigne = (ligne: LigneForm) => {
const prix = ligne.prix_unitaire_ht || ligne.articles?.prix_vente || 0;
const remise = ligne.remise_pourcentage ?? 0;
const prixRemise = prix * (1 - remise / 100);
return prixRemise * ligne.quantite;
};
const calculerTotalTva = () => {
const taxesParLignes = editLignes.reduce((total, ligne) => {
const totalHtLigne = calculerTotalLigne(ligne);
const tva = totalHtLigne * (ligne.taux_taxe1! / 100);
return total + tva;
}, 0);
const valeur_frais = commande.valeur_frais;
return taxesParLignes + valeur_frais * (commande.taxes1 ?? 0.2);
};
const calculerTotalHT = () => {
const total_ligne = editLignes.map((ligne) => calculerTotalLigne(ligne));
const totalHTLigne = total_ligne.reduce((acc, ligne) => acc + ligne, 0);
const totalHtNet = totalHTLigne + commande.valeur_frais;
return totalHtNet;
};
const editTotalHT = calculerTotalHT();
const editTotalTVA = calculerTotalTva();
const editTotalTTC = editTotalHT + editTotalTVA;
const canSave = useMemo(() => {
if (!editClient) return false;
const lignesValides = editLignes.filter((l) => l.article_code);
return lignesValides.length > 0;
}, [editClient, editLignes]);
const handleSave = async () => {
if (!canSave) return;
const lignesValides = editLignes.filter((l) => l.article_code);
try {
setIsSaving(true);
setError(null);
const payload = {
client_id: editClient!.numero,
reference: editReference,
date_livraison: (() => {
const d = new Date(editDateLivraison);
d.setDate(d.getDate() + 1);
return d.toISOString().split("T")[0];
})(),
date_commande: (() => {
const d = new Date(editDateCommande);
d.setDate(d.getDate() + 1);
return d.toISOString().split("T")[0];
})(),
lignes: lignesValides.map((l) => ({
article_code: l.articles?.reference || "",
quantite: l.quantite,
})),
};
const result = (await dispatch(
updateCommande({
numero: commande.numero,
data: payload,
})
).unwrap()) as any;
const numero = result.commande.numero;
toast({
title: "Commande mise à jour !",
description: `La commande ${numero} a été mise à jour avec succès.`,
className: "bg-green-500 text-white border-green-600",
});
// Recharger la commande
const updatedCommande = (await dispatch(
getCommande(numero)
).unwrap()) as Commande;
dispatch(selectcommande(updatedCommande));
setIsEditing(false);
} catch (err: any) {
setError(err.message || "Erreur lors de la sauvegarde");
toast({
title: "Erreur",
description: "Impossible de mettre à jour la commande.",
className: "bg-red-500 text-white border-red-600",
});
} finally {
setIsSaving(false);
}
};
const handleCancelEdit = () => {
setIsEditing(false);
setError(null);
};
const tabs = [
{ id: "identification", label: "Identification" },
{ id: "lines", label: "Lignes de commande" },
{ id: "totals", label: "Totaux" },
{ id: "documents", label: "Documents", count: commande.lignes?.length },
{ id: "timeline", label: "Timeline", count: commande.lignes?.length },
];
const timeline = [
{
type: "commande" as const,
title: "Commande créée",
description: `${commande.numero} pour ${commande.total_ttc.toLocaleString(
"fr-FR"
)}`,
date: commande.date,
user: commande.statut,
link: `/home/commandes/${commande.numero}`,
item: commande,
},
];
// Totaux pour l'affichage (mode lecture ou édition)
const displayTotalHT = isEditing ? editTotalHT : commande.total_ht_calcule;
const displayTotalTVA = isEditing
? editTotalTVA
: commande.total_taxes_calcule;
const displayTotalTTC = isEditing ? editTotalTTC : commande.total_ttc_calcule;
return (
<>
<Helmet>
<title>{commande.numero} - Commande</title>
</Helmet>
<div className="space-y-6">
{/* HEADER */}
<div className="sticky top-0 z-30 bg-white border-b border-gray-200 shadow-sm dark:bg-gray-950 dark:border-gray-800">
<div className="px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex gap-4 items-center">
<button
onClick={() => navigate("/home/commandes")}
title="Retour à la liste des commandes"
className="p-2 text-gray-500 rounded-full transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
disabled={isEditing}
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<div className="flex gap-3 items-center">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
{commande.numero}
</h1>
<StatusBadge status={commande.statut} type_doc={10} />
{isEditing && (
<span className="px-2 py-1 text-xs font-medium text-amber-700 bg-amber-100 rounded-lg">
Mode édition
</span>
)}
</div>
<div className="flex gap-2 items-center text-sm text-gray-500">
{commande.client_intitule ? (
<span
className="font-medium text-gray-900 cursor-pointer dark:text-gray-300 hover:underline"
onClick={() => {
if (isEditing) return;
const clientSelected = clients.find(
(item) => item.numero === commande.client_code
);
if (clientSelected) {
dispatch(selectClient(clientSelected));
navigate(`/home/clients/${commande.client_code}`);
}
}}
>
{commande.client_intitule}
</span>
) : (
<span className="italic text-gray-400">
Client non défini
</span>
)}
<span></span>
<span>{formatDateFR(commande.date)}</span>
</div>
</div>
</div>
{/* ACTIONS */}
<div className="flex gap-2 items-center">
{isEditing ? (
<>
<button
onClick={handleCancelEdit}
className="flex gap-2 items-center px-4 py-2 text-sm font-medium text-gray-600 rounded-xl border border-gray-200 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-white dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
>
<X className="w-4 h-4" />
Annuler
</button>
<button
onClick={handleSave}
disabled={!canSave || isSaving}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-[#007E45] rounded-xl hover:bg-[#006838] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4" />
{isSaving ? "Enregistrement..." : "Enregistrer"}
</button>
</>
) : (
<>
{commande.statut === 2 ? (
<>
<button
onClick={() => setIsOpenWorkflowToBL(true)}
className="flex gap-2 items-center px-3 py-2 text-sm font-medium text-teal-700 bg-teal-50 rounded-xl border border-teal-100 transition-colors hover:bg-teal-100 dark:bg-teal-900/20 dark:border-teal-900 dark:text-teal-300 dark:hover:bg-teal-900/40"
>
<ShoppingCart className="w-4 h-4" />
<span className="hidden sm:inline">
Transformer en BL
</span>
</button>
<button
onClick={() => setIsOpenBLToFacture(true)}
className="flex gap-2 items-center px-3 py-2 text-sm font-medium text-teal-700 bg-teal-50 rounded-xl border border-teal-100 transition-colors hover:bg-teal-100 dark:bg-teal-900/20 dark:border-teal-900 dark:text-teal-300 dark:hover:bg-teal-900/40"
>
<FileText className="w-4 h-4" /> Créer Facture
</button>
</>
) : (
<button
onClick={() => setOpenStatus(true)}
className="flex gap-2 items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white rounded-xl border border-gray-200 transition-colors hover:bg-gray-50"
title="Changer le statut"
>
<Settings className="w-4 h-4 text-gray-500" />
<span className="hidden lg:inline">
Changer le statut
</span>
</button>
)}
<button
onClick={() => setIsEditing(true)}
className="px-4 py-2 text-sm font-medium text-gray-600 rounded-xl border border-gray-200 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-white dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
>
Modifier
</button>
</>
)}
</div>
</div>
</div>
</div>
{/* CONTENT */}
<div className="px-4 mx-auto mt-6 w-full max-w-7xl sm:px-6 lg:px-8">
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
<div className="mt-6">
{/* --- IDENTIFICATION TAB --- */}
{activeTab === "identification" && (
<div className="space-y-6">
<div className="overflow-hidden relative p-6 bg-white rounded-2xl border border-gray-200 dark:bg-gray-950 dark:border-gray-800">
<h3 className="mb-4 text-lg font-bold text-gray-900 dark:text-white">
Informations Générales
</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{isEditing ? (
<>
<FormField label="Client" required fullWidth>
<ClientAutocomplete
value={editClient}
onChange={setEditClient}
required
/>
</FormField>
<div className="grid grid-cols-2 gap-4">
<FormField label="Date commande" required>
<Input
type="date"
value={formatForDateInput(editDateCommande)}
onChange={(e) =>
setEditDateCommande(e.target.value)
}
/>
</FormField>
<FormField label="Livraison prévue" required>
<Input
type="date"
value={formatForDateInput(editDateLivraison)}
onChange={(e) =>
setEditDateLivraison(e.target.value)
}
/>
</FormField>
</div>
<FormField label="Référence externe">
<Input
value={editReference}
onChange={(e) => setEditReference(e.target.value)}
placeholder="Bon de commande client..."
/>
</FormField>
<FormField label="Affaire / Deal">
<Input
value=""
disabled
placeholder="Non disponible"
className="bg-gray-50 opacity-50 cursor-not-allowed dark:bg-gray-900"
/>
</FormField>
</>
) : (
<>
<FormField label="Client" required>
<Input
type="text"
value={commande.client_code}
disabled
className="bg-gray-50 opacity-50 cursor-not-allowed dark:bg-gray-900"
/>
</FormField>
<div className="grid grid-cols-2 gap-4">
<FormField label="Date commande" required>
<Input
type="date"
value={formatForDateInput(commande.date)}
disabled
className="bg-gray-50 opacity-50 cursor-not-allowed dark:bg-gray-900"
/>
</FormField>
<FormField label="Livraison prévue" required>
<Input
type="date"
value={formatForDateInput(
commande.date_livraison
)}
disabled
className="bg-gray-50 opacity-50 cursor-not-allowed dark:bg-gray-900"
/>
</FormField>
</div>
<FormField label="Référence externe">
<Input
value={commande.reference}
disabled
placeholder="Bon de commande client..."
className="bg-gray-50 opacity-50 cursor-not-allowed dark:bg-gray-900"
/>
</FormField>
<FormField label="Affaire / Deal">
<Input
value="__"
disabled
className="bg-gray-50 opacity-50 cursor-not-allowed dark:bg-gray-900"
/>
</FormField>
</>
)}
<RepresentantInput />
<FormField label="Commentaires" fullWidth>
<Textarea
rows={3}
value={
isEditing
? editCommentaire
: "Commande urgente, merci de vérifier le conditionnement."
}
onChange={
isEditing
? (e) => setEditCommentaire(e.target.value)
: undefined
}
disabled={!isEditing}
placeholder="Instructions spécifiques..."
className={
!isEditing
? "bg-gray-50 opacity-50 cursor-not-allowed dark:bg-gray-900"
: ""
}
/>
</FormField>
</div>
</div>
</div>
)}
{/* --- LINES TAB --- */}
{activeTab === "lines" && (
<div className="p-6 bg-white rounded-2xl border border-gray-200 shadow-sm dark:bg-gray-950 dark:border-gray-800">
<div className="overflow-x-auto min-h-[200px] mb-8">
<table className="w-full">
<thead className="text-xs font-semibold tracking-wider text-left text-gray-500 uppercase bg-gray-50 dark:bg-gray-900/50">
<tr>
<th className="px-4 py-3 rounded-l-lg w-[35%]">
Article / Description
</th>
<th className="px-4 py-3 text-right w-[10%]">Qté</th>
<th className="px-4 py-3 text-right w-[12%]">
P.U. HT
</th>
<th className="px-4 py-3 text-right w-[10%]">Remise</th>
<th className="px-4 py-3 text-right w-[8%]">TVA</th>
<th className="px-4 py-3 text-right w-[15%]">
Total HT
</th>
{isEditing && <th className="w-[5%] rounded-r-lg"></th>}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{isEditing
? editLignes.map((ligne, index) => (
<tr
key={index}
className="group hover:bg-gray-50/50 dark:hover:bg-gray-900/20"
>
<td className="px-4 py-2">
<ArticleAutocomplete
value={ligne.articles!}
onChange={(article) =>
updateLigne(index, "articles", article)
}
required
/>
</td>
<td
className="px-4 py-2"
style={{ width: "12vh" }}
>
<input
type="number"
value={ligne.quantite}
onChange={(e) =>
updateLigne(
index,
"quantite",
parseFloat(e.target.value) || 0
)
}
min={0}
step={1}
className="w-full text-right px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-[#007E45] focus:border-transparent"
/>
</td>
<td
className="px-4 py-2"
style={{ width: "12vh" }}
>
<input
type="number"
value={
ligne.prix_unitaire_ht ||
ligne.articles?.prix_vente ||
0
}
onChange={(e) =>
updateLigne(
index,
"prix_unitaire_ht",
parseFloat(e.target.value) || 0
)
}
min={0}
step={0.01}
className="w-full text-right px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-[#007E45] focus:border-transparent"
/>
</td>
<td className="px-4 py-3 text-sm text-right text-gray-600">
0%
</td>
<td className="px-4 py-3 text-sm text-right text-gray-600">
20%
</td>
<td className="px-4 py-3 font-mono font-bold text-right">
{calculerTotalLigne(ligne).toFixed(2)}
</td>
<td className="px-1 py-3 text-center">
<button
onClick={() => supprimerLigne(index)}
disabled={editLignes.length === 1}
className="p-2 text-gray-400 rounded-lg transition-colors hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-30 disabled:cursor-not-allowed"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))
: commande.lignes?.map((item, i) => (
<tr
key={i}
className="group hover:bg-gray-50/50 dark:hover:bg-gray-900/20"
>
<td className="px-4 py-3 pt-5 font-mono text-sm font-bold text-right align-top">
{item.article_code}
{item.designation && (
<div className="mt-1 ml-9 text-xs text-gray-400">
{item.designation}
</div>
)}
</td>
<td className="px-4 py-3 pt-5 font-mono text-sm font-bold text-right align-top">
{item.quantite}
</td>
<td className="px-4 py-3 pt-5 font-mono text-sm font-bold text-right align-top">
{item.prix_unitaire_ht?.toFixed(2)}
</td>
<td className="px-4 py-3 pt-5 text-sm text-right text-gray-600 align-top">
0%
</td>
<td className="px-4 py-3 pt-5 text-sm text-right text-gray-600 align-top">
20%
</td>
<td className="px-4 py-3 pt-5 font-mono font-bold text-right align-top">
{item.montant_ligne_ht?.toFixed(2)}
</td>
</tr>
)) || (
<tr>
<td
colSpan={6}
className="py-4 text-center text-gray-500"
>
Aucune ligne
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Bouton ajouter ligne + Totaux */}
<div className="flex justify-between items-start">
{isEditing && (
<button
onClick={ajouterLigne}
className="text-sm text-[#007E45] font-medium hover:underline flex items-center gap-1"
>
<Plus className="w-4 h-4" /> Ajouter une ligne
</button>
)}
{!isEditing && <div />}
<div
style={{ width: "42vh" }}
className="p-6 space-y-3 bg-gray-50 rounded-xl border border-gray-100 lg:w-96 dark:bg-gray-900/50 dark:border-gray-800"
>
<div className="flex justify-between text-sm">
<span className="text-gray-500 dark:text-gray-400">
Total HT
</span>
<span className="font-mono font-semibold text-gray-900 dark:text-white">
{displayTotalHT.toLocaleString("fr-FR", {
minimumFractionDigits: 2,
})}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500 dark:text-gray-400">
Total TVA
</span>
<span className="font-mono font-semibold text-gray-900 dark:text-white">
{displayTotalTVA.toLocaleString("fr-FR", {
minimumFractionDigits: 2,
})}
</span>
</div>
<div className="my-2 h-px bg-gray-200 dark:bg-gray-700" />
<div className="flex justify-between text-lg font-bold">
<span className="text-gray-900 dark:text-white">
Net à payer
</span>
<span className="text-[#007E45] font-mono">
{displayTotalTTC.toLocaleString("fr-FR", {
minimumFractionDigits: 2,
})}
</span>
</div>
</div>
</div>
</div>
)}
{/* --- TOTALS TAB --- */}
{activeTab === "totals" && (
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="p-6 bg-white rounded-2xl border border-gray-200 shadow-sm dark:bg-gray-950 dark:border-gray-800 h-fit">
<h3 className="mb-4 text-lg font-bold text-gray-900 dark:text-white">
Poids & Colisage
</h3>
<div className="space-y-4">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
Poids Net Estimé
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{totalPoidsNet || 0} kg
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
Poids Brut Estimé
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{totalPoidsBrut || 0} kg
</span>
</div>
<div className="my-2 h-px bg-gray-100 dark:bg-gray-800"></div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
Nombre de lignes
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{commande.lignes?.length}
</span>
</div>
</div>
</div>
</div>
)}
{activeTab === "timeline" && <Timeline events={timeline} />}
{activeTab === "documents" && (
<div className="space-y-6">
<div className="p-12 text-center bg-white rounded-2xl border border-gray-200 border-dashed transition-colors cursor-pointer dark:bg-gray-950 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900">
<File className="mx-auto mb-4 w-12 h-12 text-gray-300" />
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">
Glissez-déposez vos fichiers ici
</p>
</div>
<div className="overflow-hidden bg-white rounded-2xl border border-gray-200 shadow-sm dark:bg-gray-950 dark:border-gray-800">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200 dark:bg-gray-900 dark:border-gray-800">
<tr className="text-xs font-semibold text-left text-gray-500 uppercase">
<th className="px-6 py-3">Fichier</th>
<th className="px-6 py-3">Type</th>
<th className="px-6 py-3">Date</th>
<th className="px-6 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
<tr className="hover:bg-gray-50 dark:hover:bg-gray-900/50">
<td className="flex gap-3 items-center px-6 py-4 text-sm">
<FileText className="w-5 h-5 text-[#007E45]" />
<span className="font-medium text-gray-900 dark:text-white">
Bon_Commande_Client_{commande.numero}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500">PDF</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatDateFRCourt(commande.date)}
</td>
<td className="px-6 py-4 text-right">
<button className="mr-4 text-sm font-medium text-blue-600 hover:underline">
Télécharger
</button>
<button className="text-[#007E45] hover:text-red-700">
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
</div>
{/* MODALS */}
<ModalCommandetoFacture
open={isOpenBLToFacture}
onClose={() => setIsOpenBLToFacture(false)}
/>
<ModalWorkflowToBL
open={isOpenWorkflowToBL}
onClose={() => setIsOpenWorkflowToBL(false)}
/>
<PDFPreviewModal
isOpen={isOpen}
onClose={closePreview}
pdfUrl={pdfUrl}
fileName={fileName}
/>
<ModalStatus
open={openStatus}
onClose={() => setOpenStatus(false)}
type_doc={SageDocumentType.BON_COMMANDE}
/>
{(loadingTransform || isSaving) && <ModalLoading />}
</>
);
};
export default OrderDetailPage;

View file

@ -0,0 +1,557 @@
import React, { ReactNode, useEffect, useMemo, useState } from 'react';
import { Helmet } from 'react-helmet';
import { Plus, ShoppingCart, CheckSquare, AlertCircle, Euro, Eye, Copy } from 'lucide-react';
import KPIBar, { PeriodType } from '@/components/KPIBar';
import PrimaryButton_v2 from '@/components/PrimaryButton_v2';
import DataTable from '@/components/DataTable';
import { Commande, CommandeRequest, CommandeResponse } from '@/types/commandeTypes';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { useNavigate } from 'react-router-dom';
import { commandeStatus, getAllcommandes, getcommandeSelected } from '@/store/features/commande/selectors';
import { createCommande, getCommande, getCommandes, selectCommandeAsync } from '@/store/features/commande/thunk';
import { cn, formatDateFRCourt } from '@/lib/utils';
import { filterItemByPeriod, getPreviousPeriodItems } from '@/components/filter/ItemsFilter';
import { DropdownMenuTable } from '@/components/DropdownMenu';
import { ModalCommande } from '@/components/modal/ModalCommande';
import { clientStatus, getAllClients } from '@/store/features/client/selectors';
import { Client } from '@/types/clientType';
import { selectcommande } from '@/store/features/commande/slice';
import { toast } from '@/components/ui/use-toast';
import FormModal from '@/components/ui/FormModal';
import { ModalLoading } from '@/components/modal/ModalLoading';
import PeriodSelector from '@/components/common/PeriodSelector';
import AdvancedFilters from '@/components/common/AdvancedFilters';
import { articleStatus } from '@/store/features/article/selectors';
import { getArticles } from '@/store/features/article/thunk';
import { getClients } from '@/store/features/client/thunk';
import ModalStatus from '@/components/modal/ModalStatus';
import StatusBadge from '@/components/ui/StatusBadge';
import { SageDocumentType } from '@/types/sageTypes';
import ColumnSelector, { ColumnConfig } from '@/components/common/ColumnSelector';
import ExportDropdown from '@/components/common/ExportDropdown';
import { Commercial } from '@/types/commercialType';
import { commercialsStatus, getAllcommercials } from '@/store/features/commercial/selectors';
import { getCommercials } from '@/store/features/commercial/thunk';
import { useDashboardData } from '@/store/hooks/useAppData';
export const STATUS_LABELS_COMMANDE = {
0: { label: 'Saisi', color: 'bg-gray-400' },
1: { label: 'Confirmé', color: 'bg-yellow-400' },
2: { label: 'A préparer', color: 'bg-green-400' },
5: { label: 'Préparé', color: 'bg-gray-500' },
} as const;
export type StatusCodeCommande = keyof typeof STATUS_LABELS_COMMANDE;
// ============================================
// CONFIGURATION DES COLONNES
// ============================================
const DEFAULT_COLUMNS: ColumnConfig[] = [
{ key: 'numero', label: 'Numéro', visible: true, locked: true },
{ key: 'client_code', label: 'Client', visible: true },
{ key: 'date', label: 'Date', visible: true },
{ key: 'total_ht_calcule', label: 'Montant HT', visible: true },
{ key: 'total_taxes_calcule', label: 'Montant TVA', visible: true },
{ key: 'total_ttc_calcule', label: 'Montant TTC', visible: true },
{ key: 'statut', label: 'Statut', visible: true },
];
const OrdersPage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [period, setPeriod] = useState<PeriodType>('all');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [openStatus, setOpenStatus] = useState(false);
const [activeFilters, setActiveFilters] = useState<Record<string, string[] | undefined>>({});
// État des colonnes visibles
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(DEFAULT_COLUMNS);
const commandes = useAppSelector(getAllcommandes) as Commande[];
const commercials = useAppSelector(getAllcommercials) as Commercial[];
const statusCommande = useAppSelector(commandeStatus);
const commandeSelected = useAppSelector(getcommandeSelected) as Commande;
const [editing, setEditing] = useState<Commande | null>(null);
const [isDuplicate, setIsDuplicate] = useState(false);
const [loading, setLoading] = useState(false);
const clients = useAppSelector(getAllClients) as Client[];
const statusClient = useAppSelector(clientStatus);
const statusArticle = useAppSelector(articleStatus);
const statusCommercial = useAppSelector(commercialsStatus) ;
useEffect(() => {
const load = async () => {
try {
if (statusArticle === 'idle') {
await dispatch(getArticles()).unwrap();
}
if (statusClient === 'idle' || statusClient === 'failed') {
await dispatch(getClients()).unwrap();
}
if (statusCommercial === "idle") await dispatch(getCommercials()).unwrap();
} catch (error) {
console.error(error);
}
};
load();
}, [statusArticle, statusClient, statusCommercial, dispatch]);
const isLoading = statusCommande === 'loading' && commandes.length === 0 && statusClient === 'loading';
useEffect(() => {
const load = async () => {
if (statusCommande === 'idle') await dispatch(getCommandes()).unwrap();
};
load();
}, [statusCommande, dispatch]);
const { refresh } = useDashboardData();
const commercialOptions = useMemo(() => {
return commercials.map(c => ({
value: c.numero.toString(),
label: `${c.prenom || ''} ${c.nom || ''}`.trim() || `Commercial ${c.numero}`,
}));
}, [commercials]);
const filterDefinitions = [
{
key: 'status',
label: 'Statut',
options: (Object.entries(STATUS_LABELS_COMMANDE) as [string, (typeof STATUS_LABELS_COMMANDE)[StatusCodeCommande]][]).map(
([value, { label, color }]) => ({
value: value.toString(),
label,
color,
})
),
},
{
key: 'rep',
label: 'Commercial',
options: commercialOptions,
},
];
// ============================================
// 1. CRÉER UNE MAP CLIENT -> COMMERCIAL (dans le composant)
// ============================================
// Map pour retrouver le commercial d'un client rapidement
const clientCommercialMap = useMemo(() => {
const map = new Map<string, string>();
clients.forEach(client => {
if (client.commercial?.numero) {
map.set(client.numero, client.commercial.numero.toString());
}
});
return map;
}, [clients]);
const filteredCommandes = useMemo(() => {
let result = filterItemByPeriod(commandes, period, 'date');
if (activeFilters.status && activeFilters.status.length > 0) {
result = result.filter(item => activeFilters.status!.includes(item.statut.toString()));
}
if (activeFilters.rep && activeFilters.rep.length > 0) {
result = result.filter(item => {
const commercialCode = clientCommercialMap.get(item.client_code);
return commercialCode && activeFilters.rep!.includes(commercialCode);
});
}
return [...result].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}, [commandes, period, activeFilters, clientCommercialMap]);
const kpis = useMemo(() => {
const totalHT = Math.round(filteredCommandes.reduce((sum, item) => sum + item.total_ht, 0))
const validated = filteredCommandes.filter((item: Commande) => item.statut === 2);
const pending = filteredCommandes.filter((item: Commande) => item.statut === 1);
const validatedValue = Math.round(validated.reduce((sum, item) => sum + item.total_ht, 0))
const previousPeriodCommandes = getPreviousPeriodItems(commandes, period);
const previousTotalHT = previousPeriodCommandes.reduce((sum, item) => sum + item.total_ht, 0);
const previousValidated = previousPeriodCommandes.filter((item: Commande) => item.statut === 2);
const previousValidatedValue = previousValidated.reduce((sum, item) => sum + item.total_ht, 0);
const htChange = previousTotalHT > 0 ? (((totalHT - previousTotalHT) / previousTotalHT) * 100).toFixed(1) : '0';
const htTrend = totalHT >= previousTotalHT ? 'up' : 'down';
const countChange = filteredCommandes.length - previousPeriodCommandes.length;
const countTrend = countChange >= 0 ? 'up' : 'down';
const validatedChange = previousValidated.length > 0 ? validated.length - previousValidated.length : 0;
const validatedTrend = validatedChange >= 0 ? 'up' : 'down';
const pendingChange = previousPeriodCommandes.filter((item: Commande) => item.statut === 1).length;
const pendingDiff = pending.length - pendingChange;
const pendingTrend = pendingDiff >= 0 ? 'up' : 'down';
const validatedValueChange =
previousValidatedValue > 0 ? (((validatedValue - previousValidatedValue) / previousValidatedValue) * 100).toFixed(1) : '0';
const validatedValueTrend = validatedValue >= previousValidatedValue ? 'up' : 'down';
return [
{
title: 'Total HT',
value: `${totalHT.toLocaleString('fr-FR')}`,
change: `${htTrend === 'up' ? '+' : ''}${htChange}%`,
trend: htTrend,
icon: Euro,
tooltip: { content: "Total des commandes sur la période.", source: "Ventes > Commandes" }
},
{
title: 'Nombre Commandes',
value: filteredCommandes.length,
change: countChange !== 0 ? `${countChange > 0 ? '+' : ''}${countChange}` : '0',
trend: countTrend,
icon: ShoppingCart,
},
{
title: 'Livrées',
value: validated.length,
change: validatedChange !== 0 ? `${validatedChange > 0 ? '+' : ''}${validatedChange}` : '',
trend: validatedTrend,
icon: CheckSquare,
},
{
title: 'En Cours',
value: pending.length,
change: pendingDiff !== 0 ? `${pendingDiff > 0 ? '+' : ''}${pendingDiff}` : '',
trend: pendingTrend,
icon: AlertCircle,
},
{
title: 'Montant Livré',
value: `${validatedValue.toLocaleString('fr-FR')}`,
change: `${validatedValueTrend === 'up' ? '+' : ''}${validatedValueChange}%`,
trend: validatedValueTrend,
icon: CheckSquare,
tooltip: { content: "Total des commandes livrées sur la période.", source: "Ventes > Commandes" }
},
];
}, [filteredCommandes, commandes, period]);
// ============================================
// COLONNES DYNAMIQUES
// ============================================
const allColumnsDefinition = useMemo(() => {
const clientsMap = new Map(clients.map(c => [c.numero, c]));
return {
numero: { key: 'numero', label: 'Numéro', sortable: true },
client_code: {
key: 'client_code',
label: 'Client',
sortable: true,
render: (clientCode: string) => {
const client = clientsMap.get(clientCode);
if (!client) return <span className="text-gray-400">{clientCode}</span>;
const avatar = client.intitule?.charAt(0).toUpperCase() || '?';
return (
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-xs font-bold text-gray-600 dark:text-gray-300">
{avatar}
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{client.intitule}</p>
<p className="text-xs text-gray-500">{client.email || client.telephone || clientCode}</p>
</div>
</div>
);
},
},
date: {
key: 'date',
label: 'Date',
sortable: true,
render: (date: string) => <span>{formatDateFRCourt(date)}</span>,
},
total_ht_calcule: {
key: 'total_ht_calcule',
label: 'Montant HT',
sortable: true,
render: (value: number) => `${value.toLocaleString()}`,
},
total_taxes_calcule: {
key: 'total_taxes_calcule',
label: 'Montant TVA',
sortable: true,
render: (value: number) => `${value.toLocaleString()}`,
},
total_ttc_calcule: {
key: 'total_ttc_calcule',
label: 'Montant TTC',
sortable: true,
render: (value: number) => <span className="font-medium text-[#007E45]">{value.toLocaleString()}</span>,
},
statut: {
key: 'statut',
label: 'Statut',
sortable: true,
render: (value: number) => <StatusBadge status={value} type_doc={SageDocumentType.BON_COMMANDE} />,
},
};
}, [clients]);
// Colonnes filtrées selon la config
const visibleColumns = useMemo(() => {
return columnConfig
.filter(col => col.visible)
.map(col => allColumnsDefinition[col.key as keyof typeof allColumnsDefinition])
.filter(Boolean);
}, [columnConfig, allColumnsDefinition]);
// ============================================
// DÉFINIR LES FORMATEURS POUR L'EXPORT
// ============================================
// Formateurs personnalisés pour transformer les valeurs à l'export
const columnFormatters: Record<string, (value: any, row: any) => string> = {
date: (value) => {
if (!value) return '';
return formatDateFRCourt(value);
},
total_ht_calcule: (value) => {
if (value === null || value === undefined) return '0 €';
return `${value.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €`;
},
total_taxes_calcule: (value) => {
if (value === null || value === undefined) return '0 €';
return `${value.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €`;
},
total_ttc_calcule: (value) => {
if (value === null || value === undefined) return '0 €';
return `${value.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €`;
},
statut: (value) => {
return STATUS_LABELS_COMMANDE[value as StatusCodeCommande]?.label || 'Inconnu';
},
client_intitule: (value, row) => {
// Si client_intitule n'existe pas, on peut le récupérer depuis la map des clients
return value || row.client_code || '';
},
};
const onDuplicate = async () => {
try {
setLoading(true);
const payloadCreate: CommandeRequest = {
client_id: commandeSelected.client_code,
date_commande: (() => {
const d = new Date();
d.setDate(d.getDate() + 1);
return d.toISOString().split('T')[0];
})(),
lignes: commandeSelected.lignes!.map(ligne => ({
article_code: ligne.article_code,
quantite: ligne.quantite,
})),
reference: commandeSelected.reference,
};
const result = (await dispatch(createCommande(payloadCreate)).unwrap()) as CommandeResponse;
const data = result.data;
await new Promise(resolve => setTimeout(resolve, 1500));
const itemCreated = (await dispatch(getCommande(data.numero_commande)).unwrap()) as any;
const res = itemCreated as Commande;
dispatch(selectcommande(res));
toast({
title: 'Commande dupliquée avec succès !',
description: `La commande ${commandeSelected.numero} a été dupliquée avec succès.`,
className: 'bg-green-500 text-white border-green-600',
});
setLoading(false);
setIsDuplicate(false);
navigate(`/home/commandes/${data.numero_commande}`);
} catch (err: any) {
setLoading(false);
setIsDuplicate(false);
}
};
const actions = (row: Commande) => {
const handleStatus =
row.statut !== 2 && row.statut !== 3 && row.statut !== 4
? async () => {
await dispatch(selectCommandeAsync(row)).unwrap();
setOpenStatus(true);
}
: undefined;
return (
<>
<button
onClick={async () => {
await dispatch(selectCommandeAsync(row)).unwrap();
navigate(`/home/commandes/${row.numero}`);
}}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-gray-600 dark:text-gray-400"
title="Voir"
>
<Eye className="w-4 h-4" />
</button>
<DropdownMenuTable
row={row}
onEdit={() => handleEdit(row)}
onStatus={handleStatus}
onDulipcate={async () => {
await dispatch(selectCommandeAsync(row)).unwrap();
setIsDuplicate(true);
}}
/>
</>
);
};
const handleCreate = () => {
setEditing(null);
setIsCreateModalOpen(true);
};
const handleEdit = (row: Commande) => {
setEditing(row);
setIsCreateModalOpen(true);
};
return (
<>
<Helmet>
<title>Commandes - Dataven</title>
<meta name="description" content="Gestion de vos commandes" />
</Helmet>
<div className="space-y-6">
<KPIBar kpis={kpis} period={period} loading={statusCommande} onRefresh={refresh} />
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Commandes</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{filteredCommandes.length} commandes</p>
</div>
<PeriodSelector value={period} onChange={setPeriod} />
</div>
<div className="flex gap-3 flex-wrap items-center">
{/* Sélecteur de colonnes */}
<ColumnSelector columns={columnConfig} onChange={setColumnConfig} />
<ExportDropdown
data={filteredCommandes}
columns={columnConfig}
columnFormatters={columnFormatters}
filename="commande"
/>
<AdvancedFilters
filters={filterDefinitions}
activeFilters={activeFilters}
onFilterChange={(key, values) => {
setActiveFilters(prev => ({
...prev,
[key]: values,
}));
}}
onReset={() => setActiveFilters({})}
/>
<PrimaryButton_v2 icon={Plus} onClick={handleCreate}>
Nouvelle commande
</PrimaryButton_v2>
</div>
</div>
<DataTable
columns={visibleColumns}
data={filteredCommandes}
onRowClick={async (row: Commande) => {
await dispatch(selectCommandeAsync(row)).unwrap();
navigate(`/home/commandes/${row.numero}`);
}}
actions={actions}
status={isLoading}
/>
</div>
<ModalCommande
open={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
title={editing ? `Mettre à jour la commande ${editing.numero}` : 'Créer une commande'}
editing={editing}
/>
<ModalStatus open={openStatus} onClose={() => setOpenStatus(false)} type_doc={SageDocumentType.BON_COMMANDE} />
<FormModal
isOpen={isDuplicate}
onClose={() => setIsDuplicate(false)}
title={`Dupliquer la commande ${commandeSelected?.numero}`}
onSubmit={onDuplicate}
submitLabel="Dupliquer"
>
<div className="mb-6 p-4 bg-teal-50 text-teal-800 rounded-xl text-sm flex gap-3 border border-teal-200">
<Copy className="w-5 h-5 shrink-0" />
<p>Vous allez dupliquer cette commande</p>
</div>
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl overflow-hidden mb-6">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800">
<tr>
<th className="px-4 py-3 text-left font-semibold text-gray-600">Article</th>
<th className="px-4 py-3 text-right font-semibold text-gray-600">Qté Commande</th>
<th className="px-4 py-3 text-right font-semibold text-gray-600">TVA</th>
<th className="px-4 py-3 text-right font-semibold text-gray-600">Total HT</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{commandeSelected?.lignes ? (
commandeSelected?.lignes.map((item, i) => (
<tr key={i} className="hover:bg-gray-50/50">
<td className="px-4 py-3">
<div className="font-medium text-gray-900 dark:text-white">{item.designation}</div>
<div className="text-xs text-gray-500">{item.article_code}</div>
</td>
<td className="px-4 py-3 text-right text-gray-600">{item.quantite}</td>
<td className="px-4 py-3 text-right font-medium text-gray-900">20%</td>
<td className="px-4 py-3 text-right font-medium text-gray-900 dark:text-white">
{item.montant_ligne_ht!.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="py-4 text-center text-gray-500">
Aucune ligne
</td>
</tr>
)}
</tbody>
<tfoot className="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800">
<tr>
<td colSpan={3} className="px-4 py-3 text-right font-bold text-gray-900 dark:text-white">
Total HT à générer
</td>
<td className="px-4 py-3 text-right font-bold text-[#007E45]">{commandeSelected?.total_ht_calcule?.toFixed(2)} </td>
</tr>
</tfoot>
</table>
</div>
</FormModal>
{loading && <ModalLoading />}
</>
);
};
export default OrdersPage;

View file

@ -0,0 +1,514 @@
import React, { useState, useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import {
ArrowLeft, Lock, Download, MoreVertical, Trash2,
CheckCircle, Plus, RefreshCw, Eye
} from 'lucide-react';
import StatusBadge from '@/components/StatusBadge';
import PrimaryButton from '@/components/PrimaryButton';
import Tabs from '@/components/Tabs';
import Timeline from '@/components/Timeline';
import { Input, Select, Textarea, FormField } from '@/components/FormModal';
import ClientSearchInput from '@/components/inputs/ClientSearchInput';
import PDFPreviewPanel from '@/components/panels/PDFPreviewPanel';
import { toast } from '@/components/ui/use-toast';
import { mockPayments, mockInvoices, mockClients, currentUser } from '@/data/mockData';
import { cn } from '@/lib/utils';
const formatCurrency = (val) => new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(val);
const emptyPayment = {
id: 'new',
number: 'REG-PROVISOIRE',
clientId: null,
clientName: '',
date: new Date().toISOString().split('T')[0],
mode: 'Virement',
reference: '',
amount: 0,
allocatedAmount: 0,
remainingBalance: 0,
status: 'Brouillon',
notes: '',
allocations: [],
timeline: []
};
const PaymentDetailPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const location = useLocation();
const isNew = id === 'new';
const [activeTab, setActiveTab] = useState('identification');
const [isEditing, setIsEditing] = useState(isNew);
const [isPDFPreviewOpen, setIsPDFPreviewOpen] = useState(false);
const [payment, setPayment] = useState(null);
const [selectedClient, setSelectedClient] = useState(null);
const [clientInvoices, setClientInvoices] = useState([]);
useEffect(() => {
if (isNew) {
// Check for initial client from state (e.g., coming from Client Detail)
const initialClient = location.state?.client || null;
const initialInvoice = location.state?.invoice || null;
let newPayment = { ...emptyPayment };
if (initialClient) {
newPayment.clientId = initialClient.id;
newPayment.clientName = initialClient.company;
setSelectedClient(initialClient);
}
if (initialInvoice) {
// Pre-fill amount from invoice balance
newPayment.amount = initialInvoice.balanceDue;
newPayment.reference = `Regl. ${initialInvoice.number}`;
}
setPayment(newPayment);
setIsEditing(true);
setActiveTab('identification');
} else {
const found = mockPayments.find(p => p.id === parseInt(id));
if (found) {
setPayment({
...found,
// Ensure fields exist
notes: found.notes || '',
timeline: found.timeline || []
});
const fullClient = mockClients.find(c => c.id === found.clientId);
setSelectedClient(fullClient || { id: found.clientId, company: found.clientName });
}
}
}, [id, isNew, location.state]);
// Load Invoices when client changes
useEffect(() => {
if (payment?.clientId) {
// Find unpaid invoices for this client
const openInvoices = mockInvoices.filter(inv =>
inv.clientId === payment.clientId &&
inv.status !== 'Brouillon' &&
inv.balanceDue > 0
).sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate)); // Sort by Due Date Ascending
// Augment invoices with allocation data from current payment
const invoicesWithAllocation = openInvoices.map(inv => {
const existingAlloc = payment.allocations.find(a => a.invoiceId === inv.id);
return {
...inv,
currentAllocation: existingAlloc ? existingAlloc.amount : 0
};
});
setClientInvoices(invoicesWithAllocation);
} else {
setClientInvoices([]);
}
}, [payment?.clientId, payment?.allocations]); // Re-run if allocations change to keep sync, though simple state update below handles input
// Calculations
useEffect(() => {
if (!payment) return;
// Only recalc if editing
if (isEditing) {
const totalAllocated = payment.allocations.reduce((sum, a) => sum + a.amount, 0);
setPayment(prev => ({
...prev,
allocatedAmount: totalAllocated,
remainingBalance: Math.max(0, prev.amount - totalAllocated)
}));
}
}, [payment?.allocations, payment?.amount, isEditing]);
if (!payment) return <div className="min-h-screen flex items-center justify-center"><div className="animate-spin w-8 h-8 border-4 border-[#941403] border-t-transparent rounded-full"></div></div>;
const isDraft = payment.status === 'Brouillon';
const isValidated = payment.status === 'Validé' || payment.status === 'Imputé' || payment.status === 'Crédit client';
const fieldsDisabled = !isEditing || isValidated;
// --- HANDLERS ---
const handleClientSelect = (client) => {
if (fieldsDisabled) return;
setSelectedClient(client);
setPayment(prev => ({
...prev,
clientName: client ? (client.company || `${client.firstName} ${client.lastName}`) : '',
clientId: client ? client.id : null,
allocations: [] // Clear allocations on client change
}));
};
const handleAmountChange = (val) => {
const newAmount = parseFloat(val) || 0;
setPayment(prev => ({ ...prev, amount: newAmount }));
};
const handleAutoAllocate = () => {
let remaining = payment.amount;
const newAllocations = [];
const updatedInvoices = [...clientInvoices]; // These are sorted by due date
for (const inv of updatedInvoices) {
if (remaining <= 0) break;
const amountToAllocate = Math.min(remaining, inv.balanceDue);
if (amountToAllocate > 0) {
newAllocations.push({
invoiceId: inv.id,
invoiceNumber: inv.number,
amount: amountToAllocate
});
remaining -= amountToAllocate;
}
}
setPayment(prev => ({
...prev,
allocations: newAllocations
}));
toast({ title: "Imputation automatique", description: "Les montants ont été répartis par échéance." });
};
const handleManualAllocation = (invoiceId, val) => {
const amount = parseFloat(val) || 0;
// Validate max amount
const invoice = clientInvoices.find(i => i.id === invoiceId);
if (!invoice) return;
if (amount > invoice.balanceDue) {
// Prevent over allocation on invoice
return;
}
// Check global limit? Maybe soft check or just visual
setPayment(prev => {
const filtered = prev.allocations.filter(a => a.invoiceId !== invoiceId);
if (amount > 0) {
filtered.push({
invoiceId: invoiceId,
invoiceNumber: invoice.number,
amount: amount
});
}
return { ...prev, allocations: filtered };
});
};
const handleSave = () => {
if (!payment.clientId) {
toast({ title: "Client manquant", description: "Veuillez sélectionner un client.", variant: "destructive" });
return;
}
if (payment.amount <= 0) {
toast({ title: "Montant invalide", description: "Le montant doit être supérieur à 0.", variant: "destructive" });
return;
}
setIsEditing(false);
if (isNew) {
toast({ title: "Règlement créé", description: "Le brouillon a été enregistré.", variant: "success" });
navigate('/reglements');
} else {
setPayment(prev => ({
...prev,
timeline: [{ date: new Date().toISOString(), type: 'update', title: 'Règlement modifié', user: currentUser.name }, ...prev.timeline]
}));
toast({ title: "Sauvegardé", description: "Les modifications ont été enregistrées.", variant: "success" });
}
};
const handleValidate = () => {
let finalStatus = 'Validé';
if (payment.remainingBalance === 0 && payment.amount > 0) finalStatus = 'Imputé';
if (payment.remainingBalance > 0) finalStatus = 'Crédit client';
setPayment(prev => ({
...prev,
status: finalStatus,
number: prev.number.replace('PROVISOIRE', `REG-2025-${Math.floor(Math.random()*1000)}`),
timeline: [{ date: new Date().toISOString(), type: 'validation', title: 'Règlement validé', description: `Statut: ${finalStatus}`, user: currentUser.name }, ...prev.timeline]
}));
setIsEditing(false);
toast({ title: "Règlement validé", description: "Le paiement est verrouillé et les écritures sont passées.", variant: "success" });
};
const tabs = [
{ id: 'identification', label: 'Identification' },
{ id: 'allocations', label: 'Imputations', count: payment.allocations.length },
{ id: 'documents', label: 'Documents', count: payment.linkedDocuments?.length || 0 },
{ id: 'timeline', label: 'Historique' },
];
return (
<>
<Helmet>
<title>{payment.number} - Règlement - Bijou ERP</title>
</Helmet>
<div className="flex flex-col min-h-screen bg-gray-50/50 dark:bg-black/20 pb-20">
{/* TOP HEADER */}
<div className="bg-white dark:bg-gray-950 border-b border-gray-200 dark:border-gray-800 sticky top-0 z-30 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-4">
<button onClick={() => navigate('/reglements')} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors text-gray-500">
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">{payment.number}</h1>
<StatusBadge status={payment.status} />
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<span className="font-medium text-gray-900 dark:text-gray-300">{payment.clientName || 'Client inconnu'}</span>
<span></span>
<span>{new Date(payment.date).toLocaleDateString()}</span>
<span className="text-[#941403] font-bold ml-2">{formatCurrency(payment.amount)}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setIsPDFPreviewOpen(true)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-700 transition-colors"
>
<Eye className="w-4 h-4" />
<span className="hidden sm:inline">Aperçu</span>
</button>
{isDraft && (
<PrimaryButton onClick={handleValidate} className="bg-blue-600 hover:bg-blue-700">
<Lock className="w-4 h-4 mr-2" /> Valider
</PrimaryButton>
)}
{!isDraft && !isNew && (
<button className="p-2 border border-gray-200 dark:border-gray-800 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-900 text-gray-600 dark:text-gray-400" title="Télécharger">
<Download className="w-5 h-5"/>
</button>
)}
{isDraft && !isEditing && (
<button
onClick={() => setIsEditing(true)}
className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Modifier
</button>
)}
{isEditing && (
<>
<button
onClick={() => isNew ? navigate('/reglements') : setIsEditing(false)}
className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors"
>
Annuler
</button>
<PrimaryButton onClick={handleSave}>Enregistrer</PrimaryButton>
</>
)}
</div>
</div>
</div>
</div>
{/* LOCKED BANNER */}
{isValidated && (
<div className="bg-blue-50 dark:bg-blue-900/20 border-b border-blue-100 dark:border-blue-800 px-4 py-3 text-center">
<p className="text-sm text-blue-800 dark:text-blue-200 font-medium flex items-center justify-center gap-2">
<Lock className="w-4 h-4" />
Ce règlement est validé et n'est plus modifiable.
</p>
</div>
)}
{/* CONTENT */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full mt-6">
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
<div className="mt-6">
{/* --- IDENTIFICATION TAB --- */}
{activeTab === 'identification' && (
<div className="space-y-6">
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6 relative overflow-hidden">
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Détails du paiement</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField label="Client *" required fullWidth>
<ClientSearchInput
value={selectedClient}
onChange={handleClientSelect}
disabled={fieldsDisabled}
placeholder="Rechercher un client..."
/>
</FormField>
<div className="grid grid-cols-2 gap-4">
<FormField label="Date de règlement" required>
<Input type="date" value={payment.date} onChange={(e) => !fieldsDisabled && setPayment({...payment, date: e.target.value})} disabled={fieldsDisabled} />
</FormField>
<FormField label="Mode de paiement">
<Select value={payment.mode} onChange={(e) => !fieldsDisabled && setPayment({...payment, mode: e.target.value})} disabled={fieldsDisabled}>
<option value="Virement">Virement</option>
<option value="CB">Carte Bancaire</option>
<option value="Prélèvement">Prélèvement</option>
<option value="Chèque">Chèque</option>
<option value="Espèces">Espèces</option>
<option value="Autre">Autre</option>
</Select>
</FormField>
</div>
<FormField label="Référence bancaire / Libellé">
<Input value={payment.reference} onChange={(e) => !fieldsDisabled && setPayment({...payment, reference: e.target.value})} disabled={fieldsDisabled} placeholder="Ex: VIR 12345" />
</FormField>
<FormField label="Montant reçu (€) *" required>
<Input
type="number"
min="0"
step="0.01"
value={payment.amount}
onChange={(e) => !fieldsDisabled && handleAmountChange(e.target.value)}
disabled={fieldsDisabled}
className="font-bold text-lg"
/>
</FormField>
<FormField label="Notes internes" fullWidth>
<Textarea value={payment.notes} onChange={(e) => !fieldsDisabled && setPayment({...payment, notes: e.target.value})} disabled={fieldsDisabled} rows={2} />
</FormField>
</div>
</div>
</div>
)}
{/* --- ALLOCATIONS TAB --- */}
{activeTab === 'allocations' && (
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6 shadow-sm">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
<div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white">Imputations</h3>
<p className="text-sm text-gray-500">Répartissez le montant reçu sur les factures en attente.</p>
</div>
{!fieldsDisabled && (
<button
onClick={handleAutoAllocate}
className="flex items-center gap-2 px-4 py-2 bg-blue-50 text-blue-700 hover:bg-blue-100 rounded-lg text-sm font-medium transition-colors"
>
<RefreshCw className="w-4 h-4" /> Imputation automatique
</button>
)}
</div>
{/* Summary Bar */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-xl border border-gray-100 dark:border-gray-800">
<p className="text-xs text-gray-500 uppercase font-bold">Montant Reçu</p>
<p className="text-xl font-bold text-gray-900 dark:text-white">{formatCurrency(payment.amount)}</p>
</div>
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-xl border border-green-100 dark:border-green-800">
<p className="text-xs text-green-700 uppercase font-bold">Montant Imputé</p>
<p className="text-xl font-bold text-green-700">{formatCurrency(payment.allocatedAmount)}</p>
</div>
<div className={cn(
"p-4 rounded-xl border",
payment.remainingBalance > 0 ? "bg-purple-50 dark:bg-purple-900/20 border-purple-100 dark:border-purple-800" : "bg-gray-50 dark:bg-gray-900 border-gray-100 dark:border-gray-800"
)}>
<p className={cn("text-xs uppercase font-bold", payment.remainingBalance > 0 ? "text-purple-700" : "text-gray-500")}>
{payment.remainingBalance > 0 ? "Solde (Crédit)" : "Solde"}
</p>
<p className={cn("text-xl font-bold", payment.remainingBalance > 0 ? "text-purple-700" : "text-gray-500")}>
{formatCurrency(payment.remainingBalance)}
</p>
</div>
</div>
{clientInvoices.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900 text-xs uppercase text-gray-500 font-semibold border-b border-gray-200 dark:border-gray-800">
<tr>
<th className="px-4 py-3 text-left">Facture</th>
<th className="px-4 py-3 text-left">Date</th>
<th className="px-4 py-3 text-left text-red-600"> le</th>
<th className="px-4 py-3 text-right">Montant TTC</th>
<th className="px-4 py-3 text-right">Reste à payer</th>
<th className="px-4 py-3 text-right w-40">Imputation</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{clientInvoices.map(inv => {
const allocatedHere = payment.allocations.find(a => a.invoiceId === inv.id)?.amount || 0;
return (
<tr key={inv.id} className="hover:bg-gray-50 dark:hover:bg-gray-900/50">
<td className="px-4 py-3 font-medium">{inv.number}</td>
<td className="px-4 py-3 text-gray-500">{new Date(inv.date).toLocaleDateString()}</td>
<td className="px-4 py-3 text-red-600 font-medium">{new Date(inv.dueDate).toLocaleDateString()}</td>
<td className="px-4 py-3 text-right text-gray-600">{formatCurrency(inv.amountTTC)}</td>
<td className="px-4 py-3 text-right font-bold">{formatCurrency(inv.balanceDue)}</td>
<td className="px-4 py-3 text-right">
<Input
type="number"
min="0"
max={inv.balanceDue}
value={allocatedHere}
onChange={(e) => handleManualAllocation(inv.id, e.target.value)}
disabled={fieldsDisabled}
className={cn(
"text-right h-8 py-1",
allocatedHere > 0 && "border-green-500 bg-green-50 text-green-900 font-bold"
)}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12 text-gray-500">
<CheckCircle className="w-12 h-12 mx-auto mb-2 text-gray-300" />
<p>Aucune facture en attente de paiement pour ce client.</p>
</div>
)}
</div>
)}
{/* --- DOCUMENTS TAB --- */}
{activeTab === 'documents' && (
<div className="space-y-6">
{/* Reuse simplified doc view */}
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-8 text-center text-gray-500">
<p>Module Documents identique aux autres pages...</p>
</div>
</div>
)}
{/* --- TIMELINE TAB --- */}
{activeTab === 'timeline' && (
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6">
<Timeline events={payment.timeline} />
</div>
)}
</div>
</div>
</div>
<PDFPreviewPanel
isOpen={isPDFPreviewOpen}
onClose={() => setIsPDFPreviewOpen(false)}
title={`Recu - ${payment.number}`}
/>
</>
);
};
export default PaymentDetailPage;

View file

@ -0,0 +1,575 @@
import { useState, useMemo, useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import {
Eye, Download, CheckCircle, Euro, X, Wallet,
Clock, AlertTriangle, Calendar, Receipt
} from 'lucide-react';
import KPIBar, { PeriodType } from '@/components/KPIBar';
import { toast } from '@/components/ui/use-toast';
import { cn, formatDateFRCourt } from '@/lib/utils';
import { Facture } from '@/types/factureType';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import PrimaryButton_v2 from '@/components/PrimaryButton_v2';
import { filterItemByPeriod, getPreviousPeriodItems } from '@/components/filter/ItemsFilter';
import ColumnSelector, { ColumnConfig } from '@/components/common/ColumnSelector';
import PeriodSelector from '@/components/common/PeriodSelector';
import ExportDropdown from '@/components/common/ExportDropdown';
import DataTable from '@/components/DataTable';
import { getAllReglements, getReglementStats, reglementStatus } from '@/store/features/reglement/selectors';
import { loadAllReglementData } from '@/store/features/reglement/thunk';
import ModalPaymentPanel from '@/components/modal/ModalPaymentPanel';
import { FacturesReglement, Statistique } from '@/types/reglementType';
// ============================================
// CONSTANTES - STATUTS DE RÈGLEMENT
// ============================================
const STATUT_REGLEMENT = {
SOLDE: 'Soldé',
PARTIEL: 'Partiellement réglé',
NON_REGLE: 'Non réglé',
} as const;
type StatutReglement = typeof STATUT_REGLEMENT[keyof typeof STATUT_REGLEMENT];
// ============================================
// TYPES
// ============================================
type FilterType = 'all' | 'paid' | 'partial' | 'unpaid';
interface KPIConfig {
id: FilterType;
title: string;
icon: React.ElementType;
color: string;
getValue: (factures: FacturesReglement[], stats?: Statistique) => number | string;
getSubtitle: (factures: FacturesReglement[], value: number | string, stats?: Statistique) => string;
getChange: (factures: FacturesReglement[], value: number | string, period: PeriodType, allFactures: FacturesReglement[]) => string;
getTrend: (factures: FacturesReglement[], value: number | string, period: PeriodType, allFactures: FacturesReglement[]) => 'up' | 'down' | 'neutral';
filter: (factures: FacturesReglement[]) => FacturesReglement[];
tooltip?: { content: string; source: string };
}
// ============================================
// CONFIGURATION DES COLONNES
// ============================================
const DEFAULT_COLUMNS: ColumnConfig[] = [
{ key: 'rg_no', label: 'N° Règlement', visible: true, locked: true },
{ key: 'numero', label: 'N° Pièce', visible: true, locked: true },
{ key: 'client', label: 'Client', visible: true },
{ key: 'date', label: 'Date', visible: true },
{ key: 'total_ttc', label: 'Montant TTC', visible: true },
{ key: 'montant_regle', label: 'Montant Réglé', visible: true },
{ key: 'reste_a_regler', label: 'Reste à Régler', visible: true },
{ key: 'statut_reglement', label: 'Statut', visible: true },
{ key: 'nb_echeances', label: 'Échéances', visible: false },
{ key: 'nb_reglements', label: 'Règlements', visible: false },
];
// ============================================
// CONFIGURATION DES KPIs
// ============================================
const KPI_CONFIG: KPIConfig[] = [
{
id: 'all',
title: 'Total Facturé',
icon: Euro,
color: 'blue',
getValue: (factures, stats) => stats?.total_factures || factures.reduce((sum, f) => sum + (f.montants?.total_ttc || 0), 0),
getSubtitle: (factures, _, stats) => {
const totalRegle = stats?.total_regle || factures.reduce((sum, f) => sum + (f.montants?.montant_regle || 0), 0);
return `${totalRegle.toLocaleString('fr-FR')}€ réglés`;
},
getChange: (factures, value, period, allFactures) => {
const previousPeriodFactures = getPreviousPeriodItems(allFactures, period, 'date');
const previousTotal = previousPeriodFactures.reduce((sum, f) => sum + (f.montants?.total_ttc || 0), 0);
return previousTotal > 0 ? (((Number(value) - previousTotal) / previousTotal) * 100).toFixed(1) : '0';
},
getTrend: (factures, value, period, allFactures) => {
const previousPeriodFactures = getPreviousPeriodItems(allFactures, period, 'date');
const previousTotal = previousPeriodFactures.reduce((sum, f) => sum + (f.montants?.total_ttc || 0), 0);
return Number(value) >= previousTotal ? 'up' : 'down';
},
filter: (factures) => factures,
tooltip: { content: "Total des factures sur la période.", source: "Règlements > Factures" }
},
{
id: 'paid',
title: 'Factures Soldées',
icon: CheckCircle,
color: 'green',
getValue: (factures, stats) => stats?.nb_soldees || factures.filter(f => f.statut_reglement === STATUT_REGLEMENT.SOLDE).length,
getSubtitle: (factures) => {
const soldees = factures.filter(f => f.statut_reglement === STATUT_REGLEMENT.SOLDE);
const total = soldees.reduce((sum, f) => sum + (f.montants?.total_ttc || 0), 0);
return `${total.toLocaleString('fr-FR')}`;
},
getChange: (factures, value) => {
const total = factures.length;
return total > 0 ? `${((Number(value) / total) * 100).toFixed(0)}%` : '0%';
},
getTrend: () => 'up',
filter: (factures) => factures.filter(f => f.statut_reglement === STATUT_REGLEMENT.SOLDE),
tooltip: { content: "Factures entièrement réglées.", source: "Règlements > Factures" }
},
{
id: 'partial',
title: 'Partiellement Réglées',
icon: Clock,
color: 'orange',
getValue: (factures, stats) => stats?.nb_partiellement_reglees || factures.filter(f => f.statut_reglement === STATUT_REGLEMENT.PARTIEL).length,
getSubtitle: (factures) => {
const partielles = factures.filter(f => f.statut_reglement === STATUT_REGLEMENT.PARTIEL);
const reste = partielles.reduce((sum, f) => sum + (f.montants?.reste_a_regler || 0), 0);
return `${reste.toLocaleString('fr-FR')}€ restant`;
},
getChange: (factures, value) => {
const total = factures.length;
return total > 0 ? `${((Number(value) / total) * 100).toFixed(0)}%` : '0%';
},
getTrend: (_, value) => Number(value) > 0 ? 'down' : 'neutral',
filter: (factures) => factures.filter(f => f.statut_reglement === STATUT_REGLEMENT.PARTIEL),
tooltip: { content: "Factures avec règlement partiel.", source: "Règlements > Factures" }
},
{
id: 'unpaid',
title: 'Non Réglées',
icon: AlertTriangle,
color: 'red',
getValue: (factures, stats) => stats?.nb_non_reglees || factures.filter(f => f.statut_reglement === STATUT_REGLEMENT.NON_REGLE).length,
getSubtitle: (factures, _, stats) => {
const reste = stats?.total_reste || factures
.filter(f => f.statut_reglement === STATUT_REGLEMENT.NON_REGLE)
.reduce((sum, f) => sum + (f.montants?.reste_a_regler || 0), 0);
return `${reste.toLocaleString('fr-FR')}€ à encaisser`;
},
getChange: (factures, value) => {
const total = factures.length;
return total > 0 ? `${((Number(value) / total) * 100).toFixed(0)}%` : '0%';
},
getTrend: (_, value) => Number(value) > 0 ? 'down' : 'up',
filter: (factures) => factures.filter(f => f.statut_reglement === STATUT_REGLEMENT.NON_REGLE),
tooltip: { content: "Factures sans aucun règlement.", source: "Règlements > Factures" }
},
];
// ============================================
// STATUT BADGE POUR RÈGLEMENTS
// ============================================
const ReglementStatusBadge = ({ status }: { status: string }) => {
const config: Record<string, { label: string; className: string; icon: React.ElementType }> = {
[STATUT_REGLEMENT.SOLDE]: {
label: 'Soldé',
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
icon: CheckCircle
},
[STATUT_REGLEMENT.PARTIEL]: {
label: 'Partiel',
className: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
icon: Clock
},
[STATUT_REGLEMENT.NON_REGLE]: {
label: 'Non réglé',
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
icon: AlertTriangle
},
};
const statusConfig = config[status] || config[STATUT_REGLEMENT.NON_REGLE];
const Icon = statusConfig.icon;
return (
<span className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium",
statusConfig.className
)}>
<Icon className="w-3 h-3" />
{statusConfig.label}
</span>
);
};
// ============================================
// COMPOSANT PRINCIPAL
// ============================================
const PaymentsPage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [period, setPeriod] = useState<PeriodType>('all');
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(DEFAULT_COLUMNS);
const [selectedInvoiceIds, setSelectedInvoiceIds] = useState<string[]>([]);
const [isPaymentPanelOpen, setIsPaymentPanelOpen] = useState(false);
// Redux
const reglements = useAppSelector(getAllReglements) as FacturesReglement[];
const stats = useAppSelector(getReglementStats) as Statistique;
const statusReglement = useAppSelector(reglementStatus);
const isLoading = statusReglement === 'loading' && reglements.length === 0;
useEffect(() => {
const load = async () => {
try {
if (statusReglement === 'idle' || statusReglement === 'failed') {
await dispatch(loadAllReglementData()).unwrap();
}
} catch (error) {
console.error(error);
}
};
load();
}, [statusReglement, dispatch]);
// ============================================
// KPIs
// ============================================
const kpis = useMemo(() => {
const periodFilteredFactures = filterItemByPeriod(reglements, period, 'date');
return KPI_CONFIG.map(config => {
const value = config.getValue(periodFilteredFactures, stats);
return {
id: config.id,
title: config.title,
value: config.id === 'all' ? `${Number(value).toLocaleString('fr-FR')}` : value,
change: config.getChange(periodFilteredFactures, value, period, reglements),
trend: config.getTrend(periodFilteredFactures, value, period, reglements),
icon: config.icon,
subtitle: config.getSubtitle(periodFilteredFactures, value, stats),
color: config.color,
tooltip: config.tooltip,
isActive: activeFilter === config.id,
onClick: () => setActiveFilter(prev => (prev === config.id ? 'all' : config.id)),
};
});
}, [reglements, stats, period, activeFilter]);
// ============================================
// Filtrage
// ============================================
const filteredReglements = useMemo(() => {
let result = filterItemByPeriod(reglements, period, 'date');
const kpiConfig = KPI_CONFIG.find(k => k.id === activeFilter);
if (kpiConfig && activeFilter !== 'all') {
result = kpiConfig.filter(result);
}
return [...result].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}, [reglements, period, activeFilter]);
// Factures sélectionnables (non soldées)
const selectableFactures = useMemo(() => {
return filteredReglements.filter(f => f.statut_reglement !== STATUT_REGLEMENT.SOLDE);
}, [filteredReglements]);
// Factures sélectionnées
const selectedFactures = useMemo(() => {
return reglements.filter(f => selectedInvoiceIds.includes(f.numero));
}, [selectedInvoiceIds, reglements]);
// Total sélectionné (reste à régler)
const totalSelectedBalance = useMemo(() => {
return selectedInvoiceIds.reduce((sum, id) => {
const facture = reglements.find(f => f.numero === id);
return sum + (facture?.montants?.reste_a_regler || 0);
}, 0);
}, [selectedInvoiceIds, reglements]);
const formatCurrency = (amount: number) => {
return `${amount.toLocaleString('fr-FR', { minimumFractionDigits: 2 })}€`;
};
// ============================================
// Handlers
// ============================================
const handlePayment = () => {
setIsPaymentPanelOpen(true);
};
const activeFilterLabel = useMemo(() => {
const config = KPI_CONFIG.find(k => k.id === activeFilter);
return config?.title || 'Tous';
}, [activeFilter]);
// ============================================
// Colonnes
// ============================================
const allColumnsDefinition = useMemo(() => ({
rg_no: {
key: 'rg_no',
label: 'N° règlement',
sortable: true,
render: (value: string, row: FacturesReglement) => {
// Vérifier si les données existent avant d'y accéder
const firstReglement = row.echeances?.[0]?.reglements?.[0];
const reglementNo = firstReglement?.rg_no;
return (
<div>
<span className="font-bold text-gray-900 dark:text-white">
{reglementNo ? `REG_${reglementNo}` : '-'}
</span>
<div className="text-xs text-gray-500">
{reglementNo ? 'Règlement' : 'Non réglé'}
</div>
</div>
);
},
},
numero: {
key: 'numero',
label: 'N° Pièce',
sortable: true,
render: (value: string, row: FacturesReglement) => (
<div>
<span className="font-bold text-gray-900 dark:text-white">{value}</span>
<div className="text-xs text-gray-500">{row.type_libelle}</div>
</div>
),
},
client: {
key: 'client',
label: 'Client',
sortable: true,
render: (_: any, row: FacturesReglement) => {
const client = row.client;
if (!client) return <span className="text-gray-400">-</span>;
const avatar = client.intitule?.charAt(0).toUpperCase() || '?';
return (
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-xs font-bold text-gray-600 dark:text-gray-300">
{avatar}
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{client.intitule}</p>
<p className="text-xs text-gray-500">{client.numero}</p>
</div>
</div>
);
},
},
date: {
key: 'date',
label: 'Date',
sortable: true,
render: (date: string) => <span>{formatDateFRCourt(date)}</span>,
},
total_ttc: {
key: 'montants.total_ttc',
label: 'Montant TTC',
sortable: true,
render: (_: any, row: FacturesReglement) => (
<span className="font-semibold">{formatCurrency(row.montants?.total_ttc || 0)}</span>
),
},
montant_regle: {
key: 'montants.montant_regle',
label: 'Montant Réglé',
sortable: true,
render: (_: any, row: FacturesReglement) => (
<span className={cn(
"font-medium",
row.montants?.montant_regle > 0 ? "text-green-600 dark:text-green-400" : "text-gray-400"
)}>
{formatCurrency(row.montants?.montant_regle || 0)}
</span>
),
},
reste_a_regler: {
key: 'montants.reste_a_regler',
label: 'Reste à Régler',
sortable: true,
render: (_: any, row: FacturesReglement) => (
<span className={cn(
"font-bold",
row.montants?.reste_a_regler > 0 ? "text-red-600 dark:text-red-400" : "text-gray-400"
)}>
{formatCurrency(row.montants?.reste_a_regler || 0)}
</span>
),
},
statut_reglement: {
key: 'statut_reglement',
label: 'Statut',
sortable: true,
render: (status: string) => <ReglementStatusBadge status={status} />,
},
nb_echeances: {
key: 'nb_echeances',
label: 'Échéances',
sortable: true,
render: (value: number) => (
<div className="flex items-center gap-1.5">
<Calendar className="w-4 h-4 text-gray-400" />
<span>{value || 0}</span>
</div>
),
},
nb_reglements: {
key: 'nb_reglements',
label: 'Règlements',
sortable: true,
render: (value: number) => (
<div className="flex items-center gap-1.5">
<Receipt className="w-4 h-4 text-gray-400" />
<span>{value || 0}</span>
</div>
),
},
}), []);
const visibleColumns = useMemo(() => {
return columnConfig
.filter(col => col.visible)
.map(col => allColumnsDefinition[col.key as keyof typeof allColumnsDefinition])
.filter(Boolean);
}, [columnConfig, allColumnsDefinition]);
const columnFormatters: Record<string, (value: any, row: any) => string> = {
date: (value) => value ? formatDateFRCourt(value) : '',
'montants.total_ttc': (_, row) => formatCurrency(row.montants?.total_ttc || 0),
'montants.montant_regle': (_, row) => formatCurrency(row.montants?.montant_regle || 0),
'montants.reste_a_regler': (_, row) => formatCurrency(row.montants?.reste_a_regler || 0),
statut_reglement: (value) => value || '',
client: (_, row) => row.client?.intitule || '',
};
const actions = (row: FacturesReglement) => (
<>
<button
onClick={() => navigate(`/home/reglements/${row.numero}`)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-500"
title="Voir détails"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => toast({ title: 'Téléchargement', description: 'Génération du PDF...' })}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-500"
title="Télécharger"
>
<Download className="w-4 h-4" />
</button>
</>
);
// Convertir FacturesReglement en Facture pour le modal
const selectedFacturesForModal = useMemo((): Facture[] => {
return selectedFactures.map(f => ({
numero: f.numero,
date: f.date,
reference: f.reference || '',
client_code: f.client?.numero || '',
client_intitule: f.client?.intitule || '',
client_adresse: f.client?.adresse || '',
client_code_postal: f.client?.code_postal || '',
client_ville: f.client?.ville || '',
client_email: f.client?.email || '',
client_telephone: f.client?.telephone || '',
valide: 1,
statut: f.statut_reglement === STATUT_REGLEMENT.SOLDE ? 4 : (f.statut_reglement === STATUT_REGLEMENT.PARTIEL ? 2 : 1),
total_ht: f.montants?.total_ttc || 0,
total_ttc: f.montants?.total_ttc || 0,
total_ht_net: f.montants?.total_ttc || 0,
total_ht_calcule: f.montants?.total_ttc || 0,
total_ttc_calcule: f.montants?.reste_a_regler || 0,
total_taxes_calcule: 0,
valeur_frais: 0,
}));
}, [selectedFactures]);
return (
<>
<Helmet>
<title>Règlements - Dataven</title>
</Helmet>
<div className="space-y-6">
<KPIBar kpis={kpis} period={period} loading={statusReglement} />
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Règlements</h1>
{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">
{filteredReglements.length} facture{filteredReglements.length > 1 ? 's' : ''}
{activeFilter !== 'all' && ` (${activeFilterLabel.toLowerCase()})`}
</p>
</div>
<PeriodSelector value={period} onChange={setPeriod} />
</div>
<div className="flex gap-3 flex-wrap items-center">
<ColumnSelector columns={columnConfig} onChange={setColumnConfig} />
<ExportDropdown
data={filteredReglements}
columns={columnConfig}
columnFormatters={columnFormatters}
filename="Reglements"
/>
{selectedInvoiceIds.length > 0 && (
<PrimaryButton_v2
onClick={handlePayment}
className="bg-[#338660] hover:bg-[#2A6F4F] shadow-lg shadow-green-900/20 animate-in fade-in zoom-in duration-200"
>
<Wallet className="w-4 h-4 mr-2" />
Régler ({selectedInvoiceIds.length}) · {formatCurrency(totalSelectedBalance)}
</PrimaryButton_v2>
)}
</div>
</div>
<DataTable
columns={visibleColumns}
data={filteredReglements}
actions={actions}
status={isLoading}
// selectable={true}
// selectableFactures={selectableFactures}
// selectedIds={selectedInvoiceIds}
// onSelectRow={handleSelectInvoice}
// onSelectAll={handleSelectAll}
onRowClick={(row: FacturesReglement) => {
console.log("Row clicked:", row.numero);
}}
/>
</div>
<ModalPaymentPanel
isOpen={isPaymentPanelOpen}
onClose={() => setIsPaymentPanelOpen(false)}
selectedInvoices={selectedFacturesForModal}
totalAmount={totalSelectedBalance}
/>
</>
);
};
export default PaymentsPage;

View file

@ -0,0 +1,773 @@
import React, { useState, useMemo, useCallback } from "react";
import { Helmet } from "react-helmet";
import { useNavigate } from "react-router-dom";
import {
ArrowLeft,
Save,
Loader2,
Plus,
Trash2,
PenLine,
Package,
Lock,
EyeOff,
} from "lucide-react";
import { toast } from "@/components/ui/use-toast";
import { useAppDispatch, useAppSelector } from "@/store/hooks";
import { DevisListItem, DevisRequest, DevisResponse } from "@/types/devisType";
import { createDevis, getDevisById } from "@/store/features/devis/thunk";
import { ModalLoading } from "@/components/modal/ModalLoading";
import { cn, formatForDateInput } from "@/lib/utils";
import { getAllClients } from "@/store/features/client/selectors";
import { Client } from "@/types/clientType";
import { Article } from "@/types/articleType";
import { selectDevis } from "@/store/features/devis/slice";
import { ModalArticle } from "@/components/modal/ModalArticle";
import { getuserConnected } from "@/store/features/user/selectors";
import { UserInterface } from "@/types/userInterface";
import ClientAutocomplete from "@/components/molecules/ClientAutocomplete";
import ArticleAutocomplete from "@/components/molecules/ArticleAutocomplete";
import StatusBadge from "@/components/ui/StatusBadge";
import { Input, Textarea } from "@/components/ui/FormModal";
import { Tooltip, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
import StickyTotals from "@/components/document-entry/StickyTotals";
// ============================================
// TYPES
// ============================================
export interface LigneForm {
id: string;
article_code: string;
quantite: number;
prix_unitaire_ht: number;
total_taxes: number;
taux_taxe1: number;
montant_ligne_ht: number;
remise_pourcentage: number;
designation: string;
articles: Article | null;
isManual: boolean;
}
export interface Note {
publique: string;
prive: string;
}
// Générer un ID unique
const generateLineId = () =>
`line_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Créer une ligne vide
const createEmptyLine = (): LigneForm => ({
id: generateLineId(),
article_code: "",
quantite: 1,
prix_unitaire_ht: 0,
total_taxes: 0,
taux_taxe1: 20,
montant_ligne_ht: 0,
remise_pourcentage: 0,
designation: "",
articles: null,
isManual: false,
});
// ============================================
// COMPOSANT PRINCIPAL
// ============================================
const QuoteCreatePage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const clients = useAppSelector(getAllClients) as Client[];
const userConnected = useAppSelector(getuserConnected) as UserInterface;
// États UI
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// États pour le modal article
const [isArticleModalOpen, setIsArticleModalOpen] = useState(false);
const [currentLineId, setCurrentLineId] = useState<string | null>(null);
const [activeLineId, setActiveLineId] = useState<string | null>(null);
// États des champs éditables
const [editDateEmission, setEditDateEmission] = useState(() => {
const d = new Date();
d.setDate(d.getDate() + 1);
return d.toISOString().split("T")[0];
});
const [editReference, setEditReference] = useState("");
const [editClient, setEditClient] = useState<Client | null>(null);
const [editLignes, setEditLignes] = useState<LigneForm[]>([createEmptyLine()]);
// Notes
const [note, setNote] = useState<Note>({
publique: "Offre valable 30 jours",
prive: "",
});
const [description, setDescription] = useState<string | null>(null);
// ============================================
// VÉRIFIER SI LA DERNIÈRE LIGNE EST VIDE
// ============================================
const isLastLineEmpty = useCallback((lignes: LigneForm[]) => {
if (lignes.length === 0) return false;
const lastLine = lignes[lignes.length - 1];
// Une ligne est considérée comme vide si :
// - En mode article : pas d'article sélectionné
// - En mode manuel : pas de désignation
if (lastLine.isManual) {
return !lastLine.designation || lastLine.designation.trim() === "";
}
return !lastLine.article_code;
}, []);
// ============================================
// AJOUTER UNE LIGNE SI NÉCESSAIRE
// ============================================
const addLineIfNeeded = useCallback((currentLignes: LigneForm[], updatedLineId: string) => {
// Si la ligne mise à jour est la dernière ET qu'elle n'est plus vide
const lastLine = currentLignes[currentLignes.length - 1];
if (lastLine && lastLine.id === updatedLineId) {
// Vérifier si la ligne est maintenant remplie
const isLineFilled = lastLine.isManual
? lastLine.designation && lastLine.designation.trim() !== ""
: lastLine.article_code !== "";
if (isLineFilled) {
// Ajouter une nouvelle ligne vide
return [...currentLignes, createEmptyLine()];
}
}
return currentLignes;
}, []);
// ============================================
// CALCULS
// ============================================
const calculerTotalLigne = (ligne: LigneForm) => {
const prix = ligne.prix_unitaire_ht || ligne.articles?.prix_vente || 0;
const remise = ligne.remise_pourcentage ?? 0;
const prixRemise = prix * (1 - remise / 100);
return prixRemise * ligne.quantite;
};
const calculerTotalHT = () => {
return editLignes.reduce((acc, ligne) => acc + calculerTotalLigne(ligne), 0);
};
const calculerTotalTva = () => {
return editLignes.reduce((total, ligne) => {
const totalHtLigne = calculerTotalLigne(ligne);
const tva = totalHtLigne * (ligne.taux_taxe1 / 100);
return total + tva;
}, 0);
};
const editTotalHT = calculerTotalHT();
const editTotalTVA = calculerTotalTva();
const editTotalTTC = editTotalHT + editTotalTVA;
// ============================================
// GESTION DES LIGNES
// ============================================
const toggleLineMode = (lineId: string) => {
setEditLignes((prev) =>
prev.map((ligne) => {
if (ligne.id === lineId) {
const newIsManual = !ligne.isManual;
return {
...ligne,
isManual: newIsManual,
article_code: newIsManual ? "" : ligne.article_code,
articles: newIsManual ? null : ligne.articles,
designation: "",
prix_unitaire_ht: newIsManual ? 0 : ligne.prix_unitaire_ht,
};
}
return ligne;
})
);
};
const ajouterLigne = () => {
// Ne pas ajouter si la dernière ligne est déjà vide
if (isLastLineEmpty(editLignes)) return;
setEditLignes([...editLignes, createEmptyLine()]);
};
const supprimerLigne = (lineId: string) => {
if (editLignes.length > 1) {
const newLignes = editLignes.filter((l) => l.id !== lineId);
// S'assurer qu'il y a toujours au moins une ligne vide à la fin
if (!isLastLineEmpty(newLignes)) {
setEditLignes([...newLignes, createEmptyLine()]);
} else {
setEditLignes(newLignes);
}
}
};
const updateLigne = (lineId: string, field: keyof LigneForm, value: any) => {
setEditLignes((prev) => {
let updatedLignes = prev.map((ligne) => {
if (ligne.id !== lineId) return ligne;
if (field === "articles" && value) {
const article = value as Article;
return {
...ligne,
articles: article,
article_code: article.reference,
designation: article.designation,
prix_unitaire_ht: article.prix_vente,
};
} else if (field === "articles" && !value) {
return {
...ligne,
articles: null,
article_code: "",
designation: "",
prix_unitaire_ht: 0,
};
} else {
return { ...ligne, [field]: value };
}
});
// ✅ Auto-ajouter une ligne si on a rempli la dernière
// Seulement pour la sélection d'article ou la désignation en mode manuel
if (field === "articles" && value) {
updatedLignes = addLineIfNeeded(updatedLignes, lineId);
}
return updatedLignes;
});
};
// ✅ Handler spécial pour la désignation en mode manuel (avec debounce)
const handleManualDesignationBlur = (lineId: string, designation: string) => {
if (designation && designation.trim() !== "") {
setEditLignes((prev) => {
const lastLine = prev[prev.length - 1];
// Si c'est la dernière ligne et qu'elle a une désignation
if (lastLine && lastLine.id === lineId && lastLine.isManual) {
// Vérifier si une nouvelle ligne vide n'existe pas déjà
if (!isLastLineEmpty(prev)) {
return prev; // Déjà une ligne vide
}
// Ajouter une nouvelle ligne
return [...prev, createEmptyLine()];
}
return prev;
});
}
};
// ============================================
// VALIDATION
// ============================================
const canSave = useMemo(() => {
if (!editClient) return false;
const lignesValides = editLignes.filter(
(l) => l.article_code || (l.isManual && l.designation && l.designation.trim() !== "")
);
return lignesValides.length > 0;
}, [editClient, editLignes]);
// ============================================
// HANDLERS
// ============================================
const handleCancelEdit = () => {
navigate("/home/devis");
};
const handleSaveEdit = async () => {
if (!editClient) {
setError("Veuillez sélectionner un client");
return;
}
// Filtrer les lignes valides (ignorer les lignes vides)
const lignesValides = editLignes.filter(
(l) => l.article_code || (l.isManual && l.designation && l.designation.trim() !== "")
);
if (lignesValides.length === 0) {
setError("Veuillez ajouter au moins un article ou une ligne");
return;
}
try {
setIsSaving(true);
setError(null);
const payloadCreate: DevisRequest = {
client_id: editClient.numero,
reference: editReference,
date_devis: editDateEmission,
lignes: lignesValides.map((l) => ({
article_code: l.article_code || "DIVERS",
quantite: l.quantite,
prix_unitaire_ht: l.prix_unitaire_ht,
remise_pourcentage: l.remise_pourcentage,
...(l.isManual && l.designation && { designation: l.designation }),
})),
};
const result = (await dispatch(createDevis(payloadCreate)).unwrap()) as DevisResponse;
await new Promise((resolve) => setTimeout(resolve, 1500));
const devisCreated = (await dispatch(getDevisById(result.id)).unwrap()) as any;
const res = devisCreated.data as DevisListItem;
dispatch(selectDevis(res));
toast({
title: "Devis créé avec succès !",
description: `Le devis ${res.numero} a été créé.`,
className: "bg-green-500 text-white border-green-600",
});
navigate(`/home/devis/${result.id}`);
} catch (err: any) {
setError(err.message || "Erreur lors de la création du devis");
toast({
title: "Erreur",
description: "Impossible de créer le devis.",
className: "bg-red-500 text-white border-red-600",
});
} finally {
setIsSaving(false);
}
};
const handleOpenArticleModal = (lineId: string) => {
setCurrentLineId(lineId);
setIsArticleModalOpen(true);
setActiveLineId(null);
};
// ============================================
// RENDER
// ============================================
return (
<>
<Helmet>
<title>Nouveau devis - Dataven</title>
</Helmet>
<div className="flex flex-col h-[80vh] overflow-hidden">
{/* HEADER */}
<div className="flex-none bg-white dark:bg-gray-950 border-b border-gray-200 dark:border-gray-800 z-30 shadow-[0_1px_3px_rgba(0,0,0,0.05)] py-2">
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col gap-4 justify-between items-start lg:flex-row lg:items-center">
{/* Left Section */}
<div className="flex flex-col flex-1 gap-3 w-full min-w-0 lg:w-auto">
{/* Row 1 */}
<div className="flex flex-wrap gap-4 items-center">
{/* Back + Title */}
<div className="flex gap-3 items-center mr-2 shrink-0">
<button
onClick={handleCancelEdit}
className="p-1.5 -ml-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors text-gray-500"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="flex flex-col">
<div className="flex gap-2 items-center">
<h1 className="text-xl font-bold tracking-tight text-gray-900 dark:text-white">
NOUVEAU
</h1>
<StatusBadge status={0} type_doc={0} />
<span className="px-2 py-1 text-xs font-medium text-blue-800 bg-blue-100 rounded-full">
Brouillon
</span>
</div>
</div>
</div>
<div className="hidden mx-2 w-px h-8 bg-gray-200 dark:bg-gray-800 sm:block" />
{/* Client */}
<div className="flex-1 min-w-[250px] max-w-[400px] relative z-20">
<ClientAutocomplete
value={editClient}
onChange={setEditClient}
required
placeholder="Client..."
/>
</div>
{/* Date + User */}
<div className="flex gap-4 shrink-0">
<div className="w-auto">
<Input
type="date"
value={formatForDateInput(editDateEmission)}
onChange={(e) => setEditDateEmission(e.target.value)}
className="h-10 text-xs"
/>
</div>
<div className="w-[140px]">
<div className="flex flex-row gap-2 items-center px-1 py-1 w-full text-sm bg-gray-50 rounded-xl border shadow-sm opacity-80 cursor-not-allowed dark:bg-gray-900 border-gray-200 dark:border-gray-800">
<div
className="w-6 h-6 rounded-full bg-[#007E45] text-white flex items-center justify-center text-[10px] font-semibold"
style={{ width: "3vh", height: "3vh" }}
>
{userConnected
? `${userConnected.prenom?.[0] || ""}${userConnected.nom?.[0] || ""}`
: "JD"}
</div>
<span className="text-sm text-gray-900 dark:text-white">
{userConnected ? `${userConnected.prenom}` : "_"}
</span>
</div>
</div>
</div>
</div>
{/* Row 2 - Référence */}
<div className="flex flex-wrap gap-4 items-center pt-1">
<div className="w-[250px]">
<Input
value={editReference}
onChange={(e) => setEditReference(e.target.value)}
placeholder="Référence..."
className="h-9 text-xs"
/>
</div>
</div>
</div>
{/* Right Section - Actions */}
<div className="flex gap-3 items-center self-start mt-2 ml-auto shrink-0 lg:self-center lg:mt-0">
<button
onClick={handleCancelEdit}
disabled={isSaving}
className="px-4 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 disabled:opacity-50"
>
Annuler
</button>
<button
onClick={handleSaveEdit}
disabled={!canSave || isSaving}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-[#007E45] hover:bg-[#006837] rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Enregistrer
</button>
</div>
</div>
</div>
</div>
{/* CONTENT */}
<div className="flex-1 overflow-y-auto scroll-smooth">
<div className="px-4 mx-auto w-full sm:px-6 lg:px-8">
<div className="w-full">
{/* Tableau des lignes */}
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm dark:bg-gray-950 dark:border-gray-800">
<div className="overflow-x-auto mb-2">
<table className="w-full">
<thead className="text-xs font-semibold tracking-wider text-gray-500 uppercase bg-gray-50 border-b dark:bg-gray-900/50">
<tr className="text-[10px]">
<th className="px-2 py-3 w-5 text-center">Mode</th>
<th className="px-4 py-3 text-left w-[25%]">Désignation</th>
<th className="px-4 py-3 text-right w-[20%]">Description détaillée</th>
<th className="px-4 py-3 text-right w-[8%]">Qté</th>
<th className="px-4 py-3 text-right w-[15%]">P.U. HT</th>
<th className="px-4 py-3 text-right w-[8%]">Rem. %</th>
<th className="px-4 py-3 text-right w-[8%]">TVA</th>
<th className="px-4 py-3 text-right w-[15%]">Total HT</th>
<th className="px-2 py-3 w-12"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{editLignes.map((item, index) => {
// Déterminer si c'est la dernière ligne (ligne vide)
const isLastEmptyLine = index === editLignes.length - 1 && isLastLineEmpty(editLignes);
return (
<tr
key={item.id}
className={cn(
"group hover:bg-gray-50/50 dark:hover:bg-gray-900/20",
isLastEmptyLine && "bg-gray-50/30"
)}
>
{/* Mode Toggle */}
<td className="px-2 py-3">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => toggleLineMode(item.id)}
className={cn(
"p-1.5 rounded-md transition-all duration-200",
item.isManual
? "text-amber-600 bg-amber-50 hover:bg-amber-100"
: "text-[#007E45] bg-green-50 hover:bg-green-100"
)}
>
{item.isManual ? (
<PenLine className="w-4 h-4" />
) : (
<Package className="w-4 h-4" />
)}
</button>
</TooltipTrigger>
</Tooltip>
</TooltipProvider>
</td>
{/* Article / Désignation */}
<td className="px-4 py-3 min-w-[280px]">
{item.isManual ? (
<div className="relative">
<input
type="text"
value={item.designation}
onChange={(e) => {
updateLigne(item.id, "designation", e.target.value);
}}
onFocus={() => setActiveLineId(item.id)}
onBlur={(e) => {
setTimeout(() => setActiveLineId(null), 150);
// ✅ Auto-ajouter une ligne quand on quitte le champ
handleManualDesignationBlur(item.id, e.target.value);
}}
className="w-full border-0 text-sm focus:outline-none bg-transparent text-gray-900"
placeholder={isLastEmptyLine ? "Saisir une désignation..." : "Désignation..."}
/>
{activeLineId === item.id && item.designation && (
<div className="overflow-hidden mt-1 w-full bg-white rounded-xl border border-gray-200 shadow-lg absolute z-10">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleOpenArticleModal(item.id)}
className="flex gap-3 items-center px-4 py-3 w-full text-left transition-colors hover:bg-green-50"
>
<div className="p-1.5 bg-[#007E45] rounded-lg text-white">
<Plus className="w-4 h-4" />
</div>
<span className="text-sm font-medium text-gray-900">
Créer l'article "<span className="text-[#007E45]">{item.designation}</span>"
</span>
</button>
<div className="border-t border-gray-100" />
<div className="flex flex-row gap-3 justify-center items-center py-2 w-full text-xs text-center text-gray-400">
<PenLine className="w-4 h-4" />
<span className="text-xs font-medium">Texte libre accepté</span>
</div>
</div>
)}
</div>
) : (
<ArticleAutocomplete
value={item.articles}
onChange={(article) => updateLigne(item.id, "articles", article)}
required
className="text-sm"
placeholder={isLastEmptyLine ? "Rechercher un article..." : "Article..."}
/>
)}
</td>
{/* Description */}
<td className="px-1">
<input
type="text"
value={item.isManual ? description || "" : item.designation || ""}
onChange={(e) =>
item.isManual
? setDescription(e.target.value)
: updateLigne(item.id, "designation", e.target.value)
}
placeholder="Description détaillée..."
className="w-full border-0 text-sm focus:outline-none bg-transparent text-gray-900 text-right"
/>
</td>
{/* Quantité */}
<td className="px-1" style={{ width: "100px" }}>
<input
type="number"
value={item.quantite}
onChange={(e) =>
updateLigne(item.id, "quantite", parseFloat(e.target.value) || 0)
}
min={0}
className="w-full border-0 text-sm focus:outline-none bg-transparent text-gray-900 text-right"
/>
</td>
{/* Prix Unitaire */}
<td className="px-1 text-center" style={{ width: "60px" }}>
<input
type="number"
value={item.prix_unitaire_ht || item.articles?.prix_vente || 0}
onChange={(e) =>
updateLigne(item.id, "prix_unitaire_ht", parseFloat(e.target.value) || 0)
}
min={0}
step={0.01}
className="w-full border-0 text-sm focus:outline-none bg-transparent text-gray-900 text-right"
/>
</td>
{/* Remise */}
<td className="px-1 text-center" style={{ width: "60px" }}>
<input
type="number"
value={item.remise_pourcentage || 0}
onChange={(e) =>
updateLigne(item.id, "remise_pourcentage", parseFloat(e.target.value) || 0)
}
min={0}
step={1}
className="w-full border-0 text-sm focus:outline-none bg-transparent text-gray-900 text-right"
/>
</td>
{/* TVA */}
<td className="px-1 text-center text-gray-500 text-right text-sm">
{item.taux_taxe1}%
</td>
{/* Total */}
<td className="px-1 text-right">
<span className={cn(
"font-bold font-mono text-sm",
isLastEmptyLine ? "text-gray-300" : "text-gray-900"
)}>
{calculerTotalLigne(item).toFixed(2)}
</span>
</td>
{/* Delete */}
<td className="px-1 text-center">
<button
onClick={() => supprimerLigne(item.id)}
disabled={editLignes.length <= 1}
className={cn(
"p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-30",
isLastEmptyLine && "invisible"
)}
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Bouton ajouter ligne - caché si la dernière ligne est vide */}
{!isLastLineEmpty(editLignes) && (
<div className="m-6">
<button
onClick={ajouterLigne}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-[#2A6F4F] bg-white border border-[#2A6F4F] rounded-lg hover:bg-green-50 transition-all shadow-sm"
>
<Plus className="w-4 h-4" /> Ajouter une ligne
</button>
</div>
)}
{/* Message d'erreur */}
{error && (
<div className="p-4 m-4 text-sm text-red-800 bg-red-50 rounded-xl border border-red-200">
{error}
</div>
)}
</div>
{/* Section Notes */}
<div className="grid grid-cols-1 gap-6 p-6 mt-8 bg-gray-50 rounded-2xl border border-gray-100 md:grid-cols-2 dark:bg-gray-900/30 dark:border-gray-800">
{/* Notes publiques */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<label className="flex gap-2 items-center text-sm font-semibold text-gray-900 dark:text-white">
Notes publiques <Lock className="w-3 h-3 text-gray-400" />
</label>
<span className="text-xs text-gray-500">Visible sur le PDF</span>
</div>
<Textarea
rows={4}
placeholder="Conditions de paiement, délais de livraison, modalités particulières..."
className="bg-white resize-none dark:bg-gray-950"
value={note.publique}
onChange={(e) => setNote({ ...note, publique: e.target.value })}
/>
</div>
{/* Notes privées */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<label className="flex gap-2 items-center text-sm font-semibold text-gray-900 dark:text-white">
Notes privées <EyeOff className="w-3 h-3 text-gray-400" />
</label>
<span className="text-xs text-gray-500">Interne uniquement</span>
</div>
<Textarea
rows={4}
placeholder="Notes internes, marge de négociation, contexte client..."
className="bg-white border-yellow-200 resize-none dark:bg-gray-950 focus:border-yellow-400"
value={note.prive}
onChange={(e) => setNote({ ...note, prive: e.target.value })}
/>
</div>
</div>
</div>
</div>
</div>
{/* StickyTotals */}
<div className="flex-none bg-white dark:bg-gray-950 border-t border-gray-200 dark:border-gray-800 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] z-20">
<div className="px-4 mx-auto w-full sm:px-6 lg:px-8">
<StickyTotals
total_ht_calcule={editTotalHT}
total_taxes_calcule={editTotalTVA}
total_ttc_calcule={editTotalTTC}
/>
</div>
</div>
</div>
{/* Modal création article */}
<ModalArticle
open={isArticleModalOpen}
onClose={() => setIsArticleModalOpen(false)}
/>
{isSaving && <ModalLoading />}
</>
);
};
export default QuoteCreatePage;

View file

@ -0,0 +1,732 @@
import React, { useState, useMemo, useEffect } from "react";
import { Helmet } from "react-helmet";
import { useNavigate } from "react-router-dom";
import {
FileText,
ShoppingCart,
} from "lucide-react";
import { toast } from "@/components/ui/use-toast";
import { useAppDispatch, useAppSelector } from "@/store/hooks";
import {
getDevisSelected,
} from "@/store/features/devis/selectors";
import { DevisListItem } from "@/types/devisType";
import {
changerStatutDevis,
devisToCommande,
devisToFacture,
getDevisById,
updateDevis,
} from "@/store/features/devis/thunk";
import { getCommande } from "@/store/features/commande/thunk";
import FormModal from "@/components/ui/FormModal";
import { usePDFPreview } from "@/components/modal/ModalPDFPreview";
import { ModalLoading } from "@/components/modal/ModalLoading";
import { cn } from "@/lib/utils";
import { getFacture } from "@/store/features/factures/thunk";
import ModalSendSignatureRequest from "@/components/modal/ModalSendSignatureRequest";
import { sageService } from "@/service/sageService";
import { getAllClients } from "@/store/features/client/selectors";
import { Client } from "@/types/clientType";
import { Article } from "@/types/articleType";
import SignatureWorkflow from "@/components/indicators/SignatureWorkflow";
import { Commande } from "@/types/commandeTypes";
import { selectClient } from "@/store/features/client/slice";
import { selectcommande } from "@/store/features/commande/slice";
import { selectfacture } from "@/store/features/factures/slice";
import { selectDevis } from "@/store/features/devis/slice";
import { Facture } from "@/types/factureType";
import { ModalStatus } from "@/components/modal/ModalStatus";
import StickyTotals from "@/components/document-entry/StickyTotals";
import { ModalArticle } from "@/components/modal/ModalArticle";
import PDFPreviewPanel from "@/components/panels/PDFPreviewPanel";
import { useDisplayMode } from "@/context/DisplayModeContext";
import { getUniversigns } from "@/store/features/universign/thunk";
import { UniversignType } from "@/types/sageTypes";
import { getAlluniversign } from "@/store/features/universign/selectors";
import DevisContent from "@/components/page/devis/DevisContent";
import { TransformOption } from "@/components/page/devis/TransformOption";
import DevisHeader from "@/components/page/devis/DevisHeader";
import { getuserConnected } from "@/store/features/user/selectors";
import { UserInterface } from "@/types/userInterface";
// Interface mise à jour avec id et isManual
export interface LigneForm {
id: string;
article_code: string;
quantite: number;
prix_unitaire_ht: number;
total_taxes: number;
taux_taxe1: number;
montant_ligne_ht: number;
remise_pourcentage: number;
designation: string;
articles: Article | null;
isManual: boolean;
}
export interface Note {
publique: string;
prive: string;
}
// Générer un ID unique
const generateLineId = () =>
`line_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const QuoteDetailPage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const clients = useAppSelector(getAllClients) as Client[];
const universign = useAppSelector(getAlluniversign) as UniversignType[];
const devis = useAppSelector(getDevisSelected) as DevisListItem;
const userConnected = useAppSelector(getuserConnected) as UserInterface
// États pour transformation
const [isTransformOpen, setIsTransformOpen] = useState(false);
const [loadingTransform, setLoadingTransform] = useState(false);
// États UI
const {
openPreview,
closePreview,
isOpen,
pdfUrl,
fileName,
PDFPreviewModal,
} = usePDFPreview();
const [openStatusDevis, setOpenStatusDevis] = useState(false);
const [isSendSignatureModalOpen, setIsSendSignatureModalOpen] =
useState(false);
// États pour le modal article
const [isArticleModalOpen, setIsArticleModalOpen] = useState(false);
const [currentLineId, setCurrentLineId] = useState<string | null>(null);
// États pour l'édition inline
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// États des champs éditables
const [editDateEmission, setEditDateEmission] = useState("");
const [editDateLivraison, setEditDateLivraison] = useState("");
const [editReference, setEditReference] = useState("");
const [editClient, setEditClient] = useState<Client | null>(null);
const [editLignes, setEditLignes] = useState<LigneForm[]>([]);
const { isPdfPreviewVisible, togglePdfPreview } = useDisplayMode();
const editNotLignes: LigneForm[] = devis.lignes.map((line, index) => ({
id: `${index}`, // ou line.article_code si unique
article_code: line.article_code,
quantite: line.quantite,
prix_unitaire_ht: line.prix_unitaire_ht ?? 0,
total_taxes: line.total_taxes,
taux_taxe1: line.taux_taxe1,
montant_ligne_ht: line.montant_ligne_ht ?? 0,
designation: line.designation ?? "",
remise_pourcentage: line.remise_pourcentage ?? 0,
articles: null, // à remplir si tu veux relier un Article complet
isManual: false, // par défaut false
}));
// Initialiser les valeurs d'édition
useEffect(() => {
if (devis && isEditing) {
setEditDateEmission(devis.date || "");
setEditDateLivraison(devis.date_livraison || "");
setEditReference(devis.reference || "");
const clientFound = clients.find((c) => c.numero === devis.client_code);
setEditClient(
clientFound ||
({
numero: devis.client_code,
intitule: devis.client_intitule,
compte_collectif: "",
adresse: "",
code_postal: "",
ville: "",
email: "",
telephone: "",
} as Client)
);
const lignesInitiales: LigneForm[] =
devis.lignes?.map((ligne) => ({
id: generateLineId(),
article_code: ligne.article_code,
quantite: ligne.quantite,
prix_unitaire_ht: ligne.prix_unitaire_ht ?? 0,
designation: ligne.designation ?? "",
remise_pourcentage: ligne.remise_valeur1 ?? 0,
total_taxes: ligne.total_taxes ?? 0,
taux_taxe1: ligne.taux_taxe1 ?? 0,
montant_ligne_ht: ligne.montant_ligne_ht ?? 0,
articles: ligne.article_code
? ({
reference: ligne.article_code,
designation: ligne.designation ?? "",
prix_vente: ligne.prix_unitaire_ht ?? 0,
stock_reel: 0,
} as Article)
: null,
isManual: !ligne.article_code,
})) ?? [];
setEditLignes(
lignesInitiales.length > 0
? lignesInitiales
: [
{
id: generateLineId(),
article_code: "",
quantite: 1,
prix_unitaire_ht: 0,
total_taxes: 0,
taux_taxe1: 0,
montant_ligne_ht: 0,
remise_pourcentage: 0,
designation: "",
articles: null,
isManual: false,
},
]
);
}
}, [devis, isEditing, clients]);
if (!devis) return <div>Devis introuvable</div>;
const client = clients.find(
(item: Client) => item.numero === devis.client_code
) as Client;
const devisSigned = useMemo(() => {
if (!universign || !devis?.numero) return null;
const idx = universign.findIndex(
(item) => item.sage_document_id === devis.numero
);
return idx !== -1 ? universign[idx] : null;
}, [universign, devis?.numero]);
useEffect(() => {
const updateStatut = async () => {
if (devisSigned?.local_status === "SIGNE") {
const payload = {
numero: devis.numero,
status: 2,
};
try {
if (devis.statut === 1)
await dispatch(changerStatutDevis(payload)).unwrap();
} catch (error) {
console.error("Erreur lors du changement de statut:", error);
}
}
};
updateStatut();
}, [devisSigned?.local_status, devis.numero, dispatch]);
const calculerTotalLigne = (ligne: LigneForm) => {
const prix = ligne.prix_unitaire_ht || ligne.articles?.prix_vente || 0;
const remise = ligne.remise_pourcentage ?? 0;
const prixRemise = prix * (1 - remise / 100);
return prixRemise * ligne.quantite;
};
const calculerTotalTva = () => {
const taxesParLignes = editLignes.reduce((total, ligne) => {
const totalHtLigne = calculerTotalLigne(ligne);
const tva = totalHtLigne * (ligne.taux_taxe1 / 100);
return total + tva;
}, 0);
const valeur_frais = devis.valeur_frais;
return taxesParLignes + valeur_frais * (devis.taxes1 ?? 0.2);
};
const calculerTotalHT = () => {
const total_ligne = editLignes.map((ligne) => calculerTotalLigne(ligne));
const totalHTLigne = total_ligne.reduce((acc, ligne) => acc + ligne, 0);
const totalHtNet = totalHTLigne + devis.valeur_frais;
return totalHtNet;
};
const editTotalHT = calculerTotalHT();
const editTotalTVA = calculerTotalTva();
const editTotalTTC = editTotalHT + editTotalTVA;
const [description, setDescription] = useState<string | null>(null);
const [activeLineId, setActiveLineId] = useState<string | null>(null);
const [note, setNote] = useState<Note>({
publique: "Offre valable 30 jours",
prive: "",
});
// Toggle mode Article/Libre
const toggleLineMode = (lineId: string) => {
setEditLignes((prev) =>
prev.map((ligne) => {
if (ligne.id === lineId) {
const newIsManual = !ligne.isManual;
return {
...ligne,
isManual: newIsManual,
article_code: newIsManual ? "" : ligne.article_code,
articles: newIsManual ? null : ligne.articles,
designation: "",
prix_unitaire_ht: newIsManual ? 0 : ligne.prix_unitaire_ht,
};
}
return ligne;
})
);
};
// Gestion des lignes
const ajouterLigne = () => {
setEditLignes([
...editLignes,
{
id: generateLineId(),
article_code: "",
quantite: 1,
prix_unitaire_ht: 0,
total_taxes: 0,
taux_taxe1: 0,
montant_ligne_ht: 0,
remise_pourcentage: 0,
designation: "",
articles: null,
isManual: false,
},
]);
};
const supprimerLigne = (lineId: string) => {
if (editLignes.length > 1) {
setEditLignes(editLignes.filter((l) => l.id !== lineId));
}
};
const updateLigne = (lineId: string, field: keyof LigneForm, value: any) => {
setEditLignes((prev) =>
prev.map((ligne) => {
if (ligne.id !== lineId) return ligne;
if (field === "articles" && value) {
const article = value as Article;
return {
...ligne,
articles: article,
article_code: article.reference,
designation: article.designation,
prix_unitaire_ht: article.prix_vente,
};
} else if (field === "articles" && !value) {
return {
...ligne,
articles: null,
article_code: "",
designation: "",
prix_unitaire_ht: 0,
};
} else {
return { ...ligne, [field]: value };
}
})
);
};
// Validation - accepte aussi les lignes manuelles avec désignation
const canSave = useMemo(() => {
if (!editClient) return false;
const lignesValides = editLignes.filter(
(l) => l.article_code || (l.isManual && l.designation)
);
return lignesValides.length > 0;
}, [editClient, editLignes]);
// Gestion de l'édition
const handleStartEdit = () => setIsEditing(true);
const handleCancelEdit = () => {
setIsEditing(false);
setError(null);
};
const handleSaveEdit = async () => {
if (!editClient) {
setError("Veuillez sélectionner un client");
return;
}
const lignesValides = editLignes.filter(
(l) => l.article_code || (l.isManual && l.designation)
);
if (lignesValides.length === 0) {
setError("Veuillez ajouter au moins un article ou une ligne");
return;
}
try {
setIsSaving(true);
setError(null);
const payloadUpdate = {
client_id: editClient.numero,
date_devis: editDateEmission,
date_livraison: editDateLivraison,
reference: editReference,
lignes: lignesValides.map((l) => ({
article_code: l.article_code || "DIVERS",
quantite: l.quantite,
remise_pourcentage: l.remise_pourcentage,
})),
};
await dispatch(
updateDevis({ numero: devis.numero, data: payloadUpdate })
).unwrap();
toast({
title: "Devis mis à jour !",
description: `Le devis ${devis.numero} a été mis à jour avec succès.`,
className: "bg-green-500 text-white border-green-600",
});
const devisUpdated = (await dispatch(
getDevisById(devis.numero)
).unwrap()) as any;
dispatch(selectDevis(devisUpdated.data as DevisListItem));
setIsEditing(false);
} catch (err: any) {
setError(err.message || "Erreur lors de la mise à jour");
toast({
title: "Erreur",
description: "Impossible de mettre à jour le devis.",
className: "bg-red-500 text-white border-red-600",
});
} finally {
setIsSaving(false);
}
};
// Handlers transformation
const handleTransform = async () => {
setIsTransformOpen(false);
try {
setLoadingTransform(true);
const response = await dispatch(devisToCommande(devis!.numero)).unwrap();
const res = (await dispatch(
getCommande(response.document_cible)
).unwrap()) as Commande;
dispatch(selectcommande(res));
setLoadingTransform(false);
toast({
title: "Commande créée !",
description: `Le devis ${devis.numero} a été transformé en commande.`,
className: "bg-green-500 text-white border-green-600",
});
setTimeout(() => navigate(`/home/commandes/${res.numero}`), 1000);
} catch (err: any) {
setLoadingTransform(false);
toast({
title: "Transformation échouée !",
description: `Le devis ${devis.numero} ne peut pas être transformé en commande.`,
className: "bg-red-500 text-white border-red-600",
});
}
};
const handleTransformToFacture = async () => {
setIsTransformOpen(false);
try {
setLoadingTransform(true);
const response = await dispatch(devisToFacture(devis!.numero)).unwrap();
const res = (await dispatch(
getFacture(response.document_cible)
).unwrap()) as Facture;
dispatch(selectfacture(res));
setLoadingTransform(false);
toast({
title: "Facture créée !",
description: `Le devis ${devis.numero} a été transformé en facture.`,
className: "bg-green-500 text-white border-green-600",
});
setTimeout(() => navigate(`/home/factures/${res.numero}`), 1000);
} catch (err: any) {
setLoadingTransform(false);
toast({
title: "Transformation échouée !",
description: `Le devis ${devis.numero} ne peut pas être transformé en facture.`,
className: "bg-red-500 text-white border-red-600",
});
}
};
const handleSendSignatureRequest = async (data: {
emailSignataire: string;
nomSignataire: string;
}) => {
try {
setLoadingTransform(true);
const a = await sageService.envoyerDevisSignature(
devis.numero,
data.emailSignataire,
data.nomSignataire
);
console.log("signed : ", a);
await dispatch(getUniversigns()).unwrap();
const payload = {
numero: devis.numero,
status: 1,
};
await dispatch(changerStatutDevis(payload)).unwrap();
toast({
title: "Devis envoyé !",
description: `Demande de signature envoyée avec succès`,
className: "bg-green-500 text-white border-green-600",
});
setIsSendSignatureModalOpen(false);
} catch (error) {
// toast({ title: "Erreur !", description: `Demande de signature refusée`, className: "bg-red-500 text-white border-red-600" });
await dispatch(getUniversigns()).unwrap();
const payload = {
numero: devis.numero,
status: 1,
};
await dispatch(changerStatutDevis(payload)).unwrap();
toast({
title: "Devis envoyé !",
description: `Demande de signature envoyée avec succès`,
className: "bg-green-500 text-white border-green-600",
});
setIsSendSignatureModalOpen(false);
} finally {
setLoadingTransform(false);
}
};
return (
<>
<Helmet>
<title>{devis.numero} - Devis</title>
</Helmet>
<div className={`flex flex-col overflow-hidden h-[80vh]`}>
{/* TOP HEADER */}
<div className="flex-none">
<DevisHeader
// Données
devis={devis}
client={client}
devisSigned={devisSigned}
userConnected={userConnected}
// États d'édition
isEditing={isEditing}
isSaving={isSaving}
canSave={canSave}
// Champs éditables
editClient={editClient}
editDateEmission={editDateEmission}
editDateLivraison={editDateLivraison}
editReference={editReference}
// États UI
isPdfPreviewVisible={isPdfPreviewVisible}
// Handlers d'édition
onSetEditClient={setEditClient}
onSetEditDateEmission={setEditDateEmission}
onSetEditDateLivraison={setEditDateLivraison}
onSetEditReference={setEditReference}
// Handlers d'actions
onStartEdit={handleStartEdit}
onCancelEdit={handleCancelEdit}
onSaveEdit={handleSaveEdit}
onTogglePdfPreview={togglePdfPreview}
onOpenStatusModal={() => setOpenStatusDevis(true)}
onOpenSignatureModal={() => setIsSendSignatureModalOpen(true)}
onOpenTransformModal={() => setIsTransformOpen(true)}
onNavigateToClient={(clientCode) => {
const clientSelected = clients.find((item: Client) => item.numero === clientCode);
if (clientSelected) {
dispatch(selectClient(clientSelected));
navigate(`/home/clients/${clientCode}`);
}
}}
/>
</div>
<div className="flex overflow-hidden">
{/* Left/Center Panel - Form/Table */}
<div
className={cn(
"flex relative flex-col flex-1 min-w-0 transition-all duration-300",
isPdfPreviewVisible
? "w-1/2 border-r border-gray-200 dark:border-gray-800"
: "w-full"
)}
>
{/* Signature Workflow */}
{!isEditing && (
<div className="w-3xl">
<SignatureWorkflow
status={
devisSigned?.local_status === "SIGNE"
? "Signé"
: devis.statut === 2
? "Accepté"
: "Vu"
}
/>
</div>
)}
{/* Content */}
<DevisContent
// Données
devis={devis}
editLignes={editLignes}
note={note}
// États
isEditing={isEditing}
isPdfPreviewVisible={isPdfPreviewVisible}
activeLineId={activeLineId}
description={description}
error={error}
// Totaux
editTotalHT={editTotalHT}
editTotalTVA={editTotalTVA}
editTotalTTC={editTotalTTC}
// Handlers
onToggleLineMode={toggleLineMode}
onUpdateLigne={updateLigne}
onAjouterLigne={ajouterLigne}
onSupprimerLigne={supprimerLigne}
onSetActiveLineId={setActiveLineId}
onSetDescription={setDescription}
onSetNote={setNote}
onOpenArticleModal={(lineId) => {
setCurrentLineId(lineId);
setIsArticleModalOpen(true);
}}
calculerTotalLigne={calculerTotalLigne}
/>
</div>
{/* Right Panel - Live PDF Preview */}
{isPdfPreviewVisible && (
<div className="w-[45%] hidden lg:block bg-[#525659] shadow-inner relative z-10 animate-in slide-in-from-right duration-300">
{/* <PDFPreviewPanel
isOpen={true}
variant="inline"
devis={devis}
notes={note}
data={isEditing ? editLignes : editNotLignes}
total_ht={isEditing ? editTotalHT : devis.total_ht_calcule}
total_taxes_calcule={
isEditing ? editTotalTVA : devis.total_taxes_calcule
}
total_ttc_calcule={
isEditing ? editTotalTTC : devis.total_ttc_calcule
}
onClose={togglePdfPreview}
isEdit={isEditing}
/> */}
<PDFPreviewPanel
isOpen={true}
onClose={() => {}}
documentType="devis"
document={devis}
data={isEditing ? editLignes : editNotLignes}
notes={note}
total_ht={isEditing ? editTotalHT : devis.total_ht_calcule}
total_taxes_calcule={
isEditing ? editTotalTVA : devis.total_taxes_calcule
}
total_ttc_calcule={
isEditing ? editTotalTTC : devis.total_ttc_calcule
}
variant="inline"
isEdit={isEditing}
/>
</div>
)}
</div>
</div>
{/* Modals */}
<FormModal
isOpen={isTransformOpen}
onClose={() => setIsTransformOpen(false)}
title="Transformer le devis"
>
<div className="p-6 space-y-4">
<TransformOption
icon={ShoppingCart}
title="Transformer en Commande"
description="Crée un bon de commande client basé sur ce devis."
colorClass="bg-blue-500"
onClick={handleTransform}
/>
<TransformOption
icon={FileText}
title="Transformer en Facture"
description="Crée une facture directe sans passer par la commande."
colorClass="bg-purple-500"
onClick={handleTransformToFacture}
/>
</div>
</FormModal>
<PDFPreviewModal
isOpen={isOpen}
onClose={closePreview}
pdfUrl={pdfUrl}
fileName={fileName}
/>
<ModalSendSignatureRequest
isOpen={isSendSignatureModalOpen}
onClose={() => setIsSendSignatureModalOpen(false)}
client={client}
quote={devis}
onConfirm={handleSendSignatureRequest}
/>
<ModalStatus
open={openStatusDevis}
onClose={() => setOpenStatusDevis(false)}
type_doc={0}
/>
{/* Modal création article */}
<ModalArticle
open={isArticleModalOpen}
onClose={() => {
setIsArticleModalOpen(false);
}}
/>
{(loadingTransform || isSaving) && <ModalLoading />}
</>
);
};
export default QuoteDetailPage;

View file

@ -0,0 +1,698 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { Plus, Eye, FileText, Euro, Percent, CheckCircle, TrendingUp, Edit, X } from 'lucide-react';
import DataTable from '@/components/DataTable';
import KPIBar, { PeriodType } from '@/components/KPIBar';
import { toast } from '@/components/ui/use-toast';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { devisStatus, getAllDevis, getDevisSelected } from '@/store/features/devis/selectors';
import { createDevis, getDevisById, getDevisList, selectDevisAsync } from '@/store/features/devis/thunk';
import PrimaryButton_v2 from '@/components/PrimaryButton_v2';
import { DevisListItem, DevisRequest, DevisResponse } from '@/types/devisType';
import { ModalQuote } from '@/components/modal/ModalQuote';
import { cn, formatDateFRCourt } from '@/lib/utils';
import { filterItemByPeriod, getPreviousPeriodItems } from '@/components/filter/ItemsFilter';
import { DropdownMenuTable } from '@/components/DropdownMenu';
import { ModalStatus } from '@/components/modal/ModalStatus';
import { ModalLoading } from '@/components/modal/ModalLoading';
import { selectDevis } from '@/store/features/devis/slice';
import { Client } from '@/types/clientType';
import { clientStatus, getAllClients } from '@/store/features/client/selectors';
import FormModal from '@/components/ui/FormModal';
import PeriodSelector from '@/components/common/PeriodSelector';
import AdvancedFilters from '@/components/common/AdvancedFilters';
import { usePDFPreview } from '@/components/ui/PDFActionButtons';
import PDFPreview, { DocumentData } from '@/components/modal/PDFPreview';
import { CompanyInfo } from '@/data/mockData';
import StatusBadge from '@/components/ui/StatusBadge';
import { SageDocumentType, UniversignType } from '@/types/sageTypes';
import ColumnSelector, { ColumnConfig } from '@/components/common/ColumnSelector';
import { articleStatus, getAllArticles } from '@/store/features/article/selectors';
import { familleStatus } from '@/store/features/famille/selectors';
import { getArticles } from '@/store/features/article/thunk';
import { getfamilles } from '@/store/features/famille/thunk';
import ExportDropdown from '@/components/common/ExportDropdown';
import { Commercial } from '@/types/commercialType';
import { commercialsStatus, getAllcommercials } from '@/store/features/commercial/selectors';
import { getCommercials } from '@/store/features/commercial/thunk';
import { getAlluniversign, universignStatus } from '@/store/features/universign/selectors';
import { getUniversigns } from '@/store/features/universign/thunk';
import { useDashboardData } from '@/store/hooks/useAppData';
// ============================================
// TYPES
// ============================================
export const STATUS_LABELS = {
0: { label: 'Saisi', color: 'bg-gray-400' },
1: { label: 'Confirmé', color: 'bg-yellow-400' },
2: { label: 'Accepté', color: 'bg-green-400' },
3: { label: 'Perdu', color: 'bg-red-400' },
4: { label: 'Archivé', color: 'bg-orange-400' },
} as const;
export type StatusCode = keyof typeof STATUS_LABELS;
type FilterType = 'all' | 'accepted' | 'pending' | 'signed' | 'lost';
interface KPIConfig {
id: FilterType;
title: string;
icon: React.ElementType;
color: string;
getValue: (devis: DevisListItem[], universign: UniversignType[]) => number;
getSubtitle: (devis: DevisListItem[], value: number, universign?: UniversignType[]) => string;
getChange: (devis: DevisListItem[], value: number, period: PeriodType, allDevis: DevisListItem[]) => string;
getTrend: (devis: DevisListItem[], value: number, period: PeriodType, allDevis: DevisListItem[]) => 'up' | 'down' | 'neutral';
filter: (devis: DevisListItem[], universign: UniversignType[]) => DevisListItem[];
tooltip?: { content: string; source: string };
}
// ============================================
// CONFIGURATION DES COLONNES
// ============================================
const DEFAULT_COLUMNS: ColumnConfig[] = [
{ key: 'numero', label: 'Numéro', visible: true, locked: true },
{ key: 'client_code', label: 'Client', visible: true },
{ key: 'date', label: 'Date', visible: true },
{ key: 'total_ht_calcule', label: 'Montant HT', visible: true },
{ key: 'total_taxes_calcule', label: 'Montant TVA', visible: true },
{ key: 'total_ttc_calcule', label: 'Montant TTC', visible: true },
{ key: 'statut', label: 'Statut', visible: true },
];
// ============================================
// CONFIGURATION DES KPIs
// ============================================
const KPI_CONFIG: KPIConfig[] = [
{
id: 'all',
title: 'Total HT',
icon: Euro,
color: 'blue',
getValue: (devis) => Math.round(devis.reduce((sum, item) => sum + item.total_ht, 0)),
getSubtitle: (devis) => {
const totalTTC = devis.reduce((sum, item) => sum + item.total_ttc, 0);
return `${totalTTC.toLocaleString('fr-FR', { minimumFractionDigits: 2 })}€ TTC`;
},
getChange: (devis, value, period, allDevis) => {
const previousPeriodDevis = getPreviousPeriodItems(allDevis, period);
const previousTotalHT = previousPeriodDevis.reduce((sum, item) => sum + item.total_ht, 0);
return previousTotalHT > 0 ? ((value - previousTotalHT) / previousTotalHT * 100).toFixed(1) : '0';
},
getTrend: (devis, value, period, allDevis) => {
const previousPeriodDevis = getPreviousPeriodItems(allDevis, period);
const previousTotalHT = previousPeriodDevis.reduce((sum, item) => sum + item.total_ht, 0);
return value >= previousTotalHT ? 'up' : 'down';
},
filter: (devis) => devis,
tooltip: { content: "Total des devis sur la période.", source: "Ventes > Devis" }
},
{
id: 'accepted',
title: 'Devis Acceptés',
icon: CheckCircle,
color: 'green',
getValue: (devis) => devis.filter(d => d.statut === 2).length,
getSubtitle: (devis) => {
const accepted = devis.filter(d => d.statut === 2);
const acceptedAmount = accepted.reduce((sum, item) => sum + item.total_ht, 0);
return `${acceptedAmount.toLocaleString('fr-FR', { maximumFractionDigits: 0 })}€`;
},
getChange: (devis, value, period, allDevis) => {
const previousPeriodDevis = getPreviousPeriodItems(allDevis, period);
const previousAccepted = previousPeriodDevis.filter(d => d.statut === 2);
const acceptedChange = value - previousAccepted.length;
return acceptedChange !== 0 ? `${acceptedChange > 0 ? '+' : ''}${acceptedChange}` : '';
},
getTrend: (devis, value, period, allDevis) => {
const previousPeriodDevis = getPreviousPeriodItems(allDevis, period);
const previousAccepted = previousPeriodDevis.filter(d => d.statut === 2);
return value >= previousAccepted.length ? 'up' : 'down';
},
filter: (devis) => devis.filter(d => d.statut === 2),
},
{
id: 'pending',
title: 'Nombre de Devis',
icon: FileText,
color: 'orange',
getValue: (devis) => devis.length,
getSubtitle: (devis) => {
const avgDevis = devis.length > 0
? devis.reduce((sum, item) => sum + item.total_ht, 0) / devis.length
: 0;
return `${avgDevis.toLocaleString('fr-FR', { maximumFractionDigits: 0 })}€ moyen`;
},
getChange: (devis, value, period, allDevis) => {
const previousPeriodDevis = getPreviousPeriodItems(allDevis, period);
const countChange = value - previousPeriodDevis.length;
return countChange !== 0 ? `${countChange > 0 ? '+' : ''}${countChange}` : '0';
},
getTrend: (devis, value, period, allDevis) => {
const previousPeriodDevis = getPreviousPeriodItems(allDevis, period);
return value >= previousPeriodDevis.length ? 'up' : 'down';
},
filter: (devis) => devis.filter(d => d.statut === 1),
},
{
id: 'signed',
title: 'Devis Signés',
icon: Percent,
color: 'purple',
getValue: (devis, universign) => {
return universign.filter(u => u.local_status === "SIGNE").length;
},
getSubtitle: (devis, value, universign) => {
const pending = devis.filter(d => d.statut === 1).length;
return `${pending} en attente`;
},
getChange: (devis, value) => {
return `${value}/${devis.length}`;
},
getTrend: (devis, value) => {
const acceptanceRate = devis.length > 0 ? (value / devis.length) * 100 : 0;
return acceptanceRate >= 50 ? 'up' : 'down';
},
filter: (devis, universign) => {
const signedDocIds = new Set(
universign
.filter(u => u.local_status === "SIGNE")
.map(u => u.sage_document_id)
);
return devis.filter(d => signedDocIds.has(d.numero));
},
},
];
// ============================================
// COMPOSANT PRINCIPAL
// ============================================
const QuotesPage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const { showPreview, openPreview, closePreview } = usePDFPreview();
const [openStatusDevis, setOpenStatusDevis] = useState(false);
const [loadingPdf, setLoadingPdf] = useState(false);
const [period, setPeriod] = useState<PeriodType>('all');
const [isDuplicate, setIsDuplicate] = useState(false);
const [loading, setLoading] = useState(false);
// État du filtre actif
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
const clients = useAppSelector(getAllClients) as Client[];
const commercials = useAppSelector(getAllcommercials) as Commercial[];
const universign = useAppSelector(getAlluniversign) as UniversignType[];
const statusClient = useAppSelector(clientStatus);
const statusArticle = useAppSelector(articleStatus);
const statusFamille = useAppSelector(familleStatus);
const statusCommercial = useAppSelector(commercialsStatus);
const statusUniversign = useAppSelector(universignStatus);
const [activeFilters, setActiveFilters] = useState<Record<string, string[] | undefined>>({});
// État des colonnes visibles
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(DEFAULT_COLUMNS);
const devis = useAppSelector(getAllDevis) as DevisListItem[];
const devisSelected = useAppSelector(getDevisSelected) as DevisListItem;
const statusDevis = useAppSelector(devisStatus);
const [editing, setEditing] = useState<DevisListItem | null>(null);
const isLoading = statusDevis === 'loading' && devis.length === 0 && statusClient === 'loading';
const { refresh } = useDashboardData();
useEffect(() => {
const load = async () => {
if (statusDevis === 'idle') await dispatch(getDevisList()).unwrap();
if (statusFamille === 'idle') await dispatch(getfamilles()).unwrap();
if (statusUniversign === "idle") await dispatch(getUniversigns()).unwrap();
if (statusArticle === 'idle') await dispatch(getArticles()).unwrap();
if (statusCommercial === "idle") await dispatch(getCommercials()).unwrap();
};
load();
}, [statusDevis, statusFamille, statusArticle, statusCommercial, statusUniversign, dispatch]);
const commercialOptions = useMemo(() => {
return commercials.map(c => ({
value: c.numero.toString(),
label: `${c.prenom || ''} ${c.nom || ''}`.trim() || `Commercial ${c.numero}`,
}));
}, [commercials]);
const filterDefinitions = [
{
key: 'status',
label: 'Statut',
options: (Object.entries(STATUS_LABELS) as [
string,
typeof STATUS_LABELS[StatusCode]
][]).map(([value, { label, color }]) => ({
value: value.toString(),
label,
color,
})),
},
{
key: 'rep',
label: 'Commercial',
options: commercialOptions,
},
];
// Map pour retrouver le commercial d'un client rapidement
const clientCommercialMap = useMemo(() => {
const map = new Map<string, string>();
clients.forEach(client => {
if (client.commercial?.numero) {
map.set(client.numero, client.commercial.numero.toString());
}
});
return map;
}, [clients]);
// ============================================
// KPIs avec onClick
// ============================================
const kpis = useMemo(() => {
// Filtrer d'abord par période
const periodFilteredDevis = filterItemByPeriod(devis, period, 'date')
.filter(item => item.numero !== 'DE00126');
return KPI_CONFIG.map(config => {
const value = config.getValue(periodFilteredDevis, universign);
return {
id: config.id,
title: config.title,
value: config.id === 'all' ? `${value.toLocaleString('fr-FR')}` : value,
change: config.getChange(periodFilteredDevis, value, period, devis),
trend: config.getTrend(periodFilteredDevis, value, period, devis),
icon: config.icon,
subtitle: config.getSubtitle(periodFilteredDevis, value, universign),
color: config.color,
tooltip: config.tooltip,
isActive: activeFilter === config.id,
onClick: () => {
setActiveFilter(prev => (prev === config.id ? 'all' : config.id));
},
};
});
}, [devis, period, universign, activeFilter]);
// ============================================
// Filtrage combiné : Période + KPI + Filtres avancés
// ============================================
const filteredDevis = useMemo(() => {
// 1. Filtrer par période
let result = filterItemByPeriod(devis, period, 'date');
result = result.filter(item => item.numero !== 'DE00126');
// 2. Filtrer par KPI actif
const kpiConfig = KPI_CONFIG.find(k => k.id === activeFilter);
if (kpiConfig && activeFilter !== 'all') {
result = kpiConfig.filter(result, universign);
}
// 3. Filtres avancés (statut)
if (activeFilters.status && activeFilters.status.length > 0) {
result = result.filter(item =>
activeFilters.status!.includes(item.statut.toString())
);
}
// 4. Filtre par commercial
if (activeFilters.rep && activeFilters.rep.length > 0) {
result = result.filter(item => {
const commercialCode = clientCommercialMap.get(item.client_code);
return commercialCode && activeFilters.rep!.includes(commercialCode);
});
}
// 5. Tri par date décroissante
return [...result].sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
}, [devis, period, activeFilter, activeFilters, clientCommercialMap, universign]);
// ============================================
// Label du filtre actif
// ============================================
const activeFilterLabel = useMemo(() => {
const config = KPI_CONFIG.find(k => k.id === activeFilter);
return config?.title || 'Tous';
}, [activeFilter]);
const pdfData = useMemo(() => {
if (!devisSelected) return null;
return {
numero: devisSelected.numero,
type: 'devis' as const,
date: devisSelected.date,
client: {
code: devisSelected.client_code,
nom: devisSelected.client_intitule,
adresse: devisSelected.client_adresse,
code_postal: devisSelected.client_code_postal,
ville: devisSelected.client_ville,
email: devisSelected.client_email,
telephone: devisSelected.client_telephone,
},
reference_externe: devisSelected.reference,
lignes: (devisSelected.lignes ?? []).map(l => ({
article: l.article_code,
designation: l.designation,
quantite: l.quantite,
prix_unitaire: l.prix_unitaire_ht ?? 0,
tva: 20,
total_ht: l.quantite * (l.prix_unitaire_ht ?? 0),
remise: l.remise_valeur1
})),
total_ht: devisSelected.total_ht_calcule,
total_tva: devisSelected.total_taxes_calcule,
total_ttc: devisSelected.total_ttc_calcule,
};
}, [devisSelected]) as DocumentData;
const handleCreate = () => {
navigate('/home/devis/nouveau');
};
const handleEdit = (row: DevisListItem) => {
setEditing(row);
setIsCreateModalOpen(true);
};
const openPDF = async (row: DevisListItem) => {
await dispatch(selectDevisAsync(row)).unwrap();
openPreview();
};
// ============================================
// COLONNES DYNAMIQUES
// ============================================
const allColumnsDefinition = useMemo(() => {
const clientsMap = new Map(clients.map(c => [c.numero, c]));
const universignMap = new Map(
universign.map(u => [u.sage_document_id, u])
);
return {
numero: { key: 'numero', label: 'Numéro', sortable: true },
client_code: {
key: 'client_code',
label: 'Client',
sortable: true,
render: (clientCode: string) => {
const client = clientsMap.get(clientCode);
if (!client) return <span className="text-gray-400">{clientCode}</span>;
const avatar = client.intitule?.charAt(0).toUpperCase() || '?';
return (
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-xs font-bold text-gray-600 dark:text-gray-300">
{avatar}
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{client.intitule}</p>
<p className="text-xs text-gray-500">{client.email || client.telephone || clientCode}</p>
</div>
</div>
);
},
},
date: {
key: 'date',
label: 'Date',
sortable: true,
render: (date: string) => <span>{formatDateFRCourt(date)}</span>,
},
total_ht_calcule: {
key: 'total_ht_calcule',
label: 'Montant HT',
sortable: true,
render: (value: number) => `${value.toLocaleString()}`,
},
total_taxes_calcule: {
key: 'total_taxes_calcule',
label: 'Montant TVA',
sortable: true,
render: (value: number) => `${value.toLocaleString()}`,
},
total_ttc_calcule: {
key: 'total_ttc_calcule',
label: 'Montant TTC',
sortable: true,
render: (value: number) => <span className="font-medium text-[#007E45]">{value.toLocaleString()}</span>,
},
statut: {
key: 'statut',
label: 'Statut',
sortable: true,
render: (value: any, row: DevisListItem) => {
const signature = universignMap.get(row.numero);
return (
<StatusBadge status={signature?.local_status === "SIGNE" ? 6 : value} type_doc={SageDocumentType.DEVIS} />
);
},
},
};
}, [clients, universign]);
// Colonnes filtrées selon la config
const visibleColumns = useMemo(() => {
return columnConfig
.filter(col => col.visible)
.map(col => allColumnsDefinition[col.key as keyof typeof allColumnsDefinition])
.filter(Boolean);
}, [columnConfig, allColumnsDefinition]);
// Formateurs personnalisés pour transformer les valeurs à l'export
const columnFormatters: Record<string, (value: any, row: any) => string> = {
date: (value) => {
if (!value) return '';
return formatDateFRCourt(value);
},
total_ht_calcule: (value) => {
if (value === null || value === undefined) return '0 €';
return `${value.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €`;
},
total_taxes_calcule: (value) => {
if (value === null || value === undefined) return '0 €';
return `${value.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €`;
},
total_ttc_calcule: (value) => {
if (value === null || value === undefined) return '0 €';
return `${value.toLocaleString('fr-FR', { minimumFractionDigits: 2 })} €`;
},
statut: (value) => {
return STATUS_LABELS[value as StatusCode]?.label || 'Inconnu';
},
client_intitule: (value, row) => {
return value || row.client_code || '';
},
};
const onDuplicate = async () => {
try {
setLoading(true);
const payloadCreate: DevisRequest = {
client_id: devisSelected.client_code,
reference: devisSelected.reference,
date_devis: (() => {
const d = new Date();
d.setDate(d.getDate() + 1);
return d.toISOString().split('T')[0];
})(),
lignes: devisSelected.lignes.map(ligne => ({
article_code: ligne.article_code,
quantite: ligne.quantite,
prix_unitaire_ht: ligne.prix_unitaire_ht,
})),
};
const result = (await dispatch(createDevis(payloadCreate)).unwrap()) as DevisResponse;
await new Promise(resolve => setTimeout(resolve, 1500));
const devisCreated = (await dispatch(getDevisById(result.id)).unwrap()) as any;
const res = devisCreated.data as DevisListItem;
dispatch(selectDevis(res));
toast({
title: 'Devis dupliqué avec succès !',
description: `Le devis ${devisSelected.numero} a été dupliqué avec succès.`,
className: 'bg-green-500 text-white border-green-600',
});
setLoading(false);
setIsDuplicate(false);
navigate(`/home/devis/${result.id}`);
} catch (err: any) {
setLoading(false);
setIsDuplicate(false);
}
};
const actions = (row: DevisListItem) => {
const handleStatus =
row.statut !== 2 && row.statut !== 3 && row.statut !== 4
? () => {
dispatch(selectDevis(row));
setOpenStatusDevis(true);
}
: undefined;
return (
<>
<button
onClick={async () => openPDF(row)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-gray-600 dark:text-gray-400"
title="Voir"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleEdit(row)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-gray-600 dark:text-gray-400"
title="Éditer"
>
<Edit className="w-4 h-4" />
</button>
<DropdownMenuTable
row={row}
onDulipcate={() => {
dispatch(selectDevis(row));
setIsDuplicate(true);
}}
onStatus={handleStatus}
onDownload={() => {
dispatch(selectDevis(row));
openPDF(row);
}}
/>
</>
);
};
return (
<>
<Helmet>
<title>Devis - Dataven</title>
<meta name="description" content="Gestion de vos devis" />
</Helmet>
<div className="space-y-6">
<KPIBar kpis={kpis} period={period} loading={statusDevis} onRefresh={refresh} />
{loadingPdf && <ModalLoading />}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Devis</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' ? (
`${filteredDevis.length} devis`
) : (
<>
{filteredDevis.length} devis{' '}
({activeFilterLabel.toLowerCase()})
</>
)}
</p>
</div>
<PeriodSelector value={period} onChange={setPeriod} />
</div>
<div className="flex gap-3 flex-wrap items-center">
<ColumnSelector columns={columnConfig} onChange={setColumnConfig} />
<ExportDropdown
data={filteredDevis}
columns={columnConfig}
columnFormatters={columnFormatters}
filename="devis"
/>
<AdvancedFilters
filters={filterDefinitions}
activeFilters={activeFilters}
onFilterChange={(key, values) => {
setActiveFilters(prev => ({
...prev,
[key]: values,
}));
}}
onReset={() => setActiveFilters({})}
/>
<PrimaryButton_v2 icon={Plus} onClick={handleCreate}>
Nouveau devis
</PrimaryButton_v2>
</div>
</div>
<DataTable
columns={visibleColumns}
data={filteredDevis}
onRowClick={(row: DevisListItem) => {
dispatch(selectDevis(row));
navigate(`/home/devis/${row.numero}`);
}}
actions={actions}
status={isLoading}
searchLabel={'Rechercher par: nom, n° devis, email, ...'}
/>
</div>
<ModalQuote
open={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
title={editing ? `Mettre à jour le devis ${editing.numero}` : 'Créer un devis'}
editing={editing}
/>
<ModalStatus open={openStatusDevis} onClose={() => setOpenStatusDevis(false)} type_doc={SageDocumentType.DEVIS} />
<FormModal isOpen={isDuplicate} onClose={() => setIsDuplicate(false)} title="Dupliquer le devis" onSubmit={onDuplicate}>
<div className="mb-6 p-4 bg-green-50 text-green-800 rounded-xl text-sm flex gap-3">
<FileText className="w-5 h-5 shrink-0" />
<p>Vous allez dupliquer le devis</p>
</div>
</FormModal>
<PDFPreview open={showPreview} onClose={closePreview} data={pdfData} entreprise={CompanyInfo} />
{loading && <ModalLoading />}
</>
);
};
export default QuotesPage;

View file

@ -0,0 +1,169 @@
import React, { useState } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Check, Wallet, ShieldCheck, CreditCard } from 'lucide-react';
import { toast } from '@/components/ui/use-toast';
import { motion } from 'framer-motion';
import { useSignature } from '@/contexts/SignatureContext';
const CreditPackage = ({ credits, price, savings, popular, onSelect, loading }) => (
<motion.div
whileHover={{ scale: 1.02 }}
className={`relative p-6 rounded-2xl border-2 cursor-pointer transition-all ${
popular
? 'border-[#338660] bg-white dark:bg-gray-900 shadow-xl shadow-[#338660]/10'
: 'border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 hover:border-[#338660]/50'
}`}
onClick={onSelect}
>
{popular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-[#338660] text-white px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider">
Recommandé
</div>
)}
<div className="text-center mb-6">
<div className="text-4xl font-extrabold text-gray-900 dark:text-white mb-2">{credits}</div>
<div className="text-sm font-medium text-gray-500 uppercase tracking-wide">Crédits</div>
</div>
<div className="text-center mb-6">
<div className="text-3xl font-bold text-[#338660]">{price}</div>
<div className="text-xs text-gray-400">HT</div>
<div className="text-xs text-gray-500 mt-1">{(price / credits).toFixed(2)} / signature</div>
</div>
{savings && (
<div className="mb-6 flex justify-center">
<span className="bg-green-100 text-green-800 text-xs font-bold px-2 py-1 rounded-lg">
Économisez {savings}%
</span>
</div>
)}
<ul className="space-y-3 mb-6">
<li className="flex items-center text-sm text-gray-600 dark:text-gray-400">
<Check className="w-4 h-4 text-[#338660] mr-2" />
Signature certifiée eIDAS
</li>
<li className="flex items-center text-sm text-gray-600 dark:text-gray-400">
<Check className="w-4 h-4 text-[#338660] mr-2" />
Horodatage qualifié
</li>
<li className="flex items-center text-sm text-gray-600 dark:text-gray-400">
<Check className="w-4 h-4 text-[#338660] mr-2" />
Archivage 10 ans
</li>
</ul>
<button
disabled={loading}
className={`w-full py-3 rounded-xl font-bold transition-all ${
popular
? 'bg-[#338660] text-white hover:bg-[#2A6F4F] shadow-lg shadow-[#338660]/20'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
{loading ? 'Traitement...' : 'Acheter'}
</button>
</motion.div>
);
const SignatureCreditPurchase = () => {
const navigate = useNavigate();
const { stats, purchaseCredits } = useSignature();
const [loading, setLoading] = useState(false);
const handlePurchase = async (amount, price) => {
setLoading(true);
// Simulate payment processing
setTimeout(async () => {
const success = await purchaseCredits(amount);
setLoading(false);
if (success) {
toast({
title: "Achat confirmé",
description: `${amount} crédits ont été ajoutés à votre solde.`,
variant: "success"
});
navigate('/home/signature/dashboard');
}
}, 1500);
};
return (
<>
<Helmet>
<title>Achat de crédits - Signature - Bijou ERP</title>
</Helmet>
<div className="max-w-5xl mx-auto space-y-8">
<div className="flex items-center gap-4">
<button onClick={() => navigate('/home/signature/dashboard')} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Recharger mon compte</h1>
<p className="text-gray-500">Choisissez le pack adapté à vos besoins</p>
</div>
<div className="ml-auto flex items-center gap-2 bg-white dark:bg-gray-950 px-4 py-2 rounded-xl border border-gray-200 dark:border-gray-800">
<Wallet className="w-5 h-5 text-[#338660]" />
<span className="font-medium">Solde actuel : </span>
<span className="font-bold text-[#338660]">{stats?.credits.remaining || 0} crédits</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<CreditPackage
credits={10}
price={15}
onSelect={() => handlePurchase(10, 15)}
loading={loading}
/>
<CreditPackage
credits={25}
price={35}
savings={7}
onSelect={() => handlePurchase(25, 35)}
loading={loading}
/>
<CreditPackage
credits={50}
price={65}
savings={13}
popular
onSelect={() => handlePurchase(50, 65)}
loading={loading}
/>
<CreditPackage
credits={100}
price={120}
savings={20}
onSelect={() => handlePurchase(100, 120)}
loading={loading}
/>
</div>
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-2xl p-8 flex flex-col md:flex-row items-center justify-between gap-8">
<div className="flex items-center gap-4">
<div className="p-3 bg-white dark:bg-gray-800 rounded-xl shadow-sm">
<ShieldCheck className="w-8 h-8 text-[#338660]" />
</div>
<div>
<h3 className="font-bold text-lg">Paiement 100% sécurisé</h3>
<p className="text-gray-500">Transactions chiffrées via Stripe. Facture disponible immédiatement.</p>
</div>
</div>
<div className="flex gap-4 opacity-50 grayscale hover:grayscale-0 transition-all">
{/* Simple representation of payment methods */}
<div className="h-8 w-12 bg-gray-300 rounded"></div>
<div className="h-8 w-12 bg-gray-300 rounded"></div>
<div className="h-8 w-12 bg-gray-300 rounded"></div>
</div>
</div>
</div>
</>
);
};
export default SignatureCreditPurchase;

View file

@ -0,0 +1,109 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { Settings, ShoppingCart, FileText } from 'lucide-react';
import SignatureKPIs from '@/components/signature/SignatureKPIs';
import SignatureChart from '@/components/signature/SignatureChart';
import SignatureCreditAlert from '@/components/signature/SignatureCreditAlert';
import DataTable from '@/components/DataTable';
import { useSignature } from '@/contexts/SignatureContext';
const SignatureDashboard = () => {
const { stats, history, signatures, loading } = useSignature();
const navigate = useNavigate();
// Recent 5 signatures
const recentSignatures = signatures.slice(0, 5);
const columns = [
{ key: 'documentNumber', label: 'Document', render: v => <span className="font-medium">{v}</span> },
{ key: 'clientName', label: 'Client' },
{ key: 'sentAt', label: 'Date', render: v => new Date(v).toLocaleDateString() },
// {
// key: 'status',
// label: 'Statut',
// render: (status) => {
// const map = {
// 'signed': 'Signé',
// 'pending': 'En attente',
// 'viewed': 'Vu',
// 'sent': 'Envoyé',
// 'refused': 'Refusé',
// 'expired': 'Expiré'
// };
// return <StatusBadge status={map[status] || status} />;
// }
// }
];
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#338660]"></div>
</div>
);
}
return (
<>
<Helmet>
<title>Signature Électronique - Dashboard - Bijou ERP</title>
</Helmet>
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Signature Électronique</h1>
<p className="text-gray-500">Pilotez vos processus de signature et suivez votre consommation.</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => navigate('/home/signature/purchase')}
className="flex items-center gap-2 px-4 py-2 bg-[#338660] text-white rounded-xl font-medium hover:bg-[#2A6F4F] shadow-lg shadow-[#338660]/20 transition-all"
>
<ShoppingCart className="w-4 h-4" />
Acheter des crédits
</button>
<button
// onClick={() => navigate('/signature/settings')}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors"
>
<Settings className="w-5 h-5 text-gray-500" />
</button>
</div>
</div>
<SignatureCreditAlert credits={stats?.credits} />
<SignatureKPIs stats={stats} />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<SignatureChart data={history} />
</div>
<div className="bg-white dark:bg-gray-950 rounded-2xl border border-gray-200 dark:border-gray-800 p-6 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-lg text-gray-900 dark:text-white">Derniers envois</h3>
<button
onClick={() => navigate('/home/signature/tracking')}
className="text-sm text-[#338660] hover:underline font-medium"
>
Tout voir
</button>
</div>
<div className="-mx-6">
<DataTable
columns={columns}
data={recentSignatures}
onRowClick={() => {}}
/>
</div>
</div>
</div>
</div>
</>
);
};
export default SignatureDashboard;

View file

@ -0,0 +1,122 @@
import React, { useState } from 'react';
import { Helmet } from 'react-helmet';
import PrimaryButton from '@/components/PrimaryButton';
import { Save, Lock, Bell, FileText, Check } from 'lucide-react';
import { Input, FormSection, FormField } from '@/components/FormModal';
import { Switch } from '@/components/ui/switch';
import { toast } from '@/components/ui/use-toast';
const SignatureSettings = () => {
const [apiKey, setApiKey] = useState('************************');
const handleSave = () => {
toast({
title: "Paramètres enregistrés",
description: "Vos préférences de signature ont été mises à jour.",
variant: "success"
});
};
return (
<>
<Helmet>
<title>Paramètres Signature - Bijou ERP</title>
</Helmet>
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Configuration Signature</h1>
<p className="text-gray-500">Gérez votre connexion Universign et vos préférences d'envoi.</p>
</div>
<PrimaryButton icon={Save} onClick={handleSave}>Enregistrer</PrimaryButton>
</div>
<div className="bg-white dark:bg-gray-950 p-6 rounded-2xl border border-gray-200 dark:border-gray-800 space-y-8">
{/* API Config */}
<div>
<h3 className="flex items-center gap-2 font-bold text-lg mb-4 text-gray-900 dark:text-white">
<Lock className="w-5 h-5 text-[#338660]" />
Connexion Universign
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField label="Clé API">
<Input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
</FormField>
<FormField label="Environnement">
<select className="w-full px-3 py-2 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl text-sm">
<option>Production</option>
<option>Test / Sandbox</option>
</select>
</FormField>
</div>
<div className="mt-2 flex items-center gap-2 text-sm text-[#338660]">
<Check className="w-4 h-4" />
Connexion établie avec succès
</div>
</div>
<div className="border-t border-gray-100 dark:border-gray-800" />
{/* Docs Integration */}
<div>
<h3 className="flex items-center gap-2 font-bold text-lg mb-4 text-gray-900 dark:text-white">
<FileText className="w-5 h-5 text-[#338660]" />
Documents éligibles
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
<div>
<p className="font-medium">Devis clients</p>
<p className="text-xs text-gray-500">Activer la signature électronique pour les devis</p>
</div>
<Switch checked={true} />
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
<div>
<p className="font-medium">Bons de commande</p>
<p className="text-xs text-gray-500">Activer pour les confirmations de commande</p>
</div>
<Switch checked={true} />
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
<div>
<p className="font-medium">Contrats cadres</p>
<p className="text-xs text-gray-500">Pour les documents juridiques longs</p>
</div>
<Switch checked={false} />
</div>
</div>
</div>
<div className="border-t border-gray-100 dark:border-gray-800" />
{/* Notifications */}
<div>
<h3 className="flex items-center gap-2 font-bold text-lg mb-4 text-gray-900 dark:text-white">
<Bell className="w-5 h-5 text-[#338660]" />
Notifications
</h3>
<div className="space-y-4">
<div className="flex items-center gap-2">
<input type="checkbox" className="rounded text-[#338660]" defaultChecked />
<span className="text-sm">M'avertir quand un document est signé</span>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" className="rounded text-[#338660]" defaultChecked />
<span className="text-sm">M'avertir quand un document est refusé</span>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" className="rounded text-[#338660]" defaultChecked />
<span className="text-sm">Alerte stock critique (moins de 10 crédits)</span>
</div>
</div>
</div>
</div>
</div>
</>
);
};
export default SignatureSettings;

View file

@ -0,0 +1,490 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Helmet } from 'react-helmet';
import DataTable from '@/components/DataTable';
import {
Eye,
Send,
FileText,
CheckCircle2,
Clock,
XCircle,
AlertTriangle,
RefreshCw,
ExternalLink,
Users,
Mail,
} from 'lucide-react';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { getAlluniversign, universignStatus } from '@/store/features/universign/selectors';
import { getUniversigns } from '@/store/features/universign/thunk';
import { UniversignType, Signer } from '@/types/sageTypes';
import { toast } from '@/components/ui/use-toast';
import ColumnSelector, { ColumnConfig } from '@/components/common/ColumnSelector';
import AdvancedFilters from '@/components/common/AdvancedFilters';
import ExportDropdown from '@/components/common/ExportDropdown';
import { cn, formatDateFRCourt } from '@/lib/utils';
import PeriodSelector from '@/components/common/PeriodSelector';
import { PeriodType } from '@/components/KPIBar';
import { filterItemByPeriod } from '@/components/filter/ItemsFilter';
// ============================================
// STATUS CONFIGURATION
// ============================================
const UNIVERSIGN_STATUS = {
draft: { label: 'Brouillon', color: 'bg-gray-100 text-gray-700', icon: FileText },
sent: { label: 'Envoyé', color: 'bg-blue-100 text-blue-700', icon: Send },
viewed: { label: 'Vu', color: 'bg-purple-100 text-purple-700', icon: Eye },
signed: { label: 'Signé', color: 'bg-green-100 text-green-700', icon: CheckCircle2 },
refused: { label: 'Refusé', color: 'bg-red-100 text-red-700', icon: XCircle },
expired: { label: 'Expiré', color: 'bg-orange-100 text-orange-700', icon: AlertTriangle },
pending: { label: 'En attente', color: 'bg-yellow-100 text-yellow-700', icon: Clock },
cancelled: { label: 'Annulé', color: 'bg-gray-100 text-gray-600', icon: XCircle },
} as const;
type UniversignStatusKey = keyof typeof UNIVERSIGN_STATUS;
// ============================================
// CONFIGURATION DES COLONNES
// ============================================
const DEFAULT_COLUMNS: ColumnConfig[] = [
{ key: 'sage_document_id', label: 'Document', visible: true, locked: true },
{ key: 'sage_document_type', label: 'Type', visible: true },
{ key: 'signers', label: 'Signataires', visible: true },
{ key: 'sent_at', label: 'Envoyé le', visible: true },
{ key: 'signed_at', label: 'Signé le', visible: false },
{ key: 'local_status', label: 'Statut', visible: true },
{ key: 'needs_sync', label: 'Sync', visible: false },
{ key: 'transaction_id', label: 'Transaction ID', visible: false },
];
// ============================================
// COMPOSANT STATUS BADGE
// ============================================
const SignatureStatusBadge: React.FC<{ status: string }> = ({ status }) => {
const statusKey = status?.toLowerCase() as UniversignStatusKey;
const config = UNIVERSIGN_STATUS[statusKey] || UNIVERSIGN_STATUS.pending;
const Icon = config.icon;
return (
<span className={cn('inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium', config.color)}>
<Icon className="w-3 h-3" />
{config.label}
</span>
);
};
// ============================================
// COMPOSANT PRINCIPAL
// ============================================
const SignatureTracking = () => {
const dispatch = useAppDispatch();
const [period, setPeriod] = useState<PeriodType>('all');
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(DEFAULT_COLUMNS);
const [activeFilters, setActiveFilters] = useState<Record<string, string[] | undefined>>({});
const universign = useAppSelector(getAlluniversign) as UniversignType[];
const statusUniversign = useAppSelector(universignStatus);
const isLoading = statusUniversign === 'loading' && universign.length === 0;
// Chargement initial
useEffect(() => {
const load = async () => {
if (statusUniversign === 'idle') {
await dispatch(getUniversigns()).unwrap();
}
};
load();
}, [statusUniversign, dispatch]);
// Rafraîchir
const handleRefresh = async () => {
try {
await dispatch(getUniversigns()).unwrap();
toast({
title: 'Actualisé',
description: 'Les signatures ont été mises à jour.',
className: 'bg-green-500 text-white border-green-600',
});
} catch (error) {
toast({
title: 'Erreur',
description: "Impossible d'actualiser les signatures.",
variant: 'destructive',
});
}
};
// ============================================
// FILTRES
// ============================================
const filterDefinitions = useMemo(
() => [
{
key: 'status',
label: 'Statut',
options: Object.entries(UNIVERSIGN_STATUS).map(([value, { label, color }]) => ({
value,
label,
color: color.split(' ')[0], // Extraire juste bg-xxx
})),
},
{
key: 'type',
label: 'Type de document',
options: [
{ value: 'devis', label: 'Devis', color: 'bg-blue-400' },
{ value: 'facture', label: 'Facture', color: 'bg-green-400' },
{ value: 'bon_commande', label: 'Bon de commande', color: 'bg-purple-400' },
{ value: 'bon_livraison', label: 'Bon de livraison', color: 'bg-orange-400' },
],
},
{
key: 'sync',
label: 'Synchronisation',
options: [
{ value: 'true', label: 'À synchroniser', color: 'bg-yellow-400' },
{ value: 'false', label: 'Synchronisé', color: 'bg-green-400' },
],
},
],
[]
);
// ============================================
// DONNÉES FILTRÉES
// ============================================
// const filteredSignatures = useMemo(() => {
// let result = filterItemByPeriod(universign, period, 'sent_at');
// // Filtre par statut
// if (activeFilters.status && activeFilters.status.length > 0) {
// result = result.filter(item => activeFilters.status!.includes(item.local_status?.toLowerCase() || ''));
// }
// // Filtre par type de document
// if (activeFilters.type && activeFilters.type.length > 0) {
// result = result.filter(item => activeFilters.type!.includes(item.sage_document_type?.toLowerCase() || ''));
// }
// // Filtre par sync
// if (activeFilters.sync && activeFilters.sync.length > 0) {
// result = result.filter(item => activeFilters.sync!.includes(item.needs_sync?.toString() || 'false'));
// }
// return [...result].sort((a, b) => new Date(b.sent_at || b.created_at).getTime() - new Date(a.sent_at || a.created_at).getTime());
// }, [universign, period, activeFilters]);
// ============================================
// COLONNES
// ============================================
const allColumnsDefinition = useMemo(
() => ({
sage_document_id: {
key: 'sage_document_id',
label: 'Document',
sortable: true,
render: (value: string, row: UniversignType) => (
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-[#007E45]/10 flex items-center justify-center">
<FileText className="w-4 h-4 text-[#007E45]" />
</div>
<div>
<p className="font-medium text-[#007E45]">{value}</p>
<p className="text-[10px] text-gray-400 font-mono">{row.transaction_id?.slice(0, 8)}...</p>
</div>
</div>
),
},
sage_document_type: {
key: 'sage_document_type',
label: 'Type',
sortable: true,
render: (value: string) => {
const typeLabels: Record<string, string> = {
devis: 'Devis',
facture: 'Facture',
bon_commande: 'Bon de commande',
bon_livraison: 'Bon de livraison',
};
return (
<span className="text-sm text-gray-700 dark:text-gray-300 capitalize">
{typeLabels[value?.toLowerCase()] || value || '-'}
</span>
);
},
},
signers: {
key: 'signers',
label: 'Signataires',
sortable: false,
render: (signers: Signer[]) => {
if (!signers || signers.length === 0) {
return <span className="text-gray-400">-</span>;
}
const mainSigner = signers[0];
const otherCount = signers.length - 1;
return (
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-[10px] font-bold text-gray-600">
{mainSigner.name?.charAt(0)?.toUpperCase() || <Mail className="w-3 h-3" />}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate max-w-[150px]">
{mainSigner.name || mainSigner.email}
</p>
{otherCount > 0 && <p className="text-[10px] text-gray-500">+{otherCount} autre{otherCount > 1 ? 's' : ''}</p>}
</div>
{mainSigner.status === 'signed' && <CheckCircle2 className="w-4 h-4 text-green-500 shrink-0" />}
</div>
);
},
},
sent_at: {
key: 'sent_at',
label: 'Envoyé le',
sortable: true,
render: (value: string) =>
value ? (
<div className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400">
<Send className="w-3 h-3" />
{formatDateFRCourt(value)}
</div>
) : (
<span className="text-gray-400">-</span>
),
},
signed_at: {
key: 'signed_at',
label: 'Signé le',
sortable: true,
render: (value: string) =>
value ? (
<div className="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="w-3 h-3" />
{formatDateFRCourt(value)}
</div>
) : (
<span className="text-gray-400">-</span>
),
},
local_status: {
key: 'local_status',
label: 'Statut',
sortable: true,
render: (value: string, row: UniversignType) => <SignatureStatusBadge status={value || row.universign_status} />,
},
needs_sync: {
key: 'needs_sync',
label: 'Sync',
sortable: true,
render: (value: boolean) =>
value ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-yellow-100 text-yellow-700">
<RefreshCw className="w-3 h-3" />À sync
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-green-100 text-green-700">
<CheckCircle2 className="w-3 h-3" />
OK
</span>
),
},
transaction_id: {
key: 'transaction_id',
label: 'Transaction ID',
sortable: true,
render: (value: string) => <span className="font-mono text-xs text-gray-500">{value || '-'}</span>,
},
}),
[]
);
const visibleColumns = useMemo(() => {
return columnConfig
.filter(col => col.visible)
.map(col => allColumnsDefinition[col.key as keyof typeof allColumnsDefinition])
.filter(Boolean);
}, [columnConfig, allColumnsDefinition]);
// ============================================
// FORMATEURS EXPORT
// ============================================
const columnFormatters: Record<string, (value: any, row: UniversignType) => string> = {
signers: (signers: Signer[]) => {
if (!signers || signers.length === 0) return '-';
return signers.map(s => `${s.name || ''} <${s.email}>`).join(', ');
},
sent_at: value => (value ? formatDateFRCourt(value) : '-'),
signed_at: value => (value ? formatDateFRCourt(value) : '-'),
local_status: (value, row) => UNIVERSIGN_STATUS[value?.toLowerCase() as UniversignStatusKey]?.label || value || row.universign_status || '-',
needs_sync: value => (value ? 'À synchroniser' : 'Synchronisé'),
};
// ============================================
// ACTIONS
// ============================================
const actions = (row: UniversignType) => (
<>
{/* Voir le document */}
{row.document_url && (
<a
href={row.document_url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-gray-600 dark:text-gray-400"
title="Voir le document"
>
<Eye className="w-4 h-4" />
</a>
)}
{/* Lien signataire */}
{row.signer_url && ['sent', 'viewed', 'pending'].includes(row.local_status?.toLowerCase()) && (
<a
href={row.signer_url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="p-2 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors text-blue-600"
title="Ouvrir lien de signature"
>
<ExternalLink className="w-4 h-4" />
</a>
)}
{/* Relancer */}
{['sent', 'viewed'].includes(row.local_status?.toLowerCase()) && (
<button
onClick={e => {
e.stopPropagation();
toast({
title: 'Relance envoyée',
description: `Relance pour ${row.sage_document_id}`,
});
}}
className="p-2 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded-lg transition-colors text-purple-600"
title="Relancer le signataire"
>
<Send className="w-4 h-4" />
</button>
)}
</>
);
// ============================================
// STATS RAPIDES
// ============================================
// const stats = useMemo(() => {
// const total = filteredSignatures.length;
// const signed = filteredSignatures.filter(s => s.local_status?.toLowerCase() === 'signed').length;
// const pending = filteredSignatures.filter(s => ['sent', 'viewed', 'pending'].includes(s.local_status?.toLowerCase())).length;
// const refused = filteredSignatures.filter(s => ['refused', 'expired', 'cancelled'].includes(s.local_status?.toLowerCase())).length;
// return { total, signed, pending, refused };
// }, [filteredSignatures]);
return (
<>
<Helmet>
<title>Suivi des signatures - Dataven</title>
<meta name="description" content="Historique et statut de vos demandes de signature" />
</Helmet>
<div className="space-y-6">
{/* Header */}
<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">Suivi des Signatures</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Historique et statut de toutes vos demandes de signature
</p>
</div>
<button
onClick={handleRefresh}
disabled={statusUniversign === 'loading'}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
<RefreshCw className={cn('w-4 h-4', statusUniversign === 'loading' && 'animate-spin')} />
Actualiser
</button>
</div>
{/* Stats rapides */}
{/* <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
<p className="text-sm text-gray-500">Total</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats.total}</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
<p className="text-sm text-green-600">Signés</p>
<p className="text-2xl font-bold text-green-600">{stats.signed}</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
<p className="text-sm text-yellow-600">En attente</p>
<p className="text-2xl font-bold text-yellow-600">{stats.pending}</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
<p className="text-sm text-red-600">Refusés/Expirés</p>
<p className="text-2xl font-bold text-red-600">{stats.refused}</p>
</div>
</div> */}
{/* Toolbar */}
{/* <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600 dark:text-gray-400">{filteredSignatures.length} signature{filteredSignatures.length > 1 ? 's' : ''}</span>
<PeriodSelector value={period} onChange={setPeriod} />
</div>
<div className="flex flex-wrap items-center gap-3">
<ColumnSelector columns={columnConfig} onChange={setColumnConfig} />
<ExportDropdown data={filteredSignatures} columns={columnConfig} columnFormatters={columnFormatters} filename="signatures" />
<AdvancedFilters
filters={filterDefinitions}
activeFilters={activeFilters}
onFilterChange={(key, values) => {
setActiveFilters(prev => ({ ...prev, [key]: values }));
}}
onReset={() => setActiveFilters({})}
/>
</div>
</div> */}
{/* DataTable */}
<DataTable
columns={visibleColumns}
data={universign}
onRowClick={(row: UniversignType) => {
toast({
title: 'Détails',
description: `Document: ${row.sage_document_id} - Statut: ${row.local_status_label || row.local_status}`,
});
}}
actions={actions}
status={isLoading}
searchLabel="Rechercher par: document, email, transaction..."
/>
</div>
</>
);
};
export default SignatureTracking;

View file

@ -0,0 +1,69 @@
import React, { useState, useMemo } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { Ticket, Clock, CheckCircle, Activity, AlertTriangle } from 'lucide-react';
import KpiCard from '@/components/KpiCard';
import ChartCard from '@/components/ChartCard';
import KPIBar from '@/components/KPIBar';
import { mockStats, mockTickets, calculateKPIs } from '@/data/mockData';
const SupportDashboardPage = () => {
const navigate = useNavigate();
const [period, setPeriod] = useState('month');
const kpis = useMemo(() => {
const stats = calculateKPIs(mockTickets, period, { dateField: 'openDate', amountField: 'id' });
const resolved = stats.items.filter(t => t.status === 'resolved');
// Dashboard specific high-level KPIs
return [
{ title: 'Tickets Ouverts', value: stats.filteredCount, change: '-2', trend: 'up', icon: Ticket, onClick: () => navigate('/tickets') },
{ title: 'SLA Respecté', value: '94%', change: '+1.5%', trend: 'up', icon: CheckCircle },
{ title: 'Temps Réponse', value: '1.5h', change: '-15m', trend: 'up', icon: Clock },
{ title: 'Satisfaction', value: '4.8/5', change: '+0.1', trend: 'up', icon: Activity },
{ title: 'Critiques', value: stats.items.filter(t => t.priority === 'critical').length, change: '', trend: 'neutral', icon: AlertTriangle },
];
}, [period]);
return (
<>
<Helmet>
<title>Tableau de bord SAV - Bijou ERP</title>
</Helmet>
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Pilotage SAV</h1>
<KPIBar kpis={kpis} period={period} onPeriodChange={setPeriod} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<ChartCard
title="Tickets par Priorité"
type="bar"
data={mockStats.ticketPriority}
/>
<ChartCard
title="État du Parc"
type="donut"
data={mockStats.ticketStatus}
/>
<div className="lg:col-span-2 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6">
<h3 className="text-lg font-semibold mb-4">Tickets Critiques (SLA &lt; 2h)</h3>
<div className="space-y-3">
<div className="flex justify-between items-center p-3 bg-red-50 dark:bg-red-900/20 rounded-xl border border-red-100">
<div className="flex items-center gap-3">
<span className="w-2 h-2 bg-red-500 rounded-full animate-pulse" />
<span className="font-medium text-red-700 dark:text-red-400">TKT-1006 Erreur 500 API</span>
</div>
<span className="text-sm font-bold text-red-600">15 min restantes</span>
</div>
</div>
</div>
</div>
</div>
</>
);
};
export default SupportDashboardPage;

View file

@ -0,0 +1,198 @@
import React, { useState } from 'react';
import { Helmet } from 'react-helmet';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Send, User, Clock, AlertCircle } from 'lucide-react';
import StatusBadge from '@/components/StatusBadget';
import { mockTickets } from '@/data/mockData';
import { toast } from '@/components/ui/use-toast';
import { motion } from 'framer-motion';
const TicketDetailPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const [ticket, setTicket] = useState(mockTickets.find(t => t.id === parseInt(id)));
const [comment, setComment] = useState('');
if (!ticket) return <div>Ticket non trouvé</div>;
const handleAddComment = (e) => {
e.preventDefault();
if (!comment.trim()) return;
const newComment = {
id: Math.random(),
user: 'Jean Dupont', // Current user
date: new Date().toISOString().split('T')[0],
text: comment
};
setTicket(prev => ({
...prev,
comments: [...(prev.comments || []), newComment]
}));
setComment('');
toast({ title: "Commentaire ajouté" });
};
const handleStatusChange = (e) => {
const newStatus = e.target.value;
setTicket(prev => ({ ...prev, status: newStatus }));
toast({ title: "Statut mis à jour", description: `Nouveau statut: ${newStatus}` });
};
return (
<>
<Helmet>
<title>{ticket.number} - Support</title>
</Helmet>
<div className="space-y-6 max-w-6xl mx-auto h-[calc(100vh-100px)] flex flex-col">
<div className="flex-shrink-0">
<button onClick={() => navigate('/tickets')} className="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-900 mb-4">
<ArrowLeft className="w-4 h-4" /> Retour
</button>
<div className="flex justify-between items-start bg-white dark:bg-gray-950 p-6 rounded-2xl border border-gray-200 dark:border-gray-800 shadow-sm">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{ticket.number}</h1>
<StatusBadge status={ticket.status} />
<span className={`px-2 py-0.5 rounded text-xs font-bold uppercase ${ticket.priority === 'critical' ? 'bg-red-100 text-red-600' :
ticket.priority === 'high' ? 'bg-orange-100 text-orange-600' : 'bg-blue-100 text-blue-600'
}`}>
{ticket.priority}
</span>
</div>
<h2 className="text-lg font-medium">{ticket.subject}</h2>
<p className="text-gray-500 text-sm">Client: <span className="font-medium text-gray-900 dark:text-white">{ticket.client}</span></p>
</div>
<div className="flex flex-col items-end gap-2">
<select
value={ticket.status}
onChange={handleStatusChange}
className="px-3 py-2 rounded-xl border border-gray-200 bg-gray-50 text-sm font-medium focus:ring-2 focus:ring-[#941403]"
>
<option value="open">Ouvert</option>
<option value="in-progress">En cours</option>
<option value="pending">En attente</option>
<option value="resolved">Résolu</option>
<option value="closed">Fermé</option>
</select>
<div className="text-xs text-gray-500 flex items-center gap-1">
<Clock className="w-3 h-3" /> Ouvert le {ticket.openDate}
</div>
</div>
</div>
</div>
<div className="flex-1 grid grid-cols-3 gap-6 min-h-0">
{/* Conversation Column */}
<div className="col-span-2 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl flex flex-col overflow-hidden">
<div className="p-4 border-b border-gray-100 bg-gray-50/50 font-medium">Fil de discussion</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Original Description */}
<div className="flex gap-4">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center shrink-0 text-blue-600 font-bold">C</div>
<div>
<div className="flex items-baseline gap-2 mb-1">
<span className="font-bold text-sm">{ticket.client}</span>
<span className="text-xs text-gray-500">{ticket.openDate}</span>
</div>
<div className="bg-gray-100 dark:bg-gray-900 p-4 rounded-2xl rounded-tl-none text-sm leading-relaxed">
{ticket.description}
</div>
</div>
</div>
{/* Comments */}
{ticket.comments?.map((c, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex gap-4"
>
<div className="w-10 h-10 rounded-full bg-[#941403] flex items-center justify-center shrink-0 text-white font-bold">
{c.user.charAt(0)}
</div>
<div>
<div className="flex items-baseline gap-2 mb-1">
<span className="font-bold text-sm">{c.user}</span>
<span className="text-xs text-gray-500">{c.date}</span>
</div>
<div className="bg-red-50 dark:bg-red-900/10 border border-red-100 dark:border-red-900/30 p-4 rounded-2xl rounded-tl-none text-sm leading-relaxed">
{c.text}
</div>
</div>
</motion.div>
))}
</div>
<div className="p-4 border-t border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-900/30">
<form onSubmit={handleAddComment} className="flex gap-3">
<input
type="text"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Écrire une réponse interne ou publique..."
className="flex-1 px-4 py-3 rounded-xl border border-gray-200 focus:ring-2 focus:ring-[#941403] outline-none text-sm"
/>
<button
type="submit"
disabled={!comment.trim()}
className="p-3 bg-[#941403] text-white rounded-xl hover:bg-[#7a1002] disabled:opacity-50 transition-colors"
>
<Send className="w-5 h-5" />
</button>
</form>
</div>
</div>
{/* Sidebar Info */}
<div className="col-span-1 space-y-6">
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6">
<h3 className="text-sm font-bold uppercase text-gray-400 mb-4">SLA</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between text-sm mb-1">
<span>Temps réponse</span>
<span className="font-medium text-green-600">OK</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-green-500 w-[80%]" />
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span>Résolution</span>
<span className="font-medium text-yellow-600">Attention</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-yellow-500 w-[90%]" />
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6">
<h3 className="text-sm font-bold uppercase text-gray-400 mb-4">Assigné à</h3>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center">
<User className="w-5 h-5 text-gray-500" />
</div>
<div>
<p className="font-medium">{ticket.assignedTo}</p>
<p className="text-xs text-gray-500">Support N2</p>
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
};
export default TicketDetailPage;

View file

@ -0,0 +1,86 @@
import React, { useState, useMemo } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { Eye, Ticket, CheckCircle, Clock, AlertTriangle } from 'lucide-react';
import DataTable from '@/components/DataTable';
import StatusBadge from '@/components/StatusBadget';
import KPIBar from '@/components/KPIBar';
import { mockTickets, calculateKPIs } from '@/data/mockData';
const TicketsPage = () => {
const navigate = useNavigate();
const [period, setPeriod] = useState('month');
const kpis = useMemo(() => {
const stats = calculateKPIs(mockTickets, period, { dateField: 'openDate', amountField: 'id' });
const resolved = stats.items.filter(t => t.status === 'resolved' || t.status === 'closed');
const open = stats.items.filter(t => t.status === 'open' || t.status === 'in-progress');
return [
{ title: 'Tickets Ouverts', value: open.length, change: '+2', trend: 'up', icon: Ticket },
{ title: 'Tickets Résolus', value: resolved.length, change: '+5', trend: 'up', icon: CheckCircle },
{ title: 'SLA Moyen', value: '96%', change: '+1%', trend: 'up', icon: Clock },
{ title: 'Tickets Critiques', value: stats.items.filter(t => t.priority === 'critical').length, change: '-1', trend: 'down', icon: AlertTriangle },
];
}, [period]);
const columns = [
{ key: 'number', label: 'Numéro', sortable: true },
{ key: 'client', label: 'Client', sortable: true },
{ key: 'subject', label: 'Sujet', sortable: true },
{
key: 'priority',
label: 'Priorité',
sortable: true,
render: (value) => <StatusBadge status={value} />
},
{
key: 'status',
label: 'Statut',
sortable: true,
render: (value) => <StatusBadge status={value} />
},
{ key: 'assignedTo', label: 'Assigné à', sortable: true },
{ key: 'openDate', label: 'Date ouverture', sortable: true },
];
const actions = (row) => (
<button
onClick={() => navigate(`/tickets/${row.id}`)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="Voir"
>
<Eye className="w-4 h-4" />
</button>
);
return (
<>
<Helmet>
<title>Tickets - Bijou ERP</title>
<meta name="description" content="Gestion des tickets support" />
</Helmet>
<div className="space-y-6">
<KPIBar kpis={kpis} period={period} onPeriodChange={setPeriod} />
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Tickets Support</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{mockTickets.length} tickets</p>
</div>
</div>
<DataTable
columns={columns}
data={mockTickets}
onRowClick={(row) => navigate(`/tickets/${row.id}`)}
actions={actions}
/>
</div>
</>
);
};
export default TicketsPage;

View file

@ -0,0 +1,465 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Helmet } from 'react-helmet';
import {
ArrowLeft, Edit, Copy, Package, Tag, Box, BarChart3, Euro,
Building2, Truck, Calendar, Hash, Scale, Percent, AlertTriangle,
CheckCircle, XCircle, MoreVertical,
PlusCircle
} from 'lucide-react';
import Tabs from '@/components/Tabs';
import PrimaryButton_v2 from '@/components/PrimaryButton_v2';
import { cn } from '@/lib/utils';
import { useAppSelector } from '@/store/hooks';
import { getArticleSelected } from '@/store/features/article/selectors';
import { Article } from '@/types/articleType';
import { toast } from '@/components/ui/use-toast';
import { ModalArticle } from '@/components/modal/ModalArticle';
import { ModalStock } from '@/components/modal/ModalStock';
// Composants réutilisables
const InfoRow = ({ label, value, mono = false }: { label: string; value?: string | number | null; mono?: boolean }) => {
if (value === null || value === undefined || value === '') return null;
return (
<div>
<p className="text-xs text-gray-500 uppercase mb-1">{label}</p>
<p className={cn("font-medium text-gray-900 dark:text-white", mono && "font-mono")}>{value}</p>
</div>
);
};
const StatCard = ({ label, value, color, icon: Icon }: { label: string; value: string | number; color: string; icon?: any }) => (
<div className={cn("p-4 rounded-xl border", color)}>
<div className="flex items-center justify-between">
<p className="text-xs uppercase font-bold opacity-80">{label}</p>
{Icon && <Icon className="w-4 h-4 opacity-60" />}
</div>
<p className="text-2xl font-bold mt-1">{value}</p>
</div>
);
const SectionCard = ({ icon: Icon, title, children, className = "" }: { icon: any; title: string; children: React.ReactNode; className?: string }) => (
<div className={cn("bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6", className)}>
<h3 className="text-lg font-bold mb-4 flex items-center gap-2 text-gray-900 dark:text-white">
<Icon className="w-5 h-5 text-[#007E45]" /> {title}
</h3>
{children}
</div>
);
const ArticleDetailPage = () => {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState('details');
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isEditModalStockOpen, setIsEditModalStockOpen] = useState(false);
const [editing, setEditing] = useState<Article | null>(null);
const article = useAppSelector(getArticleSelected) as Article;
console.log("article ! ",article);
if (!article) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-gray-500">Article non trouvé</p>
</div>
);
}
const tabs = [
{ id: 'details', label: 'Détails Article' },
{ id: 'pricing', label: 'Tarification' },
{ id: 'stock', label: 'Stock & Logistique' },
{ id: 'history', label: 'Historique' },
];
// Calculs
const marge = article.prix_achat && article.prix_vente
? ((article.prix_vente - article.prix_achat) / article.prix_vente * 100)
: null;
const stockMax = article.stock_maxi || 100;
const stockPercent = Math.min((article.stock_reel / stockMax) * 100, 100);
const isLowStock = article.stock_mini && article.stock_reel < article.stock_mini;
const getBadges = () => {
const badges = [];
if (article.est_actif === false) badges.push({ label: 'Inactif', color: 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-300' });
if (article.en_sommeil) badges.push({ label: 'En sommeil', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/20 dark:text-orange-300' });
if (isLowStock) badges.push({ label: 'Stock bas', color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-300' });
return badges;
};
const handleCopyReference = () => {
navigator.clipboard.writeText(article.reference);
toast({ title: "Copié", description: "Référence copiée dans le presse-papier" });
};
const handleEdit = (row: Article) => {
setEditing(row);
setIsEditModalOpen(true);
};
const handleStock = (row: Article) => {
setEditing(row);
setIsEditModalStockOpen(true);
};
return (
<>
<Helmet>
<title>{article.reference} - {article.designation} - Dataven</title>
</Helmet>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4">
<button
onClick={() => navigate('/home/articles')}
className="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white transition-colors w-fit"
>
<ArrowLeft className="w-4 h-4" />
Retour au articles
</button>
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4 bg-white dark:bg-gray-950 p-6 rounded-2xl border border-gray-200 dark:border-gray-800">
<div className="flex items-start gap-4">
<div style={{ width: "7vh", height: "7vh" }} className="w-16 h-16 rounded-2xl bg-gray-100 dark:bg-gray-900 flex items-center justify-center shadow-sm text-gray-400">
<Package className="w-8 h-8" />
</div>
<div>
<div className="flex items-center gap-3 mb-1">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{article.designation}</h1>
{article.est_actif !== false ? (
<span className="px-2 py-0.5 bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded-lg text-xs font-medium flex items-center gap-1">
<CheckCircle className="w-3 h-3" /> Actif
</span>
) : (
<span className="px-2 py-0.5 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300 rounded-lg text-xs font-medium flex items-center gap-1">
<XCircle className="w-3 h-3" /> Inactif
</span>
)}
</div>
{article.designation_complementaire && (
<p className="text-gray-600 dark:text-gray-400 text-sm mb-2">{article.designation_complementaire}</p>
)}
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="font-mono bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded text-xs text-gray-700 dark:text-gray-300">
{article.reference}
</span>
{article.famille_libelle && (
<span className="px-2 py-0.5 bg-purple-100 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 rounded text-xs">
{article.famille_libelle}
</span>
)}
{article.type_article_libelle && (
<span className="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 rounded text-xs">
{article.type_article_libelle}
</span>
)}
<span className={cn(
"font-medium",
isLowStock ? "text-green-600" : "text-gray-600 dark:text-gray-400"
)}>
Stock: <strong>{article.stock_reel}</strong> {article.unite_vente || 'unités'}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 mt-3">
{getBadges().map((badge, i) => (
<span key={i} className={cn("px-2.5 py-0.5 rounded-lg text-xs font-medium", badge.color)}>
{badge.label}
</span>
))}
</div>
</div>
</div>
<div className="flex gap-3">
<button
onClick={handleCopyReference}
className="p-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
title="Copier la référence"
>
<Copy className="w-5 h-5" />
</button>
<PrimaryButton_v2 icon={PlusCircle} onClick={() => handleStock(article)}>
Stock
</PrimaryButton_v2>
<PrimaryButton_v2 icon={Edit} onClick={() => handleEdit(article)}>
Modifier
</PrimaryButton_v2>
<button
className="p-2.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400"
title="More options"
>
<MoreVertical className="w-5 h-5" />
</button>
</div>
</div>
</div>
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
<div className="mt-6">
{activeTab === 'details' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<SectionCard icon={Tag} title="Identification">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Référence" value={article.reference} mono />
<InfoRow label="Code EAN" value={article.code_ean} mono />
<InfoRow label="Code Barre" value={article.code_barre} mono />
<InfoRow label="Famille" value={article.famille_libelle || article.famille_code} />
<InfoRow label="Type article" value={article.type_article_libelle} />
<InfoRow label="Unité de vente" value={article.unite_vente} />
</div>
{article.description && (
<div className="pt-4 border-t border-gray-100 dark:border-gray-800">
<p className="text-xs text-gray-500 uppercase mb-2">Description</p>
<p className="text-gray-900 dark:text-gray-300 bg-gray-50 dark:bg-gray-900 p-3 rounded-lg text-sm">
{article.description}
</p>
</div>
)}
</div>
</SectionCard>
<div className="space-y-6">
<SectionCard icon={BarChart3} title="Aperçu rapide">
<div className="grid grid-cols-2 gap-4">
<StatCard
label="Prix de vente HT"
value={`${article.prix_vente.toFixed(2)}`}
color="bg-green-50 dark:bg-green-900/20 border-green-100 dark:border-green-800 text-green-700 dark:text-green-300"
icon={Euro}
/>
<StatCard
label="Stock disponible"
value={article.stock_disponible ?? article.stock_reel}
color={isLowStock
? "bg-red-50 dark:bg-red-900/20 border-red-100 dark:border-red-800 text-red-700 dark:text-red-300"
: "bg-blue-50 dark:bg-blue-900/20 border-blue-100 dark:border-blue-800 text-blue-700 dark:text-blue-300"
}
icon={Box}
/>
</div>
</SectionCard>
{article.fournisseur_nom && (
<SectionCard icon={Truck} title="Fournisseur principal">
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-xl">
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 dark:text-blue-400">
<Building2 className="w-5 h-5" />
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">{article.fournisseur_nom}</p>
{article.fournisseur_principal && (
<p className="text-xs text-gray-500 font-mono">{article.fournisseur_principal}</p>
)}
</div>
</div>
{article.unite_achat && (
<p className="text-sm text-gray-500 mt-3">
Unité d'achat: <span className="font-medium text-gray-700 dark:text-gray-300">{article.unite_achat}</span>
</p>
)}
</SectionCard>
)}
{(article.poids || article.volume) && (
<SectionCard icon={Scale} title="Caractéristiques physiques">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Poids" value={article.poids ? `${article.poids} kg` : null} />
<InfoRow label="Volume" value={article.volume ? `${article.volume}` : null} />
</div>
</SectionCard>
)}
</div>
</div>
)}
{activeTab === 'pricing' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<SectionCard icon={Euro} title="Prix et Marges" className="lg:col-span-2">
<div className="grid grid-cols-3 gap-8 mb-8">
<div>
<p className="text-sm text-gray-500 mb-1">Prix d'achat</p>
<p className="text-xl font-bold text-gray-900 dark:text-white">
{article.prix_achat ? `${article.prix_achat.toFixed(2)}` : '-'}
</p>
</div>
<div>
<p className="text-sm text-gray-500 mb-1">Prix de vente HT</p>
<p className="text-xl font-bold text-[#007E45]">{article.prix_vente.toFixed(2)} </p>
</div>
<div>
<p className="text-sm text-gray-500 mb-1">Marge</p>
<p className={cn("text-xl font-bold", marge && marge > 0 ? "text-green-600" : "text-gray-400")}>
{marge ? `${marge.toFixed(1)}%` : '-'}
</p>
</div>
</div>
{article.prix_revient && (
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-xl mb-6">
<div className="flex justify-between items-center">
<span className="text-gray-600 dark:text-gray-400">Prix de revient</span>
<span className="font-bold text-gray-900 dark:text-white">{article.prix_revient.toFixed(2)} </span>
</div>
</div>
)}
<div className="pt-6 border-t border-gray-100 dark:border-gray-800">
<h4 className="font-semibold mb-4 flex items-center gap-2">
<Percent className="w-4 h-4 text-gray-400" /> TVA & Comptabilité
</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex justify-between p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<span className="text-gray-500">Code TVA</span>
<span className="font-mono font-medium">{article.tva_code || '-'}</span>
</div>
<div className="flex justify-between p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<span className="text-gray-500">Taux TVA</span>
<span className="font-medium">{article.tva_taux ? `${article.tva_taux}%` : '-'}</span>
</div>
</div>
</div>
</SectionCard>
<SectionCard icon={BarChart3} title="Rentabilité">
<div className="space-y-4">
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-100 dark:border-blue-800">
<p className="text-xs text-blue-600 dark:text-blue-400 uppercase font-bold">Ventes 30j</p>
<p className="text-2xl font-bold text-blue-700 dark:text-blue-300 mt-1">-</p>
</div>
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-xl border border-green-100 dark:border-green-800">
<p className="text-xs text-green-600 dark:text-green-400 uppercase font-bold">CA Généré</p>
<p className="text-2xl font-bold text-green-700 dark:text-green-300 mt-1">-</p>
</div>
</div>
</SectionCard>
</div>
)}
{activeTab === 'stock' && (
<div className="space-y-6">
<SectionCard icon={Box} title="État des stocks">
<div className="flex items-center gap-4 mb-6">
<Box className="w-8 h-8 text-gray-400" />
<div>
<h4 className="font-bold text-gray-900 dark:text-white">Stock global</h4>
<p className="text-sm text-gray-500">Tous entrepôts confondus</p>
</div>
{isLowStock && (
<div className="ml-auto flex items-center gap-2 px-3 py-1.5 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 rounded-lg text-sm font-medium">
<AlertTriangle className="w-4 h-4" />
Stock bas
</div>
)}
</div>
<div className="w-full bg-gray-100 dark:bg-gray-900 rounded-full h-4 mb-2 overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
isLowStock ? "bg-red-500" : "bg-blue-500"
)}
style={{ width: `${stockPercent}%` }}
/>
</div>
<div className="flex justify-between text-sm text-gray-500 mb-6">
<span>0</span>
<span>Stock actuel: <strong className={isLowStock ? "text-red-600" : ""}>{article.stock_reel}</strong></span>
<span>Max: {stockMax}</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 border-t border-gray-100 dark:border-gray-800 pt-6">
<div>
<p className="text-xs text-gray-500 uppercase font-bold">Stock réel</p>
<p className="text-xl font-bold">{article.stock_reel}</p>
</div>
<div>
<p className="text-xs text-gray-500 uppercase font-bold">Disponible</p>
<p className="text-xl font-bold text-green-600">{article.stock_disponible ?? article.stock_reel}</p>
</div>
<div>
<p className="text-xs text-gray-500 uppercase font-bold">Réservé</p>
<p className="text-xl font-bold text-orange-500">{article.stock_reserve ?? 0}</p>
</div>
<div>
<p className="text-xs text-gray-500 uppercase font-bold">En commande</p>
<p className="text-xl font-bold text-blue-500">{article.stock_commande ?? 0}</p>
</div>
<div>
<p className="text-xs text-gray-500 uppercase font-bold">Stock mini</p>
<p className="text-xl font-bold text-gray-400">{article.stock_mini ?? '-'}</p>
</div>
</div>
</SectionCard>
{(article.unite_vente || article.unite_achat || article.poids || article.volume) && (
<SectionCard icon={Scale} title="Logistique">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<InfoRow label="Unité de vente" value={article.unite_vente} />
<InfoRow label="Unité d'achat" value={article.unite_achat} />
<InfoRow label="Poids" value={article.poids ? `${article.poids} kg` : null} />
<InfoRow label="Volume" value={article.volume ? `${article.volume}` : null} />
</div>
</SectionCard>
)}
</div>
)}
{activeTab === 'history' && (
<SectionCard icon={Calendar} title="Historique">
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
<p className="text-xs text-gray-500 uppercase mb-1">Date de création</p>
<p className="font-medium text-gray-900 dark:text-white">
{article.date_creation ? new Date(article.date_creation).toLocaleDateString('fr-FR', {
day: 'numeric', month: 'long', year: 'numeric'
}) : '-'}
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-xl">
<p className="text-xs text-gray-500 uppercase mb-1">Dernière modification</p>
<p className="font-medium text-gray-900 dark:text-white">
{article.date_modification ? new Date(article.date_modification).toLocaleDateString('fr-FR', {
day: 'numeric', month: 'long', year: 'numeric'
}) : '-'}
</p>
</div>
</div>
<div className="text-center py-8 bg-gray-50 dark:bg-gray-900 rounded-xl border border-dashed border-gray-200 dark:border-gray-800">
<Calendar className="w-10 h-10 text-gray-300 mx-auto mb-2" />
<p className="text-gray-500">Aucun mouvement de stock récent</p>
<p className="text-sm text-gray-400 mt-1">L'historique des mouvements apparaîtra ici</p>
</div>
</SectionCard>
)}
</div>
<ModalArticle
open={isEditModalOpen}
onClose={() => setIsEditModalOpen(false)}
title={editing ? `Mettre à jour l'article ${editing.reference}` : "Crée une article"}
editing={editing}
/>
<ModalStock
open={isEditModalStockOpen}
onClose={() => setIsEditModalStockOpen(false)}
title={editing ? `Mettre à jour le stock de l'article ${editing.reference}` : "Ajouter stock"}
editing={editing}
/>
</div>
</>
);
};
export default ArticleDetailPage;

View file

@ -0,0 +1,315 @@
import { useState, useMemo, useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { Plus, Eye, Edit, Trash2, MoreVertical, Package, Copy, Euro, Box, AlertTriangle, Percent } from 'lucide-react';
import DataTable from '@/components/DataTable';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { articleStatus, getAllArticles } from '@/store/features/article/selectors';
import { getArticles } from '@/store/features/article/thunk';
import { selectArticle } from '@/store/features/article/slice';
import { Article } from '@/types/articleType';
import KPIBar, { PeriodType } from '@/components/KPIBar';
import { filterItemByPeriod, hasSpecialChars } from '@/components/filter/ItemsFilter';
import PrimaryButton_v2 from '@/components/PrimaryButton_v2';
import { ModalArticle } from '@/components/modal/ModalArticle';
import StatusBadgetLettre from '@/components/StatusBadgetLettre';
import { familleStatus } from '@/store/features/famille/selectors';
import { getfamilles } from '@/store/features/famille/thunk';
import { FAMILLE_COLORS } from './ProductFamiliesPage';
import ColumnSelector, { ColumnConfig } from '@/components/common/ColumnSelector';
import PeriodSelector from '@/components/common/PeriodSelector';
import { useDashboardData } from '@/store/hooks/useAppData';
// ============================================
// CONFIGURATION DES COLONNES
// ============================================
const DEFAULT_COLUMNS: ColumnConfig[] = [
{ key: 'reference', label: 'Référence', visible: true, locked: true },
{ key: 'designation', label: 'Désignation', visible: true },
{ key: 'famille_code', label: 'Famille', visible: true },
{ key: 'prix_vente', label: 'Prix de vente', visible: true },
{ key: 'stock_reel', label: 'Stock', visible: true },
{ key: 'est_actif', label: 'Statut', visible: true },
];
const ArticlesPage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editing, setEditing] = useState<Article | null>(null);
const [period, setPeriod] = useState<PeriodType>('all');
// État des colonnes visibles
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(DEFAULT_COLUMNS);
const articles = useAppSelector(getAllArticles) as Article[];
const statusArticle = useAppSelector(articleStatus);
const statusFamille = useAppSelector(familleStatus);
const { refresh } = useDashboardData();
const isLoading = statusArticle === 'loading' && articles.length === 0;
useEffect(() => {
const load = async () => {
if (statusFamille === 'idle') await dispatch(getfamilles()).unwrap();
};
load();
}, [statusFamille, dispatch]);
useEffect(() => {
const load = async () => {
if (statusArticle === 'idle') await dispatch(getArticles()).unwrap();
};
load();
}, [statusArticle, dispatch]);
const filteredArticles = useMemo(() => {
return [
...filterItemByPeriod(
articles
.map(article => ({
...article,
date_creation: article.date_creation ?? undefined,
}))
.filter(article => !hasSpecialChars(article.reference ?? '')),
period,
'date_creation'
),
].sort((a, b) => b.stock_reel - a.stock_reel);
}, [articles, period]);
const kpis = useMemo(() => {
const total = filteredArticles.length;
const actifs = filteredArticles.filter(a => a.est_actif !== false);
const inactifs = filteredArticles.filter(a => a.est_actif === false);
const stockTotal = filteredArticles.reduce((sum, a) => sum + (a.stock_reel || 0), 0);
const stockDisponible = filteredArticles.reduce((sum, a) => sum + (a.stock_disponible || a.stock_reel || 0), 0);
const stockReserve = filteredArticles.reduce((sum, a) => sum + (a.stock_reserve || 0), 0);
const stockBas = filteredArticles.filter(a => a.stock_mini && a.stock_reel < a.stock_mini);
const rupture = filteredArticles.filter(a => a.stock_reel <= 0);
const valeurStockAchat = filteredArticles.reduce((sum, a) => sum + (a.prix_achat || a.prix_revient || 0) * (a.stock_reel || 0), 0);
const valeurStockVente = filteredArticles.reduce((sum, a) => sum + (a.prix_vente || 0) * (a.stock_reel || 0), 0);
const prixVenteMoyen = total > 0 ? filteredArticles.reduce((sum, a) => sum + (a.prix_vente || 0), 0) / total : 0;
const articlesAvecMarge = filteredArticles.filter(a => a.prix_vente && a.prix_achat && a.prix_vente > 0);
const margeMoyenne =
articlesAvecMarge.length > 0
? articlesAvecMarge.reduce((sum, a) => {
const marge = ((a.prix_vente - (a.prix_achat || 0)) / a.prix_vente) * 100;
return sum + marge;
}, 0) / articlesAvecMarge.length
: 0;
return [
{
title: 'Total Articles',
value: total,
change: `${actifs.length} actifs`,
trend: 'up' as const,
icon: Package,
subtitle: `${inactifs.length} inactif${inactifs.length > 1 ? 's' : ''}`,
color: 'blue',
},
{
title: 'Valeur Stock',
value: `${(valeurStockVente / 1000).toFixed(1)}K€`,
change: `PV`,
trend: 'up' as const,
icon: Euro,
subtitle: `${(valeurStockAchat / 1000).toFixed(1)}K€ (PA)`,
color: 'green',
},
{
title: 'Stock Total',
value: stockTotal.toLocaleString('fr-FR'),
change: `${stockDisponible.toLocaleString()} dispo`,
trend: 'up' as const,
icon: Box,
subtitle: `${stockReserve} réservé${stockReserve > 1 ? 's' : ''}`,
color: 'purple',
},
{
title: 'Alertes Stock',
value: stockBas.length + rupture.length,
change: `${rupture.length} rupture${rupture.length > 1 ? 's' : ''}`,
trend: stockBas.length + rupture.length > 0 ? ('down' as const) : ('up' as const),
icon: AlertTriangle,
subtitle: `${stockBas.length} stock${stockBas.length > 1 ? 's' : ''} bas`,
color: 'red',
},
{
title: 'Marge Moyenne',
value: `${margeMoyenne.toFixed(1)}%`,
change: articlesAvecMarge.length > 0 ? `sur ${articlesAvecMarge.length} art.` : '',
trend: margeMoyenne >= 30 ? ('up' as const) : ('down' as const),
icon: Percent,
subtitle: `Prix moyen: ${prixVenteMoyen.toFixed(0)}`,
color: 'teal',
},
];
}, [filteredArticles]);
const handleCreate = () => {
setEditing(null);
setIsModalOpen(true);
};
const handleEdit = (article: Article) => {
dispatch(selectArticle(article));
setEditing(article);
setIsModalOpen(true);
};
const getFamilleColor = (familleCode: string) => {
if (!familleCode) return FAMILLE_COLORS[0];
const hash = familleCode.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
return FAMILLE_COLORS[hash % FAMILLE_COLORS.length];
};
// ============================================
// COLONNES DYNAMIQUES
// ============================================
const allColumnsDefinition = useMemo(() => {
return {
reference: { key: 'reference', label: 'Référence', sortable: true },
designation: { key: 'designation', label: 'Désignation', sortable: true },
famille_code: {
key: 'famille_code',
label: 'Famille',
sortable: true,
render: (familleCode: string) => {
const color = getFamilleColor(familleCode);
return (
<span
style={{
borderRadius: '20px',
backgroundColor: `${color}20`,
color: color,
padding: '4px 8px',
fontSize: '12px',
fontWeight: 500,
}}
>
{familleCode}
</span>
);
},
},
prix_vente: {
key: 'prix_vente',
label: 'Prix de vente',
sortable: true,
render: (v: any) => `${v?.toFixed(2) || '0.00'}`,
},
stock_reel: { key: 'stock_reel', label: 'Stock', sortable: true },
est_actif: {
key: 'est_actif',
label: 'Statut',
sortable: true,
render: (isActive: any) => <StatusBadgetLettre status={isActive ? 'actif' : 'inactif'} />,
},
};
}, []);
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: Article) => (
<>
<button
onClick={() => {
dispatch(selectArticle(row));
navigate(`/home/articles/${row.reference.replace(/\//g, '')}`);
}}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-gray-600 dark:text-gray-400"
title="Voir"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleEdit(row)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-gray-600 dark:text-gray-400"
title="Modifier"
>
<Edit className="w-4 h-4" />
</button>
<div className="relative group">
<button
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-gray-600 dark:text-gray-400"
title="Menu"
>
<MoreVertical className="w-4 h-4" />
</button>
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-950 rounded-xl shadow-xl border border-gray-200 dark:border-gray-800 hidden group-hover:block z-10 p-1">
<button className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-900 rounded-lg flex items-center gap-2">
<Copy className="w-4 h-4" /> Dupliquer
</button>
<button className="w-full text-left px-3 py-2 text-sm hover:bg-red-50 dark:hover:bg-red-900/20 text-red-600 rounded-lg flex items-center gap-2">
<Trash2 className="w-4 h-4" /> Désactiver
</button>
</div>
</div>
</>
);
return (
<>
<Helmet>
<title>Articles - Dataven</title>
</Helmet>
<div className="space-y-6">
<KPIBar kpis={kpis} period={period} loading={statusArticle} onRefresh={refresh} />
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Catalogue Articles</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{filteredArticles.length} articles référencés</p>
</div>
<PeriodSelector value={period} onChange={setPeriod} />
</div>
<div className="flex gap-3">
<ColumnSelector columns={columnConfig} onChange={setColumnConfig} />
<PrimaryButton_v2 icon={Plus} onClick={handleCreate}>
Nouvel article
</PrimaryButton_v2>
</div>
</div>
<DataTable
columns={visibleColumns}
data={filteredArticles}
onRowClick={(row: Article) => {
dispatch(selectArticle(row));
navigate(`/home/articles/${row.reference.replace(/\//g, '')}`);
}}
actions={actions}
status={isLoading}
/>
</div>
<ModalArticle
open={isModalOpen}
onClose={() => setIsModalOpen(false)}
title={editing ? `Mettre à jour l'article ${editing.reference}` : 'Nouvel article'}
editing={editing}
/>
</>
);
};
export default ArticlesPage;

View file

@ -0,0 +1,379 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Helmet } from 'react-helmet';
import { Plus, Eye, Edit, Mail, Phone } from 'lucide-react';
import DataTable from '@/components/DataTable';
import StatusBadgetLettre from '@/components/StatusBadgetLettre';
import { toast } from '@/components/ui/use-toast';
import { Commercial } from '@/types/commercialType';
import { commercialsStatus, getAllcommercials } from '@/store/features/commercial/selectors';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { useNavigate } from 'react-router-dom';
import { getCommercials } from '@/store/features/commercial/thunk';
import PrimaryButton_v2 from '@/components/PrimaryButton_v2';
import ColumnSelector, { ColumnConfig } from '@/components/common/ColumnSelector';
import AdvancedFilters from '@/components/common/AdvancedFilters';
import ExportDropdown from '@/components/common/ExportDropdown';
import ModalCommercial from '@/components/modal/ModalCommercial';
// ============================================
// CONFIGURATION DES COLONNES
// ============================================
const DEFAULT_COLUMNS: ColumnConfig[] = [
{ key: 'numero', label: 'Numéro', visible: true, locked: true },
{ key: 'fullName', label: 'Nom complet', visible: true },
{ key: 'fonction', label: 'Fonction', visible: true },
{ key: 'email', label: 'Email', visible: true },
{ key: 'telephone', label: 'Téléphone', visible: true },
{ key: 'service', label: 'Service', visible: false },
{ key: 'ville', label: 'Ville', visible: false },
{ key: 'roles', label: 'Rôles', visible: true },
{ key: 'est_actif', label: 'Statut', visible: true },
];
const CommercialPage = () => {
const dispatch = useAppDispatch();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editing, setEditing] = useState<Commercial | null>(null);
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(DEFAULT_COLUMNS);
const [activeFilters, setActiveFilters] = useState<Record<string, string[] | undefined>>({});
const commercials = useAppSelector(getAllcommercials) as Commercial[];
const statusCommercial = useAppSelector(commercialsStatus);
console.log("commercials : ",commercials);
const isLoading = statusCommercial === 'loading' && commercials.length === 0;
const handleRefresh = async () => {
await dispatch(getCommercials()).unwrap();
};
useEffect(() => {
const load = async () => {
if (statusCommercial === 'idle') await dispatch(getCommercials()).unwrap();
};
load();
}, [statusCommercial, dispatch]);
// ============================================
// FILTRES
// ============================================
const filterDefinitions = useMemo(() => [
{
key: 'role',
label: 'Rôle',
options: [
{ value: 'vendeur', label: 'Vendeur', color: 'bg-blue-400' },
{ value: 'acheteur', label: 'Acheteur', color: 'bg-purple-400' },
{ value: 'caissier', label: 'Caissier', color: 'bg-orange-400' },
{ value: 'chef_ventes', label: 'Chef des ventes', color: 'bg-yellow-400' },
],
},
], []);
// ============================================
// DONNÉES FILTRÉES
// ============================================
const filteredCommercials = useMemo(() => {
let result = [...commercials];
// Filtre par rôle
if (activeFilters.role && activeFilters.role.length > 0) {
result = result.filter(c => {
const roles: string[] = [];
if (c.vendeur) roles.push('vendeur');
if (c.acheteur) roles.push('acheteur');
if (c.caissier) roles.push('caissier');
if (c.chef_ventes) roles.push('chef_ventes');
return activeFilters.role!.some(r => roles.includes(r));
});
}
return result.sort((a, b) => a.numero - b.numero);
}, [commercials, activeFilters]);
// ============================================
// COLONNES
// ============================================
const allColumnsDefinition = useMemo(() => ({
numero: {
key: 'numero',
label: 'Numéro',
sortable: true,
render: (value: number) => (
<span className="font-mono text-sm font-medium text-gray-900 dark:text-white">
#{value}
</span>
),
},
fullName: {
key: 'fullName',
label: 'Nom complet',
sortable: true,
render: (_: any, row: Commercial) => {
const initials = `${row.prenom?.charAt(0) || ''}${row.nom?.charAt(0) || ''}`.toUpperCase();
return (
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-[#007E45]/10 flex items-center justify-center text-xs font-bold text-[#007E45]">
{initials || '?'}
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{row.prenom} {row.nom}
</p>
{row.matricule && (
<p className="text-xs text-gray-500">Mat: {row.matricule}</p>
)}
</div>
</div>
);
},
},
fonction: {
key: 'fonction',
label: 'Fonction',
sortable: true,
render: (value: string) => (
<span className="text-sm text-gray-700 dark:text-gray-300">
{value || '-'}
</span>
),
},
email: {
key: 'email',
label: 'Email',
sortable: true,
render: (value: string | null) => value ? (
<a
href={`mailto:${value}`}
className="text-sm text-[#007E45] hover:underline flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
<Mail className="w-3 h-3" />
{value}
</a>
) : (
<span className="text-gray-400">-</span>
),
},
telephone: {
key: 'telephone',
label: 'Téléphone',
sortable: true,
render: (value: string, row: Commercial) => {
const phone = value || row.tel_portable;
return phone ? (
<a
href={`tel:${phone}`}
className="text-sm text-gray-700 dark:text-gray-300 hover:text-[#007E45] flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
<Phone className="w-3 h-3" />
{phone}
</a>
) : (
<span className="text-gray-400">-</span>
);
},
},
service: {
key: 'service',
label: 'Service',
sortable: true,
render: (value: string) => (
<span className="text-sm text-gray-600 dark:text-gray-400">
{value || '-'}
</span>
),
},
ville: {
key: 'ville',
label: 'Ville',
sortable: true,
render: (value: string, row: Commercial) => (
<span className="text-sm text-gray-600 dark:text-gray-400">
{value ? `${value}${row.code_postal ? ` (${row.code_postal})` : ''}` : '-'}
</span>
),
},
roles: {
key: 'roles',
label: 'Rôles',
sortable: false,
render: (_: any, row: Commercial) => {
const roles: { label: string; color: string }[] = [];
if (row.vendeur) roles.push({ label: 'Vendeur', color: 'bg-blue-100 text-blue-700' });
if (row.acheteur) roles.push({ label: 'Acheteur', color: 'bg-purple-100 text-purple-700' });
if (row.caissier) roles.push({ label: 'Caissier', color: 'bg-orange-100 text-orange-700' });
if (row.chef_ventes) roles.push({ label: 'Chef', color: 'bg-yellow-100 text-yellow-700' });
if (roles.length === 0) return <span className="text-gray-400">-</span>;
return (
<div className="flex flex-wrap gap-1">
{roles.slice(0, 2).map((role, i) => (
<span
key={i}
className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${role.color}`}
>
{role.label}
</span>
))}
{roles.length > 2 && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-medium bg-gray-100 text-gray-600">
+{roles.length - 2}
</span>
)}
</div>
);
},
},
}), []);
const visibleColumns = useMemo(() => {
return columnConfig
.filter(col => col.visible)
.map(col => allColumnsDefinition[col.key as keyof typeof allColumnsDefinition])
.filter(Boolean);
}, [columnConfig, allColumnsDefinition]);
// ============================================
// FORMATEURS EXPORT
// ============================================
const columnFormatters: Record<string, (value: any, row: Commercial) => string> = {
fullName: (_, row) => `${row.prenom || ''} ${row.nom || ''}`.trim(),
roles: (_, row) => {
const roles: string[] = [];
if (row.vendeur) roles.push('Vendeur');
if (row.acheteur) roles.push('Acheteur');
if (row.caissier) roles.push('Caissier');
if (row.chef_ventes) roles.push('Chef des ventes');
return roles.join(', ') || '-';
},
est_actif: (value) => value ? 'Actif' : 'Inactif',
telephone: (value, row) => value || row.tel_portable || '-',
};
// ============================================
// HANDLERS
// ============================================
const handleCreate = () => {
setEditing(null);
setIsModalOpen(true);
};
const handleEdit = (row: Commercial) => {
setEditing(row);
setIsModalOpen(true);
};
const actions = (row: Commercial) => (
<>
<button
onClick={(e) => {
e.stopPropagation();
// navigate(`/home/commercials/${row.numero}`);
toast({ title: 'Voir détails', description: `Commercial #${row.numero}` });
}}
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();
handleEdit(row);
}}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="Modifier"
>
<Edit className="w-4 h-4" />
</button>
{row.email && (
<button
onClick={(e) => {
e.stopPropagation();
window.location.href = `mailto:${row.email}`;
}}
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>Commerciaux - Dataven</title>
<meta name="description" content="Gestion des commerciaux" />
</Helmet>
<div className="space-y-6">
{/* Header */}
<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">Commerciaux</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{filteredCommercials.length} commercial{filteredCommercials.length > 1 ? 'x' : ''}
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<ColumnSelector columns={columnConfig} onChange={setColumnConfig} />
<ExportDropdown
data={filteredCommercials}
columns={columnConfig}
columnFormatters={columnFormatters}
filename="commerciaux"
/>
<AdvancedFilters
filters={filterDefinitions}
activeFilters={activeFilters}
onFilterChange={(key, values) => {
setActiveFilters(prev => ({ ...prev, [key]: values }));
}}
onReset={() => setActiveFilters({})}
/>
<PrimaryButton_v2 icon={Plus} onClick={handleCreate}>
Nouveau commercial
</PrimaryButton_v2>
</div>
</div>
{/* DataTable */}
<DataTable
columns={visibleColumns}
data={filteredCommercials}
onRowClick={(row: Commercial) => {
toast({ title: 'Voir détails', description: `Commercial: ${row.prenom} ${row.nom}` });
}}
actions={actions}
status={isLoading}
searchLabel="Rechercher par: nom, email, fonction..."
/>
</div>
{/* Modal */}
<ModalCommercial
open={isModalOpen}
onClose={() => setIsModalOpen(false)}
editing={editing}
/>
</>
);
};
export default CommercialPage;

View file

@ -0,0 +1,228 @@
import { useState, useMemo, useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { Plus, Eye, Trash2, MoreVertical, Layers, Package, CheckCircle, Tag } from 'lucide-react';
import DataTable from '@/components/DataTable';
import KPIBar, { PeriodType } from '@/components/KPIBar';
import { mockProductFamilies, calculateKPIs } from '@/data/mockData';
import { cn } from '@/lib/utils';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { familleStatus, getAllfamilles } from '@/store/features/famille/selectors';
import { Famille } from '@/types/familleType';
import { getfamilles } from '@/store/features/famille/thunk';
import { selectfamille } from '@/store/features/famille/slice';
import PrimaryButton_v2 from '@/components/PrimaryButton_v2';
import { ModalFamille } from '@/components/modal/ModalFamille';
import { articleStatus, getAllArticles } from '@/store/features/article/selectors';
import { Article } from '@/types/articleType';
import { getArticles } from '@/store/features/article/thunk';
export const FAMILLE_COLORS = [
'#3B82F6', // blue
'#10B981', // green
'#8B5CF6', // purple
'#EC4899', // pink
'#F59E0B', // yellow
'#EF4444', // red
'#6366F1', // indigo
'#F97316', // orange
'#14B8A6', // teal
'#06B6D4', // cyan
'#10B981', // emerald
'#84CC16', // lime
'#F59E0B', // amber
'#F43F5E', // rose
'#8B5CF6', // violet
'#D946EF', // fuchsia
'#0EA5E9', // sky
];
const ProductFamiliesPage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch()
const [isModalOpen, setIsModalOpen] = useState(false);
const [period, setPeriod] = useState<PeriodType>('all');
const [editing, setEditing] = useState<Famille | null>(null);
const articles = useAppSelector(getAllArticles) as Article[];
const statusArticle = useAppSelector(articleStatus)
const familles = useAppSelector(getAllfamilles) as Famille[];
const statusFamille = useAppSelector(familleStatus)
const handleRefresh = async () => {
await dispatch(getfamilles()).unwrap();
};
const isLoading = statusFamille === 'loading' && statusArticle === 'loading' && familles.length === 0 && articles.length === 0;
useEffect(() => {
const load = async () => {
if (statusArticle === "idle") await dispatch(getArticles()).unwrap();
if (statusFamille === "idle") await dispatch(getfamilles()).unwrap();
}
load()
}, [statusFamille, statusArticle, dispatch])
// KPI Logic
const kpis = useMemo(() => {
const stats = calculateKPIs(mockProductFamilies, period, { dateField: 'createdAt', amountField: 'articleCount' });
const active = mockProductFamilies.filter(f => f.status === 'active');
return [
{ title: 'Total Familles', value: mockProductFamilies.length, change: '+1', trend: 'up', icon: Layers },
{ title: 'Familles Actives', value: active.length, change: '', trend: 'neutral', icon: CheckCircle },
{ title: 'Articles / Famille', value: Math.round(stats.totalAmount / (mockProductFamilies.length || 1)), change: '+2', trend: 'up', icon: Package },
{ title: 'Catégories', value: '4', change: '', trend: 'neutral', icon: Tag },
];
}, [period]);
const handleCreate = () => {
setEditing(null)
setIsModalOpen(true);
};
const handleEdit = (famille: Famille) => {
dispatch(selectfamille(famille))
setEditing(famille)
setIsModalOpen(true);
};
const articleCountByFamille = useMemo(() => {
return articles.reduce<Record<string, number>>((acc, article) => {
const familleCode = article.famille_code;
if (typeof familleCode === 'string' && familleCode.length > 0) {
acc[familleCode] = (acc[familleCode] || 0) + 1;
}
return acc;
}, {});
}, [articles]);
const famillesWithCount = useMemo(() => {
return familles.map((famille, index) => ({
...famille,
nb_articles: articleCountByFamille[famille?.code] || 0,
color: FAMILLE_COLORS[index % FAMILLE_COLORS.length] // ✅ Ajouter la couleur ici
}));
}, [familles, articleCountByFamille]);
const columns = useMemo(() => {
return [
{ key: 'code', label: 'Code', sortable: true },
{
key: 'intitule',
label: 'Intitulé',
sortable: true,
render: (value: string, row: Famille & { color: string }) => {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
style={{
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: row.color,
flexShrink: 0
}}
/>
<span style={{ fontWeight: 500 }}>{value}</span>
</div>
);
}
},
{
key: 'code',
label: 'Articles',
sortable: true,
render: (code: string) => {
const nbrArticle = articles.filter(article => article.famille_code === code).length;
return (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded-md text-xs font-medium">
{nbrArticle}
</span>
);
}
},
{ key: 'type_libelle', label: 'Type', sortable: false },
];
}, [articleCountByFamille]);
const actions = (row: any) => (
<>
<button
onClick={(e) => {
dispatch(selectfamille(row))
navigate(`/home/familles-articles/${row.code}`)
}}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-gray-600 dark:text-gray-400"
title="Voir"
>
<Eye className="w-4 h-4" />
</button>
{/* <button
onClick={(e) => handleEdit(row)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-gray-600 dark:text-gray-400"
title="Modifier"
>
<Edit className="w-4 h-4" />
</button> */}
<div className="relative group">
<button className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-gray-600 dark:text-gray-400" title="Menu">
<MoreVertical className="w-4 h-4" />
</button>
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-950 rounded-xl shadow-xl border border-gray-200 dark:border-gray-800 hidden group-hover:block z-10 p-1">
<button className="w-full text-left px-3 py-2 text-sm hover:bg-red-50 dark:hover:bg-red-900/20 text-red-600 rounded-lg flex items-center gap-2">
<Trash2 className="w-4 h-4" /> Supprimer
</button>
</div>
</div>
</>
);
return (
<>
<Helmet>
<title>Familles d'articles - Bijou ERP</title>
</Helmet>
<div className="space-y-6">
<KPIBar kpis={kpis} period={period} onPeriodChange={setPeriod} loading={statusFamille} onRefresh={handleRefresh} />
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Familles d'articles</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">Gérez la classification de vos produits</p>
</div>
<PrimaryButton_v2 icon={Plus} onClick={handleCreate}>
Nouvelle famille
</PrimaryButton_v2>
</div>
<DataTable
columns={columns}
data={famillesWithCount}
onRowClick={(row: Famille) => {
dispatch(selectfamille(row))
navigate(`/home/familles-articles/${row.code}`)
}}
actions={actions}
status={isLoading}
/>
</div>
<ModalFamille
open={isModalOpen}
onClose={() => setIsModalOpen(false)}
title={editing ? `Mettre à jour la famille d'article ${editing.code}` : "Nouvelle famille d'article"}
editing={editing}
/>
</>
);
};
export default ProductFamiliesPage;

View file

@ -0,0 +1,195 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Helmet } from 'react-helmet';
import { ArrowLeft, Edit, Copy, Trash2, Layers, Package, Info } from 'lucide-react';
import { mockProductFamilies, mockArticles, CompanyInfo } from '@/data/mockData';
import StatusBadge from '@/components/StatusBadget';
import Tabs from '@/components/Tabs';
import DataTable from '@/components/DataTable';
import ProductFamilyFormModal from '@/components/forms/ProductFamilyFormModal';
import { cn } from '@/lib/utils';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { getfamilleSelected } from '@/store/features/famille/selectors';
import { Famille } from '@/types/familleType';
import { Article } from '@/types/articleType';
import { articleStatus, getAllArticles } from '@/store/features/article/selectors';
import { getArticles } from '@/store/features/article/thunk';
import { selectArticle } from '@/store/features/article/slice';
import StatusBadgetLettre from '@/components/StatusBadgetLettre';
const ProductFamilyDetailPage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [activeTab, setActiveTab] = useState('details');
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const articles = useAppSelector(getAllArticles) as Article[];
const statusArticle = useAppSelector(articleStatus)
const famille = useAppSelector(getfamilleSelected) as Famille
if (!famille) return <div>Famille introuvable</div>;
const isLoading = statusArticle === 'loading' && articles.length === 0;
useEffect(() => {
const load = async () => {
if (statusArticle === "idle") await dispatch(getArticles()).unwrap();
}
load()
}, [statusArticle, dispatch])
const articleFiltered = articles.filter(item => item.famille_code === famille.code)
const tabs = [
{ id: 'details', label: 'Détails' },
{ id: 'articles', label: 'Articles associés' },
{ id: 'timeline', label: 'Historique' },
];
return (
<>
<Helmet>
<title>{famille.code} - {CompanyInfo.name}</title>
</Helmet>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4">
<button
onClick={() => navigate('/home/familles-articles')}
className="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white transition-colors w-fit"
>
<ArrowLeft className="w-4 h-4" />
Retour aux familles
</button>
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div style={{ width: "7vh", height: "7vh" }} className={cn("w-16 h-16 rounded-2xl flex items-center justify-center shadow-sm bg-gray-700")}>
<Layers className="w-8 h-8" />
</div>
<div>
{/* <div className="flex items-center gap-3 mb-1">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{famille.intitule}</h1>
<StatusBadge status={family.status} />
</div> */}
<div className="flex items-center gap-4 text-sm text-gray-500">
<span className="font-mono bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded text-xs">{famille.code}</span>
<span>{famille.type_libelle}</span>
<span>{articleFiltered.length} articles</span>
</div>
</div>
</div>
<div className="flex gap-3">
<button className="p-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" title="Copy">
<Copy className="w-5 h-5" />
</button>
{/* <button
onClick={() => setIsEditModalOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-[#007E45] text-white rounded-xl text-sm font-medium hover:bg-[#007E45] transition-colors shadow-lg shadow-red-900/20"
>
<Edit className="w-4 h-4" />
Modifier
</button> */}
</div>
</div>
</div>
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
<div className="mt-6">
{activeTab === 'details' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6 space-y-6">
<h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Info className="w-5 h-5 text-[#007E45]" />
Informations générales
</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500 mb-1">Code famille</p>
<p className="font-mono font-medium">{famille.code}</p>
</div>
<div>
<p className="text-gray-500 mb-1">Catégorie</p>
<p className="font-medium">{famille.type_libelle}</p>
</div>
<div className="col-span-2">
<p className="text-gray-500 mb-1">Description</p>
<p className="text-gray-900 dark:text-gray-300 leading-relaxed">
Cette famille regroupe l'ensemble des produits liés à {famille.intitule.toLowerCase()}.
Utilisée pour la classification analytique et le reporting des ventes.
</p>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6 space-y-6">
<h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Package className="w-5 h-5 text-[#007E45]" />
Règles par défaut
</h3>
<div className="space-y-4 text-sm">
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-gray-800">
<span className="text-gray-500">Taux TVA</span>
<span className="font-medium">20%</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-gray-800">
<span className="text-gray-500">Compte Vente</span>
<span className="font-mono">707000</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100 dark:border-gray-800">
<span className="text-gray-500">Compte Achat</span>
<span className="font-mono">607000</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-500">Unité de stock</span>
<span className="font-medium">Pièce (Pcs)</span>
</div>
</div>
</div>
</div>
)}
{activeTab === 'articles' && (
<DataTable
columns={[
{ key: 'reference', label: 'Référence', sortable: true },
{ key: 'designation', label: 'Designation', sortable: true },
{ key: 'prix_vente', label: 'Prix de vente', sortable: false, render: (v: any) => `${v.toFixed(2)}` },
{ key: 'stock_reel', label: 'Stock', sortable: false },
{
key: 'est_actif',
label: 'Statut',
sortable: true,
render: (isActive: any) => <StatusBadgetLettre status={isActive ? 'active' : 'inactive'} />
},
]}
data={articleFiltered}
onRowClick={(row: Article) => { dispatch(selectArticle(row)), navigate(`/home/articles/${row.reference.replace(/\//g, '')}`) }}
actions={null}
status={isLoading}
/>
)}
{activeTab === 'timeline' && (
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-12 text-center text-gray-500">
Aucune activité récente.
</div>
)}
</div>
</div>
{/* <ProductFamilyFormModal
isOpen={isEditModalOpen}
onClose={() => setIsEditModalOpen(false)}
initialData={family}
/> */}
</>
);
};
export default ProductFamilyDetailPage;