add pages
This commit is contained in:
parent
49949f1404
commit
13a4b3cd8a
59 changed files with 20180 additions and 0 deletions
442
src/pages/DashboardPage.tsx
Normal file
442
src/pages/DashboardPage.tsx
Normal 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;
|
||||
25
src/pages/DocumentsPage.jsx
Normal file
25
src/pages/DocumentsPage.jsx
Normal 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;
|
||||
91
src/pages/SageBuilderPage.tsx
Normal file
91
src/pages/SageBuilderPage.tsx
Normal 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
154
src/pages/UIKitPage.jsx
Normal 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;
|
||||
112
src/pages/UserProfilePage.jsx
Normal file
112
src/pages/UserProfilePage.jsx
Normal 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;
|
||||
22
src/pages/admin/ActivityLogPage.jsx
Normal file
22
src/pages/admin/ActivityLogPage.jsx
Normal 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;
|
||||
22
src/pages/admin/RolesPage.jsx
Normal file
22
src/pages/admin/RolesPage.jsx
Normal 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;
|
||||
22
src/pages/admin/SettingsPage.jsx
Normal file
22
src/pages/admin/SettingsPage.jsx
Normal 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;
|
||||
38
src/pages/admin/UsersPage.jsx
Normal file
38
src/pages/admin/UsersPage.jsx
Normal 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
272
src/pages/auth/Login.tsx
Normal 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
203
src/pages/auth/forgot.tsx
Normal 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
319
src/pages/auth/reset.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/pages/crm/ActivitiesPage.jsx
Normal file
46
src/pages/crm/ActivitiesPage.jsx
Normal 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;
|
||||
25
src/pages/crm/ArticlesPage.jsx
Normal file
25
src/pages/crm/ArticlesPage.jsx
Normal 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;
|
||||
585
src/pages/crm/ClientDetailPage.tsx
Normal file
585
src/pages/crm/ClientDetailPage.tsx
Normal 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 dû : <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 Dû</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;
|
||||
613
src/pages/crm/ClientsPage.tsx
Normal file
613
src/pages/crm/ClientsPage.tsx
Normal 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;
|
||||
1001
src/pages/crm/CreateClientPage.tsx
Normal file
1001
src/pages/crm/CreateClientPage.tsx
Normal file
File diff suppressed because it is too large
Load diff
652
src/pages/crm/CreateProspectPage.jsx
Normal file
652
src/pages/crm/CreateProspectPage.jsx
Normal 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;
|
||||
115
src/pages/crm/OpportunitiesPage.jsx
Normal file
115
src/pages/crm/OpportunitiesPage.jsx
Normal 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;
|
||||
133
src/pages/crm/OpportunitiesPipelinePage.jsx
Normal file
133
src/pages/crm/OpportunitiesPipelinePage.jsx
Normal 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;
|
||||
213
src/pages/crm/OpportunityDetailPage.jsx
Normal file
213
src/pages/crm/OpportunityDetailPage.jsx
Normal 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;
|
||||
81
src/pages/crm/PipelinePage.jsx
Normal file
81
src/pages/crm/PipelinePage.jsx
Normal 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;
|
||||
274
src/pages/crm/ProspectDetailPage.jsx
Normal file
274
src/pages/crm/ProspectDetailPage.jsx
Normal 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;
|
||||
156
src/pages/crm/ProspectsPage.jsx
Normal file
156
src/pages/crm/ProspectsPage.jsx
Normal 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;
|
||||
535
src/pages/crm/SuppliersDetailPage.tsx
Normal file
535
src/pages/crm/SuppliersDetailPage.tsx
Normal 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;
|
||||
294
src/pages/crm/SuppliersPage.tsx
Normal file
294
src/pages/crm/SuppliersPage.tsx
Normal 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;
|
||||
43
src/pages/crm/TasksPage.jsx
Normal file
43
src/pages/crm/TasksPage.jsx
Normal 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;
|
||||
74
src/pages/purchases/PurchaseInvoiceDetailPage.jsx
Normal file
74
src/pages/purchases/PurchaseInvoiceDetailPage.jsx
Normal 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;
|
||||
134
src/pages/purchases/PurchaseInvoicesPage.jsx
Normal file
134
src/pages/purchases/PurchaseInvoicesPage.jsx
Normal 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;
|
||||
140
src/pages/purchases/PurchaseOrderDetailPage.jsx
Normal file
140
src/pages/purchases/PurchaseOrderDetailPage.jsx
Normal 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;
|
||||
154
src/pages/purchases/PurchaseOrdersPage.jsx
Normal file
154
src/pages/purchases/PurchaseOrdersPage.jsx
Normal 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;
|
||||
38
src/pages/purchases/ReceptionNoteDetailPage.jsx
Normal file
38
src/pages/purchases/ReceptionNoteDetailPage.jsx
Normal 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;
|
||||
129
src/pages/purchases/ReceptionNotesPage.jsx
Normal file
129
src/pages/purchases/ReceptionNotesPage.jsx
Normal 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;
|
||||
829
src/pages/sales/CreditNotesDetailPage.tsx
Normal file
829
src/pages/sales/CreditNotesDetailPage.tsx
Normal 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;
|
||||
548
src/pages/sales/CreditNotesPage.tsx
Normal file
548
src/pages/sales/CreditNotesPage.tsx
Normal 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;
|
||||
1103
src/pages/sales/DeliveryNotesDetailPage.tsx
Normal file
1103
src/pages/sales/DeliveryNotesDetailPage.tsx
Normal file
File diff suppressed because it is too large
Load diff
736
src/pages/sales/DeliveryNotesPage.tsx
Normal file
736
src/pages/sales/DeliveryNotesPage.tsx
Normal 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;
|
||||
774
src/pages/sales/InvoiceCreatePage.tsx
Normal file
774
src/pages/sales/InvoiceCreatePage.tsx
Normal 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;
|
||||
530
src/pages/sales/InvoiceDetailPage.tsx
Normal file
530
src/pages/sales/InvoiceDetailPage.tsx
Normal 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 d’un avoir."
|
||||
colorClass="bg-blue-500"
|
||||
onClick={handleValiderFacture}
|
||||
/>
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
{isSaving && <ModalLoading />}
|
||||
</>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default InvoiceDetailPage;
|
||||
954
src/pages/sales/InvoicesPage.tsx
Normal file
954
src/pages/sales/InvoicesPage.tsx
Normal 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;
|
||||
875
src/pages/sales/OrderDetailPage.tsx
Normal file
875
src/pages/sales/OrderDetailPage.tsx
Normal 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;
|
||||
557
src/pages/sales/OrdersPage.tsx
Normal file
557
src/pages/sales/OrdersPage.tsx
Normal 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;
|
||||
514
src/pages/sales/PaymentDetailPage.jsx
Normal file
514
src/pages/sales/PaymentDetailPage.jsx
Normal 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">Dû 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;
|
||||
575
src/pages/sales/PaymentsPage.tsx
Normal file
575
src/pages/sales/PaymentsPage.tsx
Normal 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;
|
||||
773
src/pages/sales/QuoteCreate.tsx
Normal file
773
src/pages/sales/QuoteCreate.tsx
Normal 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;
|
||||
732
src/pages/sales/QuoteDetailPage.tsx
Normal file
732
src/pages/sales/QuoteDetailPage.tsx
Normal 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;
|
||||
|
||||
698
src/pages/sales/QuotesPage.tsx
Normal file
698
src/pages/sales/QuotesPage.tsx
Normal 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;
|
||||
169
src/pages/signature/SignatureCreditPurchase.jsx
Normal file
169
src/pages/signature/SignatureCreditPurchase.jsx
Normal 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;
|
||||
109
src/pages/signature/SignatureDashboard.jsx
Normal file
109
src/pages/signature/SignatureDashboard.jsx
Normal 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;
|
||||
122
src/pages/signature/SignatureSettings.jsx
Normal file
122
src/pages/signature/SignatureSettings.jsx
Normal 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;
|
||||
490
src/pages/signature/SignatureTracking.tsx
Normal file
490
src/pages/signature/SignatureTracking.tsx
Normal 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;
|
||||
69
src/pages/support/SupportDashboardPage.jsx
Normal file
69
src/pages/support/SupportDashboardPage.jsx
Normal 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 < 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;
|
||||
198
src/pages/support/TicketDetailPage.jsx
Normal file
198
src/pages/support/TicketDetailPage.jsx
Normal 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;
|
||||
86
src/pages/support/TicketsPage.jsx
Normal file
86
src/pages/support/TicketsPage.jsx
Normal 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;
|
||||
465
src/pages/tiers/ArticleDetailPage.tsx
Normal file
465
src/pages/tiers/ArticleDetailPage.tsx
Normal 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} m³` : 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} m³` : 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;
|
||||
315
src/pages/tiers/ArticlesPage.tsx
Normal file
315
src/pages/tiers/ArticlesPage.tsx
Normal 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;
|
||||
379
src/pages/tiers/CommercialPage.tsx
Normal file
379
src/pages/tiers/CommercialPage.tsx
Normal 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;
|
||||
228
src/pages/tiers/ProductFamiliesPage.tsx
Normal file
228
src/pages/tiers/ProductFamiliesPage.tsx
Normal 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;
|
||||
195
src/pages/tiers/ProductFamilyDetailPage.tsx
Normal file
195
src/pages/tiers/ProductFamilyDetailPage.tsx
Normal 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;
|
||||
Loading…
Reference in a new issue