add components
This commit is contained in:
parent
0a921e738c
commit
49949f1404
120 changed files with 28150 additions and 0 deletions
64
src/components/Breadcrumbs.jsx
Normal file
64
src/components/Breadcrumbs.jsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLocation, Link } from 'react-router-dom';
|
||||||
|
import { ChevronRight, Home } from 'lucide-react';
|
||||||
|
|
||||||
|
const routeNameMap = {
|
||||||
|
'crm': 'CRM',
|
||||||
|
'sales': 'Ventes',
|
||||||
|
'purchases': 'Achats',
|
||||||
|
'support': 'SAV',
|
||||||
|
'admin': 'Administration',
|
||||||
|
'prospects': 'Prospects',
|
||||||
|
'clients': 'Clients',
|
||||||
|
'suppliers': 'Fournisseurs',
|
||||||
|
'opportunities': 'Opportunités',
|
||||||
|
'quotes': 'Devis',
|
||||||
|
'orders': 'Commandes',
|
||||||
|
'delivery-notes': 'Bons de livraison',
|
||||||
|
'invoices': 'Factures',
|
||||||
|
'credit-notes': 'Avoirs',
|
||||||
|
'tickets': 'Tickets',
|
||||||
|
'dashboard': 'Tableau de bord',
|
||||||
|
'users': 'Utilisateurs',
|
||||||
|
'settings': 'Paramètres',
|
||||||
|
'profile': 'Profil',
|
||||||
|
'preferences': 'Préférences'
|
||||||
|
};
|
||||||
|
|
||||||
|
const Breadcrumbs = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const pathnames = location.pathname.split('/').filter((x) => x);
|
||||||
|
|
||||||
|
if (pathnames.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex items-center text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
|
<Link to="/" className="hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
|
<Home className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
{pathnames.map((value, index) => {
|
||||||
|
const to = `/${pathnames.slice(0, index + 1).join('/')}`;
|
||||||
|
const isLast = index === pathnames.length - 1;
|
||||||
|
const name = routeNameMap[value] || value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={to}>
|
||||||
|
<ChevronRight className="w-4 h-4 mx-2" />
|
||||||
|
{isLast ? (
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white capitalize">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Link to={to} className="hover:text-gray-900 dark:hover:text-white transition-colors capitalize">
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Breadcrumbs;
|
||||||
17
src/components/CallToAction.jsx
Normal file
17
src/components/CallToAction.jsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
const CallToAction = () => {
|
||||||
|
return (
|
||||||
|
<motion.h1
|
||||||
|
className='text-xl font-bold text-white leading-8 w-full'
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.5 }}
|
||||||
|
>
|
||||||
|
Let's turn your ideas into reality
|
||||||
|
</motion.h1>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CallToAction;
|
||||||
338
src/components/ChartCard.tsx
Normal file
338
src/components/ChartCard.tsx
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
LineChart, Line, BarChart, Bar, PieChart, Pie, Cell, AreaChart, Area,
|
||||||
|
ResponsiveContainer, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
||||||
|
RadialBarChart, RadialBar, LabelList
|
||||||
|
} from 'recharts';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Maximize2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const COLORS = ['#007E45', '#10b981', '#059669', '#047857', '#065f46', '#064e3b', '#022c22'];
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// FORMATTERS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const formatToK = (value: number): string => {
|
||||||
|
if (value >= 1000000) {
|
||||||
|
return `${(value / 1000000).toFixed(1)}M€`;
|
||||||
|
}
|
||||||
|
if (value >= 1000) {
|
||||||
|
return `${(value / 1000).toFixed(0)}K€`;
|
||||||
|
}
|
||||||
|
return `${value}€`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatToKShort = (value: number): string => {
|
||||||
|
if (value >= 1000000) {
|
||||||
|
return `${(value / 1000000).toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
if (value >= 1000) {
|
||||||
|
return `${(value / 1000).toFixed(0)}K`;
|
||||||
|
}
|
||||||
|
return `${value}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number): string => {
|
||||||
|
return new Intl.NumberFormat('fr-FR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CUSTOM TOOLTIP
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: any[];
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip: React.FC<TooltipProps> = ({ active, payload, label }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-900 p-3 border border-gray-200 dark:border-gray-800 rounded-xl shadow-lg text-sm z-50">
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-white mb-2">{label}</p>
|
||||||
|
{payload.map((entry, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">{entry.name}:</span>
|
||||||
|
<span className="font-semibold" style={{ color: entry.color }}>
|
||||||
|
{formatCurrency(entry.value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CUSTOM LABEL POUR LES VALEURS SUR LE GRAPHIQUE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const CustomLabel = (props: any) => {
|
||||||
|
const { x, y, value, index } = props;
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={y - 10}
|
||||||
|
fill="#007E45"
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={11}
|
||||||
|
fontWeight={600}
|
||||||
|
>
|
||||||
|
{formatToK(value)}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TYPES (exportés pour réutilisation)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface ChartCardProps {
|
||||||
|
title: string;
|
||||||
|
type?: string;
|
||||||
|
data: any[];
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
showLabels?: boolean; // Afficher les valeurs sur le graphique
|
||||||
|
showTotal?: boolean; // Afficher le total sous le titre
|
||||||
|
showYAxisLabel?: boolean; // Afficher le label de l'axe Y
|
||||||
|
yAxisLabel?: string; // Label de l'axe Y (ex: "Montant en K€")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PRINCIPAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const ChartCard: React.FC<ChartCardProps> = ({
|
||||||
|
title,
|
||||||
|
type = 'line',
|
||||||
|
data,
|
||||||
|
height = 300,
|
||||||
|
className,
|
||||||
|
showLabels = true,
|
||||||
|
showTotal = true,
|
||||||
|
showYAxisLabel = true,
|
||||||
|
yAxisLabel = "Montant en K€"
|
||||||
|
}) => {
|
||||||
|
// Guard clause
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={`bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6 flex flex-col ${className}`}>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">{title}</h3>
|
||||||
|
<div className="h-full flex items-center justify-center text-gray-400 text-sm">
|
||||||
|
Pas de données
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculer le total pour l'afficher
|
||||||
|
const total = data.reduce((sum, item) => sum + (item.value || 0), 0) / 1000
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className={`bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl p-6 flex flex-col ${className}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||||
|
{showTotal && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Total: <span className="font-semibold text-[#007E45]">{total.toFixed(0)} K €</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
|
||||||
|
<Maximize2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label de l'axe Y */}
|
||||||
|
{showYAxisLabel && (type === 'line' || type === 'area' || type === 'bar') && (
|
||||||
|
<p className="text-xs text-gray-400 mb-4">{yAxisLabel}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ width: '100%', height }}>
|
||||||
|
<ResponsiveContainer>
|
||||||
|
{type === 'line' && (
|
||||||
|
<LineChart data={data} margin={{ top: 20, right: 20, left: 0, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e5e7eb" opacity={0.5} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
stroke="#9ca3af"
|
||||||
|
fontSize={12}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
dy={10}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="#9ca3af"
|
||||||
|
fontSize={11}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={formatToKShort}
|
||||||
|
dx={-5}
|
||||||
|
width={45}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
name="CA"
|
||||||
|
stroke="#007E45"
|
||||||
|
strokeWidth={3}
|
||||||
|
dot={{ r: 5, fill: '#fff', strokeWidth: 2, stroke: '#007E45' }}
|
||||||
|
activeDot={{ r: 7, stroke: '#007E45', strokeWidth: 2, fill: '#007E45' }}
|
||||||
|
>
|
||||||
|
{showLabels && (
|
||||||
|
<LabelList
|
||||||
|
dataKey="value"
|
||||||
|
position="top"
|
||||||
|
formatter={formatToK}
|
||||||
|
style={{ fontSize: 11, fontWeight: 600, fill: '#007E45' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Line>
|
||||||
|
</LineChart>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'area' && (
|
||||||
|
<AreaChart data={data} margin={{ top: 20, right: 20, left: 0, bottom: 5 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#007E45" stopOpacity={0.3}/>
|
||||||
|
<stop offset="95%" stopColor="#007E45" stopOpacity={0.05}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e5e7eb" opacity={0.5} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
stroke="#9ca3af"
|
||||||
|
fontSize={12}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
dy={10}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="#9ca3af"
|
||||||
|
fontSize={11}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={formatToKShort}
|
||||||
|
dx={-5}
|
||||||
|
width={45}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
name="CA"
|
||||||
|
stroke="#007E45"
|
||||||
|
strokeWidth={2}
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorValue)"
|
||||||
|
>
|
||||||
|
{showLabels && (
|
||||||
|
<LabelList
|
||||||
|
dataKey="value"
|
||||||
|
position="top"
|
||||||
|
formatter={formatToK}
|
||||||
|
style={{ fontSize: 11, fontWeight: 600, fill: '#007E45' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Area>
|
||||||
|
</AreaChart>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'bar' && (
|
||||||
|
<BarChart data={data} margin={{ top: 20, right: 20, left: 0, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e5e7eb" opacity={0.5} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
stroke="#9ca3af"
|
||||||
|
fontSize={12}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
dy={10}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="#9ca3af"
|
||||||
|
fontSize={11}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={formatToKShort}
|
||||||
|
dx={-5}
|
||||||
|
width={45}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Bar dataKey="value" name="CA" radius={[4, 4, 0, 0]}>
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.fill || COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
{showLabels && (
|
||||||
|
<LabelList
|
||||||
|
dataKey="value"
|
||||||
|
position="top"
|
||||||
|
formatter={formatToK}
|
||||||
|
style={{ fontSize: 10, fontWeight: 600, fill: '#374151' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'donut' && (
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={5}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.fill || COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Legend verticalAlign="bottom" height={36} iconType="circle" />
|
||||||
|
</PieChart>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'radial' && (
|
||||||
|
<RadialBarChart cx="50%" cy="50%" innerRadius="70%" outerRadius="100%" barSize={10} data={data}>
|
||||||
|
<RadialBar
|
||||||
|
minAngle={15}
|
||||||
|
label={{ position: 'insideStart', fill: '#fff' }}
|
||||||
|
background
|
||||||
|
clockWise
|
||||||
|
dataKey="value"
|
||||||
|
/>
|
||||||
|
<Legend iconSize={10} layout="vertical" verticalAlign="middle" wrapperStyle={{ top: '50%', right: 0, transform: 'translate(0, -50%)', lineHeight: '24px' }} />
|
||||||
|
</RadialBarChart>
|
||||||
|
)}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChartCard;
|
||||||
155
src/components/DataTable.jsx
Normal file
155
src/components/DataTable.jsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronUp, Search } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import Pagination from '@/components/Pagination';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const DataTable = ({ columns, data, onRowClick, actions, status = false, searchLabel = "Rechercher..." }) => {
|
||||||
|
const [sortColumn, setSortColumn] = useState(null);
|
||||||
|
const [sortDirection, setSortDirection] = useState('asc');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
|
const handleSort = (column) => {
|
||||||
|
if (sortColumn === column) {
|
||||||
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortColumn(column);
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const filteredData = data.filter(row =>
|
||||||
|
Object.values(row).some(value =>
|
||||||
|
String(value).toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedData = [...filteredData].sort((a, b) => {
|
||||||
|
if (!sortColumn) return 0;
|
||||||
|
const aVal = a[sortColumn];
|
||||||
|
const bVal = b[sortColumn];
|
||||||
|
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
|
||||||
|
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const paginatedData = sortedData.slice(
|
||||||
|
(currentPage - 1) * itemsPerPage,
|
||||||
|
currentPage * itemsPerPage
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={searchLabel}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-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-[#007E45] text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<tr>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th
|
||||||
|
key={column.key}
|
||||||
|
onClick={() => column.sortable && handleSort(column.key)}
|
||||||
|
className={cn(
|
||||||
|
"px-6 py-3 text-left text-xs font-bold text-black dark:text-gray-400 uppercase tracking-wider",
|
||||||
|
column.sortable && "cursor-pointer hover:text-gray-900 dark:hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{column.label}
|
||||||
|
{column.sortable && sortColumn === column.key && (
|
||||||
|
sortDirection === 'asc' ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
{actions && <th className="px-6 py-3 text-right text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Actions</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
|
{status ? (
|
||||||
|
// LOADING
|
||||||
|
<tr>
|
||||||
|
<td colSpan={100} className="p-5">
|
||||||
|
<div className="flex justify-center items-center py-10">
|
||||||
|
<CircularProgress size={48} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : paginatedData.length === 0 ? (
|
||||||
|
// AUCUNE DONNÉE
|
||||||
|
<tr>
|
||||||
|
<td colSpan={100} className="p-5 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Empty
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
// DONNÉES
|
||||||
|
paginatedData.map((row, index) => (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
onClick={() => onRowClick?.(row)}
|
||||||
|
className="hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<td
|
||||||
|
key={column.key}
|
||||||
|
className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{column.render
|
||||||
|
? column.render(row[column.key], row)
|
||||||
|
: row[column.key]}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{actions && (
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-end gap-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{actions(row)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DataTable;
|
||||||
159
src/components/DropdownMenu.tsx
Normal file
159
src/components/DropdownMenu.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Edit, Download, FileSignature, MoreVertical, Trash2, CircleDot, Copy } from 'lucide-react';
|
||||||
|
|
||||||
|
export const DropdownMenuTable = ({
|
||||||
|
row,
|
||||||
|
onEdit,
|
||||||
|
onStatus,
|
||||||
|
onESign,
|
||||||
|
onDownload,
|
||||||
|
onDelete,
|
||||||
|
onDulipcate
|
||||||
|
}: {
|
||||||
|
row: any;
|
||||||
|
onEdit?: () => void;
|
||||||
|
onStatus?: () => void;
|
||||||
|
onESign?: () => void;
|
||||||
|
onDownload?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
onDulipcate?: () => void;
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Fermer le menu quand on clique à l'extérieur
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative bg" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
}}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-950 rounded-xl shadow-xl border border-gray-200 dark:border-gray-800 z-[9999] p-1">
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
onEdit && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit?.();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
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 transition-colors"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" /> Modifier
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
onDulipcate && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDulipcate?.();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
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 transition-colors"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" /> Dupliquer
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
onStatus && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onStatus?.();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
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 transition-colors"
|
||||||
|
>
|
||||||
|
<CircleDot className="w-4 h-4" /> Changer le status
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
onESign && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onESign?.();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
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 text-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
<FileSignature className="w-4 h-4" /> Signature Électronique
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
onDownload && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDownload?.();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
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 transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" /> Télécharger PDF
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
onDelete && (
|
||||||
|
<>
|
||||||
|
<div className="h-px bg-gray-100 dark:bg-gray-800 my-1" />
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete?.();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
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 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" /> Supprimer
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
146
src/components/FormModal.jsx
Normal file
146
src/components/FormModal.jsx
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export const FormSection = ({ title, description, children, className }) => (
|
||||||
|
<div className={cn("mb-6 pb-6 border-b border-gray-100 dark:border-gray-800 last:border-0 last:mb-0 last:pb-0", className)}>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">{title}</h4>
|
||||||
|
{description && <p className="text-xs text-gray-500 dark:text-gray-400 mb-4">{description}</p>}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
export const FormField = ({ label, children, required, error, fullWidth, className }) => (
|
||||||
|
<div className={cn("space-y-1.5", fullWidth ? "col-span-1 md:col-span-2" : "col-span-1", className)}>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{label} {required && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Input = React.forwardRef(({ className, error, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={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",
|
||||||
|
error
|
||||||
|
? "border-red-300 focus:border-red-500 focus:ring-red-200"
|
||||||
|
: "border-gray-200 dark:border-gray-800 focus:border-[#941403] focus:ring-red-100/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export const Select = React.forwardRef(({ className, error, children, ...props }, ref) => (
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
className={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",
|
||||||
|
error
|
||||||
|
? "border-red-300 focus:border-red-500 focus:ring-red-200"
|
||||||
|
: "border-gray-200 dark:border-gray-800 focus:border-[#941403] focus:ring-red-100/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
));
|
||||||
|
Select.displayName = "Select";
|
||||||
|
|
||||||
|
export const Textarea = React.forwardRef(({ className, error, ...props }, ref) => (
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
className={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 resize-none",
|
||||||
|
error
|
||||||
|
? "border-red-300 focus:border-red-500 focus:ring-red-200"
|
||||||
|
: "border-gray-200 dark:border-gray-800 focus:border-[#941403] focus:ring-red-100/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
|
||||||
|
const FormModal = ({ isOpen, onClose, title, children, onSubmit, loading, submitLabel = "Enregistrer", cancelLabel = "Annuler", size = "md" }) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: "max-w-md",
|
||||||
|
md: "max-w-2xl",
|
||||||
|
lg: "max-w-4xl",
|
||||||
|
xl: "max-w-6xl",
|
||||||
|
full: "max-w-[95vw]"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 100 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 100 }}
|
||||||
|
className={cn(
|
||||||
|
"relative bg-white dark:bg-gray-950 w-full rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]",
|
||||||
|
sizeClasses[size] || sizeClasses.md
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 px-6 py-4 border-b border-gray-100 dark:border-gray-800 flex justify-between items-center bg-gray-50/50 dark:bg-gray-900/50">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white">{title}</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-shrink-0 px-6 py-4 border-t border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-900/50 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
{onSubmit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center justify-center gap-2 px-6 py-2.5 bg-[#941403] text-white rounded-xl text-sm font-medium hover:bg-[#7a1002] focus:outline-none focus:ring-2 focus:ring-[#941403] focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-red-900/20"
|
||||||
|
>
|
||||||
|
{loading && <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />}
|
||||||
|
{submitLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FormModal;
|
||||||
31
src/components/HeroImage.jsx
Normal file
31
src/components/HeroImage.jsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const HeroImage = () => {
|
||||||
|
return (
|
||||||
|
<div className="relative w-8 h-8 shrink-0" data-name="ic-sparkles">
|
||||||
|
<svg
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="w-full h-full"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M11.787 9.5356C11.5053 8.82147 10.4947 8.82147 10.213 9.5356L8.742 13.2654C8.65601 13.4834 8.48343 13.656 8.2654 13.742L4.5356 15.213C3.82147 15.4947 3.82147 16.5053 4.5356 16.787L8.2654 18.258C8.48343 18.344 8.65601 18.5166 8.742 18.7346L10.213 22.4644C10.4947 23.1785 11.5053 23.1785 11.787 22.4644L13.258 18.7346C13.344 18.5166 13.5166 18.344 13.7346 18.258L17.4644 16.787C18.1785 16.5053 18.1785 15.4947 17.4644 15.213L13.7346 13.742C13.5166 13.656 13.344 13.4834 13.258 13.2654L11.787 9.5356Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M23.5621 2.38257C23.361 1.87248 22.639 1.87248 22.4379 2.38257L21.3871 5.04671C21.3257 5.20245 21.2024 5.32572 21.0467 5.38714L18.3826 6.43787C17.8725 6.63904 17.8725 7.36096 18.3826 7.56214L21.0467 8.61286C21.2024 8.67428 21.3257 8.79755 21.3871 8.95329L22.4379 11.6174C22.639 12.1275 23.361 12.1275 23.5621 11.6174L24.6129 8.95329C24.6743 8.79755 24.7976 8.67428 24.9533 8.61286L27.6174 7.56214C28.1275 7.36096 28.1275 6.63904 27.6174 6.43787L24.9533 5.38714C24.7976 5.32572 24.6743 5.20245 24.6129 5.04671L23.5621 2.38257Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M23.3373 22.2295C23.2166 21.9235 22.7834 21.9235 22.6627 22.2295L22.0323 23.828C21.9954 23.9215 21.9215 23.9954 21.828 24.0323L20.2295 24.6627C19.9235 24.7834 19.9235 25.2166 20.2295 25.3373L21.828 25.9677C21.9215 26.0046 21.9954 26.0785 22.0323 26.172L22.6627 27.7705C22.7834 28.0765 23.2166 28.0765 23.3373 27.7705L23.9677 26.172C24.0046 26.0785 24.0785 26.0046 24.172 25.9677L25.7705 25.3373C26.0765 25.2166 26.0765 24.7834 25.7705 24.6627L24.172 24.0323C24.0785 23.9954 24.0046 23.9215 23.9677 23.828L23.3373 22.2295Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroImage;
|
||||||
154
src/components/KPIBar.tsx
Normal file
154
src/components/KPIBar.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import KpiCard from '@/components/KpiCard';
|
||||||
|
import PeriodFilter from '@/components/PeriodFilter';
|
||||||
|
|
||||||
|
export type PeriodType = 'all' | 'today' | 'week' | 'month' | 'quarter' | 'year' | CustomPeriod;
|
||||||
|
|
||||||
|
export interface CustomPeriod {
|
||||||
|
id: 'custom';
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KPIBarProps {
|
||||||
|
kpis: any;
|
||||||
|
period?: PeriodType;
|
||||||
|
onPeriodChange?: (period: PeriodType) => void;
|
||||||
|
loading?: 'idle' | 'loading' | 'succeeded' | 'failed';
|
||||||
|
onRefresh?: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KPIBar: React.FC<KPIBarProps> = ({
|
||||||
|
kpis,
|
||||||
|
period,
|
||||||
|
onPeriodChange,
|
||||||
|
loading = 'idle',
|
||||||
|
onRefresh
|
||||||
|
}) => {
|
||||||
|
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
if (!onRefresh || isRefreshing) return;
|
||||||
|
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
await onRefresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du rafraîchissement:', error);
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading = loading === 'idle' || loading === 'loading';
|
||||||
|
const showRefreshButton = !isLoading && onRefresh;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: '16px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
}}>
|
||||||
|
{/* Period Filter */}
|
||||||
|
{onPeriodChange && (
|
||||||
|
<div className="">
|
||||||
|
<PeriodFilter value={period || 'month'} onChange={onPeriodChange} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{isLoading && (
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
className="sm:w-auto flex items-center justify-center gap-2 px-4 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed sm:order-2"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Refresh Button */}
|
||||||
|
{showRefreshButton && (
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
className="sm:w-auto flex items-center justify-center gap-2 px-4 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed sm:order-2"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
overflowX: 'auto',
|
||||||
|
paddingBottom: '16px',
|
||||||
|
gap: '16px',
|
||||||
|
scrollSnapType: 'x mandatory',
|
||||||
|
scrollbarWidth: 'none', /* Firefox */
|
||||||
|
msOverflowStyle: 'none', /* IE et Edge */
|
||||||
|
WebkitOverflowScrolling: 'touch'
|
||||||
|
}}>
|
||||||
|
<style dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
div::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.kpi-container {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: repeat(2, 1fr) !important;
|
||||||
|
overflow-x: visible !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.kpi-container {
|
||||||
|
grid-template-columns: repeat(4, 1fr) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.kpi-container {
|
||||||
|
grid-template-columns: repeat(5, 1fr) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}} />
|
||||||
|
{kpis.map((kpi: any, index: number) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
minWidth: '260px',
|
||||||
|
scrollSnapAlign: 'start'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KpiCard {...kpi} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default KPIBar;
|
||||||
|
|
||||||
|
|
||||||
158
src/components/KanbanBoard.jsx
Normal file
158
src/components/KanbanBoard.jsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { MoreHorizontal, Calendar, DollarSign, User, StickyNote, FileText, CheckSquare } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { toast } from '@/components/ui/use-toast';
|
||||||
|
|
||||||
|
const KanbanCard = ({ item, index, onClick, onAction }) => {
|
||||||
|
const handleAction = (e, action) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAction && onAction(action, item);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Draggable draggableId={item.id.toString()} index={index}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
onClick={() => onClick(item)}
|
||||||
|
style={{ ...provided.draggableProps.style }}
|
||||||
|
className={cn(
|
||||||
|
"bg-white dark:bg-gray-900 p-4 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm mb-3 group transition-all",
|
||||||
|
snapshot.isDragging ? "shadow-xl ring-2 ring-[#941403] rotate-2" : "hover:shadow-md hover:-translate-y-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 truncate max-w-[120px]">{item.client}</span>
|
||||||
|
<button className="opacity-0 group-hover:opacity-100 p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-all">
|
||||||
|
<MoreHorizontal className="w-4 h-4 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-gray-900 dark:text-white mb-3 line-clamp-2">{item.name}</h4>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-1 text-[#941403] font-medium bg-red-50 dark:bg-red-900/20 px-2 py-1 rounded-lg text-xs">
|
||||||
|
<DollarSign className="w-3 h-3" />
|
||||||
|
{item.amount.toLocaleString()}€
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
"text-xs font-medium px-2 py-1 rounded-lg",
|
||||||
|
item.probability >= 75 ? "bg-green-100 text-green-700" :
|
||||||
|
item.probability >= 50 ? "bg-yellow-100 text-yellow-700" :
|
||||||
|
"bg-gray-100 text-gray-700"
|
||||||
|
)}>
|
||||||
|
{item.probability}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-3 border-t border-gray-100 dark:border-gray-800">
|
||||||
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleAction(e, 'note')}
|
||||||
|
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded text-gray-500"
|
||||||
|
title="Ajouter une note"
|
||||||
|
>
|
||||||
|
<StickyNote className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleAction(e, 'task')}
|
||||||
|
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded text-gray-500"
|
||||||
|
title="Créer une tâche"
|
||||||
|
>
|
||||||
|
<CheckSquare className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleAction(e, 'quote')}
|
||||||
|
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded text-gray-500"
|
||||||
|
title="Créer un devis"
|
||||||
|
>
|
||||||
|
<FileText className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{new Date(item.closeDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||||
|
</div>
|
||||||
|
{item.owner && (
|
||||||
|
<div className="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[10px]">
|
||||||
|
{item.owner.charAt(0)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const KanbanColumn = ({ column, items, onClickItem, onActionItem }) => {
|
||||||
|
const totalAmount = items.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-w-[320px] w-[320px] bg-gray-50/50 dark:bg-gray-900/20 rounded-2xl p-3 border border-transparent hover:border-gray-200 dark:hover:border-gray-800 transition-colors">
|
||||||
|
<div className="flex items-center justify-between mb-4 px-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-gray-700 dark:text-gray-200 text-sm uppercase tracking-wide">{column.title}</h3>
|
||||||
|
<span className="bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-xs font-medium px-2 py-0.5 rounded-full">
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{totalAmount > 0 && (
|
||||||
|
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-900 px-2 py-1 rounded shadow-sm">
|
||||||
|
{totalAmount.toLocaleString()}€
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Droppable droppableId={column.id}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
{...provided.droppableProps}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 overflow-y-auto px-1 transition-colors rounded-xl custom-scrollbar",
|
||||||
|
snapshot.isDraggingOver ? "bg-red-50/30 dark:bg-red-900/10 ring-2 ring-inset ring-red-100/50" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<KanbanCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
onClick={onClickItem}
|
||||||
|
onAction={onActionItem}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const KanbanBoard = ({ columns, items, onDragEnd, onItemClick, onActionItem }) => {
|
||||||
|
return (
|
||||||
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
|
<div className="flex h-full gap-4 overflow-x-auto pb-4 items-start">
|
||||||
|
{columns.map(col => (
|
||||||
|
<KanbanColumn
|
||||||
|
key={col.id}
|
||||||
|
column={col}
|
||||||
|
items={items.filter(i => i.stage === col.id)}
|
||||||
|
onClickItem={onItemClick}
|
||||||
|
onActionItem={onActionItem}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DragDropContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default KanbanBoard;
|
||||||
42
src/components/KpiCard.jsx
Normal file
42
src/components/KpiCard.jsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||||
|
import InfoTooltip from './common/InfoTooltip';
|
||||||
|
|
||||||
|
const KpiCard = ({ title, value, change, trend, icon: Icon, onClick, tooltip }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
onClick={onClick}
|
||||||
|
className="bg-white dark:bg-gray-950 border border-[#F2F2F2] dark:border-gray-800 rounded-2xl p-6 cursor-pointer transition-shadow hover:shadow-lg relative group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-[#F2F2F2] dark:bg-gray-900 flex items-center justify-center">
|
||||||
|
{Icon && <Icon className="w-6 h-6 text-[#338660]" />}
|
||||||
|
</div>
|
||||||
|
{change && (
|
||||||
|
<div className={`flex items-center gap-1 text-sm font-medium ${
|
||||||
|
trend === 'up' ? 'text-[#338660] dark:text-green-400' :
|
||||||
|
trend === 'down' ? 'text-[#007E45] dark:text-red-400' :
|
||||||
|
'text-[#6A6A6A] dark:text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{trend === 'up' ? <TrendingUp className="w-4 h-4" /> : trend === 'down' ? <TrendingDown className="w-4 h-4" /> : null}
|
||||||
|
{change}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<h3 className="text-sm text-[#6A6A6A] dark:text-gray-400 mb-1">{title}</h3>
|
||||||
|
{tooltip && (
|
||||||
|
<div className="mb-1">
|
||||||
|
<InfoTooltip {...tooltip} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-black dark:text-white">{value}</p>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default KpiCard;
|
||||||
108
src/components/NotificationCenter.tsx
Normal file
108
src/components/NotificationCenter.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { CheckCircle, AlertCircle, Info, User, FileText } from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { UniversignType } from '@/types/sageTypes';
|
||||||
|
import { getAlluniversign } from '@/store/features/universign/selectors';
|
||||||
|
import { useAppSelector } from '@/store/hooks';
|
||||||
|
import { formatDateFRCourt } from '@/lib/utils';
|
||||||
|
|
||||||
|
const notifications = [
|
||||||
|
{ id: 1, type: 'success', icon: CheckCircle, title: 'Devis signé', message: 'Le devis DEV-00015 a été signé par ACME Corp', time: '5 min' },
|
||||||
|
{ id: 2, type: 'info', icon: FileText, title: 'Commande validée', message: 'CMD-00023 validée pour 15 450€', time: '12 min' },
|
||||||
|
{ id: 3, type: 'warning', icon: AlertCircle, title: 'Nouveau ticket', message: 'Ticket #TK-00042 créé par Sophie Martin', time: '25 min' },
|
||||||
|
{ id: 4, type: 'info', icon: User, title: 'Nouvel utilisateur', message: 'Marie Dubois a été ajoutée', time: '1h' },
|
||||||
|
{ id: 5, type: 'success', icon: FileText, title: 'Nouvelle opportunité', message: 'Opportunité "Projet Digital" créée', time: '2h' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const NotificationCenter = ({ onClose }: { onClose: () => void }) => {
|
||||||
|
|
||||||
|
const universign = useAppSelector(getAlluniversign) as UniversignType[];
|
||||||
|
|
||||||
|
const devisSigned = universign.filter(
|
||||||
|
(item) => item.local_status === "SIGNE"
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||||
|
className="absolute right-0 top-12 w-80 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl shadow-xl overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Notifications</h3>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{devisSigned.length > 0 ? (
|
||||||
|
devisSigned.map((notif) => (
|
||||||
|
<div
|
||||||
|
key={notif.id}
|
||||||
|
className="p-4 border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div
|
||||||
|
className={`shrink-0 w-8 h-8 rounded-lg flex items-center justify-center bg-green-100 dark:bg-green-900/20
|
||||||
|
// notif.type === 'success'
|
||||||
|
// ? 'bg-green-100 dark:bg-green-900/20'
|
||||||
|
// : notif.type === 'warning'
|
||||||
|
// ? 'bg-orange-100 dark:bg-orange-900/20'
|
||||||
|
// : 'bg-blue-100 dark:bg-blue-900/20'
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<i><CheckCircle></CheckCircle></i>
|
||||||
|
{/* <notif.icon
|
||||||
|
className={`w-4 h-4 ${
|
||||||
|
notif.type === 'success'
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: notif.type === 'warning'
|
||||||
|
? 'text-orange-600 dark:text-orange-400'
|
||||||
|
: 'text-blue-600 dark:text-blue-400'
|
||||||
|
}`}
|
||||||
|
/> */}
|
||||||
|
{/* <notif.icon
|
||||||
|
className={`w-4 h-4 ${
|
||||||
|
notif.type === 'success'
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: notif.type === 'warning'
|
||||||
|
? 'text-orange-600 dark:text-orange-400'
|
||||||
|
: 'text-blue-600 dark:text-blue-400'
|
||||||
|
}`}
|
||||||
|
/> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 text-left">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Devis signé
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
|
||||||
|
{/* {notif.message} */}
|
||||||
|
{`Le devis ${notif.sage_document_id} a été signé`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||||
|
{formatDateFRCourt(notif.signed_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-6 py-8 text-center text-sm text-gray-500">
|
||||||
|
Aucune notification
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/* <div className="p-3 border-t border-gray-200 dark:border-gray-800">
|
||||||
|
<button className="w-full text-sm font-medium text-[#941403] hover:text-[#7a1002] transition-colors">
|
||||||
|
Voir toutes les notifications
|
||||||
|
</button>
|
||||||
|
</div> */}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationCenter;
|
||||||
18
src/components/PageTransition.jsx
Normal file
18
src/components/PageTransition.jsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
const PageTransition = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageTransition;
|
||||||
57
src/components/Pagination.jsx
Normal file
57
src/components/Pagination.jsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Pagination = ({ currentPage, totalPages, onPageChange }) => {
|
||||||
|
const pages = [];
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
if (i === 1 || i === totalPages || (i >= currentPage - 1 && i <= currentPage + 1)) {
|
||||||
|
pages.push(i);
|
||||||
|
} else if (pages[pages.length - 1] !== '...') {
|
||||||
|
pages.push('...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Page {currentPage} sur {totalPages}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="p-2 rounded-lg border border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{pages.map((page, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => typeof page === 'number' && onPageChange(page)}
|
||||||
|
disabled={page === '...'}
|
||||||
|
className={cn(
|
||||||
|
"w-9 h-9 rounded-lg text-sm font-medium transition-colors",
|
||||||
|
page === currentPage
|
||||||
|
? "bg-[#007E45] text-white"
|
||||||
|
: "border border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900 text-gray-900 dark:text-white",
|
||||||
|
page === '...' && "cursor-default hover:bg-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="p-2 rounded-lg border border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Pagination;
|
||||||
258
src/components/PeriodFilter.jsx
Normal file
258
src/components/PeriodFilter.jsx
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Calendar as CalendarIcon, ChevronDown, X, CalendarDays } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'erp_selected_period';
|
||||||
|
|
||||||
|
const PeriodFilter = ({ value, onChange }) => {
|
||||||
|
const [isCustomOpen, setIsCustomOpen] = useState(false);
|
||||||
|
const [tempStart, setTempStart] = useState('');
|
||||||
|
const [tempEnd, setTempEnd] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Initialize from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
onChange(parsed);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load period from storage', e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save to localStorage whenever value changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// Initialize temp dates when opening custom modal
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCustomOpen && typeof value === 'object' && value.id === 'custom') {
|
||||||
|
setTempStart(value.start);
|
||||||
|
setTempEnd(value.end);
|
||||||
|
}
|
||||||
|
}, [isCustomOpen, value]);
|
||||||
|
|
||||||
|
const periods = [
|
||||||
|
{ id: 'today', label: "Aujourd'hui" },
|
||||||
|
{ id: 'week', label: "Cette semaine" },
|
||||||
|
{ id: 'month', label: "Ce mois-ci" },
|
||||||
|
{ id: 'quarter', label: "Ce trimestre" },
|
||||||
|
{ id: 'year', label: "Cette année" },
|
||||||
|
{ id: 'all', label: "Tout" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleValueChange = (newValue) => {
|
||||||
|
if (newValue === 'custom') {
|
||||||
|
setIsCustomOpen(true);
|
||||||
|
setError('');
|
||||||
|
} else {
|
||||||
|
onChange(newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomSubmit = () => {
|
||||||
|
if (!tempStart || !tempEnd) {
|
||||||
|
setError('Veuillez sélectionner les deux dates');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (new Date(tempStart) > new Date(tempEnd)) {
|
||||||
|
setError('La date de début doit être avant la date de fin');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({ id: 'custom', start: tempStart, end: tempEnd });
|
||||||
|
setIsCustomOpen(false);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsCustomOpen(false);
|
||||||
|
setError('');
|
||||||
|
setTempStart('');
|
||||||
|
setTempEnd('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLabel = () => {
|
||||||
|
if (typeof value === 'object' && value.id === 'custom') {
|
||||||
|
return `${formatDate(value.start)} - ${formatDate(value.end)}`;
|
||||||
|
}
|
||||||
|
return periods.find(p => p.id === value)?.label || "Période";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCustomActive = typeof value === 'object' && value.id === 'custom';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className={cn(
|
||||||
|
"inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium border rounded-xl transition-all shadow-sm",
|
||||||
|
isCustomActive
|
||||||
|
? "bg-[#007E45]/10 border-[#007E45] text-[#007E45]"
|
||||||
|
: "bg-white dark:bg-gray-950 border-gray-200 dark:border-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-900"
|
||||||
|
)}>
|
||||||
|
<CalendarIcon className="w-4 h-4 opacity-70" />
|
||||||
|
<span className="hidden sm:inline truncate max-w-[150px]">{getLabel()}</span>
|
||||||
|
<ChevronDown className="w-3.5 h-3.5 opacity-50" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-[200px] bg-white dark:bg-gray-950 border-gray-200 dark:border-gray-800 rounded-xl">
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={typeof value === 'string' ? value : 'custom'}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
>
|
||||||
|
{periods.map((period) => (
|
||||||
|
<DropdownMenuRadioItem
|
||||||
|
key={period.id}
|
||||||
|
value={period.id}
|
||||||
|
className="cursor-pointer rounded-lg"
|
||||||
|
>
|
||||||
|
{period.label}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuRadioItem
|
||||||
|
value="custom"
|
||||||
|
className="cursor-pointer font-medium text-[#007E45] rounded-lg"
|
||||||
|
>
|
||||||
|
<CalendarDays className="w-4 h-4 mr-2" />
|
||||||
|
Personnalisé...
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Custom Range Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isCustomOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
|
className="bg-white dark:bg-gray-950 rounded-2xl shadow-2xl w-full max-w-sm border border-gray-200 dark:border-gray-800 overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 flex justify-between items-center border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<CalendarDays className="w-5 h-5 text-[#007E45]" />
|
||||||
|
Période personnalisée
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="p-3 text-sm bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl border border-red-100 dark:border-red-800 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-red-500 flex-shrink-0" />
|
||||||
|
{error}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Date Inputs */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Date de début
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={tempStart}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTempStart(e.target.value);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-3 py-2.5 bg-white dark:bg-gray-900 border rounded-xl outline-none transition-all text-sm",
|
||||||
|
"border-gray-200 dark:border-gray-700",
|
||||||
|
"focus:ring-2 focus:ring-[#007E45]/20 focus:border-[#007E45]",
|
||||||
|
"dark:text-white dark:[color-scheme:dark]"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Date de fin
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={tempEnd}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTempEnd(e.target.value);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-3 py-2.5 bg-white dark:bg-gray-900 border rounded-xl outline-none transition-all text-sm",
|
||||||
|
"border-gray-200 dark:border-gray-700",
|
||||||
|
"focus:ring-2 focus:ring-[#007E45]/20 focus:border-[#007E45]",
|
||||||
|
"dark:text-white dark:[color-scheme:dark]"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="flex-1 px-4 py-2.5 border border-gray-200 dark:border-gray-700 rounded-xl text-gray-600 dark:text-gray-300 font-medium hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCustomSubmit}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-[#007E45] text-white rounded-xl font-medium hover:bg-[#006838] transition-colors shadow-lg shadow-green-900/20"
|
||||||
|
>
|
||||||
|
Appliquer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PeriodFilter;
|
||||||
146
src/components/PremiumHeader.jsx
Normal file
146
src/components/PremiumHeader.jsx
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Menu, Bell, Search, User, ChevronDown, Briefcase, Calculator, Sun, Moon } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { useModule, MODULE_GESTION, MODULE_COMPTABILITE } from '@/context/ModuleContext';
|
||||||
|
import Cookies from 'js-cookie';
|
||||||
|
import { ACCESS_TOKEN, REFRESH_TOKEN } from '@/lib/data';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||||
|
import { resetApp } from '@/store/resetAction';
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
import { getuserConnected } from '@/store/features/user/selectors';
|
||||||
|
import NotificationCenter from './NotificationCenter';
|
||||||
|
|
||||||
|
const PremiumHeader = ({ onMenuClick, onAnnouncementsClick }) => {
|
||||||
|
const { currentModule, changeModule } = useModule();
|
||||||
|
const [showNotifications, setShowNotifications] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const userConnected = useAppSelector(getuserConnected);
|
||||||
|
|
||||||
|
const { isDark, toggleTheme } = useTheme();
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
Cookies.remove(ACCESS_TOKEN);
|
||||||
|
Cookies.remove(REFRESH_TOKEN);
|
||||||
|
|
||||||
|
dispatch(resetApp());
|
||||||
|
|
||||||
|
navigate('/login')
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="h-16 bg-white dark:bg-gray-950 border-b border-gray-200 dark:border-gray-800 px-4 lg:px-6 flex items-center justify-between sticky top-0 z-40 transition-colors duration-200">
|
||||||
|
|
||||||
|
{/* Left: Menu & Mobile Toggle */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onMenuClick}
|
||||||
|
className="lg:hidden p-2 -ml-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg text-gray-600 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Module Switcher (Gesco / Compta) - Now primary element on left */}
|
||||||
|
<div className="flex bg-gray-100 dark:bg-gray-900 p-1 rounded-lg border border-gray-200 dark:border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={() => changeModule(MODULE_GESTION)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-bold transition-all duration-200",
|
||||||
|
currentModule === MODULE_GESTION
|
||||||
|
? "bg-white dark:bg-gray-800 text-[#2A6F4F] shadow-sm ring-1 ring-gray-200 dark:ring-gray-700"
|
||||||
|
: "text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Briefcase className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden md:inline">Gesco</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => changeModule(MODULE_COMPTABILITE)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-bold transition-all duration-200",
|
||||||
|
currentModule === MODULE_COMPTABILITE
|
||||||
|
? "bg-white dark:bg-gray-800 text-[#2A6F4F] shadow-sm ring-1 ring-gray-200 dark:ring-gray-700"
|
||||||
|
: "text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Calculator className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden md:inline">Compta</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Actions & Search */}
|
||||||
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
{/* <div className="hidden md:flex relative group">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 group-focus-within:text-[#2A6F4F] transition-colors" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Rechercher..."
|
||||||
|
className="h-9 w-48 lg:w-64 bg-gray-50 dark:bg-gray-900 border-none rounded-full pl-9 pr-4 text-sm focus:ring-2 focus:ring-[#2A6F4F]/20 focus:bg-white dark:focus:bg-gray-800 transition-all outline-none"
|
||||||
|
/>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{/* Announcements Trigger */}
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors"
|
||||||
|
title={isDark ? "Mode clair" : "Mode sombre"}
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNotifications(!showNotifications)}
|
||||||
|
className={`relative p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors text-gray-500 hover:text-[#2A6F4F] ${showNotifications && "bg-[#2a6f4f43]"}`}
|
||||||
|
title="Nouveautés & Évolutions"
|
||||||
|
>
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-green-800 rounded-full border-2 border-white dark:border-gray-950"></span>
|
||||||
|
{showNotifications && (
|
||||||
|
<NotificationCenter onClose={() => setShowNotifications(false)} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-gray-200 dark:bg-gray-800 hidden md:block" />
|
||||||
|
|
||||||
|
{/* User Menu */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className="flex items-center gap-2 p-1 pl-2 hover:bg-gray-50 dark:hover:bg-gray-900 rounded-full border border-transparent hover:border-gray-200 dark:hover:border-gray-800 transition-all">
|
||||||
|
<div className="text-right hidden sm:block">
|
||||||
|
<p className="text-sm font-bold text-gray-900 dark:text-white leading-none">{userConnected ? userConnected.prenom + " " +userConnected.nom : "User"}</p>
|
||||||
|
<p className="text-[10px] text-gray-500 dark:text-gray-400 leading-none mt-1">{userConnected ? userConnected.role : "Adminisateur"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-[#2A6F4F] flex items-center justify-center text-white shadow-md shadow-[#2A6F4F]/20">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="w-3 h-3 text-gray-400 mr-1" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuLabel>Mon Compte</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>Profil</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>Paramètres</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="text-red-600" onClick={handleLogout}>Déconnexion</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PremiumHeader;
|
||||||
27
src/components/PrimaryButton.jsx
Normal file
27
src/components/PrimaryButton.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const PrimaryButton = ({ children, onClick, loading, disabled, className, icon: Icon, ...props }) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-[#007E45] text-white rounded-xl text-sm font-medium hover:bg-[#007E45] focus:outline-none focus:ring-2 focus:ring-[#941403] focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : Icon ? (
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PrimaryButton;
|
||||||
43
src/components/PrimaryButton_v2.tsx
Normal file
43
src/components/PrimaryButton_v2.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface PrimaryButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
loading?: boolean;
|
||||||
|
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PrimaryButton_v2: React.FC<PrimaryButtonProps> = ({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
loading = false,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
icon: Icon,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
className={cn(
|
||||||
|
// "inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-[#941403] text-white rounded-xl text-sm font-medium hover:bg-[#7a1002] focus:outline-none focus:ring-2 focus:ring-[#941403] focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all",
|
||||||
|
"inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-[#007E45] text-white rounded-xl text-sm font-medium hover:bg-[#10a237] focus:outline-none focus:ring-2 focus:ring-[#00D639] focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : Icon ? (
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PrimaryButton_v2;
|
||||||
38
src/components/PriorityBanner.jsx
Normal file
38
src/components/PriorityBanner.jsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { X, Info, AlertTriangle, CheckCircle } from 'lucide-react';
|
||||||
|
import { useBanners } from '@/context/BannerContext';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
const PriorityBanner = () => {
|
||||||
|
const { priorityBanner, dismissPriority } = useBanners();
|
||||||
|
|
||||||
|
if (!priorityBanner.visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="bg-[#1F2937] text-white px-6 py-3 relative z-[1000] flex items-center justify-center border-b border-gray-700"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 text-sm font-bold tracking-wide">
|
||||||
|
<Info className="w-5 h-5 text-blue-400" />
|
||||||
|
<span>{priorityBanner.message}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{priorityBanner.dismissible && (
|
||||||
|
<button
|
||||||
|
onClick={dismissPriority}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 hover:bg-white/10 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-gray-400 hover:text-white" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PriorityBanner;
|
||||||
38
src/components/SecondaryBanner.jsx
Normal file
38
src/components/SecondaryBanner.jsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { X, Sparkles } from 'lucide-react';
|
||||||
|
import { useBanners } from '@/context/BannerContext';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
const SecondaryBanner = () => {
|
||||||
|
const { secondaryBanner, dismissSecondary } = useBanners();
|
||||||
|
|
||||||
|
if (!secondaryBanner.visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="bg-[#D1FAE5] text-[#065F46] px-6 py-2.5 relative z-[999] flex items-center justify-center border-b border-[#34D399]/30"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 text-xs font-medium">
|
||||||
|
<Sparkles className="w-4 h-4 text-[#059669]" />
|
||||||
|
<span>{secondaryBanner.message}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{secondaryBanner.dismissible && (
|
||||||
|
<button
|
||||||
|
onClick={dismissSecondary}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 hover:bg-[#10B981]/20 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5 text-[#047857]" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SecondaryBanner;
|
||||||
35
src/components/SegmentedControl.jsx
Normal file
35
src/components/SegmentedControl.jsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const SegmentedControl = ({ segments, active, onChange }) => {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex bg-gray-100 dark:bg-gray-800 p-1 rounded-xl">
|
||||||
|
{segments.map((segment) => (
|
||||||
|
<button
|
||||||
|
key={segment.id}
|
||||||
|
onClick={() => onChange(segment.id)}
|
||||||
|
className={cn(
|
||||||
|
"relative px-4 py-2 text-sm font-medium rounded-lg transition-colors",
|
||||||
|
active === segment.id
|
||||||
|
? "text-white"
|
||||||
|
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active === segment.id && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="activeSegment"
|
||||||
|
// className="absolute inset-0 bg-[#941403] rounded-lg"
|
||||||
|
className="absolute inset-0 bg-[#007E45] rounded-lg"
|
||||||
|
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative z-10">{segment.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SegmentedControl;
|
||||||
145
src/components/SmartForm.jsx
Normal file
145
src/components/SmartForm.jsx
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import PrimaryButton from '@/components/PrimaryButton';
|
||||||
|
|
||||||
|
const SmartForm = ({
|
||||||
|
schema,
|
||||||
|
defaultValues,
|
||||||
|
onSubmit,
|
||||||
|
fields,
|
||||||
|
loading = false,
|
||||||
|
submitLabel = "Enregistrer",
|
||||||
|
cancelLabel = "Annuler",
|
||||||
|
onCancel
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isDirty, isValid },
|
||||||
|
watch
|
||||||
|
} = useForm({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues,
|
||||||
|
mode: "onChange"
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{fields.map((field) => {
|
||||||
|
const isError = errors[field.name];
|
||||||
|
const value = watch(field.name);
|
||||||
|
const isSuccess = !isError && value && field.type !== 'checkbox' && field.type !== 'textarea';
|
||||||
|
|
||||||
|
if (field.type === 'section') {
|
||||||
|
return (
|
||||||
|
<div key={field.title} className="col-span-1 md:col-span-2 pt-4 pb-2 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white">{field.title}</h3>
|
||||||
|
{field.description && <p className="text-sm text-gray-500 dark:text-gray-400">{field.description}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={field.name}
|
||||||
|
className={cn(
|
||||||
|
"space-y-1.5 relative",
|
||||||
|
field.fullWidth && "col-span-1 md:col-span-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<label className="flex items-center gap-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{field.type === 'select' ? (
|
||||||
|
<select
|
||||||
|
{...register(field.name)}
|
||||||
|
className={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",
|
||||||
|
isError
|
||||||
|
? "border-red-300 focus:border-red-500 focus:ring-red-200"
|
||||||
|
: "border-gray-200 dark:border-gray-800 focus:border-[#941403] focus:ring-red-100/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner...</option>
|
||||||
|
{field.options?.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : field.type === 'textarea' ? (
|
||||||
|
<textarea
|
||||||
|
{...register(field.name)}
|
||||||
|
rows={field.rows || 3}
|
||||||
|
className={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 resize-none",
|
||||||
|
isError
|
||||||
|
? "border-red-300 focus:border-red-500 focus:ring-red-200"
|
||||||
|
: "border-gray-200 dark:border-gray-800 focus:border-[#941403] focus:ring-red-100/50"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={field.type || 'text'}
|
||||||
|
{...register(field.name)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className={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",
|
||||||
|
isError
|
||||||
|
? "border-red-300 focus:border-red-500 focus:ring-red-200 pr-10"
|
||||||
|
: isSuccess
|
||||||
|
? "border-green-300 focus:border-green-500 focus:ring-green-200 pr-10"
|
||||||
|
: "border-gray-200 dark:border-gray-800 focus:border-[#941403] focus:ring-red-100/50"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Icons validation indicators */}
|
||||||
|
{field.type !== 'select' && field.type !== 'textarea' && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||||
|
{isError && <AlertCircle className="w-4 h-4 text-red-500" />}
|
||||||
|
{isSuccess && <CheckCircle2 className="w-4 h-4 text-green-500" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Helper Text or Error Message */}
|
||||||
|
{isError ? (
|
||||||
|
<p className="text-xs text-red-500 animate-in slide-in-from-top-1">{isError.message}</p>
|
||||||
|
) : field.help ? (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{field.help}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-6 border-t border-gray-200 dark:border-gray-800">
|
||||||
|
{onCancel && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<PrimaryButton
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={!isDirty || !isValid}
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SmartForm;
|
||||||
95
src/components/StatusBadget.jsx
Normal file
95
src/components/StatusBadget.jsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const statusStyles = {
|
||||||
|
// Quotes
|
||||||
|
draft: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300',
|
||||||
|
sent: 'bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300',
|
||||||
|
accepted: 'bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300',
|
||||||
|
refused: 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300',
|
||||||
|
expired: 'bg-orange-100 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300',
|
||||||
|
|
||||||
|
// Orders & Invoices
|
||||||
|
pending: 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300',
|
||||||
|
validated: 'bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300',
|
||||||
|
paid: 'bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300',
|
||||||
|
partial: 'bg-orange-100 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300',
|
||||||
|
cancelled: 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300',
|
||||||
|
|
||||||
|
// Tickets
|
||||||
|
open: 'bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300',
|
||||||
|
'in-progress': 'bg-purple-100 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300',
|
||||||
|
resolved: 'bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300',
|
||||||
|
closed: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300',
|
||||||
|
|
||||||
|
// Priorities
|
||||||
|
low: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300',
|
||||||
|
normal: 'bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300',
|
||||||
|
high: 'bg-orange-100 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300',
|
||||||
|
critical: 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300',
|
||||||
|
|
||||||
|
// Opportunities
|
||||||
|
new: 'bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300',
|
||||||
|
qualification: 'bg-purple-100 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300',
|
||||||
|
proposal: 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300',
|
||||||
|
negotiation: 'bg-orange-100 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300',
|
||||||
|
won: 'bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300',
|
||||||
|
lost: 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
draft: 'Brouillon',
|
||||||
|
sent: 'Envoyé',
|
||||||
|
accepted: 'Accepté',
|
||||||
|
refused: 'Refusé',
|
||||||
|
expired: 'Expiré',
|
||||||
|
pending: 'En attente',
|
||||||
|
validated: 'Validé',
|
||||||
|
paid: 'Payé',
|
||||||
|
partial: 'Partiel',
|
||||||
|
cancelled: 'Annulé',
|
||||||
|
open: 'Ouvert',
|
||||||
|
'in-progress': 'En cours',
|
||||||
|
resolved: 'Résolu',
|
||||||
|
closed: 'Fermé',
|
||||||
|
low: 'Basse',
|
||||||
|
normal: 'Normale',
|
||||||
|
high: 'Haute',
|
||||||
|
critical: 'Critique',
|
||||||
|
new: 'Nouveau',
|
||||||
|
qualification: 'Qualification',
|
||||||
|
proposal: 'Proposition',
|
||||||
|
negotiation: 'Négociation',
|
||||||
|
won: 'Gagné',
|
||||||
|
lost: 'Perdu',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
0: { label: 'Saisi', color: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300' },
|
||||||
|
1: { label: 'Confirmé', color: 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300' },
|
||||||
|
2: { label: 'Accepté', color: 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800' },
|
||||||
|
3: { label: 'Perdu', color: 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300' },
|
||||||
|
4: { label: 'Archivé', color: 'bg-orange-100 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300' },
|
||||||
|
5: { label: 'Transformé', color: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300' },
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const StatusBadge = ({ status }) => {
|
||||||
|
const info = labels[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center px-2.5 py-0.5 rounded-lg text-xs font-medium",
|
||||||
|
info?.color ?? "bg-gray-200 text-gray-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{info?.label ?? `Statut ${status}`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default StatusBadge
|
||||||
|
|
||||||
29
src/components/StatusBadgetCommande.jsx
Normal file
29
src/components/StatusBadgetCommande.jsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const labelsCommande = {
|
||||||
|
0: { label: 'Préparation', color: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300' },
|
||||||
|
1: { label: 'En cours', color: 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300' },
|
||||||
|
2: { label: 'Livrée', color: 'bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300' },
|
||||||
|
5: { label: 'Facturée', color: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusBadgeCommande = ({ status }) => {
|
||||||
|
const info = labelsCommande[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center px-2.5 py-0.5 rounded-lg text-xs font-medium",
|
||||||
|
info?.color ?? "bg-gray-200 text-gray-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{info?.label ?? `Statut ${status}`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatusBadgeCommande;
|
||||||
107
src/components/StatusBadgetLettre.tsx
Normal file
107
src/components/StatusBadgetLettre.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type Status =
|
||||||
|
| 'actif'
|
||||||
|
| 'inactif'
|
||||||
|
| 'Saisi'
|
||||||
|
| 'Brouillon'
|
||||||
|
| 'Envoyé'
|
||||||
|
| 'Vu'
|
||||||
|
| 'Signé'
|
||||||
|
| 'Transformé en commande'
|
||||||
|
| 'Annulé'
|
||||||
|
| 'Refusé'
|
||||||
|
| 'Enregistrée'
|
||||||
|
| 'Préparée'
|
||||||
|
| 'Livrée'
|
||||||
|
| 'Facturée'
|
||||||
|
| 'Validée'
|
||||||
|
| 'Payée'
|
||||||
|
| 'Partiellement payée'
|
||||||
|
| 'En retard'
|
||||||
|
| 'draft'
|
||||||
|
| 'sent'
|
||||||
|
| 'accepted'
|
||||||
|
| 'paid'
|
||||||
|
| 'overdue'
|
||||||
|
| 'prospect'
|
||||||
|
| 'vip'
|
||||||
|
|
||||||
|
const statusStyles: Record<Status, string> = {
|
||||||
|
// Global Active/Inactive
|
||||||
|
actif:
|
||||||
|
'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800',
|
||||||
|
inactif:
|
||||||
|
'bg-gray-100 text-gray-600 border-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700',
|
||||||
|
|
||||||
|
// Sage 100 / Quote Statuses
|
||||||
|
Saisi:
|
||||||
|
'bg-gray-100 text-gray-600 border-gray-200 dark:bg-gray-800 dark:text-gray-400',
|
||||||
|
Brouillon:
|
||||||
|
'bg-gray-100 text-gray-600 border-gray-200 dark:bg-gray-800 dark:text-gray-400',
|
||||||
|
Envoyé:
|
||||||
|
'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800',
|
||||||
|
Vu:
|
||||||
|
'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:border-purple-800',
|
||||||
|
Signé:
|
||||||
|
'bg-[#338660]/10 text-[#338660] border-[#338660]/20 dark:bg-[#338660]/20 dark:text-green-400 dark:border-[#338660]/30',
|
||||||
|
'Transformé en commande':
|
||||||
|
'bg-[#338660]/10 text-[#338660] border-[#338660]/20 dark:bg-[#338660]/20 dark:text-green-400',
|
||||||
|
Annulé:
|
||||||
|
'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800',
|
||||||
|
Refusé:
|
||||||
|
'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
|
||||||
|
// Order Statuses
|
||||||
|
Enregistrée:
|
||||||
|
'bg-blue-50 text-blue-700 border-blue-100 dark:bg-blue-900/20 dark:text-blue-300',
|
||||||
|
Préparée:
|
||||||
|
'bg-indigo-50 text-indigo-700 border-indigo-100 dark:bg-indigo-900/20 dark:text-indigo-300',
|
||||||
|
Livrée:
|
||||||
|
'bg-[#338660]/10 text-[#338660] border-[#338660]/20 dark:bg-[#338660]/20 dark:text-green-400',
|
||||||
|
Facturée:
|
||||||
|
'bg-teal-50 text-teal-700 border-teal-100 dark:bg-teal-900/20 dark:text-teal-300',
|
||||||
|
|
||||||
|
// Payment Statuses
|
||||||
|
Validée: 'bg-blue-50 text-blue-700 border-blue-100',
|
||||||
|
Payée:
|
||||||
|
'bg-[#338660]/10 text-[#338660] border-[#338660]/20 dark:bg-[#338660]/20 dark:text-green-400',
|
||||||
|
'Partiellement payée':
|
||||||
|
'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400',
|
||||||
|
'En retard':
|
||||||
|
'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400',
|
||||||
|
|
||||||
|
// Legacy
|
||||||
|
draft: 'bg-gray-100 text-gray-600',
|
||||||
|
sent: 'bg-blue-100 text-blue-700',
|
||||||
|
accepted: 'bg-[#338660]/10 text-[#338660]',
|
||||||
|
paid: 'bg-[#338660]/10 text-[#338660]',
|
||||||
|
overdue: 'bg-red-100 text-red-700',
|
||||||
|
prospect: 'bg-blue-50 text-blue-700',
|
||||||
|
vip: 'bg-purple-50 text-purple-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusBadgetLettreProps {
|
||||||
|
status: Status
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusBadgetLettre: React.FC<StatusBadgetLettreProps> = ({
|
||||||
|
status,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold border whitespace-nowrap transition-colors',
|
||||||
|
statusStyles[status],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatusBadgetLettre
|
||||||
43
src/components/Tabs.jsx
Normal file
43
src/components/Tabs.jsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Tabs = ({ tabs, active, onChange }) => {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-[#F2F2F2] dark:border-gray-800">
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => onChange(tab.id)}
|
||||||
|
className={cn(
|
||||||
|
"relative pb-3 text-sm font-bold transition-colors",
|
||||||
|
active === tab.id
|
||||||
|
? "text-[#007E45]"
|
||||||
|
: "text-[#6A6A6A] dark:text-gray-400 hover:text-black dark:hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{tab.count !== undefined && (
|
||||||
|
<span className={cn(
|
||||||
|
"ml-2 inline-flex items-center justify-center h-5 w-5 rounded-full text-xs font-medium",
|
||||||
|
active === tab.id ? "bg-[#007E45] text-white" : "bg-[#F2F2F2] text-[#6A6A6A]"
|
||||||
|
)}>
|
||||||
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{active === tab.id && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="activeTab"
|
||||||
|
className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#007E45]"
|
||||||
|
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Tabs;
|
||||||
115
src/components/Timeline.jsx
Normal file
115
src/components/Timeline.jsx
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Mail, Phone, Calendar, FileText, User } from 'lucide-react';
|
||||||
|
import { cn, formatDateFR } from '@/lib/utils';
|
||||||
|
import StatusBadge from './StatusBadget';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAppDispatch } from '@/store/hooks';
|
||||||
|
import { selectCommandeAsync } from '@/store/features/commande/thunk';
|
||||||
|
import { selectDevis } from '@/store/features/devis/slice';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
CheckCircle2, AlertCircle,
|
||||||
|
ArrowRight, Truck, PenTool, FileCheck, XCircle, Plus
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
email: Mail,
|
||||||
|
call: Phone,
|
||||||
|
meeting: Calendar,
|
||||||
|
quote: FileText,
|
||||||
|
task: User,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventIcon = (type) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'creation': return Plus;
|
||||||
|
case 'email': return Mail;
|
||||||
|
case 'viewed': return CheckCircle2;
|
||||||
|
case 'signature': return PenTool;
|
||||||
|
case 'transform': return ArrowRight;
|
||||||
|
case 'delivery': return Truck;
|
||||||
|
case 'payment': return FileCheck;
|
||||||
|
case 'cancellation': return XCircle;
|
||||||
|
case 'update': return FileText;
|
||||||
|
default: return Calendar;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventColor = (type) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'creation': return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400';
|
||||||
|
case 'email': return 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400';
|
||||||
|
case 'signature': return 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400';
|
||||||
|
case 'delivery': return 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400';
|
||||||
|
case 'cancellation': return 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400';
|
||||||
|
default: return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const Timeline = ({ events }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-4">
|
||||||
|
<div className="relative space-y-8 before:absolute before:inset-0 before:ml-5 before:h-full before:w-0.5 before:-translate-x-px before:bg-gradient-to-b before:from-transparent before:via-gray-200 before:to-transparent dark:before:via-gray-800">
|
||||||
|
{events.map((event, index) => {
|
||||||
|
const Icon = getEventIcon(event.type);
|
||||||
|
const colorClass = getEventColor(event.type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
className="relative flex items-start gap-6 group"
|
||||||
|
onClick={() => navigate(event.link)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-4 border-white dark:border-gray-950 shadow-sm transition-transform group-hover:scale-110",
|
||||||
|
colorClass
|
||||||
|
)}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 pt-1.5">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-1 mb-1">
|
||||||
|
<h4 className="text-base font-bold text-gray-900 dark:text-white">
|
||||||
|
{event.title}
|
||||||
|
</h4>
|
||||||
|
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 whitespace-nowrap">
|
||||||
|
{new Date(event.date).toLocaleDateString()}
|
||||||
|
{event.time && ` • ${event.time}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-xl p-3 border border-gray-100 dark:border-gray-800 relative">
|
||||||
|
{/* Tiny triangle for speech bubble effect */}
|
||||||
|
<div className="absolute top-3 -left-1.5 w-3 h-3 bg-gray-50 dark:bg-gray-900/50 border-l border-b border-gray-100 dark:border-gray-800 transform rotate-45"></div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300 relative z-10">
|
||||||
|
{event.description || 'Aucun détail supplémentaire.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center gap-2 pt-2 border-t border-gray-100 dark:border-gray-800">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[9px] font-bold text-gray-600 dark:text-gray-300">
|
||||||
|
J
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Jean Dupont
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Timeline;
|
||||||
24
src/components/UserAvatar.jsx
Normal file
24
src/components/UserAvatar.jsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const UserAvatar = ({ name, role, image }) => {
|
||||||
|
const initials = name
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 pl-3 border-l border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="text-right hidden sm:block">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">{name}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{role}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-9 h-9 rounded-full bg-[#941403] text-white flex items-center justify-center text-sm font-semibold">
|
||||||
|
{image ? <img src={image} alt={name} className="w-full h-full rounded-full object-cover" /> : initials}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserAvatar;
|
||||||
63
src/components/UserMenu.jsx
Normal file
63
src/components/UserMenu.jsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator, DropdownMenuTrigger
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { User, Settings, Bell, LogOut, Moon, Sun } from 'lucide-react';
|
||||||
|
import UserAvatar from '@/components/UserAvatar';
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
import { currentUser } from '@/data/mockData';
|
||||||
|
|
||||||
|
const UserMenu = () => {
|
||||||
|
const { isDark, toggleTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className="focus:outline-none">
|
||||||
|
<div className="flex items-center gap-2 hover:bg-gray-100 dark:hover:bg-gray-800 p-1 pr-3 rounded-full transition-colors cursor-pointer">
|
||||||
|
<UserAvatar name={currentUser.name} role={currentUser.role} />
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56 bg-white dark:bg-gray-950 border-gray-200 dark:border-gray-800">
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">{currentUser.name}</p>
|
||||||
|
<p className="text-xs leading-none text-gray-500 dark:text-gray-400">{currentUser.email}</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator className="bg-gray-100 dark:bg-gray-800" />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/profile" className="cursor-pointer flex items-center gap-2 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-900">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
<span>Mon Profil</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/preferences" className="cursor-pointer flex items-center gap-2 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-900">
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
<span>Préférences</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/notifications" className="cursor-pointer flex items-center gap-2 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-900">
|
||||||
|
<Bell className="w-4 h-4" />
|
||||||
|
<span>Notifications</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator className="bg-gray-100 dark:bg-gray-800" />
|
||||||
|
<DropdownMenuItem onClick={toggleTheme} className="cursor-pointer flex items-center gap-2 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-900">
|
||||||
|
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||||
|
<span>Mode {isDark ? 'Clair' : 'Sombre'}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="cursor-pointer flex items-center gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20">
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
<span>Déconnexion</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserMenu;
|
||||||
17
src/components/WelcomeMessage.jsx
Normal file
17
src/components/WelcomeMessage.jsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
const WelcomeMessage = () => {
|
||||||
|
return (
|
||||||
|
<motion.p
|
||||||
|
className='text-sm text-white leading-5 w-full'
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.8 }}
|
||||||
|
>
|
||||||
|
Write in the chat what you want to create.
|
||||||
|
</motion.p>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WelcomeMessage;
|
||||||
260
src/components/chart/Chart.tsx
Normal file
260
src/components/chart/Chart.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { filterItemByPeriod } from '@/components/filter/ItemsFilter';
|
||||||
|
import ChartCard from '@/components/ChartCard';
|
||||||
|
import { PeriodType } from '../KPIBar';
|
||||||
|
|
||||||
|
interface CAEvolutionChartProps {
|
||||||
|
title: string
|
||||||
|
items: any[];
|
||||||
|
period: PeriodType;
|
||||||
|
className?: string;
|
||||||
|
type: string;
|
||||||
|
showLabels?: boolean;
|
||||||
|
yAxisLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction pour grouper les factures par date selon la période
|
||||||
|
const groupFacturesByPeriod = (items: any[], period: PeriodType) => {
|
||||||
|
const grouped: Record<string, number> = {};
|
||||||
|
|
||||||
|
items
|
||||||
|
.filter(f => f.statut === 2) // Seulement les factures validées
|
||||||
|
.forEach(item => {
|
||||||
|
const date = new Date(item.date);
|
||||||
|
let key: string;
|
||||||
|
|
||||||
|
switch (period) {
|
||||||
|
case 'today':
|
||||||
|
// Grouper par heure
|
||||||
|
key = `${date.getHours()}h`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'week':
|
||||||
|
// Grouper par jour de la semaine
|
||||||
|
const days = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam'];
|
||||||
|
key = days[date.getDay()];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'month':
|
||||||
|
// Grouper par jour du mois
|
||||||
|
key = `${date.getDate()}/${date.getMonth() + 1}`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'quarter':
|
||||||
|
// Grouper par semaine
|
||||||
|
const weekNum = Math.ceil(date.getDate() / 7);
|
||||||
|
key = `S${weekNum}`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'year':
|
||||||
|
// Grouper par mois
|
||||||
|
const months = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun', 'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc'];
|
||||||
|
key = months[date.getMonth()];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'all':
|
||||||
|
// Grouper par mois/année
|
||||||
|
key = `${date.getMonth() + 1}/${date.getFullYear()}`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
key = `${date.getDate()}/${date.getMonth() + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped[key] = (grouped[key] || 0) + item.total_ht;
|
||||||
|
});
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour formater les données pour le graphique
|
||||||
|
const formatChartData = (grouped: Record<string, number>, period: PeriodType) => {
|
||||||
|
const entries = Object.entries(grouped);
|
||||||
|
|
||||||
|
// Trier selon la période
|
||||||
|
if (period === 'today') {
|
||||||
|
// Trier par heure
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
const hourA = parseInt(a[0]);
|
||||||
|
const hourB = parseInt(b[0]);
|
||||||
|
return hourA - hourB;
|
||||||
|
});
|
||||||
|
} else if (period === 'week') {
|
||||||
|
// Ordre des jours de la semaine
|
||||||
|
const dayOrder = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
|
||||||
|
entries.sort((a, b) => dayOrder.indexOf(a[0]) - dayOrder.indexOf(b[0]));
|
||||||
|
} else if (period === 'year') {
|
||||||
|
// Ordre des mois
|
||||||
|
const monthOrder = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun', 'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc'];
|
||||||
|
entries.sort((a, b) => monthOrder.indexOf(a[0]) - monthOrder.indexOf(b[0]));
|
||||||
|
} else if (period === 'month') {
|
||||||
|
// Trier par jour
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
const [dayA] = a[0].split('/').map(Number);
|
||||||
|
const [dayB] = b[0].split('/').map(Number);
|
||||||
|
return dayA - dayB;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value: Math.round(value),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function ChartDataven({
|
||||||
|
items,
|
||||||
|
period,
|
||||||
|
className,
|
||||||
|
type = 'area', // ✅ Valeur par défaut typée
|
||||||
|
title,
|
||||||
|
showLabels = true,
|
||||||
|
yAxisLabel = "Montant en K€"
|
||||||
|
}: CAEvolutionChartProps) {
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!items || items.length === 0) return [];
|
||||||
|
|
||||||
|
const filteredFactures = filterItemByPeriod(items, period);
|
||||||
|
const grouped = groupFacturesByPeriod(filteredFactures, period);
|
||||||
|
const formatted = formatChartData(grouped, period);
|
||||||
|
|
||||||
|
return [...formatted].sort((a, b) => {
|
||||||
|
const [monthA, yearA] = a.name.split("/").map(Number);
|
||||||
|
const [monthB, yearB] = b.name.split("/").map(Number);
|
||||||
|
|
||||||
|
const dateA = new Date(yearA, monthA - 1) as any
|
||||||
|
const dateB = new Date(yearB, monthB - 1) as any
|
||||||
|
|
||||||
|
return dateA - dateB; // ancien → récent
|
||||||
|
});
|
||||||
|
}, [items, period]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title={title}
|
||||||
|
type={type}
|
||||||
|
data={chartData}
|
||||||
|
className={className}
|
||||||
|
height={300}
|
||||||
|
showLabels={showLabels}
|
||||||
|
yAxisLabel={yAxisLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChartDataven;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// GRAPHIQUE CA PAR STATUT
|
||||||
|
// ============================================
|
||||||
|
export function ChartByStatusChart({ items, title, period, className }: CAEvolutionChartProps) {
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!items || items.length === 0) return [];
|
||||||
|
|
||||||
|
const filteredFactures = filterItemByPeriod(items, period);
|
||||||
|
|
||||||
|
const statusLabels: Record<number, string> = {
|
||||||
|
0: "Saisi",
|
||||||
|
1: "Confirmé",
|
||||||
|
2: 'Accepté',
|
||||||
|
3: 'Perdu',
|
||||||
|
4: 'Archivé',
|
||||||
|
// 5: 'Transformé',
|
||||||
|
// 6: 'Annulé'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<number, string> = {
|
||||||
|
0: '#6b7280', // Brouillon → Gris
|
||||||
|
1: '#f59e0b', // En attente → Jaune
|
||||||
|
2: '#10b981', // Accepté → Vert
|
||||||
|
3: '#ef4444', // Perdu → Rouge
|
||||||
|
4: '#fb923c', // Archivé → Orange
|
||||||
|
5: '#3b82f6', // Transformé → Bleu
|
||||||
|
6: '#ef4444', // Annulé → Rouge
|
||||||
|
};
|
||||||
|
|
||||||
|
const grouped = filteredFactures.reduce((acc, facture) => {
|
||||||
|
const status = facture.statut;
|
||||||
|
acc[status] = (acc[status] || 0) + facture.total_ht;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<number, number>);
|
||||||
|
|
||||||
|
return Object.entries(grouped).map(([status, value]) => ({
|
||||||
|
name: statusLabels[parseInt(status)] || `Statut ${status}`,
|
||||||
|
value: Math.round(value as number),
|
||||||
|
fill: statusColors[parseInt(status)] || '#941403',
|
||||||
|
}));
|
||||||
|
}, [items, period]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title={title}
|
||||||
|
type="donut"
|
||||||
|
data={chartData}
|
||||||
|
className={className}
|
||||||
|
height={300}
|
||||||
|
showTotal={false} // ✅ Masque le total
|
||||||
|
showLabels={false} // ✅ Masque les labels sur le graphique
|
||||||
|
showYAxisLabel={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// GRAPHIQUE TOP CLIENTS
|
||||||
|
// ============================================
|
||||||
|
export function TopClientsChart({ items, title, period, className, limit = 5 }: CAEvolutionChartProps & { limit?: number }) {
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!items || items.length === 0) return [];
|
||||||
|
|
||||||
|
const filteredFactures = filterItemByPeriod(items, period);
|
||||||
|
|
||||||
|
const clientsCA = filteredFactures
|
||||||
|
.filter(f => f.statut === 2) // Seulement payées
|
||||||
|
.reduce((acc, facture) => {
|
||||||
|
const client = facture.client_intitule;
|
||||||
|
acc[client] = (acc[client] || 0) + facture.total_ht;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>)
|
||||||
|
|
||||||
|
return (Object.entries(clientsCA) as [string, number][])
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(([name, value]) => ({
|
||||||
|
name: name.length > 20 ? name.substring(0, 20) + '...' : name,
|
||||||
|
value: Math.round(value),
|
||||||
|
}));
|
||||||
|
}, [items, period, limit]);
|
||||||
|
|
||||||
|
console.log("chartData : ",chartData);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title={`Top ${limit} ${title}`}
|
||||||
|
type="bar"
|
||||||
|
data={chartData}
|
||||||
|
className={className}
|
||||||
|
height={300}
|
||||||
|
showTotal={false} // ✅ Masque le total
|
||||||
|
showLabels={false} // ✅ Masque les labels sur le graphique
|
||||||
|
showYAxisLabel={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
284
src/components/common/AdvancedFilters.tsx
Normal file
284
src/components/common/AdvancedFilters.tsx
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { Filter, X, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '../ui/buttonTsx'
|
||||||
|
|
||||||
|
/* =======================
|
||||||
|
Types
|
||||||
|
======================= */
|
||||||
|
|
||||||
|
export interface FilterOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterDefinition {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
options: FilterOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActiveFilters = Record<string, string[] | undefined>
|
||||||
|
|
||||||
|
interface AdvancedFiltersProps {
|
||||||
|
filters?: FilterDefinition[]
|
||||||
|
activeFilters?: ActiveFilters
|
||||||
|
onFilterChange: (key: string, values?: string[]) => void
|
||||||
|
onReset: () => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =======================
|
||||||
|
Component
|
||||||
|
======================= */
|
||||||
|
|
||||||
|
const AdvancedFilters: React.FC<AdvancedFiltersProps> = ({
|
||||||
|
filters = [],
|
||||||
|
activeFilters = {},
|
||||||
|
onFilterChange,
|
||||||
|
onReset,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
const [showScrollButtons, setShowScrollButtons] = React.useState(false)
|
||||||
|
const [canScrollLeft, setCanScrollLeft] = React.useState(false)
|
||||||
|
const [canScrollRight, setCanScrollRight] = React.useState(false)
|
||||||
|
|
||||||
|
const activeCount = Object.keys(activeFilters).filter(
|
||||||
|
key => activeFilters[key]?.length
|
||||||
|
).length
|
||||||
|
|
||||||
|
const handleFilterToggle = (key: string, value: string) => {
|
||||||
|
const current = activeFilters[key] ?? []
|
||||||
|
const next = current.includes(value)
|
||||||
|
? current.filter(v => v !== value)
|
||||||
|
: [...current, value]
|
||||||
|
onFilterChange(key, next.length > 0 ? next : undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si le scroll est nécessaire
|
||||||
|
const checkScroll = React.useCallback(() => {
|
||||||
|
const container = scrollContainerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const hasOverflow = container.scrollWidth > container.clientWidth
|
||||||
|
setShowScrollButtons(hasOverflow)
|
||||||
|
setCanScrollLeft(container.scrollLeft > 0)
|
||||||
|
setCanScrollRight(
|
||||||
|
container.scrollLeft < container.scrollWidth - container.clientWidth - 1
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
checkScroll()
|
||||||
|
window.addEventListener('resize', checkScroll)
|
||||||
|
return () => window.removeEventListener('resize', checkScroll)
|
||||||
|
}, [checkScroll, activeFilters])
|
||||||
|
|
||||||
|
const scroll = (direction: 'left' | 'right') => {
|
||||||
|
const container = scrollContainerRef.current
|
||||||
|
if (!container) return
|
||||||
|
const scrollAmount = 150
|
||||||
|
container.scrollBy({
|
||||||
|
left: direction === 'left' ? -scrollAmount : scrollAmount,
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active filter badges
|
||||||
|
const activeBadges = Object.entries(activeFilters)
|
||||||
|
.filter(([, values]) => values && values.length > 0)
|
||||||
|
.map(([key, values]) => {
|
||||||
|
const filterDef = filters.find(f => f.key === key)
|
||||||
|
if (!filterDef || !values) return null
|
||||||
|
return { key, values, filterDef }
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-2 min-w-0', className)}>
|
||||||
|
{/* Bouton Filtres */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'gap-2 h-10 border-dashed shrink-0',
|
||||||
|
activeCount > 0 &&
|
||||||
|
'border-solid border-[#338660] bg-[#338660]/5 text-[#338660]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Filter className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Filtres</span>
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<span className="rounded-full bg-[#338660] text-white w-5 h-5 text-[10px] flex items-center justify-center">
|
||||||
|
{activeCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
className="w-64 bg-white dark:bg-gray-950 border-gray-200 dark:border-gray-800 max-h-[70vh] overflow-y-auto"
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel>Filtres avancés</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
{filters.map(filter => {
|
||||||
|
const maxVisible = 6
|
||||||
|
const hasScroll = filter.options.length > maxVisible
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={filter.key} className="px-1 py-1">
|
||||||
|
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500 uppercase tracking-wider flex items-center justify-between">
|
||||||
|
<span>{filter.label}</span>
|
||||||
|
{hasScroll && (
|
||||||
|
<span className="text-[10px] font-normal text-gray-400">
|
||||||
|
{filter.options.length} options
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
hasScroll && 'max-h-[192px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{filter.options.map(option => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={option.value}
|
||||||
|
checked={(activeFilters[filter.key] ?? []).includes(option.value)}
|
||||||
|
onCheckedChange={() => handleFilterToggle(filter.key, option.value)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full mr-2 shrink-0',
|
||||||
|
option.color ?? 'bg-gray-300'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator className="mt-1" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<div className="p-2 sticky bottom-0 bg-white dark:bg-gray-950 border-t border-gray-100 dark:border-gray-800">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onReset}
|
||||||
|
className="w-full h-8 text-xs text-[#007E45] hover:text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Réinitialiser les filtres
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Container scrollable pour les badges */}
|
||||||
|
{activeBadges.length > 0 && (
|
||||||
|
<div className="relative flex items-center min-w-0 flex-1">
|
||||||
|
{/* Bouton scroll gauche */}
|
||||||
|
{showScrollButtons && canScrollLeft && (
|
||||||
|
<button
|
||||||
|
onClick={() => scroll('left')}
|
||||||
|
className="absolute left-0 z-10 w-6 h-6 flex items-center justify-center bg-white dark:bg-gray-950 shadow-md rounded-full border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-3 h-3 text-gray-600 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Badges scrollables */}
|
||||||
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
onScroll={checkScroll}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 overflow-x-auto scrollbar-hide scroll-smooth',
|
||||||
|
showScrollButtons && canScrollLeft && 'pl-7',
|
||||||
|
showScrollButtons && canScrollRight && 'pr-7'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
msOverflowStyle: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeBadges.map(badge => {
|
||||||
|
if (!badge) return null
|
||||||
|
const { key, values, filterDef } = badge
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center gap-1 bg-[#338660]/10 text-[#338660] px-2 py-1 rounded-md text-xs font-medium border border-[#338660]/20 whitespace-nowrap shrink-0"
|
||||||
|
>
|
||||||
|
<span className="opacity-70">{filterDef.label}:</span>
|
||||||
|
<span className="max-w-[100px] truncate">
|
||||||
|
{values.length > 2
|
||||||
|
? `${values.length} sélectionnés`
|
||||||
|
: values
|
||||||
|
.map(v => filterDef.options.find(o => o.value === v)?.label)
|
||||||
|
.join(', ')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onFilterChange(key, undefined)}
|
||||||
|
className="ml-1 hover:bg-[#338660]/20 rounded-full p-0.5 shrink-0"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bouton scroll droite */}
|
||||||
|
{showScrollButtons && canScrollRight && (
|
||||||
|
<button
|
||||||
|
onClick={() => scroll('right')}
|
||||||
|
className="absolute right-0 z-10 w-6 h-6 flex items-center justify-center bg-white dark:bg-gray-950 shadow-md rounded-full border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-3 h-3 text-gray-600 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gradient fade effect */}
|
||||||
|
{showScrollButtons && canScrollLeft && (
|
||||||
|
<div className="absolute left-6 top-0 bottom-0 w-4 bg-gradient-to-r from-white dark:from-gray-950 to-transparent pointer-events-none" />
|
||||||
|
)}
|
||||||
|
{showScrollButtons && canScrollRight && (
|
||||||
|
<div className="absolute right-6 top-0 bottom-0 w-4 bg-gradient-to-l from-white dark:from-gray-950 to-transparent pointer-events-none" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bouton reset rapide (visible uniquement sur grand écran) */}
|
||||||
|
{/* {activeCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={onReset}
|
||||||
|
className="hidden lg:flex items-center gap-1 text-xs text-gray-500 hover:text-red-600 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
<span>Tout effacer</span>
|
||||||
|
</button>
|
||||||
|
)} */}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdvancedFilters
|
||||||
135
src/components/common/ColumnSelector.tsx
Normal file
135
src/components/common/ColumnSelector.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Check, Columns, RotateCcw } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface ColumnConfig {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
visible: boolean;
|
||||||
|
locked?: boolean; // Colonnes qui ne peuvent pas être masquées
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnSelectorProps {
|
||||||
|
columns: ColumnConfig[];
|
||||||
|
onChange: (columns: ColumnConfig[]) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColumnSelector({ columns, onChange, className }: ColumnSelectorProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Fermer le dropdown si clic en dehors
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleColumn = (key: string) => {
|
||||||
|
const updated = columns.map(col =>
|
||||||
|
col.key === key && !col.locked ? { ...col, visible: !col.visible } : col
|
||||||
|
);
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetColumns = () => {
|
||||||
|
const updated = columns.map(col => ({ ...col, visible: true }));
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleCount = columns.filter(c => c.visible).length;
|
||||||
|
const totalCount = columns.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("relative", className)} ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-xl border transition-all",
|
||||||
|
isOpen
|
||||||
|
? "bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-600"
|
||||||
|
: "bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Columns className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="hidden sm:inline text-gray-700 dark:text-gray-300">Colonnes</span>
|
||||||
|
<span className="text-xs bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 px-1.5 py-0.5 rounded-md">
|
||||||
|
{visibleCount}/{totalCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-64 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-50 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Afficher les colonnes
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={resetColumns}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
Réinitialiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste des colonnes */}
|
||||||
|
<div className="max-h-64 overflow-y-auto py-2">
|
||||||
|
{columns.map((column) => (
|
||||||
|
<button
|
||||||
|
key={column.key}
|
||||||
|
onClick={() => toggleColumn(column.key)}
|
||||||
|
disabled={column.locked}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors",
|
||||||
|
column.locked
|
||||||
|
? "opacity-50 cursor-not-allowed"
|
||||||
|
: "hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-5 h-5 rounded-md border-2 flex items-center justify-center transition-all",
|
||||||
|
column.visible
|
||||||
|
? "bg-[#338660] border-[#338660]"
|
||||||
|
: "border-gray-300 dark:border-gray-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{column.visible && <Check className="w-3 h-3 text-white" />}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-sm",
|
||||||
|
column.visible
|
||||||
|
? "text-gray-900 dark:text-white"
|
||||||
|
: "text-gray-500 dark:text-gray-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
</span>
|
||||||
|
{column.locked && (
|
||||||
|
<span className="ml-auto text-xs text-gray-400">Requis</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{visibleCount} colonne{visibleCount > 1 ? 's' : ''} visible{visibleCount > 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ColumnSelector;
|
||||||
279
src/components/common/CompanySelector.tsx
Normal file
279
src/components/common/CompanySelector.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { ChevronDown, Check, Server, Loader2, AlertCircle, Building2, Plus } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Gateways } from '@/types/gateways';
|
||||||
|
import { gatewaysStatus, getAllGateways, getSociete } from '@/store/features/gateways/selectors';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||||
|
import { selectgateways } from '@/store/features/gateways/slice';
|
||||||
|
import ModalGateway from '../modal/ModalGateway';
|
||||||
|
import { Societe } from '@/types/societeType';
|
||||||
|
|
||||||
|
interface GatewaySelectorProps {
|
||||||
|
className?: string;
|
||||||
|
onGatewayChange?: (gateway: Gateways) => void;
|
||||||
|
onAddGateway?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CompanySelector: React.FC<GatewaySelectorProps> = ({
|
||||||
|
className,
|
||||||
|
onGatewayChange,
|
||||||
|
onAddGateway
|
||||||
|
}) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const societe = useAppSelector(getSociete) as Societe;
|
||||||
|
const gateways = useAppSelector(getAllGateways) as Gateways[];
|
||||||
|
const status = useAppSelector(gatewaysStatus);
|
||||||
|
|
||||||
|
const [selectedGateway, setSelectedGateway] = useState<Gateways | null>(null);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "succeeded" && gateways.length > 0 && !selectedGateway) {
|
||||||
|
const defaultGateway = gateways.find((g) => g.is_default) || gateways[0];
|
||||||
|
setSelectedGateway(defaultGateway);
|
||||||
|
onGatewayChange?.(defaultGateway);
|
||||||
|
}
|
||||||
|
}, [status, gateways, selectedGateway, onGatewayChange]);
|
||||||
|
|
||||||
|
const handleSelectGateway = (gateway: Gateways) => {
|
||||||
|
dispatch(selectgateways(gateway));
|
||||||
|
setSelectedGateway(gateway);
|
||||||
|
onGatewayChange?.(gateway);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddClick = (e: React.MouseEvent) => {
|
||||||
|
setIsCreateModalOpen(true)
|
||||||
|
onAddGateway?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
// await dispatch(getGateways()).unwrap();
|
||||||
|
// await dispatch(getSociete()).unwrap();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const getHealthStatusColor = (healthStatus: Gateways['health_status']) => {
|
||||||
|
switch (healthStatus) {
|
||||||
|
case 'healthy':
|
||||||
|
return 'bg-green-500';
|
||||||
|
case 'unhealthy':
|
||||||
|
return 'bg-red-500';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Si loading ou idle
|
||||||
|
if (status === 'idle' || status === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className={cn('px-3 py-2', className)}>
|
||||||
|
<div className="group w-full flex flex-col items-start px-3 py-2.5 rounded-lg cursor-not-allowed opacity-70">
|
||||||
|
<span className="text-[10px] font-bold text-[#6B7280] uppercase tracking-wider mb-1">
|
||||||
|
Entreprise
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-[#6B7280]" />
|
||||||
|
<span className="text-[15px] font-bold text-white/60 truncate leading-tight">
|
||||||
|
Chargement...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si erreur
|
||||||
|
if (status === 'failed') {
|
||||||
|
return (
|
||||||
|
<div className={cn('px-3 py-2', className)}>
|
||||||
|
<div className="group w-full flex flex-col items-start px-3 py-2.5 rounded-lg">
|
||||||
|
<span className="text-[10px] font-bold text-[#6B7280] uppercase tracking-wider mb-1">
|
||||||
|
Entreprise
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2 cursor-pointer" onClick={handleRefresh}>
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-400" />
|
||||||
|
<span className="text-[15px] font-bold text-red-400 truncate leading-tight">
|
||||||
|
cliquer pour actualiser
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// console.log("gateways : ",gateways);
|
||||||
|
|
||||||
|
// Si aucune gateway - afficher directement le bouton Ajouter
|
||||||
|
if (gateways.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={cn('px-3 py-2', className)}>
|
||||||
|
<div className="w-full flex flex-col items-start px-3 py-2.5 rounded-lg">
|
||||||
|
<span className="text-[10px] font-bold text-[#6B7280] uppercase tracking-wider mb-1">
|
||||||
|
Entreprise
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-2 py-2.5 rounded-lg cursor-pointer transition-colors outline-none'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-8 h-8 rounded-md flex items-center justify-center shrink-0 relative bg-[#2A6F4F] text-white shadow-sm'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Building2 className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'block text-[14px] truncate uppercase font-bold text-gray-400 '
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{societe?.raison_sociale || ""}
|
||||||
|
</span>
|
||||||
|
{societe?.forme_juridique && (
|
||||||
|
<span className="block text-[11px] text-gray-400 truncate">
|
||||||
|
{societe.forme_juridique}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-[10px] font-medium text-gray-100 bg-[#007E45] px-1.5 py-0.5 rounded shrink-0">
|
||||||
|
Défaut
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/* </DropdownMenuContent> */}
|
||||||
|
<ModalGateway
|
||||||
|
open={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Affichage normal avec dropdown
|
||||||
|
return (
|
||||||
|
<div className={cn('px-3 py-2', className)}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="group w-full flex flex-col items-start px-3 py-2.5 rounded-lg hover:bg-[#1F1F1F] cursor-pointer transition-colors duration-200 outline-none"
|
||||||
|
role="button"
|
||||||
|
aria-label="Sélectionner une gateway"
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-bold text-[#6B7280] uppercase tracking-wider mb-1 group-hover:text-[#9CA3AF] transition-colors">
|
||||||
|
Entreprise
|
||||||
|
</span>
|
||||||
|
<div className="w-full flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-[12px] uppercase font-bold text-white truncate leading-tight group-hover:text-white/90 transition-colors">
|
||||||
|
{selectedGateway?.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="w-4 h-4 text-[#6B7280] group-hover:text-white transition-colors shrink-0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
className="w-[280px] bg-white p-1.5 shadow-xl border-gray-100 rounded-xl animate-in fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 z-50 ml-2"
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider px-2 py-1.5 select-none">
|
||||||
|
Vos organisations
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
{gateways.map((gateway) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={gateway.id}
|
||||||
|
onClick={() => handleSelectGateway(gateway)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-2 py-2.5 rounded-lg cursor-pointer transition-colors outline-none',
|
||||||
|
selectedGateway?.id === gateway.id ? 'bg-green-50/50' : 'hover:bg-gray-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-8 h-8 rounded-md flex items-center justify-center shrink-0 relative',
|
||||||
|
selectedGateway?.id === gateway.id
|
||||||
|
? 'bg-[#2A6F4F] text-white shadow-sm'
|
||||||
|
: 'bg-gray-100 text-gray-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Building2 className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'block text-[14px] truncate uppercase',
|
||||||
|
selectedGateway?.id === gateway.id
|
||||||
|
? 'font-bold text-[#1F2937]'
|
||||||
|
: 'font-medium text-gray-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{gateway.name}
|
||||||
|
</span>
|
||||||
|
{gateway.sage_company && (
|
||||||
|
<span className="block text-[11px] text-gray-400 truncate">
|
||||||
|
{gateway.sage_company}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gateway.is_default && selectedGateway?.id !== gateway.id && (
|
||||||
|
<span className="text-[10px] font-medium text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded shrink-0">
|
||||||
|
Défaut
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedGateway?.id === gateway.id && (
|
||||||
|
<Check className="w-4 h-4 text-[#2A6F4F] ml-auto shrink-0" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Séparateur et bouton Ajouter */}
|
||||||
|
<DropdownMenuSeparator className="my-1.5 bg-gray-100" />
|
||||||
|
|
||||||
|
{/* <DropdownMenuItem
|
||||||
|
onClick={handleAddClick}
|
||||||
|
className="flex items-center gap-3 px-2 py-2.5 rounded-lg cursor-pointer transition-colors outline-none hover:bg-emerald-50 group"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-md flex items-center justify-center shrink-0 bg-emerald-100 text-emerald-600 group-hover:bg-emerald-200 transition-colors">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[14px] font-medium text-emerald-600 group-hover:text-emerald-700 transition-colors">
|
||||||
|
Ajouter une entreprise
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem> */}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<ModalGateway
|
||||||
|
open={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CompanySelector;
|
||||||
353
src/components/common/ExportDropdown.tsx
Normal file
353
src/components/common/ExportDropdown.tsx
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { Download, FileText, FileSpreadsheet, Printer, ChevronDown, File } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { ColumnConfig } from '@/components/common/ColumnSelector';
|
||||||
|
import { Button } from '../ui/buttonTsx';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface ExportColumnConfig {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
format?: (value: any, row: any) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportDropdownProps {
|
||||||
|
data: any[];
|
||||||
|
columns: ColumnConfig[]; // Colonnes du ColumnSelector
|
||||||
|
columnFormatters?: Record<string, (value: any, row: any) => string>; // Formateurs par clé
|
||||||
|
filename?: string;
|
||||||
|
className?: string;
|
||||||
|
onExportStart?: () => void;
|
||||||
|
onExportEnd?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExportFormat = 'csv' | 'txt' | 'excel' | 'print';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// UTILITAIRES D'EXPORT
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const formatValue = (
|
||||||
|
value: any,
|
||||||
|
key: string,
|
||||||
|
row: any,
|
||||||
|
formatters?: Record<string, (value: any, row: any) => string>
|
||||||
|
): string => {
|
||||||
|
// Utiliser le formateur personnalisé si disponible
|
||||||
|
if (formatters && formatters[key]) {
|
||||||
|
return formatters[key](value, row);
|
||||||
|
}
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'number') return value.toLocaleString('fr-FR');
|
||||||
|
if (typeof value === 'boolean') return value ? 'Oui' : 'Non';
|
||||||
|
if (value instanceof Date) return value.toLocaleDateString('fr-FR');
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeForCSV = (value: string): string => {
|
||||||
|
if (value.includes(';') || value.includes('"') || value.includes('\n')) {
|
||||||
|
return `"${value.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateCSV = (
|
||||||
|
data: any[],
|
||||||
|
columns: ColumnConfig[],
|
||||||
|
formatters?: Record<string, (value: any, row: any) => string>
|
||||||
|
): string => {
|
||||||
|
const visibleColumns = columns.filter(col => col.visible);
|
||||||
|
const header = visibleColumns.map(col => sanitizeForCSV(col.label)).join(';');
|
||||||
|
const rows = data.map(row =>
|
||||||
|
visibleColumns
|
||||||
|
.map(col => sanitizeForCSV(formatValue(row[col.key], col.key, row, formatters)))
|
||||||
|
.join(';')
|
||||||
|
);
|
||||||
|
return [header, ...rows].join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateTXT = (
|
||||||
|
data: any[],
|
||||||
|
columns: ColumnConfig[],
|
||||||
|
formatters?: Record<string, (value: any, row: any) => string>
|
||||||
|
): string => {
|
||||||
|
const visibleColumns = columns.filter(col => col.visible);
|
||||||
|
const header = visibleColumns.map(col => col.label).join('\t');
|
||||||
|
const rows = data.map(row =>
|
||||||
|
visibleColumns
|
||||||
|
.map(col => formatValue(row[col.key], col.key, row, formatters))
|
||||||
|
.join('\t')
|
||||||
|
);
|
||||||
|
return [header, ...rows].join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateExcelXML = (
|
||||||
|
data: any[],
|
||||||
|
columns: ColumnConfig[],
|
||||||
|
filename: string,
|
||||||
|
formatters?: Record<string, (value: any, row: any) => string>
|
||||||
|
): string => {
|
||||||
|
const visibleColumns = columns.filter(col => col.visible);
|
||||||
|
|
||||||
|
const header = visibleColumns
|
||||||
|
.map(col => `<Cell><Data ss:Type="String">${col.label}</Data></Cell>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const rows = data
|
||||||
|
.map(row => {
|
||||||
|
const cells = visibleColumns
|
||||||
|
.map(col => {
|
||||||
|
const value = formatValue(row[col.key], col.key, row, formatters);
|
||||||
|
const rawValue = row[col.key];
|
||||||
|
const type = typeof rawValue === 'number' && !formatters?.[col.key] ? 'Number' : 'String';
|
||||||
|
const displayValue = type === 'Number' ? rawValue : value;
|
||||||
|
return `<Cell><Data ss:Type="${type}">${displayValue}</Data></Cell>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
return `<Row>${cells}</Row>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<?mso-application progid="Excel.Sheet"?>
|
||||||
|
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
|
||||||
|
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
|
||||||
|
<Styles>
|
||||||
|
<Style ss:ID="Header">
|
||||||
|
<Font ss:Bold="1" ss:Color="#FFFFFF"/>
|
||||||
|
<Interior ss:Color="#007E45" ss:Pattern="Solid"/>
|
||||||
|
<Alignment ss:Horizontal="Center" ss:Vertical="Center"/>
|
||||||
|
</Style>
|
||||||
|
<Style ss:ID="Currency">
|
||||||
|
<NumberFormat ss:Format="#,##0.00\ "€""/>
|
||||||
|
</Style>
|
||||||
|
</Styles>
|
||||||
|
<Worksheet ss:Name="${filename}">
|
||||||
|
<Table>
|
||||||
|
<Row ss:StyleID="Header">${header}</Row>
|
||||||
|
${rows}
|
||||||
|
</Table>
|
||||||
|
</Worksheet>
|
||||||
|
</Workbook>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadFile = (content: string, filename: string, mimeType: string) => {
|
||||||
|
const BOM = '\uFEFF';
|
||||||
|
const blob = new Blob([BOM + content], { type: `${mimeType};charset=utf-8` });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const printData = (
|
||||||
|
data: any[],
|
||||||
|
columns: ColumnConfig[],
|
||||||
|
title: string,
|
||||||
|
formatters?: Record<string, (value: any, row: any) => string>
|
||||||
|
) => {
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (!printWindow) return;
|
||||||
|
|
||||||
|
const visibleColumns = columns.filter(col => col.visible);
|
||||||
|
|
||||||
|
const tableRows = data
|
||||||
|
.map(row => {
|
||||||
|
const cells = visibleColumns
|
||||||
|
.map(col => `<td>${formatValue(row[col.key], col.key, row, formatters)}</td>`)
|
||||||
|
.join('');
|
||||||
|
return `<tr>${cells}</tr>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const headerCells = visibleColumns.map(col => `<th>${col.label}</th>`).join('');
|
||||||
|
|
||||||
|
printWindow.document.write(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>${title}</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px; margin: 0; }
|
||||||
|
h1 { color: #007E45; margin-bottom: 8px; font-size: 24px; }
|
||||||
|
.meta { color: #666; margin-bottom: 20px; font-size: 12px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: 12px; }
|
||||||
|
th { background: #007E45; color: white; padding: 10px 8px; text-align: left; font-weight: 600; }
|
||||||
|
td { border-bottom: 1px solid #e5e7eb; padding: 8px; }
|
||||||
|
tr:hover { background-color: #f9fafb; }
|
||||||
|
.footer { margin-top: 30px; padding-top: 15px; border-top: 1px solid #e5e7eb; font-size: 11px; color: #9ca3af; }
|
||||||
|
@media print {
|
||||||
|
body { padding: 0; }
|
||||||
|
.no-print { display: none; }
|
||||||
|
tr { page-break-inside: avoid; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>${title}</h1>
|
||||||
|
<p class="meta">
|
||||||
|
${data.length} élément(s) • Exporté le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}
|
||||||
|
• Colonnes: ${visibleColumns.length}/${columns.length}
|
||||||
|
</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr>${headerCells}</tr></thead>
|
||||||
|
<tbody>${tableRows}</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Document généré automatiquement - ${visibleColumns.map(c => c.label).join(', ')}</p>
|
||||||
|
</div>
|
||||||
|
<script>window.onload = function() { window.print(); }</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
printWindow.document.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PRINCIPAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const ExportDropdown: React.FC<ExportDropdownProps> = ({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
columnFormatters,
|
||||||
|
filename = 'export',
|
||||||
|
className,
|
||||||
|
onExportStart,
|
||||||
|
onExportEnd,
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const visibleColumnsCount = columns.filter(col => col.visible).length;
|
||||||
|
|
||||||
|
const exportOptions: { format: ExportFormat; label: string; icon: React.ElementType }[] = [
|
||||||
|
{ format: 'csv', label: 'CSV', icon: FileText },
|
||||||
|
{ format: 'txt', label: 'TXT', icon: File },
|
||||||
|
{ format: 'excel', label: 'EXCEL', icon: FileSpreadsheet },
|
||||||
|
{ format: 'print', label: 'PRINT', icon: Printer },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleExport = async (format: ExportFormat) => {
|
||||||
|
if (data.length === 0) {
|
||||||
|
alert('Aucune donnée à exporter');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsExporting(true);
|
||||||
|
onExportStart?.();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const timestamp = new Date().toISOString().split('T')[0];
|
||||||
|
const baseFilename = `${filename}_${timestamp}`;
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'csv': {
|
||||||
|
const csv = generateCSV(data, columns, columnFormatters);
|
||||||
|
downloadFile(csv, `${baseFilename}.csv`, 'text/csv');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'txt': {
|
||||||
|
const txt = generateTXT(data, columns, columnFormatters);
|
||||||
|
downloadFile(txt, `${baseFilename}.txt`, 'text/plain');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'excel': {
|
||||||
|
const excel = generateExcelXML(data, columns, filename, columnFormatters);
|
||||||
|
downloadFile(excel, `${baseFilename}.xls`, 'application/vnd.ms-excel');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'print': {
|
||||||
|
printData(data, columns, filename, columnFormatters);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de l'export:", error);
|
||||||
|
alert("Une erreur est survenue lors de l'export");
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
setIsOpen(false);
|
||||||
|
onExportEnd?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fermer le dropdown en cliquant à l'extérieur
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative', className)} ref={dropdownRef}>
|
||||||
|
{/* Bouton principal */}
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
variant="outline"
|
||||||
|
disabled={isExporting}
|
||||||
|
className={cn('border-solid border-[#338660] bg-[#338660]/5 text-[#338660] gap-2 h-10')}
|
||||||
|
>
|
||||||
|
{isExporting ? (
|
||||||
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span>Exporter</span>
|
||||||
|
<ChevronDown className={cn('w-4 h-4 transition-transform', isOpen && 'rotate-180')} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Dropdown menu */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-900 rounded-xl shadow-xl border border-gray-100 dark:border-gray-800 overflow-hidden z-50"
|
||||||
|
>
|
||||||
|
<div className="py-1">
|
||||||
|
{exportOptions.map(({ format, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={format}
|
||||||
|
onClick={() => handleExport(format)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="font-medium">{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer avec infos */}
|
||||||
|
<div className="px-4 py-2.5 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-100 dark:border-gray-800">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{data.length} ligne{data.length > 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{visibleColumnsCount} colonne{visibleColumnsCount > 1 ? 's' : ''} sélectionnée{visibleColumnsCount > 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExportDropdown;
|
||||||
42
src/components/common/InfoTooltip.jsx
Normal file
42
src/components/common/InfoTooltip.jsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
|
||||||
|
const InfoTooltip = ({ content, source, calculation }) => {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={300}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex items-center ml-2 cursor-help text-[#6A6A6A] hover:text-[#338660] transition-colors">
|
||||||
|
<Info className="w-3.5 h-3.5" />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs bg-[#1A1A1A] text-white border-[#333333] p-3 shadow-xl">
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
{content && <p className="font-medium">{content}</p>}
|
||||||
|
{calculation && (
|
||||||
|
<div className="pt-2 border-t border-white/10">
|
||||||
|
<span className="text-gray-400 block mb-0.5 uppercase text-[10px] tracking-wider">Calcul</span>
|
||||||
|
<p className="font-mono text-gray-300">{calculation}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{source && (
|
||||||
|
<div className="pt-2 border-t border-white/10">
|
||||||
|
<span className="text-gray-400 block mb-0.5 uppercase text-[10px] tracking-wider">Source</span>
|
||||||
|
<p className="text-[#338660]">{source}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InfoTooltip;
|
||||||
245
src/components/common/PeriodSelector.jsx
Normal file
245
src/components/common/PeriodSelector.jsx
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Calendar as CalendarIcon, ChevronDown, Check, X, CalendarDays } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
const PeriodSelector = ({ value, onChange }) => {
|
||||||
|
const [isCustomOpen, setIsCustomOpen] = useState(false);
|
||||||
|
const [tempStart, setTempStart] = useState('');
|
||||||
|
const [tempEnd, setTempEnd] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const periods = [
|
||||||
|
{ id: 'today', label: "Aujourd'hui" },
|
||||||
|
{ id: 'week', label: 'Cette semaine' },
|
||||||
|
{ id: 'month', label: 'Ce mois-ci' },
|
||||||
|
{ id: 'last_month', label: 'Mois dernier' },
|
||||||
|
{ id: 'quarter', label: 'Ce trimestre' },
|
||||||
|
{ id: 'year', label: 'Cette année' },
|
||||||
|
{ id: '30days', label: '30 derniers jours' },
|
||||||
|
{ id: '90days', label: '90 derniers jours' },
|
||||||
|
{ id: 'all', label: "Tout l'historique" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Initialize temp dates when opening custom modal
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCustomOpen && typeof value === 'object' && value.id === 'custom') {
|
||||||
|
setTempStart(value.start || '');
|
||||||
|
setTempEnd(value.end || '');
|
||||||
|
}
|
||||||
|
}, [isCustomOpen, value]);
|
||||||
|
|
||||||
|
const handlePeriodChange = (periodId) => {
|
||||||
|
if (periodId === 'custom') {
|
||||||
|
setIsCustomOpen(true);
|
||||||
|
setError('');
|
||||||
|
} else {
|
||||||
|
onChange(periodId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomSubmit = () => {
|
||||||
|
if (!tempStart || !tempEnd) {
|
||||||
|
setError('Veuillez sélectionner les deux dates');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (new Date(tempStart) > new Date(tempEnd)) {
|
||||||
|
setError('La date de début doit être avant la date de fin');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({ id: 'custom', start: tempStart, end: tempEnd });
|
||||||
|
setIsCustomOpen(false);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsCustomOpen(false);
|
||||||
|
setError('');
|
||||||
|
setTempStart('');
|
||||||
|
setTempEnd('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLabel = () => {
|
||||||
|
if (typeof value === 'object' && value.id === 'custom') {
|
||||||
|
return `${formatDate(value.start)} - ${formatDate(value.end)}`;
|
||||||
|
}
|
||||||
|
return periods.find(p => p.id === value)?.label || 'Période';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCustomActive = typeof value === 'object' && value.id === 'custom';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-[200px] justify-between text-left font-normal h-10 rounded-xl",
|
||||||
|
isCustomActive && "border-[#007E45] bg-[#007E45]/5 text-[#007E45]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 truncate">
|
||||||
|
<CalendarIcon className={cn(
|
||||||
|
"h-4 w-4",
|
||||||
|
isCustomActive ? "text-[#007E45]" : "opacity-50"
|
||||||
|
)} />
|
||||||
|
<span className="truncate">{getLabel()}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50 flex-shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className="w-[220px] bg-white dark:bg-gray-950 border-gray-200 dark:border-gray-800 rounded-xl"
|
||||||
|
>
|
||||||
|
{periods.map((period) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={period.id}
|
||||||
|
onClick={() => handlePeriodChange(period.id)}
|
||||||
|
className="cursor-pointer flex items-center justify-between rounded-lg"
|
||||||
|
>
|
||||||
|
{period.label}
|
||||||
|
{value === period.id && <Check className="h-4 w-4 text-[#007E45]" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handlePeriodChange('custom')}
|
||||||
|
className="cursor-pointer text-[#007E45] font-medium rounded-lg flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<CalendarDays className="w-4 h-4" />
|
||||||
|
Personnalisé...
|
||||||
|
{isCustomActive && <Check className="h-4 w-4 ml-auto" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Custom Range Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isCustomOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
|
className="bg-white dark:bg-gray-950 rounded-2xl shadow-2xl w-full max-w-sm border border-gray-200 dark:border-gray-800 overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 flex justify-between items-center border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<CalendarDays className="w-5 h-5 text-[#007E45]" />
|
||||||
|
Période personnalisée
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="p-3 text-sm bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl border border-red-100 dark:border-red-800 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-red-500 flex-shrink-0" />
|
||||||
|
{error}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Date Inputs */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Date de début
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={tempStart}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTempStart(e.target.value);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-3 py-2.5 bg-white dark:bg-gray-900 border rounded-xl outline-none transition-all text-sm",
|
||||||
|
"border-gray-200 dark:border-gray-700",
|
||||||
|
"focus:ring-2 focus:ring-[#007E45]/20 focus:border-[#007E45]",
|
||||||
|
"dark:text-white dark:[color-scheme:dark]"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Date de fin
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={tempEnd}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTempEnd(e.target.value);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-full px-3 py-2.5 bg-white dark:bg-gray-900 border rounded-xl outline-none transition-all text-sm",
|
||||||
|
"border-gray-200 dark:border-gray-700",
|
||||||
|
"focus:ring-2 focus:ring-[#007E45]/20 focus:border-[#007E45]",
|
||||||
|
"dark:text-white dark:[color-scheme:dark]"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="flex-1 px-4 py-2.5 border border-gray-200 dark:border-gray-700 rounded-xl text-gray-600 dark:text-gray-300 font-medium hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCustomSubmit}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-[#007E45] text-white rounded-xl font-medium hover:bg-[#006838] transition-colors shadow-lg shadow-green-900/20"
|
||||||
|
>
|
||||||
|
Appliquer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PeriodSelector;
|
||||||
19
src/components/common/ProductLogo.jsx
Normal file
19
src/components/common/ProductLogo.jsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import logo from '../../assets/logo/logo.png'
|
||||||
|
|
||||||
|
const ProductLogo = ({ className }) => {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center justify-center py-6 border-b border-[#1F1F1F]", className)}>
|
||||||
|
{/* New Sage Logo image */}
|
||||||
|
<img
|
||||||
|
src={logo}
|
||||||
|
alt="Dataven Technologies"
|
||||||
|
className="h-[50px] w-auto object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductLogo;
|
||||||
207
src/components/document-entry/DocumentHeader.jsx
Normal file
207
src/components/document-entry/DocumentHeader.jsx
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useDisplayMode } from '@/context/DisplayModeContext';
|
||||||
|
import { ArrowLeft, Save, Eye, EyeOff, Maximize2, Minimize2, MoreVertical } from 'lucide-react';
|
||||||
|
import StatusBadge from '@/components/StatusBadge';
|
||||||
|
import ClientSearchInput from '@/components/inputs/ClientSearchInput';
|
||||||
|
import RepresentativeSearchInput from '@/components/inputs/RepresentativeSearchInput';
|
||||||
|
import { Input } from '@/components/FormModal';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import PrimaryButton from '@/components/PrimaryButton';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const DocumentHeader = ({
|
||||||
|
documentNumber,
|
||||||
|
status,
|
||||||
|
client,
|
||||||
|
onClientChange,
|
||||||
|
onCreateClient,
|
||||||
|
date,
|
||||||
|
onDateChange,
|
||||||
|
dueDate,
|
||||||
|
onDueDateChange,
|
||||||
|
reference,
|
||||||
|
onReferenceChange,
|
||||||
|
representative,
|
||||||
|
onRepresentativeChange,
|
||||||
|
isEditMode,
|
||||||
|
isNew,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
onEdit,
|
||||||
|
backPath,
|
||||||
|
actions
|
||||||
|
}) => {
|
||||||
|
const { displayMode, toggleDisplayMode, isPdfPreviewVisible, togglePdfPreview } = useDisplayMode();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isCompact = displayMode === 'compact';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
"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)] transition-all duration-300",
|
||||||
|
"shrink-0", // Prevent header from shrinking in flex container
|
||||||
|
isCompact ? "py-2" : "py-4"
|
||||||
|
)}>
|
||||||
|
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4">
|
||||||
|
|
||||||
|
{/* Main Info Area - Optimized Grid/Flex */}
|
||||||
|
<div className="flex flex-col gap-3 flex-1 w-full lg:w-auto min-w-0">
|
||||||
|
|
||||||
|
{/* Row 1: Title & Primary Fields */}
|
||||||
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
|
{/* Back & Title */}
|
||||||
|
<div className="flex items-center gap-3 shrink-0 mr-2">
|
||||||
|
<button onClick={() => navigate(backPath)} 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 items-center gap-2">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white tracking-tight">{documentNumber}</h1>
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-8 w-px bg-gray-200 dark:bg-gray-800 hidden sm:block mx-2" />
|
||||||
|
|
||||||
|
{/* Client Field - Dominant */}
|
||||||
|
<div className="flex-1 min-w-[250px] max-w-[400px]">
|
||||||
|
{isEditMode ? (
|
||||||
|
<div className="relative z-20">
|
||||||
|
<ClientSearchInput
|
||||||
|
value={client}
|
||||||
|
onChange={onClientChange}
|
||||||
|
onClientSelect={onClientChange}
|
||||||
|
onCreateClient={onCreateClient}
|
||||||
|
placeholder="Client / Prospect..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs text-gray-500 font-medium">Client</span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white truncate">
|
||||||
|
{client ? (client.company || `${client.firstName} ${client.lastName}`) : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date & Rep Fields - Compact */}
|
||||||
|
<div className="flex gap-4 shrink-0">
|
||||||
|
<div className="w-[140px]">
|
||||||
|
{isEditMode ? (
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={onDateChange}
|
||||||
|
className="h-10 text-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs text-gray-500 font-medium">Date</span>
|
||||||
|
<span className="text-sm text-gray-900 dark:text-white">{new Date(date).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-[180px]">
|
||||||
|
{isEditMode ? (
|
||||||
|
<RepresentativeSearchInput
|
||||||
|
value={representative}
|
||||||
|
onChange={onRepresentativeChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs text-gray-500 font-medium">Commercial</span>
|
||||||
|
<span className="text-sm text-gray-900 dark:text-white truncate">
|
||||||
|
{representative ? `${representative.firstName} ${representative.lastName}` : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Secondary Fields (Collapsible or always visible based on design) */}
|
||||||
|
{isEditMode && (
|
||||||
|
<div className="flex items-center gap-4 flex-wrap pt-1 animate-in fade-in slide-in-from-top-1">
|
||||||
|
<div className="w-[140px]">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={onDueDateChange}
|
||||||
|
placeholder="Échéance"
|
||||||
|
className="h-9 text-xs"
|
||||||
|
title="Date d'échéance"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-[200px]">
|
||||||
|
<Input
|
||||||
|
value={reference}
|
||||||
|
onChange={onReferenceChange}
|
||||||
|
placeholder="Votre référence..."
|
||||||
|
className="h-9 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Section: Actions & Controls */}
|
||||||
|
<div className="flex items-center gap-3 shrink-0 ml-auto self-start lg:self-center mt-2 lg:mt-0">
|
||||||
|
|
||||||
|
{/* View Controls */}
|
||||||
|
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1 mr-2">
|
||||||
|
<button
|
||||||
|
onClick={toggleDisplayMode}
|
||||||
|
className="p-1.5 hover:bg-white dark:hover:bg-gray-700 rounded-md transition-all text-gray-500"
|
||||||
|
title={isCompact ? "Mode confort" : "Mode compact"}
|
||||||
|
>
|
||||||
|
{isCompact ? <Maximize2 className="w-4 h-4" /> : <Minimize2 className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-4 bg-gray-300 dark:bg-gray-700 mx-1" />
|
||||||
|
<button
|
||||||
|
onClick={togglePdfPreview}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded-md transition-all",
|
||||||
|
isPdfPreviewVisible
|
||||||
|
? "bg-white dark:bg-gray-700 text-[#2A6F4F] shadow-sm"
|
||||||
|
: "text-gray-500 hover:text-gray-900 hover:bg-white"
|
||||||
|
)}
|
||||||
|
title="Aperçu PDF"
|
||||||
|
>
|
||||||
|
{isPdfPreviewVisible ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary Actions */}
|
||||||
|
{!isEditMode && !isNew ? (
|
||||||
|
<>
|
||||||
|
{actions}
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 border border-gray-300 rounded-xl hover:bg-gray-50 transition-colors bg-white shadow-sm"
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<PrimaryButton icon={Save} onClick={onSave}>
|
||||||
|
Enregistrer
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentHeader;
|
||||||
255
src/components/document-entry/DocumentLinesTable.jsx
Normal file
255
src/components/document-entry/DocumentLinesTable.jsx
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { useDisplayMode } from '@/context/DisplayModeContext';
|
||||||
|
import { Plus, GripVertical, FilePlus, Package, PenLine, Trash2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import ArticleSearchInput from '../molecules/ArticleAutocomplete';
|
||||||
|
|
||||||
|
// Helper for currency formatting
|
||||||
|
const formatCurrency = (amount) => {
|
||||||
|
if (amount === undefined || amount === null) return '0,00 €';
|
||||||
|
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentLinesTable = ({
|
||||||
|
lines,
|
||||||
|
onChangeLine,
|
||||||
|
onRemoveLine,
|
||||||
|
onAddLine,
|
||||||
|
onAddManualLine,
|
||||||
|
onArticleSelect,
|
||||||
|
onCreateArticle,
|
||||||
|
readOnly = false
|
||||||
|
}) => {
|
||||||
|
const { displayMode } = useDisplayMode();
|
||||||
|
const isCompact = displayMode === 'compact';
|
||||||
|
|
||||||
|
const toggleMode = (line) => {
|
||||||
|
const isCurrentlyManual = line.type === 'manual' || (!line.code && !line.type);
|
||||||
|
const newType = isCurrentlyManual ? 'article' : 'manual';
|
||||||
|
onChangeLine(line.id, 'type', newType);
|
||||||
|
if (newType === 'manual') onChangeLine(line.id, 'code', '');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="flex flex-col h-full w-full">
|
||||||
|
{/* Table Header - Sticky */}
|
||||||
|
<div className="sticky top-0 z-20 bg-[#F9FAFB] dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="flex items-center px-4 py-3 text-xs font-bold text-gray-500 uppercase tracking-wider">
|
||||||
|
{!readOnly && <div className="w-8"></div>} {/* Grip */}
|
||||||
|
<div className="w-10"></div> {/* Mode Toggle */}
|
||||||
|
<div className="w-[220px]">Désignation</div>
|
||||||
|
<div className="flex-1 min-w-[200px] pl-4">Description détaillée</div>
|
||||||
|
<div className="w-20 text-right">Qté</div>
|
||||||
|
<div className="w-28 text-right">P.U. HT</div>
|
||||||
|
<div className="w-20 text-right">Rem. %</div>
|
||||||
|
<div className="w-24 text-right">TVA</div>
|
||||||
|
<div className="w-28 text-right">Total HT</div>
|
||||||
|
<div className="w-10"></div> {/* Actions */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Body */}
|
||||||
|
<div className="flex-1 divide-y divide-gray-100 dark:divide-gray-800 bg-white dark:bg-gray-950">
|
||||||
|
{lines.map((line, index) => {
|
||||||
|
const isManual = line.type === 'manual' || (!line.code && line.type === 'manual');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={line.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-start px-4 py-3 group transition-colors",
|
||||||
|
isManual ? "bg-gray-50/50 dark:bg-gray-900/20" : "hover:bg-gray-50 dark:hover:bg-gray-900/50",
|
||||||
|
"focus-within:bg-green-50/30 dark:focus-within:bg-green-900/10 focus-within:ring-1 focus-within:ring-inset focus-within:ring-[#2A6F4F]/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Drag Handle */}
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="w-8 pt-2 text-gray-300 cursor-move opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<GripVertical className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mode Toggle */}
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="w-10 pt-1.5 mr-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleMode(line)}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded-md transition-all duration-200",
|
||||||
|
isManual
|
||||||
|
? "text-gray-400 hover:text-gray-600 hover:bg-gray-200"
|
||||||
|
: "text-[#2A6F4F] bg-green-50 hover:bg-green-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isManual ? <PenLine className="w-4 h-4" /> : <Package className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p>Mode: {isManual ? 'Libre' : 'Article'}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Designation (Article or Manual Title) */}
|
||||||
|
<div className="w-[220px]">
|
||||||
|
{readOnly ? (
|
||||||
|
<div className="py-1 font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
{line.designation || line.code || '—'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
isManual ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={line.designation || ''}
|
||||||
|
onChange={(e) => onChangeLine(line.id, 'designation', e.target.value)}
|
||||||
|
className="w-full bg-transparent border-b border-transparent focus:border-[#2A6F4F] focus:ring-0 p-0 py-1 text-sm font-medium"
|
||||||
|
placeholder="Titre..."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ArticleSearchInput
|
||||||
|
value={line.designation}
|
||||||
|
onChange={(val) => onChangeLine(line.id, 'designation', val)}
|
||||||
|
onArticleSelect={(article) => onArticleSelect(line.id, article)}
|
||||||
|
onCreateArticle={onCreateArticle}
|
||||||
|
placeholder="Rechercher article..."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description (Textarea) */}
|
||||||
|
<div className="flex-1 min-w-[200px] px-4">
|
||||||
|
{readOnly ? (
|
||||||
|
<div className="py-1 text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap">
|
||||||
|
{line.description}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
value={line.description || ''}
|
||||||
|
onChange={(e) => onChangeLine(line.id, 'description', e.target.value)}
|
||||||
|
className="w-full bg-transparent border-none p-0 text-sm text-gray-600 dark:text-gray-400 placeholder:text-gray-300 focus:ring-0 resize-none overflow-hidden"
|
||||||
|
placeholder="Description détaillée (visible sur PDF)..."
|
||||||
|
rows={Math.max(1, (line.description?.match(/\n/g) || []).length + 1)}
|
||||||
|
style={{ fieldSizing: 'content' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quantity */}
|
||||||
|
<div className="w-20 px-2">
|
||||||
|
{!readOnly ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={line.quantity}
|
||||||
|
onChange={(e) => onChangeLine(line.id, 'quantity', e.target.value)}
|
||||||
|
className="w-full text-right bg-transparent border-b border-transparent focus:border-[#2A6F4F] focus:ring-0 p-0 py-1 font-mono text-sm"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-right font-mono text-sm py-1">{line.quantity}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unit Price */}
|
||||||
|
<div className="w-28 px-2">
|
||||||
|
{!readOnly ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={line.unitPrice}
|
||||||
|
onChange={(e) => onChangeLine(line.id, 'unitPrice', e.target.value)}
|
||||||
|
className="w-full text-right bg-transparent border-b border-transparent focus:border-[#2A6F4F] focus:ring-0 p-0 py-1 font-mono text-sm"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-right font-mono text-sm py-1">{formatCurrency(line.unitPrice)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Discount % */}
|
||||||
|
<div className="w-20 px-2">
|
||||||
|
{!readOnly ? (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={line.discount}
|
||||||
|
onChange={(e) => onChangeLine(line.id, 'discount', e.target.value)}
|
||||||
|
className="w-full text-right bg-transparent border-b border-transparent focus:border-[#2A6F4F] focus:ring-0 p-0 py-1 font-mono text-sm text-gray-500 focus:text-gray-900 pr-3"
|
||||||
|
placeholder="0"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-0 top-1 text-xs text-gray-400">%</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-right font-mono text-sm py-1 text-gray-500">{line.discount > 0 ? `${line.discount}%` : '-'}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tax - MULTI RATE VAT IMPLEMENTATION */}
|
||||||
|
<div className="w-24 px-2">
|
||||||
|
{!readOnly ? (
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={line.vat}
|
||||||
|
onChange={(e) => onChangeLine(line.id, 'vat', parseFloat(e.target.value))}
|
||||||
|
className="w-full text-right bg-transparent border-b border-transparent focus:border-[#2A6F4F] focus:ring-0 p-0 py-1 font-mono text-sm appearance-none pr-4 cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value={0}>0 %</option>
|
||||||
|
<option value={2.1}>2.1 %</option>
|
||||||
|
<option value={5.5}>5.5 %</option>
|
||||||
|
<option value={10}>10 %</option>
|
||||||
|
<option value={20}>20 %</option>
|
||||||
|
</select>
|
||||||
|
<span className="absolute right-0 top-1.5 text-[10px] text-gray-400 pointer-events-none">▼</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-right font-mono text-sm py-1 text-gray-500">{line.vat}%</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div className="w-28 px-2 text-right py-1 font-mono font-bold text-sm text-gray-900 dark:text-white">
|
||||||
|
{formatCurrency(line.totalHT)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="w-10 flex justify-end opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => onRemoveLine(line.id)}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual Add Actions - hidden if we rely on auto-add, but kept for explicit actions */}
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="p-4 border-t border-gray-100 dark:border-gray-800 bg-gray-50/30 opacity-60 hover:opacity-100 transition-opacity">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onAddLine}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentLinesTable;
|
||||||
110
src/components/document-entry/StickyTotals.tsx
Normal file
110
src/components/document-entry/StickyTotals.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ChevronUp, Info } from 'lucide-react';
|
||||||
|
import { useDisplayMode } from '@/context/DisplayModeContext';
|
||||||
|
|
||||||
|
interface StickyTotalsProps {
|
||||||
|
total_ht_calcule: number;
|
||||||
|
total_taxes_calcule: number;
|
||||||
|
total_ttc_calcule: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number): string => {
|
||||||
|
return new Intl.NumberFormat('fr-FR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
}).format(amount || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StickyTotals: React.FC<StickyTotalsProps> = ({
|
||||||
|
total_ht_calcule,
|
||||||
|
total_taxes_calcule,
|
||||||
|
total_ttc_calcule,
|
||||||
|
}) => {
|
||||||
|
const { displayMode } = useDisplayMode();
|
||||||
|
const isCompact = displayMode === 'compact';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'sticky bottom-0 z-40 bg-white/95 backdrop-blur-sm dark:bg-gray-950/95 border-t border-gray-200 dark:border-gray-800 shadow-[0_-4px_20px_-5px_rgba(0,0,0,0.1)] transition-all duration-300',
|
||||||
|
'shrink-0',
|
||||||
|
isCompact ? 'py-2' : 'py-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
|
||||||
|
{/* Left: Conditions & Notes */}
|
||||||
|
<div className="hidden md:flex items-center gap-6 flex-1">
|
||||||
|
<div className="flex flex-col gap-1 w-[200px]">
|
||||||
|
<label className="text-[10px] uppercase font-bold text-gray-400 tracking-wider">
|
||||||
|
Conditions de paiement
|
||||||
|
</label>
|
||||||
|
<select className="bg-transparent border-b border-gray-200 dark:border-gray-700 text-sm py-1 focus:outline-none focus:border-[#2A6F4F] transition-colors text-gray-700 dark:text-gray-300">
|
||||||
|
<option>30 jours fin de mois</option>
|
||||||
|
<option>45 jours fin de mois</option>
|
||||||
|
<option>Comptant à la commande</option>
|
||||||
|
<option>A réception de facture</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 flex-1 max-w-[300px]">
|
||||||
|
<label className="text-[10px] uppercase font-bold text-gray-400 tracking-wider">
|
||||||
|
Note rapide
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Ajouter une mention sur la facture..."
|
||||||
|
className="bg-transparent border-b border-gray-200 dark:border-gray-700 text-sm py-1 focus:outline-none focus:border-[#2A6F4F] transition-colors w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Totals */}
|
||||||
|
<div className="flex items-center gap-8 ml-auto">
|
||||||
|
|
||||||
|
{/* Breakdown */}
|
||||||
|
<div className="flex items-center gap-6 text-gray-500 dark:text-gray-400 border-r border-gray-200 dark:border-gray-800 pr-6 mr-2">
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<span className="text-[10px] uppercase font-bold tracking-wider text-gray-400">
|
||||||
|
Total HT
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-medium text-gray-900 dark:text-gray-200 text-base">
|
||||||
|
{formatCurrency(total_ht_calcule)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<span className="text-[10px] uppercase font-bold tracking-wider text-gray-400">
|
||||||
|
TVA (20%)
|
||||||
|
</span>
|
||||||
|
<span className="font-mono font-medium text-gray-900 dark:text-gray-200 text-base">
|
||||||
|
{formatCurrency(total_taxes_calcule)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grand Total */}
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<span className="text-[10px] uppercase font-bold tracking-wider text-[#2A6F4F]">
|
||||||
|
Net à Payer TTC
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-mono font-bold text-[#1F2937] dark:text-white leading-none mt-1',
|
||||||
|
isCompact ? 'text-xl' : 'text-2xl'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatCurrency(total_ttc_calcule)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StickyTotals;
|
||||||
246
src/components/filter/ItemsFilter.tsx
Normal file
246
src/components/filter/ItemsFilter.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
|
||||||
|
// const filterItemByPeriod = (items: any[], period: string): any[] => {
|
||||||
|
// if (period === 'all') return items;
|
||||||
|
|
||||||
|
|
||||||
|
// const now = new Date();
|
||||||
|
// const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
|
||||||
|
// return items.filter((item) => {
|
||||||
|
// const date = new Date(item.date);
|
||||||
|
|
||||||
|
// switch (period) {
|
||||||
|
// case 'today': {
|
||||||
|
// const itemDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
|
// return itemDay.getTime() === today.getTime();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// case 'week': {
|
||||||
|
// const weekStart = new Date(today);
|
||||||
|
// weekStart.setDate(today.getDate() - today.getDay() + 1); // Lundi de cette semaine
|
||||||
|
// return date >= weekStart && date <= now;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// case 'month': {
|
||||||
|
// return (
|
||||||
|
// date.getMonth() === now.getMonth() &&
|
||||||
|
// date.getFullYear() === now.getFullYear()
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// case '30days': {
|
||||||
|
// const thirtyDaysAgo = new Date(today);
|
||||||
|
// thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||||
|
// return date >= thirtyDaysAgo && date <= now;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// case 'quarter': {
|
||||||
|
// const currentQuarter = Math.floor(now.getMonth() / 3);
|
||||||
|
// const devisQuarter = Math.floor(date.getMonth() / 3);
|
||||||
|
// return (
|
||||||
|
// devisQuarter === currentQuarter &&
|
||||||
|
// date.getFullYear() === now.getFullYear()
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// case 'year': {
|
||||||
|
// return date.getFullYear() === now.getFullYear();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// default:
|
||||||
|
// return true;
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// };
|
||||||
|
|
||||||
|
type PeriodType = 'all' | 'today' | 'week' | 'month' | 'quarter' | 'year' | CustomPeriod;
|
||||||
|
|
||||||
|
interface CustomPeriod {
|
||||||
|
id: 'custom';
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction utilitaire pour vérifier si c'est une période custom
|
||||||
|
const isCustomPeriod = (period: PeriodType): period is CustomPeriod => {
|
||||||
|
return typeof period === 'object' && period !== null && period.id === 'custom';
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const filterItemByPeriod = <T extends { date?: string; date_creation?: string }>(
|
||||||
|
items: T[],
|
||||||
|
period: PeriodType,
|
||||||
|
dateField: 'date' | 'date_creation' = 'date'
|
||||||
|
): T[] => {
|
||||||
|
const now = new Date();
|
||||||
|
now.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
// Si "all", retourner tous les items
|
||||||
|
if (period === 'all') {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si période custom avec start et end
|
||||||
|
if (isCustomPeriod(period)) {
|
||||||
|
const startDate = new Date(period.start);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const endDate = new Date(period.end);
|
||||||
|
endDate.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
return items.filter(item => {
|
||||||
|
const itemDate = item[dateField];
|
||||||
|
if (!itemDate) return false;
|
||||||
|
|
||||||
|
const date = new Date(itemDate);
|
||||||
|
return date >= startDate && date <= endDate;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Périodes prédéfinies
|
||||||
|
let startDate: Date;
|
||||||
|
|
||||||
|
switch (period) {
|
||||||
|
case 'today':
|
||||||
|
startDate = new Date(now);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
startDate = new Date(now);
|
||||||
|
startDate.setDate(now.getDate() - 7);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
startDate = new Date(now);
|
||||||
|
startDate.setMonth(now.getMonth() - 1);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
case 'quarter':
|
||||||
|
startDate = new Date(now);
|
||||||
|
startDate.setMonth(now.getMonth() - 3);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
startDate = new Date(now);
|
||||||
|
startDate.setFullYear(now.getFullYear() - 1);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.filter(item => {
|
||||||
|
const itemDate = item[dateField];
|
||||||
|
if (!itemDate) return false;
|
||||||
|
|
||||||
|
const date = new Date(itemDate);
|
||||||
|
return date >= startDate && date <= now;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPreviousPeriodItems = <T extends { date?: string; date_creation?: string }>(
|
||||||
|
items: T[],
|
||||||
|
period: PeriodType,
|
||||||
|
dateField: 'date' | 'date_creation' = 'date'
|
||||||
|
): T[] => {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Si "all", pas de période précédente
|
||||||
|
if (period === 'all') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let startDate: Date;
|
||||||
|
let endDate: Date;
|
||||||
|
|
||||||
|
// Si période custom
|
||||||
|
if (isCustomPeriod(period)) {
|
||||||
|
const customStart = new Date(period.start);
|
||||||
|
const customEnd = new Date(period.end);
|
||||||
|
|
||||||
|
// Calculer la durée de la période custom
|
||||||
|
const duration = customEnd.getTime() - customStart.getTime();
|
||||||
|
|
||||||
|
// Période précédente = même durée avant la date de début
|
||||||
|
endDate = new Date(customStart.getTime() - 1); // Jour avant le début
|
||||||
|
endDate.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
startDate = new Date(endDate.getTime() - duration);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
} else {
|
||||||
|
// Périodes prédéfinies
|
||||||
|
switch (period) {
|
||||||
|
case 'today':
|
||||||
|
// Hier
|
||||||
|
startDate = new Date(now);
|
||||||
|
startDate.setDate(now.getDate() - 1);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
endDate = new Date(startDate);
|
||||||
|
endDate.setHours(23, 59, 59, 999);
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
// Semaine précédente (J-14 à J-7)
|
||||||
|
startDate = new Date(now);
|
||||||
|
startDate.setDate(now.getDate() - 14);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
endDate = new Date(now);
|
||||||
|
endDate.setDate(now.getDate() - 7);
|
||||||
|
endDate.setHours(23, 59, 59, 999);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
// Mois précédent (M-2 à M-1)
|
||||||
|
startDate = new Date(now);
|
||||||
|
startDate.setMonth(now.getMonth() - 2);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
endDate = new Date(now);
|
||||||
|
endDate.setMonth(now.getMonth() - 1);
|
||||||
|
endDate.setHours(23, 59, 59, 999);
|
||||||
|
break;
|
||||||
|
case 'quarter':
|
||||||
|
// Trimestre précédent (M-6 à M-3)
|
||||||
|
startDate = new Date(now);
|
||||||
|
startDate.setMonth(now.getMonth() - 6);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
endDate = new Date(now);
|
||||||
|
endDate.setMonth(now.getMonth() - 3);
|
||||||
|
endDate.setHours(23, 59, 59, 999);
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
// Année précédente (A-2 à A-1)
|
||||||
|
startDate = new Date(now);
|
||||||
|
startDate.setFullYear(now.getFullYear() - 2);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
endDate = new Date(now);
|
||||||
|
endDate.setFullYear(now.getFullYear() - 1);
|
||||||
|
endDate.setHours(23, 59, 59, 999);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.filter(item => {
|
||||||
|
const itemDate = item[dateField];
|
||||||
|
if (!itemDate) return false;
|
||||||
|
|
||||||
|
const date = new Date(itemDate);
|
||||||
|
return date >= startDate && date <= endDate;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const hasSpecialChars = (text: string): boolean => {
|
||||||
|
const regexSpecialChars =
|
||||||
|
/[&é~"#'{(\[\-|è`_\\ç^à@)\]°=+}^¨$£¤ù%*µ,?;.:/!§€]/;
|
||||||
|
|
||||||
|
const forbiddenWord = /\bBAOR01\b/;
|
||||||
|
|
||||||
|
return regexSpecialChars.test(text) || forbiddenWord.test(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Export de la fonction utilitaire
|
||||||
|
export { filterItemByPeriod, getPreviousPeriodItems, hasSpecialChars };
|
||||||
443
src/components/forms/ArticleFormModal.tsx
Normal file
443
src/components/forms/ArticleFormModal.tsx
Normal file
|
|
@ -0,0 +1,443 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useForm, Controller, type SubmitHandler } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import z from "zod";
|
||||||
|
import { mockProductFamilies } from '@/data/mockData';
|
||||||
|
import FormModal, { FormSection } from '@/components/ui/FormModal';
|
||||||
|
import { InputField, InputType, validators } from "../ui/InputValidator";
|
||||||
|
import { Article, ArticleRequest } from '@/types/articleType';
|
||||||
|
import { Euro, Warehouse } from 'lucide-react';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SCHÉMA DE VALIDATION ZOD
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const articleSchema = z.object({
|
||||||
|
// Informations générales
|
||||||
|
reference: z.string().min(1, "La référence est requise"),
|
||||||
|
reference_client: z.string().optional(),
|
||||||
|
designation: z.string().min(1, "Le libellé est requis"),
|
||||||
|
designation_complementaire: z.string().optional(),
|
||||||
|
famille_code: z.string().min(1, "La famille est requise"),
|
||||||
|
est_actif: z.boolean().default(true),
|
||||||
|
en_sommeil: z.boolean().default(false),
|
||||||
|
description: z.string().optional(),
|
||||||
|
code_ean: z.string().optional(),
|
||||||
|
|
||||||
|
// Tarification
|
||||||
|
prix_achat: z.coerce.number().min(0, "Le prix d'achat doit être positif").optional(),
|
||||||
|
prix_vente: z.coerce.number().min(0, "Le prix de vente doit être positif"),
|
||||||
|
tva_code: z.string().default("20"),
|
||||||
|
|
||||||
|
// Logistique & Stock
|
||||||
|
unite_vente: z.string().default("pcs"),
|
||||||
|
stock_reel: z.coerce.number().default(0),
|
||||||
|
stock_mini: z.coerce.number().default(5),
|
||||||
|
stock_maxi: z.coerce.number().optional(),
|
||||||
|
poids: z.coerce.number().optional(),
|
||||||
|
volume: z.coerce.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ArticleFormData = z.infer<typeof articleSchema>;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// INTERFACE PROPS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface ArticleFormModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
editing?: Article | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PRINCIPAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export function ArticleFormModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
editing
|
||||||
|
}: ArticleFormModalProps) {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const isEditing = !!editing;
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
reset,
|
||||||
|
formState: { errors }
|
||||||
|
} = useForm<ArticleFormData>({
|
||||||
|
resolver: zodResolver(articleSchema),
|
||||||
|
mode: "onBlur",
|
||||||
|
defaultValues: {
|
||||||
|
reference: "",
|
||||||
|
reference_client: "",
|
||||||
|
designation: "",
|
||||||
|
designation_complementaire: "",
|
||||||
|
famille_code: "",
|
||||||
|
est_actif: true,
|
||||||
|
en_sommeil: false,
|
||||||
|
description: "",
|
||||||
|
code_ean: "",
|
||||||
|
prix_achat: 0,
|
||||||
|
prix_vente: 0,
|
||||||
|
tva_code: "20",
|
||||||
|
unite_vente: "pcs",
|
||||||
|
stock_reel: 0,
|
||||||
|
stock_mini: 5,
|
||||||
|
stock_maxi: undefined,
|
||||||
|
poids: undefined,
|
||||||
|
volume: undefined,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const formValues = watch();
|
||||||
|
const prixAchat = watch("prix_achat") || 0;
|
||||||
|
const prixVente = watch("prix_vente") || 0;
|
||||||
|
|
||||||
|
const marge = useMemo(() => {
|
||||||
|
if (prixVente > 0 && prixAchat > 0) {
|
||||||
|
const margeValue = ((prixVente - prixAchat) / prixVente) * 100;
|
||||||
|
return margeValue.toFixed(2) + "%";
|
||||||
|
}
|
||||||
|
return "—";
|
||||||
|
}, [prixAchat, prixVente]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
reset({
|
||||||
|
reference: editing.reference || "",
|
||||||
|
reference_client: "",
|
||||||
|
designation: editing.designation || "",
|
||||||
|
designation_complementaire: editing.designation_complementaire || "",
|
||||||
|
famille_code: editing.famille_code || "",
|
||||||
|
est_actif: editing.est_actif ?? true,
|
||||||
|
en_sommeil: editing.en_sommeil ?? false,
|
||||||
|
description: editing.description || "",
|
||||||
|
code_ean: editing.code_ean || "",
|
||||||
|
prix_achat: editing.prix_achat || 0,
|
||||||
|
prix_vente: editing.prix_vente || 0,
|
||||||
|
tva_code: editing.tva_code || "20",
|
||||||
|
unite_vente: editing.unite_vente || "pcs",
|
||||||
|
stock_reel: editing.stock_reel || 0,
|
||||||
|
stock_mini: editing.stock_mini || 5,
|
||||||
|
stock_maxi: editing.stock_maxi,
|
||||||
|
poids: editing.poids,
|
||||||
|
volume: editing.volume,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reset({
|
||||||
|
reference: "",
|
||||||
|
reference_client: "",
|
||||||
|
designation: "",
|
||||||
|
designation_complementaire: "",
|
||||||
|
famille_code: "",
|
||||||
|
est_actif: true,
|
||||||
|
en_sommeil: false,
|
||||||
|
description: "",
|
||||||
|
code_ean: "",
|
||||||
|
prix_achat: 0,
|
||||||
|
prix_vente: 0,
|
||||||
|
tva_code: "20",
|
||||||
|
unite_vente: "pcs",
|
||||||
|
stock_reel: 0,
|
||||||
|
stock_mini: 5,
|
||||||
|
stock_maxi: undefined,
|
||||||
|
poids: undefined,
|
||||||
|
volume: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, editing, reset]);
|
||||||
|
|
||||||
|
|
||||||
|
const handleFormSubmit: SubmitHandler<ArticleFormData> = async (data) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: ArticleRequest = {
|
||||||
|
reference: data.reference,
|
||||||
|
designation: data.designation,
|
||||||
|
famille: data.famille_code,
|
||||||
|
prix_vente: data.prix_vente.toString(),
|
||||||
|
prix_achat: (data.prix_achat || 0).toString(),
|
||||||
|
stock_reel: data.stock_reel.toString(),
|
||||||
|
stock_mini: data.stock_mini.toString(),
|
||||||
|
code_ean: data.code_ean || "",
|
||||||
|
unite_vente: data.unite_vente,
|
||||||
|
tva_code: data.tva_code,
|
||||||
|
description: data.description || "",
|
||||||
|
};
|
||||||
|
console.log("payload : ",payload);
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la sauvegarde:", error);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title || (isEditing ? "Modifier l'article" : "Créer un nouvel article")}
|
||||||
|
size="xl"
|
||||||
|
submitLabel={isEditing ? "Mettre à jour" : "Créer l'article"}
|
||||||
|
>
|
||||||
|
{/* Section: Informations générales */}
|
||||||
|
<FormSection
|
||||||
|
title="Informations générales"
|
||||||
|
description="Identification du produit"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="reference"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Référence"
|
||||||
|
inputType="text"
|
||||||
|
required
|
||||||
|
disabled={isEditing}
|
||||||
|
placeholder="Ex: ART-2025-001"
|
||||||
|
error={errors.reference?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="code_ean"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Code EAN"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Code-barres EAN13"
|
||||||
|
error={errors.code_ean?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="designation"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Libellé"
|
||||||
|
inputType="text"
|
||||||
|
required
|
||||||
|
placeholder="Désignation du produit..."
|
||||||
|
error={errors.designation?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* <Controller
|
||||||
|
name="famille_code"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Famille"
|
||||||
|
as="select"
|
||||||
|
required
|
||||||
|
error={errors.famille_code?.message}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "Sélectionner une famille..." },
|
||||||
|
...mockProductFamilies.map(f => ({
|
||||||
|
value: f.id,
|
||||||
|
label: f.name
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/> */}
|
||||||
|
<Controller
|
||||||
|
name="est_actif"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value ? "active" : "inactive"}
|
||||||
|
onChange={(e) => field.onChange(e.target.value === "active")}
|
||||||
|
label="Statut"
|
||||||
|
as="select"
|
||||||
|
options={[
|
||||||
|
{ value: "active", label: "Actif" },
|
||||||
|
{ value: "inactive", label: "Inactif" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="description"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Description"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Description commerciale..."
|
||||||
|
containerClassName="col-span-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Section: Tarification */}
|
||||||
|
<FormSection
|
||||||
|
title="Tarification"
|
||||||
|
description="Prix et marges"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="prix_achat"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || ""}
|
||||||
|
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
label="Prix d'achat HT (€)"
|
||||||
|
inputType="number"
|
||||||
|
placeholder="0.00"
|
||||||
|
error={errors.prix_achat?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="prix_vente"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || ""}
|
||||||
|
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
label="Prix de vente HT (€)"
|
||||||
|
inputType="number"
|
||||||
|
required
|
||||||
|
placeholder="0.00"
|
||||||
|
error={errors.prix_vente?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="tva_code"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Taux TVA"
|
||||||
|
as="select"
|
||||||
|
options={[
|
||||||
|
{ value: "20", label: "20%" },
|
||||||
|
{ value: "10", label: "10%" },
|
||||||
|
{ value: "5.5", label: "5.5%" },
|
||||||
|
{ value: "0", label: "0%" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Marge théorique"
|
||||||
|
inputType="text"
|
||||||
|
value={marge}
|
||||||
|
disabled
|
||||||
|
className="bg-gray-50 dark:bg-gray-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Section: Logistique & Stock */}
|
||||||
|
<FormSection
|
||||||
|
title="Logistique & Stock"
|
||||||
|
description="Gestion des stocks"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="unite_vente"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Unité"
|
||||||
|
as="select"
|
||||||
|
options={[
|
||||||
|
{ value: "pcs", label: "Pièce" },
|
||||||
|
{ value: "h", label: "Heure" },
|
||||||
|
{ value: "kg", label: "Kg" },
|
||||||
|
{ value: "l", label: "Litre" },
|
||||||
|
{ value: "m", label: "Mètre" },
|
||||||
|
{ value: "m2", label: "M²" },
|
||||||
|
{ value: "m3", label: "M³" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="stock_reel"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || "0"}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||||
|
label="Stock actuel"
|
||||||
|
inputType="number"
|
||||||
|
error={errors.stock_reel?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="stock_mini"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || "5"}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||||
|
label="Stock minimum"
|
||||||
|
inputType="number"
|
||||||
|
error={errors.stock_mini?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="poids"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || ""}
|
||||||
|
onChange={(e) => field.onChange(parseFloat(e.target.value) || undefined)}
|
||||||
|
label="Poids (kg)"
|
||||||
|
inputType="number"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
407
src/components/forms/ClientFormModal.jsx
Normal file
407
src/components/forms/ClientFormModal.jsx
Normal file
|
|
@ -0,0 +1,407 @@
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
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 {
|
||||||
|
X, ChevronDown, ChevronUp, CheckCircle2, AlertCircle,
|
||||||
|
Save, Building2, MapPin, Wallet, Zap, Shield,
|
||||||
|
Loader2
|
||||||
|
} from 'lucide-react';
|
||||||
|
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 clientSchema = z.object({
|
||||||
|
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(),
|
||||||
|
assignedTo: z.string().optional(),
|
||||||
|
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'),
|
||||||
|
civility: z.string().optional(),
|
||||||
|
contactName: z.string().min(2, "Nom requis"),
|
||||||
|
contactFirstName: z.string().min(2, "Prénom requis"),
|
||||||
|
email: z.string().email("Email invalide"),
|
||||||
|
phone: z.string().min(10, "Numéro invalide"),
|
||||||
|
contactPref: z.enum(['email', 'phone', 'sms', 'whatsapp']).default('email'),
|
||||||
|
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('')),
|
||||||
|
paymentMethod: z.string().optional(),
|
||||||
|
paymentTerm: z.string().optional(),
|
||||||
|
interestLevel: z.enum(['low', 'medium', 'high']).default('medium'),
|
||||||
|
urgency: z.string().optional(),
|
||||||
|
budget: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
autoOpportunity: z.boolean().default(false),
|
||||||
|
autoTask: z.boolean().default(false),
|
||||||
|
sendWelcome: z.boolean().default(false),
|
||||||
|
autoAssign: z.boolean().default(false),
|
||||||
|
visibility: z.enum(['me', 'team', 'all']).default('team'),
|
||||||
|
}).refine((data) => {
|
||||||
|
if (data.type === 'entreprise' && !data.companyName) return false;
|
||||||
|
return true;
|
||||||
|
}, {
|
||||||
|
message: "Raison sociale requise",
|
||||||
|
path: ["companyName"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Internal Components ---
|
||||||
|
|
||||||
|
const Section = ({ title, icon: Icon, children, defaultOpen = true, error }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||||
|
return (
|
||||||
|
<div className={cn("border-b border-gray-100 dark:border-gray-800 last:border-0", error && "bg-red-50/30 dark:bg-red-900/10")}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Icon className={cn("w-4 h-4", error ? "text-red-500" : "text-gray-500")} />
|
||||||
|
<span className={cn("font-semibold text-sm", error ? "text-red-600" : "text-gray-900 dark:text-white")}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isOpen ? <ChevronUp className="w-4 h-4 text-gray-400" /> : <ChevronDown className="w-4 h-4 text-gray-400" />}
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="p-4 pt-0 space-y-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const InputGroup = ({ label, error, required, children, className }) => (
|
||||||
|
<div className={cn("space-y-1.5", className)}>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{label} {required && <span className="text-[#941403]">*</span>}
|
||||||
|
</label>
|
||||||
|
{error && <span className="text-[10px] text-red-500 font-medium">{error.message}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
{children}
|
||||||
|
{!error && required && children?.props?.value && (
|
||||||
|
<CheckCircle2 className="absolute right-3 top-1/2 -translate-y-1/2 w-3 h-3 text-green-500 pointer-events-none" />
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<AlertCircle className="absolute right-3 top-1/2 -translate-y-1/2 w-3 h-3 text-red-500 pointer-events-none" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ClientFormModal = ({ isOpen, onClose, initialData = null }) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [completion, setCompletion] = useState(0);
|
||||||
|
|
||||||
|
const { register, handleSubmit, watch, control, formState: { errors, isValid }, reset } = useForm({
|
||||||
|
resolver: zodResolver(clientSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: {
|
||||||
|
type: 'entreprise',
|
||||||
|
contactPref: 'email',
|
||||||
|
country: 'France',
|
||||||
|
visibility: 'team',
|
||||||
|
...initialData
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (initialData) reset(initialData);
|
||||||
|
else reset({
|
||||||
|
type: 'entreprise',
|
||||||
|
contactPref: 'email',
|
||||||
|
country: 'France',
|
||||||
|
visibility: 'team'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isOpen, initialData, reset]);
|
||||||
|
|
||||||
|
const watchAll = watch();
|
||||||
|
const type = watch('type');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const requiredFields = ['name', 'address1', 'zipCode', 'city', 'contactName', 'contactFirstName', 'email', 'phone'];
|
||||||
|
if (type === 'entreprise') requiredFields.push('companyName');
|
||||||
|
const filled = requiredFields.filter(field => !!watchAll[field]);
|
||||||
|
setCompletion(Math.round((filled.length / requiredFields.length) * 100));
|
||||||
|
}, [watchAll, type]);
|
||||||
|
|
||||||
|
const onSubmit = async (data) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
console.log(data);
|
||||||
|
toast({
|
||||||
|
title: initialData ? "Client modifié" : "Client créé",
|
||||||
|
description: `${data.name} a été ${initialData ? "mis à jour" : "ajouté"} avec succès.`,
|
||||||
|
variant: "success"
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClass = (error) => cn(
|
||||||
|
"w-full px-3 py-2 bg-white dark:bg-gray-950 border rounded-lg text-sm shadow-sm transition-all focus:outline-none focus:ring-2 pr-8",
|
||||||
|
error
|
||||||
|
? "border-red-300 focus:border-red-500 focus:ring-red-200"
|
||||||
|
: "border-gray-200 dark:border-gray-800 focus:border-[#941403] focus:ring-red-100/50"
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "100%" }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||||
|
className="fixed inset-y-0 right-0 w-full max-w-xl bg-white dark:bg-gray-950 shadow-2xl z-50 flex flex-col border-l border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-950/80 backdrop-blur-md z-10">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
{initialData ? "Modifier le client" : "Nouveau client"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{completion}% complété
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Progress value={completion} className="h-1 rounded-none" />
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar bg-gray-50/50 dark:bg-black/20">
|
||||||
|
<form id="client-form" onSubmit={handleSubmit(onSubmit)} className="pb-6">
|
||||||
|
|
||||||
|
<Section title="Informations Générales" icon={Building2} error={errors.name || errors.companyName}>
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
{['entreprise', 'particulier'].map((val) => (
|
||||||
|
<label key={val} className={cn(
|
||||||
|
"flex-1 flex items-center justify-center py-2 rounded-lg border cursor-pointer text-xs font-medium transition-colors",
|
||||||
|
type === val
|
||||||
|
? "bg-[#941403] text-white border-[#941403]"
|
||||||
|
: "bg-white dark:bg-gray-900 text-gray-600 border-gray-200 dark:border-gray-800"
|
||||||
|
)}>
|
||||||
|
<input type="radio" value={val} {...register('type')} className="hidden" />
|
||||||
|
<span className="capitalize">{val}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<InputGroup label="Nom du client" error={errors.name} required>
|
||||||
|
<input {...register('name')} className={inputClass(errors.name)} placeholder="Nom d'affichage" />
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{type === 'entreprise' && (
|
||||||
|
<InputGroup label="Raison sociale" error={errors.companyName} required>
|
||||||
|
<input {...register('companyName')} className={inputClass(errors.companyName)} placeholder="Raison sociale officielle" />
|
||||||
|
</InputGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<InputGroup label="SIRET" error={errors.siret}>
|
||||||
|
<input {...register('siret')} className={inputClass(errors.siret)} placeholder="14 chiffres" />
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup label="Catégorie">
|
||||||
|
<select {...register('category')} className={inputClass()}>
|
||||||
|
<option value="standard">Standard</option>
|
||||||
|
<option value="premium">Premium</option>
|
||||||
|
<option value="vip">VIP</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InputGroup label="Commercial">
|
||||||
|
<select {...register('assignedTo')} className={inputClass()}>
|
||||||
|
<option value="">Sélectionner...</option>
|
||||||
|
{mockUsers.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Coordonnées" icon={MapPin} error={errors.address1 || errors.zipCode || errors.city || errors.email}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<InputGroup label="Adresse" error={errors.address1} required>
|
||||||
|
<input {...register('address1')} className={inputClass(errors.address1)} />
|
||||||
|
</InputGroup>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<InputGroup label="Code Postal" error={errors.zipCode} required>
|
||||||
|
<input {...register('zipCode')} className={inputClass(errors.zipCode)} />
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup label="Ville" error={errors.city} required>
|
||||||
|
<input {...register('city')} className={inputClass(errors.city)} />
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-gray-100 dark:border-gray-800 mt-2"></div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<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>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<InputGroup label="Email" error={errors.email} required>
|
||||||
|
<input {...register('email')} className={inputClass(errors.email)} />
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup label="Téléphone" error={errors.phone} required>
|
||||||
|
<input {...register('phone')} className={inputClass(errors.phone)} />
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{type === 'entreprise' && (
|
||||||
|
<Section title="Entreprise" icon={Building2} defaultOpen={false}>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<InputGroup label="Forme Juridique">
|
||||||
|
<select {...register('legalForm')} className={inputClass()}>
|
||||||
|
<option value="">Sélectionner...</option>
|
||||||
|
<option value="SARL">SARL</option>
|
||||||
|
<option value="SAS">SAS</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="50+">50+</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup label="CA (€)">
|
||||||
|
<input {...register('revenue')} className={inputClass()} />
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup label="Site Web" error={errors.website}>
|
||||||
|
<input {...register('website')} className={inputClass(errors.website)} />
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title="Comptabilité" icon={Wallet} defaultOpen={false}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<InputGroup label="Mode de règlement">
|
||||||
|
<select {...register('paymentMethod')} className={inputClass()}>
|
||||||
|
<option value="virement">Virement</option>
|
||||||
|
<option value="cb">Carte Bancaire</option>
|
||||||
|
<option value="cheque">Chèque</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>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Automatisations" icon={Zap} defaultOpen={false}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800">
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Accès portail client</span>
|
||||||
|
<Controller name="sendWelcome" control={control} render={({ field }) => <Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Permissions" icon={Shield} defaultOpen={false}>
|
||||||
|
<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 dark:hover:bg-gray-900/50 dark:border-gray-800">
|
||||||
|
<input type="radio" value={opt.val} {...register('visibility')} className="w-4 h-4 text-[#941403]" />
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 z-10 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
form="client-form"
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !isValid}
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-2 bg-[#941403] text-white text-sm font-medium rounded-lg hover:bg-[#7a1002] disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-lg shadow-red-900/20"
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
{initialData ? "Mettre à jour" : "Créer le client"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClientFormModal;
|
||||||
90
src/components/forms/ProductFamilyFormModal.jsx
Normal file
90
src/components/forms/ProductFamilyFormModal.jsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import FormModal, { FormSection, FormField, Input, Select, Textarea } from '@/components/FormModal';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { toast } from '@/components/ui/use-toast';
|
||||||
|
|
||||||
|
const ProductFamilyFormModal = ({ isOpen, onClose, initialData, onSubmit }) => {
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Simulate form submission
|
||||||
|
setTimeout(() => {
|
||||||
|
if (onSubmit) {
|
||||||
|
onSubmit(initialData ? { ...initialData } : { id: Math.random() });
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
toast({
|
||||||
|
title: initialData ? "Famille modifiée" : "Famille créée",
|
||||||
|
description: "Les informations ont été enregistrées avec succès.",
|
||||||
|
variant: "success"
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={initialData ? `Modifier ${initialData.name}` : "Nouvelle famille d'articles"}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<FormSection title="Informations générales" description="Identification de la famille">
|
||||||
|
<FormField label="Code famille" required>
|
||||||
|
<Input defaultValue={initialData?.code} placeholder="EX: FAM-001" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Intitulé" required>
|
||||||
|
<Input defaultValue={initialData?.name} placeholder="Ex: Ordinateurs" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Catégorie">
|
||||||
|
<Select defaultValue={initialData?.category || 'produit'}>
|
||||||
|
<option value="produit">Produit Stocké</option>
|
||||||
|
<option value="service">Service</option>
|
||||||
|
<option value="consommable">Consommable</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Couleur (Tag)">
|
||||||
|
<Select defaultValue={initialData?.color || 'bg-blue-100 text-blue-800'}>
|
||||||
|
<option value="bg-blue-100 text-blue-800">Bleu</option>
|
||||||
|
<option value="bg-green-100 text-green-800">Vert</option>
|
||||||
|
<option value="bg-purple-100 text-purple-800">Violet</option>
|
||||||
|
<option value="bg-orange-100 text-orange-800">Orange</option>
|
||||||
|
<option value="bg-gray-100 text-gray-800">Gris</option>
|
||||||
|
<option value="bg-red-100 text-red-800">Rouge</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Description" fullWidth>
|
||||||
|
<Textarea rows={3} defaultValue={initialData?.description} placeholder="Description interne..." />
|
||||||
|
</FormField>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection title="Règles par défaut" description="Appliquées aux nouveaux articles">
|
||||||
|
<FormField label="Taux TVA par défaut">
|
||||||
|
<Select defaultValue="20">
|
||||||
|
<option value="20">20%</option>
|
||||||
|
<option value="10">10%</option>
|
||||||
|
<option value="5.5">5.5%</option>
|
||||||
|
<option value="0">0%</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Compte comptable vente">
|
||||||
|
<Input placeholder="707..." />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Compte comptable achat">
|
||||||
|
<Input placeholder="607..." />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Unité par défaut">
|
||||||
|
<Select defaultValue="pcs">
|
||||||
|
<option value="pcs">Pièce</option>
|
||||||
|
<option value="h">Heure</option>
|
||||||
|
<option value="kg">Kg</option>
|
||||||
|
<option value="l">Litre</option>
|
||||||
|
<option value="m">Mètre</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
</FormSection>
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductFamilyFormModal;
|
||||||
405
src/components/forms/ProspectFormModal.jsx
Normal file
405
src/components/forms/ProspectFormModal.jsx
Normal file
|
|
@ -0,0 +1,405 @@
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
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 {
|
||||||
|
X, ChevronDown, ChevronUp, Info, CheckCircle2, AlertCircle,
|
||||||
|
Save, Building2, MapPin, Wallet, Zap, FileText, Shield,
|
||||||
|
UploadCloud, Loader2
|
||||||
|
} from 'lucide-react';
|
||||||
|
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 (Same as Page) ---
|
||||||
|
const prospectSchema = z.object({
|
||||||
|
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(),
|
||||||
|
assignedTo: z.string().optional(),
|
||||||
|
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'),
|
||||||
|
civility: z.string().optional(),
|
||||||
|
contactName: z.string().min(2, "Nom requis"),
|
||||||
|
contactFirstName: z.string().min(2, "Prénom requis"),
|
||||||
|
email: z.string().email("Email invalide"),
|
||||||
|
phone: z.string().min(10, "Numéro invalide"),
|
||||||
|
contactPref: z.enum(['email', 'phone', 'sms', 'whatsapp']).default('email'),
|
||||||
|
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('')),
|
||||||
|
paymentMethod: z.string().optional(),
|
||||||
|
paymentTerm: z.string().optional(),
|
||||||
|
interestLevel: z.enum(['low', 'medium', 'high']).default('medium'),
|
||||||
|
urgency: z.string().optional(),
|
||||||
|
budget: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
autoOpportunity: z.boolean().default(false),
|
||||||
|
autoTask: z.boolean().default(false),
|
||||||
|
sendWelcome: z.boolean().default(false),
|
||||||
|
autoAssign: z.boolean().default(false),
|
||||||
|
visibility: z.enum(['me', 'team', 'all']).default('team'),
|
||||||
|
}).refine((data) => {
|
||||||
|
if (data.type === 'entreprise' && !data.companyName) return false;
|
||||||
|
return true;
|
||||||
|
}, {
|
||||||
|
message: "Raison sociale requise",
|
||||||
|
path: ["companyName"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Internal Components ---
|
||||||
|
|
||||||
|
const Section = ({ title, icon: Icon, children, defaultOpen = true, error }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||||
|
return (
|
||||||
|
<div className={cn("border-b border-gray-100 dark:border-gray-800 last:border-0", error && "bg-red-50/30 dark:bg-red-900/10")}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Icon className={cn("w-4 h-4", error ? "text-red-500" : "text-gray-500")} />
|
||||||
|
<span className={cn("font-semibold text-sm", error ? "text-red-600" : "text-gray-900 dark:text-white")}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isOpen ? <ChevronUp className="w-4 h-4 text-gray-400" /> : <ChevronDown className="w-4 h-4 text-gray-400" />}
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="p-4 pt-0 space-y-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const InputGroup = ({ label, error, required, children, className }) => (
|
||||||
|
<div className={cn("space-y-1.5", className)}>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{label} {required && <span className="text-[#941403]">*</span>}
|
||||||
|
</label>
|
||||||
|
{error && <span className="text-[10px] text-red-500 font-medium">{error.message}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
{children}
|
||||||
|
{!error && required && children?.props?.value && (
|
||||||
|
<CheckCircle2 className="absolute right-3 top-1/2 -translate-y-1/2 w-3 h-3 text-green-500 pointer-events-none" />
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<AlertCircle className="absolute right-3 top-1/2 -translate-y-1/2 w-3 h-3 text-red-500 pointer-events-none" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ProspectFormModal = ({ isOpen, onClose, initialData = null }) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [completion, setCompletion] = useState(0);
|
||||||
|
|
||||||
|
const { register, handleSubmit, watch, control, formState: { errors, isValid }, reset } = useForm({
|
||||||
|
resolver: zodResolver(prospectSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: {
|
||||||
|
type: 'entreprise',
|
||||||
|
contactPref: 'email',
|
||||||
|
interestLevel: 'medium',
|
||||||
|
country: 'France',
|
||||||
|
autoOpportunity: false,
|
||||||
|
visibility: 'team',
|
||||||
|
...initialData
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (initialData) reset(initialData);
|
||||||
|
else reset({
|
||||||
|
type: 'entreprise',
|
||||||
|
contactPref: 'email',
|
||||||
|
interestLevel: 'medium',
|
||||||
|
country: 'France',
|
||||||
|
autoOpportunity: false,
|
||||||
|
visibility: 'team'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isOpen, initialData, reset]);
|
||||||
|
|
||||||
|
const watchAll = watch();
|
||||||
|
const type = watch('type');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const requiredFields = ['name', 'address1', 'zipCode', 'city', 'contactName', 'contactFirstName', 'email', 'phone'];
|
||||||
|
if (type === 'entreprise') requiredFields.push('companyName');
|
||||||
|
const filled = requiredFields.filter(field => !!watchAll[field]);
|
||||||
|
setCompletion(Math.round((filled.length / requiredFields.length) * 100));
|
||||||
|
}, [watchAll, type]);
|
||||||
|
|
||||||
|
const onSubmit = async (data) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
console.log(data);
|
||||||
|
toast({
|
||||||
|
title: initialData ? "Prospect modifié" : "Prospect créé",
|
||||||
|
description: `${data.name} a été ${initialData ? "mis à jour" : "ajouté"} avec succès.`,
|
||||||
|
variant: "success"
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClass = (error) => cn(
|
||||||
|
"w-full px-3 py-2 bg-white dark:bg-gray-950 border rounded-lg text-sm shadow-sm transition-all focus:outline-none focus:ring-2 pr-8",
|
||||||
|
error
|
||||||
|
? "border-red-300 focus:border-red-500 focus:ring-red-200"
|
||||||
|
: "border-gray-200 dark:border-gray-800 focus:border-[#941403] focus:ring-red-100/50"
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "100%" }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||||
|
className="fixed inset-y-0 right-0 w-full max-w-xl bg-white dark:bg-gray-950 shadow-2xl z-50 flex flex-col border-l border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-950/80 backdrop-blur-md z-10">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
{initialData ? "Modifier le prospect" : "Nouveau prospect"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{completion}% complété
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Progress value={completion} className="h-1 rounded-none" />
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar bg-gray-50/50 dark:bg-black/20">
|
||||||
|
<form id="prospect-form" onSubmit={handleSubmit(onSubmit)} className="pb-6">
|
||||||
|
|
||||||
|
<Section title="Informations Générales" icon={Building2} error={errors.name || errors.companyName}>
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
{['entreprise', 'particulier'].map((val) => (
|
||||||
|
<label key={val} className={cn(
|
||||||
|
"flex-1 flex items-center justify-center py-2 rounded-lg border cursor-pointer text-xs font-medium transition-colors",
|
||||||
|
type === val
|
||||||
|
? "bg-[#941403] text-white border-[#941403]"
|
||||||
|
: "bg-white dark:bg-gray-900 text-gray-600 border-gray-200 dark:border-gray-800"
|
||||||
|
)}>
|
||||||
|
<input type="radio" value={val} {...register('type')} className="hidden" />
|
||||||
|
<span className="capitalize">{val}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<InputGroup label="Nom du prospect" error={errors.name} required>
|
||||||
|
<input {...register('name')} className={inputClass(errors.name)} placeholder="Nom d'affichage" />
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{type === 'entreprise' && (
|
||||||
|
<InputGroup label="Raison sociale" error={errors.companyName} required>
|
||||||
|
<input {...register('companyName')} className={inputClass(errors.companyName)} placeholder="Raison sociale officielle" />
|
||||||
|
</InputGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<InputGroup label="SIRET" error={errors.siret}>
|
||||||
|
<input {...register('siret')} className={inputClass(errors.siret)} placeholder="14 chiffres" />
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup label="Catégorie">
|
||||||
|
<select {...register('category')} className={inputClass()}>
|
||||||
|
<option value="standard">Standard</option>
|
||||||
|
<option value="premium">Premium</option>
|
||||||
|
<option value="vip">VIP</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InputGroup label="Commercial">
|
||||||
|
<select {...register('assignedTo')} className={inputClass()}>
|
||||||
|
<option value="">Sélectionner...</option>
|
||||||
|
{mockUsers.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Coordonnées" icon={MapPin} error={errors.address1 || errors.zipCode || errors.city || errors.email}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<InputGroup label="Adresse" error={errors.address1} required>
|
||||||
|
<input {...register('address1')} className={inputClass(errors.address1)} />
|
||||||
|
</InputGroup>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<InputGroup label="Code Postal" error={errors.zipCode} required>
|
||||||
|
<input {...register('zipCode')} className={inputClass(errors.zipCode)} />
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup label="Ville" error={errors.city} required>
|
||||||
|
<input {...register('city')} className={inputClass(errors.city)} />
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-gray-100 dark:border-gray-800 mt-2"></div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<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>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<InputGroup label="Email" error={errors.email} required>
|
||||||
|
<input {...register('email')} className={inputClass(errors.email)} />
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup label="Téléphone" error={errors.phone} required>
|
||||||
|
<input {...register('phone')} className={inputClass(errors.phone)} />
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{type === 'entreprise' && (
|
||||||
|
<Section title="Entreprise" icon={Building2} defaultOpen={false}>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<InputGroup label="Forme Juridique">
|
||||||
|
<select {...register('legalForm')} className={inputClass()}>
|
||||||
|
<option value="">Sélectionner...</option>
|
||||||
|
<option value="SARL">SARL</option>
|
||||||
|
<option value="SAS">SAS</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="50+">50+</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup label="CA (€)">
|
||||||
|
<input {...register('revenue')} className={inputClass()} />
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup label="Site Web" error={errors.website}>
|
||||||
|
<input {...register('website')} className={inputClass(errors.website)} />
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title="Besoins & Potentiel" icon={Zap} defaultOpen={false}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<InputGroup label="Intérêt">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{['low', 'medium', 'high'].map(lvl => (
|
||||||
|
<label key={lvl} className={cn(
|
||||||
|
"flex-1 text-center py-1.5 border rounded-md text-xs font-medium cursor-pointer",
|
||||||
|
watchAll.interestLevel === lvl ? "bg-gray-900 text-white border-gray-900" : "bg-white text-gray-600"
|
||||||
|
)}>
|
||||||
|
<input type="radio" value={lvl} {...register('interestLevel')} className="hidden" />
|
||||||
|
{lvl === 'low' ? 'Faible' : lvl === 'medium' ? 'Moyen' : 'Fort'}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup label="Budget (€)">
|
||||||
|
<input type="number" {...register('budget')} className={inputClass()} />
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup label="Notes">
|
||||||
|
<textarea {...register('notes')} rows={3} className={cn(inputClass(), "resize-none")} placeholder="Besoins spécifiques..." />
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Automatisations" icon={Zap} defaultOpen={false}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800">
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Créer opportunité</span>
|
||||||
|
<Controller name="autoOpportunity" control={control} render={({ field }) => <Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800">
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Email de bienvenue</span>
|
||||||
|
<Controller name="sendWelcome" control={control} render={({ field }) => <Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 z-10 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
form="prospect-form"
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !isValid}
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-2 bg-[#941403] text-white text-sm font-medium rounded-lg hover:bg-[#7a1002] disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-lg shadow-red-900/20"
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
{initialData ? "Mettre à jour" : "Créer le prospect"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProspectFormModal;
|
||||||
122
src/components/indicators/SignatureWorkflow.tsx
Normal file
122
src/components/indicators/SignatureWorkflow.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { Check, Send, Eye, PenTool, ThumbsUp, LucideIcon } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
|
||||||
|
type StepStatus = 'completed' | 'current' | 'pending'
|
||||||
|
|
||||||
|
interface StepProps {
|
||||||
|
icon: LucideIcon
|
||||||
|
label: string
|
||||||
|
status: StepStatus
|
||||||
|
isLast?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Step: React.FC<StepProps> = ({ icon: Icon, label, status, isLast }) => {
|
||||||
|
const isCompleted = status === 'completed'
|
||||||
|
const isCurrent = status === 'current'
|
||||||
|
const isPending = status === 'pending'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center flex-1 last:flex-none">
|
||||||
|
<div className="flex flex-col items-center gap-2 relative z-10">
|
||||||
|
<motion.div
|
||||||
|
initial={false}
|
||||||
|
animate={{
|
||||||
|
backgroundColor: isCompleted
|
||||||
|
? '#22c55e'
|
||||||
|
: isCurrent
|
||||||
|
? '#3b82f6'
|
||||||
|
: '#e5e7eb',
|
||||||
|
borderColor: isCompleted
|
||||||
|
? '#22c55e'
|
||||||
|
: isCurrent
|
||||||
|
? '#3b82f6'
|
||||||
|
: '#e5e7eb',
|
||||||
|
scale: isCurrent ? 1.1 : 1,
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'w-8 h-8 rounded-full flex items-center justify-center border-2 transition-colors duration-300',
|
||||||
|
isPending &&
|
||||||
|
'bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400',
|
||||||
|
isCompleted && 'text-white',
|
||||||
|
isCurrent && 'text-white shadow-lg shadow-blue-500/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCompleted ? <Check className="w-4 h-4" /> : <Icon className="w-4 h-4" />}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-medium whitespace-nowrap mt-1",
|
||||||
|
isCompleted
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: isCurrent
|
||||||
|
? "text-blue-600 dark:text-blue-400"
|
||||||
|
: "text-gray-400 dark:text-gray-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLast && (
|
||||||
|
<div className="h-0.5 flex-1 mx-2 bg-gray-100 dark:bg-gray-800 overflow-hidden relative">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: '0%' }}
|
||||||
|
animate={{ width: isCompleted ? '100%' : '0%' }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
|
className="absolute top-0 left-0 h-full bg-green-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignatureStatus =
|
||||||
|
| 'Saisi'
|
||||||
|
| 'Brouillon'
|
||||||
|
| 'Envoyé'
|
||||||
|
| 'Vu'
|
||||||
|
| 'Accepté'
|
||||||
|
| 'Signé'
|
||||||
|
| 'Transformé en commande'
|
||||||
|
|
||||||
|
interface SignatureWorkflowProps {
|
||||||
|
status?: SignatureStatus
|
||||||
|
}
|
||||||
|
const SignatureWorkflow: React.FC<SignatureWorkflowProps> = ({ status }) => {
|
||||||
|
const getStepStatus = (step: SignatureStatus): StepStatus => {
|
||||||
|
if (!status || status === 'Saisi' || status === 'Brouillon') return 'pending'
|
||||||
|
|
||||||
|
const progression: SignatureStatus[] = [
|
||||||
|
'Envoyé',
|
||||||
|
'Vu',
|
||||||
|
'Accepté',
|
||||||
|
'Signé',
|
||||||
|
'Transformé en commande',
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentIndex = progression.indexOf(status)
|
||||||
|
const stepIndex = progression.indexOf(step)
|
||||||
|
|
||||||
|
if (currentIndex > -1 && stepIndex > -1) {
|
||||||
|
if (currentIndex > stepIndex) return 'completed'
|
||||||
|
if (currentIndex === stepIndex) return 'current'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between w-full max-w-lg mx-auto py-4 px-2 mb-6">
|
||||||
|
<Step icon={Send} label="Envoyé" status={getStepStatus('Envoyé')} />
|
||||||
|
<Step icon={Eye} label="Vu" status={getStepStatus('Vu')} />
|
||||||
|
<Step icon={ThumbsUp} label="Accepté" status={getStepStatus('Accepté')} />
|
||||||
|
<Step icon={PenTool} label="Signé" status={getStepStatus('Signé')} isLast />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SignatureWorkflow
|
||||||
190
src/components/layout/AppLayout.jsx
Normal file
190
src/components/layout/AppLayout.jsx
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
|
||||||
|
import React, { ReactNode, useEffect, useState } from 'react';
|
||||||
|
import Sidebar from '@/components/layout/Sidebar';
|
||||||
|
import Topbar from '@/components/layout/Topbar';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||||
|
import { getClients } from '@/store/features/client/thunk';
|
||||||
|
import { getArticles } from '@/store/features/article/thunk';
|
||||||
|
import { getDevisList } from '@/store/features/devis/thunk';
|
||||||
|
import { getCommandes } from '@/store/features/commande/thunk';
|
||||||
|
import LoadingPage from '../loading';
|
||||||
|
import { clientStatus } from '@/store/features/client/selectors';
|
||||||
|
import { articleStatus } from '@/store/features/article/selectors';
|
||||||
|
import { devisStatus } from '@/store/features/devis/selectors';
|
||||||
|
import { commandeStatus } from '@/store/features/commande/selectors';
|
||||||
|
import GlobalMessageBanner from './GlobalMessageBanner';
|
||||||
|
import { GlobalMessageProvider, useGlobalMessage } from '@/contexts/GlobalMessageContext';
|
||||||
|
import SystemStatusDrawer from '../system/SystemStatusDrawer';
|
||||||
|
import { CompanyProvider } from '@/context/CompanyContext';
|
||||||
|
import PriorityBanner from '../PriorityBanner';
|
||||||
|
import SecondaryBanner from '../SecondaryBanner';
|
||||||
|
import { BannerProvider } from '@/context/BannerContext';
|
||||||
|
import { ModuleProvider } from '@/context/ModuleContext';
|
||||||
|
import PremiumHeader from '../PremiumHeader';
|
||||||
|
import { DisplayModeProvider } from '@/context/DisplayModeContext';
|
||||||
|
import { getUniversigns } from '@/store/features/universign/thunk';
|
||||||
|
import { universignStatus } from '@/store/features/universign/selectors';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { authMe } from '@/store/features/user/thunk';
|
||||||
|
import { getGateways, getSociete } from '@/store/features/gateways/thunk';
|
||||||
|
import { getFactures } from '@/store/features/factures/thunk';
|
||||||
|
import { getCommercials } from '@/store/features/commercial/thunk';
|
||||||
|
import { getAvoirs } from '@/store/features/avoir/thunk';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const AppContent = ({ children, onMenuClick, isSidebarOpen, onCloseSidebar }) => {
|
||||||
|
const { showMessage } = useGlobalMessage();
|
||||||
|
|
||||||
|
// Show the requested banner on mount
|
||||||
|
useEffect(() => {
|
||||||
|
showMessage("Nouvelle version disponible : Découvrez la gestion de stock avancée et les nouvelles fonctionnalités.", {
|
||||||
|
type: 'success', // This ensures green color (#338660)
|
||||||
|
dismissible: true,
|
||||||
|
actionLabel: "Voir les nouveautés",
|
||||||
|
onAction: () => console.log('Changelog opened')
|
||||||
|
});
|
||||||
|
}, [showMessage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:pl-64 flex flex-col min-h-screen transition-all duration-300">
|
||||||
|
{/* <Topbar onMenuClick={onMenuClick} /> */}
|
||||||
|
{/* <GlobalMessageBanner /> */}
|
||||||
|
|
||||||
|
{/* Banners Layer - Stacked */}
|
||||||
|
<div className="flex flex-col z-30 relative">
|
||||||
|
<PriorityBanner />
|
||||||
|
<SecondaryBanner />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PremiumHeader
|
||||||
|
onMenuClick={onMenuClick}
|
||||||
|
onAnnouncementsClick={() => setIsAnnouncementsOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-x-hidden p-6">
|
||||||
|
<div className="max-w-[1600px] mx-auto animate-in fade-in duration-500">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const AppLayout = ({ children }) => {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [isSystemStatusOpen, setIsSystemStatusOpen] = useState(false);
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [value, setValue] = useState(0)
|
||||||
|
const [text, setText] = useState("Chargement")
|
||||||
|
|
||||||
|
const statusClients = useAppSelector(clientStatus) ;
|
||||||
|
const statusArticle = useAppSelector(articleStatus) ;
|
||||||
|
const statusDevis = useAppSelector(devisStatus) ;
|
||||||
|
const statusCommandes = useAppSelector(commandeStatus) ;
|
||||||
|
|
||||||
|
const toggleSidebar = () => setSidebarOpen(!sidebarOpen);
|
||||||
|
// console.log("statusClients : ",statusClients);
|
||||||
|
// console.log("statusArticle : ",statusArticle);
|
||||||
|
// console.log("statusDevis : ",statusDevis);
|
||||||
|
// console.log("statusCommandes : ",statusCommandes);
|
||||||
|
|
||||||
|
const statusUniversign = useAppSelector(universignStatus) ;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
await dispatch(authMe()).unwrap();
|
||||||
|
} catch (error) {
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
}, [dispatch, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadUniversigns = async () => {
|
||||||
|
try {
|
||||||
|
await dispatch(getUniversigns()).unwrap();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors du chargement:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (statusUniversign === "idle") {
|
||||||
|
loadUniversigns();
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
loadUniversigns();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [dispatch, statusUniversign]);
|
||||||
|
|
||||||
|
|
||||||
|
// ✅ Charger les données au montage
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
await dispatch(getClients()).unwrap();
|
||||||
|
// await dispatch(getGateways()).unwrap();
|
||||||
|
await dispatch(getSociete()).unwrap();
|
||||||
|
await dispatch(getDevisList()).unwrap();
|
||||||
|
await dispatch(getArticles()).unwrap();
|
||||||
|
await dispatch(getFactures()).unwrap();
|
||||||
|
await dispatch(getCommercials()).unwrap();
|
||||||
|
await dispatch(getCommandes()).unwrap();
|
||||||
|
await dispatch(getAvoirs()).unwrap();
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingPage text={text} value={value}></LoadingPage>
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlobalMessageProvider>
|
||||||
|
<ModuleProvider>
|
||||||
|
<DisplayModeProvider>
|
||||||
|
<CompanyProvider>
|
||||||
|
<BannerProvider>
|
||||||
|
<div className="min-h-screen bg-[#F3F4F6] dark:bg-[#050505] font-sans text-slate-900 dark:text-slate-50">
|
||||||
|
<Sidebar
|
||||||
|
isOpen={sidebarOpen}
|
||||||
|
onClose={() => setSidebarOpen(false)}
|
||||||
|
onSystemStatusClick={() => setIsSystemStatusOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AppContent
|
||||||
|
children={children}
|
||||||
|
onMenuClick={toggleSidebar}
|
||||||
|
isSidebarOpen={sidebarOpen}
|
||||||
|
onCloseSidebar={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SystemStatusDrawer
|
||||||
|
isOpen={isSystemStatusOpen}
|
||||||
|
onClose={() => setIsSystemStatusOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</BannerProvider>
|
||||||
|
</CompanyProvider>
|
||||||
|
</DisplayModeProvider>
|
||||||
|
</ModuleProvider>
|
||||||
|
</GlobalMessageProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppLayout;
|
||||||
105
src/components/layout/AuthLayout.tsx
Normal file
105
src/components/layout/AuthLayout.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Building2} from 'lucide-react';
|
||||||
|
import logo from '../../assets/logo/logo.png'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.ReactNode
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthLayout: React.FC<Props> = ({ children }) => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>Connexion - Dataven ERP</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<div className="min-h-screen w-full flex bg-white dark:bg-gray-950" style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
backgroundColor: 'white'
|
||||||
|
}}>
|
||||||
|
|
||||||
|
{/* Left Side - Visual Branding */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||||
|
className="hidden lg:flex w-1/2 bg-[#0F172A] relative overflow-hidden flex-col justify-between p-12"
|
||||||
|
>
|
||||||
|
{/* Animated Gradient Background */}
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<div className="absolute top-0 left-0 w-full h-full bg-gradient-to-br from-[#0F172A] via-[#1E293B] to-[#0F172A] z-0" />
|
||||||
|
<div className="absolute top-[-20%] left-[-20%] w-[70%] h-[70%] bg-[#338660]/20 rounded-full blur-[120px] animate-pulse" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-[#338660]/10 rounded-full blur-[100px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="flex items-center gap-3 mb-12">
|
||||||
|
{/* <div className="w-10 h-10 rounded-xl bg-[#338660] flex items-center justify-center shadow-lg shadow-[#338660]/30">
|
||||||
|
<Building2 className="w-6 h-6 text-white" />
|
||||||
|
</div> */}
|
||||||
|
<div><img src={logo} alt="" className='w-25 h-20'/></div>
|
||||||
|
{/* <h1 className="text-2xl font-bold text-white tracking-tight">Dataven</h1> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-md">
|
||||||
|
<motion.h2
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
className="text-5xl font-extrabold text-white leading-tight mb-6"
|
||||||
|
>
|
||||||
|
L'intelligence au cœur de votre <span className="text-[#338660]">croissance</span>.
|
||||||
|
</motion.h2>
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.7 }}
|
||||||
|
className="text-lg text-gray-400 leading-relaxed"
|
||||||
|
>
|
||||||
|
Pilotez votre entreprise avec une précision chirurgicale. CRM, Ventes, Achats et Comptabilité unifiés dans une plateforme d'exception.
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Testimonial / Footer */}
|
||||||
|
<div className="relative z-10 mt-auto">
|
||||||
|
<div className="flex items-center gap-4 p-4 bg-white/5 backdrop-blur-md rounded-2xl border border-white/10">
|
||||||
|
<div className="flex -space-x-3">
|
||||||
|
{[1,2,3].map(i => (
|
||||||
|
<div key={i} className="w-10 h-10 rounded-full bg-gray-700 border-2 border-[#0F172A] flex items-center justify-center text-xs font-bold text-white">
|
||||||
|
{String.fromCharCode(64+i)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-white">Rejoignez +2,000 entreprises</div>
|
||||||
|
<div className="text-xs text-gray-400">Leaders de leur marché</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Right Side - Login Form */}
|
||||||
|
<div className="w-full lg:w-1/2 flex items-center justify-center p-6 sm:p-12 relative bg-white dark:bg-gray-950">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Support Link Absolute */}
|
||||||
|
<div className="absolute top-6 right-6 lg:top-8 lg:right-12">
|
||||||
|
<Link to="/" className="text-sm font-medium text-gray-500 hover:text-[#338660] transition-colors">
|
||||||
|
Besoin d'aide ?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthLayout;
|
||||||
102
src/components/layout/GlobalMessageBanner.jsx
Normal file
102
src/components/layout/GlobalMessageBanner.jsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { X, Info, AlertTriangle, CheckCircle, AlertOctagon, ExternalLink } from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useGlobalMessage } from '@/contexts/GlobalMessageContext';
|
||||||
|
|
||||||
|
const GlobalMessageBanner = () => {
|
||||||
|
const { messageState, hideMessage } = useGlobalMessage();
|
||||||
|
const { isVisible, text, type, dismissible, scrolling, actionLabel, onAction } = messageState;
|
||||||
|
|
||||||
|
// Configuration map for banner styles
|
||||||
|
const styles = {
|
||||||
|
info: {
|
||||||
|
bg: 'bg-blue-50 dark:bg-blue-950/50',
|
||||||
|
border: 'border-blue-200 dark:border-blue-800',
|
||||||
|
text: 'text-blue-800 dark:text-blue-200',
|
||||||
|
icon: Info,
|
||||||
|
iconColor: 'text-blue-600 dark:text-blue-400'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bg: 'bg-amber-50 dark:bg-amber-950/50',
|
||||||
|
border: 'border-amber-200 dark:border-amber-800',
|
||||||
|
text: 'text-amber-800 dark:text-amber-200',
|
||||||
|
icon: AlertTriangle,
|
||||||
|
iconColor: 'text-amber-600 dark:text-amber-400'
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
bg: 'bg-emerald-50 dark:bg-emerald-950/50',
|
||||||
|
border: 'border-emerald-200 dark:border-emerald-800',
|
||||||
|
text: 'text-emerald-800 dark:text-emerald-200',
|
||||||
|
icon: CheckCircle,
|
||||||
|
iconColor: 'text-emerald-600 dark:text-emerald-400'
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
bg: 'bg-red-50 dark:bg-red-950/50',
|
||||||
|
border: 'border-red-200 dark:border-red-800',
|
||||||
|
text: 'text-red-800 dark:text-red-200',
|
||||||
|
icon: AlertOctagon,
|
||||||
|
iconColor: 'text-red-600 dark:text-red-400'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const style = styles[type] || styles.info;
|
||||||
|
const Icon = style.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isVisible && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
className={cn(
|
||||||
|
"relative w-full border-b px-4 py-3 flex items-center justify-center overflow-hidden",
|
||||||
|
style.bg,
|
||||||
|
style.border
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="max-w-7xl mx-auto flex items-center justify-center gap-3 w-full text-sm font-medium">
|
||||||
|
<Icon className={cn("w-5 h-5 flex-shrink-0", style.iconColor)} />
|
||||||
|
|
||||||
|
<div className={cn("flex-1", scrolling ? "overflow-hidden whitespace-nowrap" : "")}>
|
||||||
|
<div className={cn(scrolling ? "animate-marquee inline-block" : "", style.text)}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionLabel && onAction && (
|
||||||
|
<button
|
||||||
|
onClick={onAction}
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider transition-colors",
|
||||||
|
"bg-white/50 hover:bg-white/80 dark:bg-black/20 dark:hover:bg-black/40",
|
||||||
|
style.text
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dismissible && (
|
||||||
|
<button
|
||||||
|
onClick={hideMessage}
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 p-1 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors ml-4",
|
||||||
|
style.text
|
||||||
|
)}
|
||||||
|
aria-label="Fermer"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlobalMessageBanner;
|
||||||
126
src/components/layout/Sidebar.jsx
Normal file
126
src/components/layout/Sidebar.jsx
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { Activity, Grid3x3, LayoutDashboard, PenTool, RotateCcw, UserCheck } from "lucide-react";
|
||||||
|
import { X, Users, UserPlus, Package, Building2, FileText, ShoppingCart, Truck, Receipt, CreditCard, Target, BarChart3, Calendar, CheckSquare, Ticket, LayoutDashboard as LayoutDashboardIcon, FolderOpen, Settings, Shield, Clock, Building, Layers, Tag } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import logo from '../../assets/logo/logo.png'
|
||||||
|
import CompanySelector from '../common/CompanySelector';
|
||||||
|
import ProductLogo from '../common/ProductLogo';
|
||||||
|
import { useModule } from '@/context/ModuleContext';
|
||||||
|
import { getMenuForModule } from '@/config/moduleConfig';
|
||||||
|
|
||||||
|
const SidebarItem = ({ to, icon: Icon, label, onClick, end = false }) => (
|
||||||
|
<NavLink
|
||||||
|
to={to}
|
||||||
|
end={end}
|
||||||
|
onClick={onClick}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"flex items-center gap-3 px-4 py-2.5 mx-2 rounded-lg text-sm font-medium transition-all duration-200 group relative",
|
||||||
|
isActive
|
||||||
|
? "bg-[#338660] text-white shadow-md shadow-[#338660]/20"
|
||||||
|
: "text-gray-400 hover:bg-[#1F1F1F] hover:text-white"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className={cn("w-4 h-4 shrink-0 transition-colors opacity-90 group-hover:opacity-100")} />
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SidebarSection = ({ title, children }) => (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="px-6 mb-2 text-[10px] font-bold text-[#525252] uppercase tracking-wider">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
const Sidebar = ({ isOpen, onClose, onSystemStatusClick }) => {
|
||||||
|
|
||||||
|
const { currentModule } = useModule();
|
||||||
|
const menuStructure = getMenuForModule(currentModule);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile overlay - Only visible on small screens when open */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden transition-opacity duration-300",
|
||||||
|
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
|
)}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sidebar Container */}
|
||||||
|
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 left-0 z-50 h-screen w-64 bg-[#0F0F0F] border-r border-[#1F1F1F] transition-transform duration-300 lg:translate-x-0 flex flex-col shadow-2xl",
|
||||||
|
isOpen ? "translate-x-0" : "-translate-x-full"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Line 1: Static Logo Branding */}
|
||||||
|
<ProductLogo />
|
||||||
|
|
||||||
|
{/* Line 2: Company Selector Dropdown */}
|
||||||
|
<div className="pt-2 pb-2">
|
||||||
|
<CompanySelector />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Separator before navigation */}
|
||||||
|
<div className="h-px bg-[#1F1F1F] mx-4 mb-4" />
|
||||||
|
|
||||||
|
{/* Close button for mobile */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-4 right-4 lg:hidden p-2 hover:bg-[#1F1F1F] rounded-lg text-white/70 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Navigation - Scrollable Area */}
|
||||||
|
<nav className="flex-1 overflow-y-auto py-2 custom-scrollbar scroll-smooth">
|
||||||
|
{menuStructure.map((section, index) => (
|
||||||
|
<SidebarSection key={index} title={section.title}>
|
||||||
|
{section.items.map((item, itemIndex) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={itemIndex}
|
||||||
|
to={item.to}
|
||||||
|
icon={item.icon}
|
||||||
|
label={item.label}
|
||||||
|
end={item.end || false}
|
||||||
|
onClick={() => window.innerWidth < 1024 && onClose()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SidebarSection>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-[#1F1F1F] bg-[#0A0A0A] shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={onSystemStatusClick}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg bg-[#1F1F1F] border border-[#2A2A2A] hover:bg-[#2A2A2A] hover:border-[#338660]/50 hover:shadow-lg hover:shadow-[#338660]/10 transition-all duration-300 group cursor-pointer text-left"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
<div className="absolute inset-0 w-2.5 h-2.5 rounded-full bg-green-500 blur-[2px] opacity-50" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-gray-300 group-hover:text-white transition-colors">Système opérationnel</span>
|
||||||
|
</button>
|
||||||
|
<div className="text-[10px] text-center text-[#555555] mt-3 font-medium">
|
||||||
|
v3.1.0 • Dataven Corp
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
269
src/components/layout/TextField.tsx
Normal file
269
src/components/layout/TextField.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
|
||||||
|
// React Imports
|
||||||
|
import { forwardRef } from 'react'
|
||||||
|
|
||||||
|
// MUI Imports
|
||||||
|
import { styled } from '@mui/material/styles'
|
||||||
|
import TextField from '@mui/material/TextField'
|
||||||
|
import type { TextFieldProps } from '@mui/material/TextField'
|
||||||
|
import type { InputLabelProps } from '@mui/material/InputLabel'
|
||||||
|
|
||||||
|
const TextFieldStyled = styled(TextField)<TextFieldProps>(({ theme }) => ({
|
||||||
|
'& .MuiInputLabel-root': {
|
||||||
|
transform: 'none',
|
||||||
|
width: 'fit-content',
|
||||||
|
maxWidth: '100%',
|
||||||
|
lineHeight: 1.153,
|
||||||
|
position: 'relative',
|
||||||
|
fontSize: theme.typography.body2.fontSize,
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
color: 'var(--text-color)',
|
||||||
|
'&:not(.Mui-error).MuiFormLabel-colorPrimary.Mui-focused': {
|
||||||
|
color: 'var(--mui-palette-primary-main) !important'
|
||||||
|
},
|
||||||
|
'&.Mui-disabled': {
|
||||||
|
color: 'var(--mui-palette-text-disabled)'
|
||||||
|
},
|
||||||
|
'&.Mui-error': {
|
||||||
|
color: 'var(--error-color)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'& .MuiInputBase-root': {
|
||||||
|
backgroundColor: 'transparent !important',
|
||||||
|
border: `1px solid var(--text-color)`,
|
||||||
|
'&:not(.Mui-focused):not(.Mui-disabled):not(.Mui-error):hover': {
|
||||||
|
borderColor: 'var(--mui-palette-action-active)'
|
||||||
|
},
|
||||||
|
'&:before, &:after': {
|
||||||
|
display: 'none'
|
||||||
|
},
|
||||||
|
'&.MuiInputBase-sizeSmall': {
|
||||||
|
borderRadius: 'var(--mui-shape-borderRadius)'
|
||||||
|
},
|
||||||
|
'&.Mui-error': {
|
||||||
|
borderColor: 'var(--error-color)'
|
||||||
|
},
|
||||||
|
'&.Mui-focused': {
|
||||||
|
borderWidth: 2,
|
||||||
|
'& .MuiInputBase-input:not(.MuiInputBase-readOnly):not([readonly])::placeholder': {
|
||||||
|
transform: 'translateX(4px)'
|
||||||
|
},
|
||||||
|
'& :not(textarea).MuiFilledInput-input': {
|
||||||
|
padding: '6.25px 13px'
|
||||||
|
},
|
||||||
|
'&:not(.Mui-error).MuiInputBase-colorPrimary': {
|
||||||
|
borderColor: 'var(--mui-palette-primary-main)',
|
||||||
|
boxShadow: 'var(--mui-customShadows-primary-sm)'
|
||||||
|
},
|
||||||
|
'&.MuiInputBase-colorSecondary': {
|
||||||
|
borderColor: 'var(--mui-palette-secondary-main)'
|
||||||
|
},
|
||||||
|
'&.MuiInputBase-colorInfo': {
|
||||||
|
borderColor: 'var(--mui-palette-info-main)'
|
||||||
|
},
|
||||||
|
'&.MuiInputBase-colorSuccess': {
|
||||||
|
borderColor: 'var(--mui-palette-success-main)'
|
||||||
|
},
|
||||||
|
'&.MuiInputBase-colorWarning': {
|
||||||
|
borderColor: 'var(--mui-palette-warning-main)'
|
||||||
|
},
|
||||||
|
'&.MuiInputBase-colorError': {
|
||||||
|
borderColor: 'var(--error-color)'
|
||||||
|
},
|
||||||
|
'&.Mui-error': {
|
||||||
|
borderColor: 'var(--error-color)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'&.Mui-disabled': {
|
||||||
|
backgroundColor: 'var(--mui-palette-action-hover) !important'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Adornments
|
||||||
|
'& .MuiInputAdornment-root': {
|
||||||
|
marginBlockStart: '0px !important',
|
||||||
|
'&.MuiInputAdornment-positionStart + .MuiInputBase-input:not(textarea)': {
|
||||||
|
paddingInlineStart: '0px !important'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'& .MuiInputBase-inputAdornedEnd.MuiInputBase-input': {
|
||||||
|
paddingInlineEnd: '0px !important'
|
||||||
|
},
|
||||||
|
|
||||||
|
'& .MuiInputBase-sizeSmall.MuiInputBase-adornedStart.Mui-focused': {
|
||||||
|
paddingInlineStart: '13px',
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
paddingInlineStart: '0px !important'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'& .MuiInputBase-sizeSmall.MuiInputBase-adornedStart:not(.MuiAutocomplete-inputRoot)': {
|
||||||
|
paddingInlineStart: '14px'
|
||||||
|
},
|
||||||
|
'& .MuiInputBase-sizeSmall.MuiInputBase-adornedEnd:not(.MuiAutocomplete-inputRoot)': {
|
||||||
|
paddingInlineEnd: '14px'
|
||||||
|
},
|
||||||
|
'& .MuiInputBase-sizeSmall.MuiInputBase-adornedEnd.Mui-focused:not(.MuiAutocomplete-inputRoot)': {
|
||||||
|
paddingInlineEnd: '13px',
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
paddingInlineEnd: '0px !important'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'& :not(.MuiInputBase-sizeSmall).MuiInputBase-adornedStart.Mui-focused': {
|
||||||
|
paddingInlineStart: '15px',
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
paddingInlineStart: '0px !important'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'& :not(.MuiInputBase-sizeSmall).MuiInputBase-adornedStart': {
|
||||||
|
paddingInlineStart: '16px'
|
||||||
|
},
|
||||||
|
'& :not(.MuiInputBase-sizeSmall).MuiInputBase-adornedEnd.Mui-focused': {
|
||||||
|
paddingInlineEnd: '15px',
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
paddingInlineEnd: '0px !important'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'& :not(.MuiInputBase-sizeSmall).MuiInputBase-adornedEnd': {
|
||||||
|
paddingInlineEnd: '16px'
|
||||||
|
},
|
||||||
|
'& .MuiInputAdornment-sizeMedium': {
|
||||||
|
'i, svg': {
|
||||||
|
fontSize: '1.25rem'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
'&:not(textarea).MuiInputBase-inputSizeSmall': {
|
||||||
|
padding: '7.25px 14px'
|
||||||
|
},
|
||||||
|
'&:not(.MuiInputBase-readOnly):not([readonly])::placeholder': {
|
||||||
|
transition: theme.transitions.create(['opacity', 'transform'], {
|
||||||
|
duration: theme.transitions.duration.shorter
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'& :not(.MuiInputBase-sizeSmall).MuiInputBase-root': {
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '17px',
|
||||||
|
lineHeight: '1.41',
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
padding: '10.8px 16px'
|
||||||
|
},
|
||||||
|
'&.Mui-focused': {
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
padding: '9.8px 15px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'& .MuiFormHelperText-root': {
|
||||||
|
lineHeight: 1.154,
|
||||||
|
margin: theme.spacing(1, 0, 0),
|
||||||
|
fontSize: theme.typography.body2.fontSize,
|
||||||
|
'&.Mui-error': {
|
||||||
|
color: 'var(--error-color)'
|
||||||
|
},
|
||||||
|
'&.Mui-disabled': {
|
||||||
|
color: 'var(--mui-palette-text-disabled)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// For Select
|
||||||
|
'& .MuiSelect-select.MuiInputBase-inputSizeSmall, & .MuiNativeSelect-select.MuiInputBase-inputSizeSmall': {
|
||||||
|
'& ~ i, & ~ svg': {
|
||||||
|
inlineSize: '1.125rem',
|
||||||
|
blockSize: '1.125rem'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'& .MuiSelect-select': {
|
||||||
|
// lineHeight: 1.5,
|
||||||
|
minHeight: 'unset !important',
|
||||||
|
lineHeight: '1.4375em',
|
||||||
|
'&.MuiInputBase-input': {
|
||||||
|
paddingInlineEnd: '32px !important'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'& .Mui-focused .MuiSelect-select': {
|
||||||
|
'& ~ i, & ~ svg': {
|
||||||
|
right: '0.9375rem'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
'& .MuiSelect-select:focus, & .MuiNativeSelect-select:focus': {
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
},
|
||||||
|
|
||||||
|
// For Autocomplete
|
||||||
|
'& :not(.MuiInputBase-sizeSmall).MuiAutocomplete-inputRoot': {
|
||||||
|
paddingBlock: '5.55px',
|
||||||
|
'& .MuiAutocomplete-input': {
|
||||||
|
paddingInline: '8px !important',
|
||||||
|
paddingBlock: '5.25px !important'
|
||||||
|
},
|
||||||
|
'&.Mui-focused .MuiAutocomplete-input': {
|
||||||
|
paddingInlineStart: '7px !important'
|
||||||
|
},
|
||||||
|
'&.Mui-focused': {
|
||||||
|
paddingBlock: '4.55px !important'
|
||||||
|
},
|
||||||
|
'& .MuiAutocomplete-endAdornment': {
|
||||||
|
top: 'calc(50% - 12px)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'& .MuiAutocomplete-inputRoot.MuiInputBase-sizeSmall': {
|
||||||
|
paddingBlock: '4.75px !important',
|
||||||
|
paddingInlineStart: '10px',
|
||||||
|
'&.Mui-focused': {
|
||||||
|
paddingBlock: '3.75px !important',
|
||||||
|
paddingInlineStart: '9px',
|
||||||
|
'.MuiAutocomplete-input': {
|
||||||
|
paddingBlock: '2.5px',
|
||||||
|
paddingInline: '3px !important'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'& .MuiAutocomplete-input': {
|
||||||
|
paddingInline: '3px !important'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'& .MuiAutocomplete-inputRoot': {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.25rem',
|
||||||
|
'& .MuiAutocomplete-tag': {
|
||||||
|
margin: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'& .MuiAutocomplete-inputRoot.Mui-focused .MuiAutocomplete-endAdornment': {
|
||||||
|
right: '.9375rem'
|
||||||
|
},
|
||||||
|
|
||||||
|
// For Textarea
|
||||||
|
'& .MuiInputBase-multiline': {
|
||||||
|
'&.MuiInputBase-sizeSmall': {
|
||||||
|
padding: '6px 14px',
|
||||||
|
'&.Mui-focused': {
|
||||||
|
padding: '5px 13px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'& textarea.MuiInputBase-inputSizeSmall:placeholder-shown': {
|
||||||
|
overflowX: 'hidden'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const CustomTextField = forwardRef((props: TextFieldProps, ref) => {
|
||||||
|
const { size = 'small', slotProps, ...rest } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextFieldStyled
|
||||||
|
size={size}
|
||||||
|
inputRef={ref}
|
||||||
|
{...rest}
|
||||||
|
variant='filled'
|
||||||
|
slotProps={{
|
||||||
|
...slotProps,
|
||||||
|
inputLabel: { ...slotProps?.inputLabel, shrink: true } as InputLabelProps
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default CustomTextField
|
||||||
132
src/components/layout/Topbar.jsx
Normal file
132
src/components/layout/Topbar.jsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Menu, Bell, Clock, Moon, Sun, User, LogOut, Settings, UserCircle } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
import NotificationCenter from '@/components/NotificationCenter';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { ACCESS_TOKEN, REFRESH_TOKEN } from '@/lib/data';
|
||||||
|
import Cookies from 'js-cookie';
|
||||||
|
import { resetApp } from '@/store/resetAction';
|
||||||
|
import { useAppDispatch } from '@/store/hooks';
|
||||||
|
import { CompanyInfo } from '@/data/mockData';
|
||||||
|
|
||||||
|
const Topbar = ({ onMenuClick }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const { isDark, toggleTheme } = useTheme();
|
||||||
|
const [showNotifications, setShowNotifications] = useState(false);
|
||||||
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
Cookies.remove(ACCESS_TOKEN);
|
||||||
|
Cookies.remove(REFRESH_TOKEN);
|
||||||
|
|
||||||
|
dispatch(resetApp());
|
||||||
|
|
||||||
|
navigate('/login')
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-30 bg-white dark:bg-gray-950 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="flex items-center justify-between px-4 lg:px-8 h-16">
|
||||||
|
<button
|
||||||
|
onClick={onMenuClick}
|
||||||
|
className="lg:hidden p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors"
|
||||||
|
title={isDark ? "Mode clair" : "Mode sombre"}
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNotifications(!showNotifications)}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors relative"
|
||||||
|
>
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-[#007E45] rounded-full" />
|
||||||
|
</button>
|
||||||
|
{showNotifications && (
|
||||||
|
<NotificationCenter onClose={() => setShowNotifications(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative ml-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||||
|
className="flex items-center gap-3 pl-3 border-l border-gray-200 dark:border-gray-800 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<div className="text-right hidden sm:block">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">{CompanyInfo.name}</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Administrateur</p>
|
||||||
|
</div>
|
||||||
|
{/* <div className="w-9 h-9 rounded-full bg-[#941403] text-white flex items-center justify-center text-sm font-semibold"> */}
|
||||||
|
<div className="w-9 h-9 rounded-full bg-[#007E45] text-white flex items-center justify-center text-sm font-semibold">
|
||||||
|
SG
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showUserMenu && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={() => setShowUserMenu(false)}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
className="absolute right-0 top-12 w-56 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl shadow-xl z-50 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="p-2 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<p className="px-2 py-1.5 text-sm font-medium text-gray-900 dark:text-white">{CompanyInfo.name}</p>
|
||||||
|
<p className="px-2 text-xs text-gray-500 dark:text-gray-400">{CompanyInfo.email}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { navigate('/profile'); setShowUserMenu(false); }}
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<UserCircle className="w-4 h-4" />
|
||||||
|
Mon profil
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { navigate('/parametres'); setShowUserMenu(false); }}
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
Préférences
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-1 border-t border-gray-100 dark:border-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Topbar;
|
||||||
55
src/components/loading.tsx
Normal file
55
src/components/loading.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext'
|
||||||
|
|
||||||
|
import { styled } from '@mui/material/styles'
|
||||||
|
|
||||||
|
|
||||||
|
import logoRed from '@/assets/logo/red.svg';
|
||||||
|
import logoWhite from '@/assets/logo/white.svg';
|
||||||
|
|
||||||
|
type LoadingPageProps = {
|
||||||
|
text: string;
|
||||||
|
value:number
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoginIllustration = styled('img')(({ theme }) => ({
|
||||||
|
zIndex: 2,
|
||||||
|
blockSize: 'auto',
|
||||||
|
maxBlockSize: 680,
|
||||||
|
maxInlineSize: '100%',
|
||||||
|
margin: theme.spacing(12),
|
||||||
|
[theme.breakpoints.down(1536)]: {
|
||||||
|
maxBlockSize: 550
|
||||||
|
},
|
||||||
|
[theme.breakpoints.down('lg')]: {
|
||||||
|
maxBlockSize: 450
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const LoadingPage = ({ text, value }: LoadingPageProps) => {
|
||||||
|
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-screen gap-2 absolute w-full z-50">
|
||||||
|
<img
|
||||||
|
src="https://www.sage.com/Areas/SageDotCom/img/sage-logo-green.svg"
|
||||||
|
alt="Sage Logo"
|
||||||
|
style={{ width: "500px", height: "auto" }}
|
||||||
|
/>
|
||||||
|
{/* {
|
||||||
|
isDark ? (
|
||||||
|
<LoginIllustration src={logoWhite} alt='character-illustration' />
|
||||||
|
):(
|
||||||
|
<LoginIllustration src={logoRed} alt='character-illustration' />
|
||||||
|
)
|
||||||
|
} */}
|
||||||
|
<div>
|
||||||
|
<div className="w-40 bg-[#941403] dark:bg-[#C94635] rounded-full h-1"><div className="h-1 rounded-full bg-[#c10302]" style={{width: `${value}%`}}></div></div>
|
||||||
|
{/* <progress className="progress progress-success w-56 bg-green-600" value="10" max="100"></progress> */}
|
||||||
|
</div>
|
||||||
|
<div><span className="text-[11px] font-semibold">{text} {value}%...</span></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoadingPage;
|
||||||
32
src/components/logo.jsx
Normal file
32
src/components/logo.jsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
|
||||||
|
import logoWhite from '@/assets/logo/white.svg'
|
||||||
|
import logoRed from '@/assets/logo/red.svg'
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
|
||||||
|
const Logo = () => {
|
||||||
|
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
// <div className="h-[7vh] w-full">
|
||||||
|
<div className="h-[4vh] w-full">
|
||||||
|
<img
|
||||||
|
src="https://www.sage.com/Areas/SageDotCom/img/sage-logo-green.svg"
|
||||||
|
alt="Sage Logo"
|
||||||
|
style={{ width: "100px", height: "auto" }}
|
||||||
|
/>
|
||||||
|
{/* {
|
||||||
|
isDark ? (
|
||||||
|
<img src={logoWhite} alt="logo-dataven" className="w-full h-full object-cover" />
|
||||||
|
):(
|
||||||
|
<img src={logoRed} alt="logo-dataven" className="w-full h-full object-cover" />
|
||||||
|
)
|
||||||
|
} */}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default Logo;
|
||||||
609
src/components/modal/ModalArticle.tsx
Normal file
609
src/components/modal/ModalArticle.tsx
Normal file
|
|
@ -0,0 +1,609 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useForm, Controller, type SubmitHandler } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import z from "zod";
|
||||||
|
import { mockProductFamilies } from '@/data/mockData';
|
||||||
|
import FormModal, { FormSection } from '@/components/ui/FormModal';
|
||||||
|
import { InputField } from "../ui/InputValidator";
|
||||||
|
import { Article, ArticleRequest, ArticleUpdateRequest } from '@/types/articleType';
|
||||||
|
import { Info, InfoIcon, Loader2, Save } from 'lucide-react';
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/store/hooks";
|
||||||
|
import { articleStatus, getAllArticles } from "@/store/features/article/selectors";
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import { createArticle, getarticleById, updateArticle } from "@/store/features/article/thunk";
|
||||||
|
import { toast } from "../ui/use-toast";
|
||||||
|
import { selectArticle } from "@/store/features/article/slice";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { getAllfamilles } from "@/store/features/famille/selectors";
|
||||||
|
import { Famille } from "@/types/familleType";
|
||||||
|
import { Alert } from "@mui/material";
|
||||||
|
import { Switch } from "../ui/switchTsx";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SCHÉMA DE VALIDATION ZOD
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const articleSchema = z.object({
|
||||||
|
reference: z.string()
|
||||||
|
.min(1, "La référence est requise")
|
||||||
|
.regex(
|
||||||
|
/^[A-Za-z0-9]+$/,
|
||||||
|
"La référence ne peut contenir que des lettres et des chiffres"
|
||||||
|
),
|
||||||
|
reference_client: z.string().optional(),
|
||||||
|
designation: z.string().min(1, "Le libellé est requis"),
|
||||||
|
designation_complementaire: z.string().optional(),
|
||||||
|
famille_code: z.string().min(1, "La famille est requise"),
|
||||||
|
est_actif: z.boolean().default(true),
|
||||||
|
en_sommeil: z.boolean().default(false),
|
||||||
|
description: z.string().optional(),
|
||||||
|
code_ean: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d*$/, { message: 'Le code EAN doit contenir uniquement des chiffres' })
|
||||||
|
.max(13, { message: 'Le code EAN ne doit pas dépasser 13 chiffres' })
|
||||||
|
.optional(),
|
||||||
|
prix_achat: z.coerce.number().min(0, "Le prix d'achat doit être positif").optional(),
|
||||||
|
prix_vente: z.coerce.number().min(0, "Le prix de vente doit être positif"),
|
||||||
|
tva_code: z.string().default("20"),
|
||||||
|
unite_vente: z.string().default("pcs"),
|
||||||
|
stock_reel: z.coerce.number().default(0),
|
||||||
|
stock_mini: z.coerce.number().default(5),
|
||||||
|
stock_maxi: z.coerce.number().optional(),
|
||||||
|
poids: z.coerce.number().optional(),
|
||||||
|
volume: z.coerce.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ArticleFormData = z.infer<typeof articleSchema>;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// INTERFACE PROPS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface ArticleFormModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
editing?: Article | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PRINCIPAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export function ModalArticle({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
editing
|
||||||
|
}: ArticleFormModalProps) {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isStocked, setIsStocked] = useState(false);
|
||||||
|
const isEditing = !!editing;
|
||||||
|
const status = useAppSelector(articleStatus);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const articles = useAppSelector(getAllArticles) as Article[];
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const familles = useAppSelector(getAllfamilles) as Famille[]
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isValid }
|
||||||
|
} = useForm<ArticleFormData>({
|
||||||
|
resolver: zodResolver(articleSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: {
|
||||||
|
reference: "",
|
||||||
|
reference_client: "",
|
||||||
|
designation: "",
|
||||||
|
designation_complementaire: "",
|
||||||
|
famille_code: "",
|
||||||
|
est_actif: true,
|
||||||
|
en_sommeil: false,
|
||||||
|
description: "",
|
||||||
|
code_ean: "",
|
||||||
|
prix_achat: 0,
|
||||||
|
prix_vente: 0,
|
||||||
|
tva_code: "20",
|
||||||
|
unite_vente: "pcs",
|
||||||
|
stock_reel: 0,
|
||||||
|
stock_mini: 5,
|
||||||
|
stock_maxi: undefined,
|
||||||
|
poids: undefined,
|
||||||
|
volume: undefined,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const prixAchat = watch("prix_achat") || 0;
|
||||||
|
const prixVente = watch("prix_vente") || 0;
|
||||||
|
|
||||||
|
const marge = useMemo(() => {
|
||||||
|
if (prixVente > 0 && prixAchat > 0) {
|
||||||
|
const margeValue = ((prixVente - prixAchat) / prixVente) * 100;
|
||||||
|
return margeValue.toFixed(2) + "%";
|
||||||
|
}
|
||||||
|
return "—";
|
||||||
|
}, [prixAchat, prixVente]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setIsSubmitting(false);
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
reset({
|
||||||
|
reference: editing.reference || "",
|
||||||
|
reference_client: "",
|
||||||
|
designation: editing.designation || "",
|
||||||
|
designation_complementaire: editing.designation_complementaire || "",
|
||||||
|
famille_code: editing.famille_code || "",
|
||||||
|
est_actif: editing.est_actif ?? true,
|
||||||
|
en_sommeil: editing.en_sommeil ?? false,
|
||||||
|
description: editing.description || "",
|
||||||
|
code_ean: editing.code_ean || "",
|
||||||
|
prix_achat: editing.prix_achat || 0,
|
||||||
|
prix_vente: editing.prix_vente || 0,
|
||||||
|
tva_code: editing.tva_code || "20",
|
||||||
|
unite_vente: editing.unite_vente || "pcs",
|
||||||
|
stock_reel: editing.stock_reel || 0,
|
||||||
|
stock_mini: editing.stock_mini || 5,
|
||||||
|
stock_maxi: editing.stock_maxi,
|
||||||
|
poids: editing.poids,
|
||||||
|
volume: editing.volume,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reset({
|
||||||
|
reference: "",
|
||||||
|
reference_client: "",
|
||||||
|
designation: "",
|
||||||
|
designation_complementaire: "",
|
||||||
|
famille_code: "",
|
||||||
|
est_actif: true,
|
||||||
|
en_sommeil: false,
|
||||||
|
description: "",
|
||||||
|
code_ean: "",
|
||||||
|
prix_achat: 0,
|
||||||
|
prix_vente: 0,
|
||||||
|
tva_code: "20",
|
||||||
|
unite_vente: "pcs",
|
||||||
|
stock_reel: 0,
|
||||||
|
stock_mini: 5,
|
||||||
|
stock_maxi: undefined,
|
||||||
|
poids: undefined,
|
||||||
|
volume: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, editing, reset]);
|
||||||
|
|
||||||
|
// ✅ Fonction de soumission
|
||||||
|
const onSave: SubmitHandler<ArticleFormData> = async (data) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
if(isEditing){
|
||||||
|
const payload: ArticleUpdateRequest = {
|
||||||
|
designation: data.designation,
|
||||||
|
famille: data.famille_code,
|
||||||
|
prix_vente: Number(data.prix_vente),
|
||||||
|
prix_achat: Number(data.prix_achat ?? 0),
|
||||||
|
stock_maxi: Number(data.stock_maxi ?? 0),
|
||||||
|
stock_mini: Number(data.stock_mini ?? 0),
|
||||||
|
code_ean: data.code_ean || "",
|
||||||
|
description: data.description || "",
|
||||||
|
stock_reel: Number(data.stock_reel ?? 0),
|
||||||
|
publie: data.est_actif
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await dispatch(updateArticle({
|
||||||
|
reference: editing!.reference,
|
||||||
|
data: payload
|
||||||
|
})).unwrap() as Article;
|
||||||
|
|
||||||
|
await dispatch(getarticleById(result.reference)).unwrap() as any;
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Article modifiée !",
|
||||||
|
description: `L'article ${data.reference} a été modifié avec succès.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600"
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
dispatch(selectArticle(result)),
|
||||||
|
onClose();
|
||||||
|
navigate(`/home/articles/${result.reference.replace(/\//g, '')}`)
|
||||||
|
|
||||||
|
}else{
|
||||||
|
const payload: ArticleRequest = {
|
||||||
|
reference: data.reference,
|
||||||
|
designation: data.designation,
|
||||||
|
famille: data.famille_code,
|
||||||
|
prix_vente: Number(data.prix_vente),
|
||||||
|
prix_achat: Number(data.prix_achat ?? 0),
|
||||||
|
stock_maxi: Number(data.stock_maxi ?? 0),
|
||||||
|
stock_mini: Number(data.stock_mini ?? 0),
|
||||||
|
code_ean: data.code_ean || "",
|
||||||
|
description: data.description || "",
|
||||||
|
stock_reel: Number(data.stock_reel ?? 0)
|
||||||
|
};
|
||||||
|
const articleExist = articles.find(art => art.reference === payload.reference);
|
||||||
|
if(articleExist){
|
||||||
|
setError(`Un article avec la référence "${payload.reference}" existe déjà.`);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("payload : ",payload);
|
||||||
|
|
||||||
|
|
||||||
|
// TODO: Dispatch create/update action here
|
||||||
|
const result = await dispatch(createArticle(payload)).unwrap() as Article
|
||||||
|
|
||||||
|
await dispatch(getarticleById(result.reference)).unwrap() as any;
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Article créé !",
|
||||||
|
description: `Un nouveau article a été créé avec succès.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600"
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
dispatch(selectArticle(result)),
|
||||||
|
onClose();
|
||||||
|
navigate(`/home/articles/${result.reference.replace(/\//g, '')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
setError("Une erreur est survenue lors de la sauvegarde.");
|
||||||
|
console.error("Erreur lors de la sauvegarde:", error);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Vérifier si le formulaire peut être soumis
|
||||||
|
const canSave = isValid && !isSubmitting && status !== "loading";
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title || (isEditing ? "Modifier l'article" : "Créer un nouvel article")}
|
||||||
|
size="xl"
|
||||||
|
onSubmit={handleSubmit(onSave)}
|
||||||
|
loading={ isSubmitting || status === "loading" ? true : false}
|
||||||
|
>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" onClose={() => setError(null)} className="my-2">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{/* Section: Informations générales */}
|
||||||
|
<FormSection
|
||||||
|
title="Identification"
|
||||||
|
description="Information de base"
|
||||||
|
>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="reference"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Référence"
|
||||||
|
inputType="text"
|
||||||
|
required
|
||||||
|
disabled={isEditing}
|
||||||
|
placeholder="Ex: ART001"
|
||||||
|
error={errors.reference?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="code_ean"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Code EAN"
|
||||||
|
inputType="text"
|
||||||
|
maxLength={13}
|
||||||
|
placeholder="Code-barres EAN13"
|
||||||
|
error={errors.code_ean?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Controller
|
||||||
|
name="designation"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Libellé"
|
||||||
|
inputType="text"
|
||||||
|
required
|
||||||
|
placeholder="Désignation du produit..."
|
||||||
|
error={errors.designation?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="famille_code"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Famille"
|
||||||
|
as="select"
|
||||||
|
required
|
||||||
|
error={errors.famille_code?.message}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "Sélectionner une famille..." },
|
||||||
|
...familles.filter(item => item.type_libelle ==="Détail").map(f => ({
|
||||||
|
value: f.code,
|
||||||
|
label: f.intitule
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="est_actif"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value ? "active" : "inactive"}
|
||||||
|
onChange={(e) => field.onChange(e.target.value === "active")}
|
||||||
|
label="Statut"
|
||||||
|
as="select"
|
||||||
|
options={[
|
||||||
|
{ value: "active", label: "Actif" },
|
||||||
|
{ value: "inactive", label: "Inactif" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="description"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Description"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Description commerciale..."
|
||||||
|
containerClassName="col-span-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Section: Logistique & Stock */}
|
||||||
|
<FormSection title="Gestion des Stocks (Sage 100)" description="Paramètres logistiques stricts">
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-white dark:bg-gray-800 rounded shadow-sm">
|
||||||
|
<InfoIcon className="w-5 h-5 text-[#338660]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-bold text-gray-900 dark:text-white block">Article géré en stock ?</span>
|
||||||
|
<span className="text-xs text-gray-500">Active le suivi des mouvements et la valorisation</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch checked={isStocked} onCheckedChange={setIsStocked} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isStocked && (
|
||||||
|
<div className="space-y-4 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4 pt-2">
|
||||||
|
<Controller
|
||||||
|
name="stock_reel"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || "0"}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||||
|
label="Stock actuel"
|
||||||
|
inputType="number"
|
||||||
|
error={errors.stock_reel?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="stock_mini"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || "5"}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||||
|
label="Stock minimum"
|
||||||
|
inputType="number"
|
||||||
|
error={errors.stock_mini?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="poids"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || ""}
|
||||||
|
onChange={(e) => field.onChange(parseFloat(e.target.value) || undefined)}
|
||||||
|
label="Poids (kg)"
|
||||||
|
inputType="number"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormSection>
|
||||||
|
{/* <FormSection
|
||||||
|
title="Logistique & Stock"
|
||||||
|
description="Gestion des stocks"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="unite_vente"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Unité"
|
||||||
|
as="select"
|
||||||
|
options={[
|
||||||
|
{ value: "pcs", label: "Pièce" },
|
||||||
|
{ value: "h", label: "Heure" },
|
||||||
|
{ value: "kg", label: "Kg" },
|
||||||
|
{ value: "l", label: "Litre" },
|
||||||
|
{ value: "m", label: "Mètre" },
|
||||||
|
{ value: "m2", label: "M²" },
|
||||||
|
{ value: "m3", label: "M³" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="stock_reel"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || "0"}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||||
|
label="Stock actuel"
|
||||||
|
inputType="number"
|
||||||
|
error={errors.stock_reel?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="stock_mini"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || "5"}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||||
|
label="Stock minimum"
|
||||||
|
inputType="number"
|
||||||
|
error={errors.stock_mini?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="poids"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || ""}
|
||||||
|
onChange={(e) => field.onChange(parseFloat(e.target.value) || undefined)}
|
||||||
|
label="Poids (kg)"
|
||||||
|
inputType="number"
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormSection> */}
|
||||||
|
|
||||||
|
{/* Section: Tarification */}
|
||||||
|
<FormSection
|
||||||
|
title="Tarification"
|
||||||
|
description="Prix et marges"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="prix_achat"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || ""}
|
||||||
|
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
label="Prix d'achat HT (€)"
|
||||||
|
inputType="number"
|
||||||
|
placeholder="0.00"
|
||||||
|
error={errors.prix_achat?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="prix_vente"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || ""}
|
||||||
|
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
label="Prix de vente HT (€)"
|
||||||
|
inputType="number"
|
||||||
|
required
|
||||||
|
placeholder="0.00"
|
||||||
|
error={errors.prix_vente?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="tva_code"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Taux TVA"
|
||||||
|
as="select"
|
||||||
|
options={[
|
||||||
|
{ value: "20", label: "20%" },
|
||||||
|
{ value: "10", label: "10%" },
|
||||||
|
{ value: "5.5", label: "5.5%" },
|
||||||
|
{ value: "0", label: "0%" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Marge théorique"
|
||||||
|
inputType="text"
|
||||||
|
value={marge}
|
||||||
|
disabled
|
||||||
|
className="bg-gray-50 dark:bg-gray-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
484
src/components/modal/ModalAvoir.tsx
Normal file
484
src/components/modal/ModalAvoir.tsx
Normal file
|
|
@ -0,0 +1,484 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import FormModal, { FormSection, FormField } from '@/components/ui/FormModal';
|
||||||
|
import { Calendar, FileText, Plus, Save, Trash2 } from "lucide-react";
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/store/hooks";
|
||||||
|
import ClientAutocomplete from "../molecules/ClientAutocomplete";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Client } from "@/types/clientType";
|
||||||
|
import ArticleAutocomplete from "../molecules/ArticleAutocomplete";
|
||||||
|
import { Article } from "@/types/articleType";
|
||||||
|
import { Alert, CircularProgress, Divider } from "@mui/material";
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "../ui/use-toast";
|
||||||
|
import { Avoir, AvoirRequest, AvoirResponse } from '@/types/avoirType';
|
||||||
|
import { createAvoir, getAvoir, updateAvoir } from '@/store/features/avoir/thunk';
|
||||||
|
import { selectavoir } from '@/store/features/avoir/slice';
|
||||||
|
import { avoirStatus } from '@/store/features/avoir/selectors';
|
||||||
|
import { ModalLoading } from "./ModalLoading";
|
||||||
|
import { formatForDateInput } from '@/lib/utils';
|
||||||
|
import { Input } from '../ui/ui';
|
||||||
|
|
||||||
|
// ✅ Interface corrigée avec les bons noms de champs
|
||||||
|
interface LigneForm {
|
||||||
|
article_code: string;
|
||||||
|
quantite: number;
|
||||||
|
prix_unitaire_ht: number;
|
||||||
|
articles: Article | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModalAvoir({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
editing,
|
||||||
|
client
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
editing?: Avoir | null;
|
||||||
|
client?: Client | null;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const statusAvoir = useAppSelector(avoirStatus);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const [dateAvoir, setDateAvoir] = useState(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
});
|
||||||
|
// du le
|
||||||
|
const [dateLivraison, setDateLivraison] = useState(() => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + 8);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
});
|
||||||
|
const [referenceClient, setReferenceClient] = useState('');
|
||||||
|
const [motif, setMotif] = useState('');
|
||||||
|
|
||||||
|
const [clientSelectionne, setClientSelectionne] = useState<Client | null>(client ?? null);
|
||||||
|
|
||||||
|
// ✅ Ligne par défaut avec articles: null
|
||||||
|
const [lignes, setLignes] = useState<LigneForm[]>([
|
||||||
|
{ article_code: '', quantite: 1, prix_unitaire_ht: 0, articles: null }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isEditing = !!editing;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
setClientSelectionne({
|
||||||
|
numero: editing.client_code,
|
||||||
|
intitule: editing.client_intitule,
|
||||||
|
compte_collectif: "",
|
||||||
|
adresse: "",
|
||||||
|
code_postal: "",
|
||||||
|
ville: "",
|
||||||
|
email: "",
|
||||||
|
telephone: "",
|
||||||
|
} as Client);
|
||||||
|
|
||||||
|
const lignesInitiales: LigneForm[] = editing.lignes?.map(ligne => ({
|
||||||
|
article_code: ligne.article_code,
|
||||||
|
quantite: ligne.quantite,
|
||||||
|
prix_unitaire_ht: ligne.prix_unitaire_ht ?? 0,
|
||||||
|
articles: {
|
||||||
|
reference: ligne.article_code,
|
||||||
|
designation: ligne.designation ?? '',
|
||||||
|
prix_vente: ligne.prix_unitaire_ht ?? 0,
|
||||||
|
stock_reel: 0,
|
||||||
|
} as Article,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
setLignes(lignesInitiales.length > 0 ? lignesInitiales : [
|
||||||
|
{ article_code: '', quantite: 1, prix_unitaire_ht: 0, articles: null }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (editing.date) setDateAvoir(editing.date);
|
||||||
|
if(editing.date_livraison) setDateLivraison(editing.date_livraison);
|
||||||
|
setReferenceClient(editing.reference || '');
|
||||||
|
} else {
|
||||||
|
if (!client) {
|
||||||
|
setClientSelectionne(null);
|
||||||
|
} else {
|
||||||
|
setClientSelectionne(client);
|
||||||
|
}
|
||||||
|
setLignes([
|
||||||
|
{ article_code: '', quantite: 1, prix_unitaire_ht: 0, articles: null }
|
||||||
|
]);
|
||||||
|
setDateAvoir(new Date().toISOString().split('T')[0]);
|
||||||
|
setDateLivraison(() => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + 8);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
});
|
||||||
|
setReferenceClient('');
|
||||||
|
setReferenceClient('');
|
||||||
|
setMotif('');
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
}, [open, editing, client]);
|
||||||
|
|
||||||
|
const appliquerBareme = async (index: number, articleRef: string) => {
|
||||||
|
if (!clientSelectionne) return;
|
||||||
|
try {
|
||||||
|
// Logique d'application du barème
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur application barème:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ajouterLigne = () => {
|
||||||
|
setLignes([
|
||||||
|
...lignes,
|
||||||
|
{ article_code: '', quantite: 1, prix_unitaire_ht: 0, articles: null },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const supprimerLigne = (index: number) => {
|
||||||
|
if (lignes.length > 1) {
|
||||||
|
setLignes(lignes.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Fonction updateLigne corrigée
|
||||||
|
const updateLigne = (index: number, field: keyof LigneForm, value: any) => {
|
||||||
|
const nouvelles = [...lignes];
|
||||||
|
|
||||||
|
if (field === 'articles' && value) {
|
||||||
|
const article = value as Article;
|
||||||
|
nouvelles[index] = {
|
||||||
|
...nouvelles[index],
|
||||||
|
articles: article,
|
||||||
|
article_code: article.reference,
|
||||||
|
prix_unitaire_ht: article.prix_vente,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (clientSelectionne) {
|
||||||
|
appliquerBareme(index, article.reference);
|
||||||
|
}
|
||||||
|
} else if (field === 'articles' && !value) {
|
||||||
|
nouvelles[index] = {
|
||||||
|
...nouvelles[index],
|
||||||
|
articles: null,
|
||||||
|
article_code: '',
|
||||||
|
prix_unitaire_ht: 0,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
(nouvelles[index] as any)[field] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLignes(nouvelles);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculerTotalLigne = (ligne: LigneForm) => {
|
||||||
|
if (!ligne.articles) return 0;
|
||||||
|
const prix = ligne.prix_unitaire_ht || ligne.articles.prix_vente;
|
||||||
|
return prix * ligne.quantite;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculerTotal = () => {
|
||||||
|
return lignes.reduce((acc, ligne) => acc + calculerTotalLigne(ligne), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSave = useMemo(() => {
|
||||||
|
if (!clientSelectionne) return false;
|
||||||
|
const lignesValides = lignes.filter((l) => l.article_code);
|
||||||
|
if (lignesValides.length === 0) return false;
|
||||||
|
return true;
|
||||||
|
}, [clientSelectionne, lignes]);
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
if (!clientSelectionne) {
|
||||||
|
setError('Veuillez sélectionner un client');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lignesValides = lignes.filter((l) => l.article_code);
|
||||||
|
|
||||||
|
if (lignesValides.length === 0) {
|
||||||
|
setError('Veuillez ajouter au moins un article');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
const payloadUpdate = {
|
||||||
|
client_id: clientSelectionne.numero,
|
||||||
|
date_avoir: (() => {
|
||||||
|
const d = new Date(dateAvoir);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
})(),
|
||||||
|
reference: referenceClient,
|
||||||
|
date_livraison: (() => {
|
||||||
|
const d = new Date(dateLivraison);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
})(),
|
||||||
|
lignes: lignesValides.map((l) => ({
|
||||||
|
article_code: l.article_code,
|
||||||
|
quantite: l.quantite
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await dispatch(updateAvoir({
|
||||||
|
numero: editing!.numero,
|
||||||
|
data: payloadUpdate
|
||||||
|
})).unwrap() as any;
|
||||||
|
|
||||||
|
const numero = result.avoir.numero as any;
|
||||||
|
|
||||||
|
setSuccess(true);
|
||||||
|
toast({
|
||||||
|
title: "Avoir mis à jour !",
|
||||||
|
description: `L'avoir a été mis à jour avec succès.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600"
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
const itemCreated = await dispatch(getAvoir(numero)).unwrap() as any;
|
||||||
|
const res = itemCreated as Avoir;
|
||||||
|
dispatch(selectavoir(res));
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
onClose();
|
||||||
|
navigate(`/home/avoirs/${numero}`);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const payloadCreate: AvoirRequest = {
|
||||||
|
client_id: clientSelectionne.numero,
|
||||||
|
date_avoir: (() => {
|
||||||
|
const d = new Date(dateAvoir);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
})(),
|
||||||
|
// reference: referenceClient,
|
||||||
|
// date_livraison: (() => {
|
||||||
|
// const d = new Date(dateLivraison);
|
||||||
|
// d.setDate(d.getDate() + 1);
|
||||||
|
// return d.toISOString().split("T")[0];
|
||||||
|
// })(),
|
||||||
|
lignes: lignesValides.map((l) => ({
|
||||||
|
article_code: l.article_code,
|
||||||
|
quantite: l.quantite
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("payloadCreate : ",payloadCreate);
|
||||||
|
|
||||||
|
|
||||||
|
const result = await dispatch(createAvoir(payloadCreate)).unwrap() as AvoirResponse;
|
||||||
|
|
||||||
|
const data = result.data;
|
||||||
|
setSuccess(true);
|
||||||
|
toast({
|
||||||
|
title: "Avoir créé !",
|
||||||
|
description: `Un nouvel avoir ${data.numero_avoir} a été créé avec succès.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600"
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
const itemCreated = await dispatch(getAvoir(data.numero_avoir)).unwrap() as any;
|
||||||
|
const res = itemCreated as Avoir;
|
||||||
|
dispatch(selectavoir(res));
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
onClose();
|
||||||
|
navigate(`/home/avoirs/${data.numero_avoir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
setError("Cet article n'est pas disponible pour ce client.");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalHT = calculerTotal();
|
||||||
|
const totalTVA = totalHT * 0.20;
|
||||||
|
const totalTTC = totalHT + totalTVA;
|
||||||
|
const lignesValides = lignes.filter((l) => l.article_code).length;
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title || (isEditing ? "Modifier l'avoir" : "Créer un avoir")}
|
||||||
|
size="xl"
|
||||||
|
onSubmit={onSave}
|
||||||
|
loading={statusAvoir === "loading" ? true : false}
|
||||||
|
>
|
||||||
|
{/* Section Client */}
|
||||||
|
<FormSection title="Informations client" description="Sélectionnez le client et les coordonnées">
|
||||||
|
<FormField label="Client" required fullWidth>
|
||||||
|
<ClientAutocomplete
|
||||||
|
value={clientSelectionne}
|
||||||
|
onChange={setClientSelectionne}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="col-span-1 md:col-span-2 grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||||
|
<FormField label="Date d'émission" required>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formatForDateInput(dateAvoir)}
|
||||||
|
onChange={(e) => setDateAvoir(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Dû le" required>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formatForDateInput(dateLivraison)}
|
||||||
|
onChange={(e) => setDateLivraison(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Référence client">
|
||||||
|
<Input
|
||||||
|
placeholder="Ex: REF-AV-123"
|
||||||
|
value={referenceClient}
|
||||||
|
onChange={(e) => setReferenceClient(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Section Lignes */}
|
||||||
|
<FormSection title="Lignes de l'avoir" description="Ajoutez les produits à rembourser">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full mb-4">
|
||||||
|
<thead className="text-left bg-gray-50 dark:bg-gray-900/50 text-xs uppercase text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 rounded-l-lg" style={{ width: '50%' }}>Article / Description</th>
|
||||||
|
<th className="px-2 py-3 text-center" style={{ width: '80px' }}>Qté</th>
|
||||||
|
<th className="px-2 py-3 text-right" style={{ width: '100px' }}>P.U. HT</th>
|
||||||
|
<th className="px-2 py-3 text-right rounded-r-lg" style={{ width: '100px' }}>Total HT</th>
|
||||||
|
<th style={{ width: '40px' }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{lignes.map((ligne, index) => (
|
||||||
|
<tr key={index} className="group hover:bg-gray-50/50 dark:hover:bg-gray-900/20">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{/* ✅ Correction: passer articles et onChange sur 'articles' */}
|
||||||
|
<ArticleAutocomplete
|
||||||
|
value={ligne.articles}
|
||||||
|
onChange={(article) => updateLigne(index, 'articles', article)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-2 py-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ligne.quantite}
|
||||||
|
onChange={(e) => updateLigne(index, '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-[#941403] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-2 py-3">
|
||||||
|
<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-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-[#941403] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-2 py-3 text-right">
|
||||||
|
<span className="font-semibold text-red-600 dark:text-red-400 text-sm">
|
||||||
|
- {calculerTotalLigne(ligne).toFixed(2)} €
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-1 py-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => supprimerLigne(index)}
|
||||||
|
disabled={lignes.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 opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-start mt-4">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Récapitulatif */}
|
||||||
|
<div style={{width: "42vh"}} className="w-72 bg-red-50 dark:bg-red-900/20 rounded-xl p-4 space-y-3 border border-red-100 dark:border-red-800">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Client</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white truncate max-w-[150px]">
|
||||||
|
{clientSelectionne?.intitule || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Lignes</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{lignesValides}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider className="!my-3" />
|
||||||
|
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Total HT</span>
|
||||||
|
<span className="font-medium text-red-600 dark:text-red-400">- {totalHT.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">TVA (20%)</span>
|
||||||
|
<span className="font-medium text-red-600 dark:text-red-400">- {totalTVA.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-3 border-t border-red-200 dark:border-red-700 flex justify-between">
|
||||||
|
<span className="font-bold text-gray-900 dark:text-white">Total TTC</span>
|
||||||
|
<span className="font-bold text-red-600 dark:text-red-400 text-lg">- {totalTTC.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Erreurs */}
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" className="mb-4">{error}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{loading && <ModalLoading />}
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
452
src/components/modal/ModalBL.tsx
Normal file
452
src/components/modal/ModalBL.tsx
Normal file
|
|
@ -0,0 +1,452 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import FormModal, { FormSection, FormField } from '@/components/ui/FormModal';
|
||||||
|
import { Calendar, FileText, Plus, Save, Trash2 } from "lucide-react";
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/store/hooks";
|
||||||
|
import ClientAutocomplete from "../molecules/ClientAutocomplete";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Client } from "@/types/clientType";
|
||||||
|
import ArticleAutocomplete from "../molecules/ArticleAutocomplete";
|
||||||
|
import { Article } from "@/types/articleType";
|
||||||
|
import { Alert, CircularProgress, Divider } from "@mui/material";
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "../ui/use-toast";
|
||||||
|
import { BL, BLRequest, BLResponse, LigneBL } from '@/types/BL_Types';
|
||||||
|
import { BLStatus } from '@/store/features/bl/selectors';
|
||||||
|
import { createBL, getBL, updateBL } from '@/store/features/bl/thunk';
|
||||||
|
import { selectBL } from '@/store/features/bl/slice';
|
||||||
|
import { ModalLoading } from "./ModalLoading";
|
||||||
|
import { formatForDateInput } from '@/lib/utils';
|
||||||
|
import { Input } from '../ui/ui';
|
||||||
|
|
||||||
|
// ✅ Interface corrigée avec Article | null (pas undefined)
|
||||||
|
interface LigneForm {
|
||||||
|
article_code: string;
|
||||||
|
quantite: number;
|
||||||
|
prix_unitaire_ht: number;
|
||||||
|
articles: Article | null; // ✅ Toujours null, jamais undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModalBL({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
editing,
|
||||||
|
client
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
editing?: BL | null;
|
||||||
|
client?: Client | null;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const statusBL = useAppSelector(BLStatus);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const [date, setDate] = useState(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
// date livraison
|
||||||
|
const [dateLivraison, setDateLivraison] = useState(() => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + 8);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
const [referenceClient, setReferenceClient] = useState('');
|
||||||
|
const [clientSelectionne, setClientSelectionne] = useState<Client | null>(client ?? null);
|
||||||
|
|
||||||
|
// ✅ Ligne par défaut avec articles: null
|
||||||
|
const [lignes, setLignes] = useState<LigneForm[]>([
|
||||||
|
{ article_code: '', quantite: 1, prix_unitaire_ht: 0, articles: null }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isEditing = !!editing;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
setClientSelectionne({
|
||||||
|
numero: editing.client_code,
|
||||||
|
intitule: editing.client_intitule,
|
||||||
|
compte_collectif: "",
|
||||||
|
adresse: "",
|
||||||
|
code_postal: "",
|
||||||
|
ville: "",
|
||||||
|
email: "",
|
||||||
|
telephone: "",
|
||||||
|
} as Client);
|
||||||
|
|
||||||
|
const lignesInitiales: LigneForm[] = editing.lignes?.map(ligne => ({
|
||||||
|
article_code: ligne.article_code,
|
||||||
|
quantite: ligne.quantite,
|
||||||
|
prix_unitaire_ht: ligne.prix_unitaire_ht ?? 0,
|
||||||
|
articles: {
|
||||||
|
reference: ligne.article_code,
|
||||||
|
designation: ligne.designation ?? '',
|
||||||
|
prix_vente: ligne.prix_unitaire_ht ?? 0,
|
||||||
|
stock_reel: 0,
|
||||||
|
} as Article,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
setLignes(lignesInitiales.length > 0 ? lignesInitiales : [
|
||||||
|
{ article_code: '', quantite: 1, prix_unitaire_ht: 0, articles: null }
|
||||||
|
]);
|
||||||
|
if (editing.date) setDate(editing.date);
|
||||||
|
if(editing.date_livraison) setDateLivraison(editing.date_livraison);
|
||||||
|
setReferenceClient(editing.reference || '');
|
||||||
|
} else {
|
||||||
|
if (!client) {
|
||||||
|
setClientSelectionne(null);
|
||||||
|
} else {
|
||||||
|
setClientSelectionne(client);
|
||||||
|
}
|
||||||
|
setDate(new Date().toISOString().split('T')[0]);
|
||||||
|
setLignes([
|
||||||
|
{ article_code: '', quantite: 1, prix_unitaire_ht: 0, articles: null }
|
||||||
|
]);
|
||||||
|
setDateLivraison(() => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + 8);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
});
|
||||||
|
setReferenceClient('');
|
||||||
|
setReferenceClient('');
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
}, [open, editing, client]);
|
||||||
|
|
||||||
|
const appliquerBareme = async (index: number, articleRef: string) => {
|
||||||
|
if (!clientSelectionne) return;
|
||||||
|
try {
|
||||||
|
// Logique d'application du barème
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur application barème:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ajouterLigne = () => {
|
||||||
|
setLignes([
|
||||||
|
...lignes,
|
||||||
|
{ article_code: '', quantite: 1, prix_unitaire_ht: 0, articles: null },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const supprimerLigne = (index: number) => {
|
||||||
|
if (lignes.length > 1) {
|
||||||
|
setLignes(lignes.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Fonction updateLigne corrigée
|
||||||
|
const updateLigne = (index: number, field: keyof LigneForm, value: any) => {
|
||||||
|
const nouvelles = [...lignes];
|
||||||
|
|
||||||
|
if (field === 'articles' && value) {
|
||||||
|
// Quand on sélectionne un article
|
||||||
|
const article = value as Article;
|
||||||
|
nouvelles[index] = {
|
||||||
|
...nouvelles[index],
|
||||||
|
articles: article,
|
||||||
|
article_code: article.reference,
|
||||||
|
prix_unitaire_ht: article.prix_vente,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (clientSelectionne) {
|
||||||
|
appliquerBareme(index, article.reference);
|
||||||
|
}
|
||||||
|
} else if (field === 'articles' && !value) {
|
||||||
|
// Quand on efface l'article
|
||||||
|
nouvelles[index] = {
|
||||||
|
...nouvelles[index],
|
||||||
|
articles: null,
|
||||||
|
article_code: '',
|
||||||
|
prix_unitaire_ht: 0,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Pour les autres champs (quantite, prix_unitaire_ht)
|
||||||
|
(nouvelles[index] as any)[field] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLignes(nouvelles);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculerTotalLigne = (ligne: LigneForm) => {
|
||||||
|
if (!ligne.articles) return 0;
|
||||||
|
const prix = ligne.prix_unitaire_ht || ligne.articles.prix_vente;
|
||||||
|
return prix * ligne.quantite;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculerTotal = () => {
|
||||||
|
return lignes.reduce((acc, ligne) => acc + calculerTotalLigne(ligne), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSave = useMemo(() => {
|
||||||
|
if (!clientSelectionne) return false;
|
||||||
|
const lignesValides = lignes.filter((l) => l.article_code);
|
||||||
|
if (lignesValides.length === 0) return false;
|
||||||
|
return true;
|
||||||
|
}, [clientSelectionne, lignes]);
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
if (!clientSelectionne) {
|
||||||
|
setError('Veuillez sélectionner un client');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lignesValides = lignes.filter((l) => l.article_code);
|
||||||
|
|
||||||
|
if (lignesValides.length === 0) {
|
||||||
|
setError('Veuillez ajouter au moins un article');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
const payloadUpdate = {
|
||||||
|
client_id: clientSelectionne.numero,
|
||||||
|
date_livraison: (() => {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
})(),
|
||||||
|
reference: referenceClient,
|
||||||
|
lignes: lignesValides.map((l) => ({
|
||||||
|
article_code: l.article_code,
|
||||||
|
quantite: l.quantite
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await dispatch(updateBL({
|
||||||
|
numero: editing!.numero,
|
||||||
|
data: payloadUpdate
|
||||||
|
})).unwrap() as any;
|
||||||
|
|
||||||
|
const numero = result.livraison.numero as any;
|
||||||
|
|
||||||
|
setSuccess(true);
|
||||||
|
toast({
|
||||||
|
title: "BL mis à jour !",
|
||||||
|
description: `Le BL a été mis à jour avec succès.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600"
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
const itemCreated = await dispatch(getBL(numero)).unwrap() as any;
|
||||||
|
const res = itemCreated as BL;
|
||||||
|
dispatch(selectBL(res));
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
onClose();
|
||||||
|
navigate(`/home/bons-livraison/${numero}`);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const payloadCreate: BLRequest = {
|
||||||
|
client_id: clientSelectionne.numero,
|
||||||
|
date_livraison: (() => {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
})(),
|
||||||
|
reference: referenceClient,
|
||||||
|
lignes: lignesValides.map((l) => ({
|
||||||
|
article_code: l.article_code,
|
||||||
|
quantite: l.quantite
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await dispatch(createBL(payloadCreate)).unwrap() as BLResponse;
|
||||||
|
const data = result.data;
|
||||||
|
|
||||||
|
setSuccess(true);
|
||||||
|
toast({
|
||||||
|
title: "BL créé !",
|
||||||
|
description: `Un nouveau BL ${data.numero_livraison} a été créé avec succès.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600"
|
||||||
|
});
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
onClose();
|
||||||
|
navigate(`/home/bons-livraison/${data.numero_livraison}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
setError("Cet article n'est pas disponible pour ce client.");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalHT = calculerTotal();
|
||||||
|
const totalTVA = totalHT * 0.20;
|
||||||
|
const totalTTC = totalHT + totalTVA;
|
||||||
|
const lignesValides = lignes.filter((l) => l.article_code).length;
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title || (isEditing ? "Modifier le bon de livraison" : "Créer un bon de livraison")}
|
||||||
|
size="xl"
|
||||||
|
onSubmit={onSave}
|
||||||
|
loading={statusBL === "loading" ? true : false}
|
||||||
|
>
|
||||||
|
{/* Section Client */}
|
||||||
|
<FormSection title="Informations client" description="Sélectionnez le client et les coordonnées">
|
||||||
|
<FormField label="Client" required fullWidth>
|
||||||
|
<ClientAutocomplete
|
||||||
|
value={clientSelectionne}
|
||||||
|
onChange={setClientSelectionne}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="col-span-1 md:col-span-2 grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||||
|
<FormField label="Date de livraison" required>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formatForDateInput(date)}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Référence client">
|
||||||
|
<Input
|
||||||
|
placeholder="Ex: REF-PROJET-123"
|
||||||
|
value={referenceClient}
|
||||||
|
onChange={(e) => setReferenceClient(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Section Lignes */}
|
||||||
|
<FormSection title="Lignes du bon de livraison" description="Ajoutez les produits à livrer">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full mb-4">
|
||||||
|
<thead className="text-left bg-gray-50 dark:bg-gray-900/50 text-xs uppercase text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 rounded-l-lg" style={{ width: '50%' }}>Article / Description</th>
|
||||||
|
<th className="px-2 py-3 text-center" style={{ width: '80px' }}>Qté</th>
|
||||||
|
<th className="px-2 py-3 text-right" style={{ width: '100px' }}>P.U. HT</th>
|
||||||
|
<th className="px-2 py-3 text-right rounded-r-lg" style={{ width: '100px' }}>Total HT</th>
|
||||||
|
<th style={{ width: '40px' }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{lignes.map((ligne, index) => (
|
||||||
|
<tr key={index} className="group hover:bg-gray-50/50 dark:hover:bg-gray-900/20">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{/* ✅ Correction: passer articles (jamais undefined) */}
|
||||||
|
<ArticleAutocomplete
|
||||||
|
value={ligne.articles}
|
||||||
|
onChange={(article) => updateLigne(index, 'articles', article)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-2 py-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ligne.quantite}
|
||||||
|
onChange={(e) => updateLigne(index, '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-[#941403] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-2 py-3">
|
||||||
|
<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-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-[#941403] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-2 py-3 text-right">
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white text-sm">
|
||||||
|
{calculerTotalLigne(ligne).toFixed(2)} €
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-1 py-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => supprimerLigne(index)}
|
||||||
|
disabled={lignes.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 opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-start mt-4">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Récapitulatif */}
|
||||||
|
<div style={{width: "42vh"}} className="w-92 bg-blue-50 dark:bg-gray-900 rounded-xl p-4 space-y-3">
|
||||||
|
|
||||||
|
<Divider className="!my-3" />
|
||||||
|
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Total HT</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">{totalHT.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Taux TVA </span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">20%</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-3 border-t border-gray-200 dark:border-gray-700 flex justify-between">
|
||||||
|
<span className="font-bold text-gray-900 dark:text-white">Total TTC</span>
|
||||||
|
<span className="font-bold text-[#007E45] text-lg">{totalTTC.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Erreurs */}
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" className="mb-4">{error}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && <ModalLoading />}
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
913
src/components/modal/ModalClient.tsx
Normal file
913
src/components/modal/ModalClient.tsx
Normal file
|
|
@ -0,0 +1,913 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
X, Save, Building2, MapPin, User, Phone, Mail, Globe, CreditCard, Hash,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/store/hooks";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "@/components/ui/use-toast";
|
||||||
|
import { Client, ClientRequest, ClientUpdateRequest } from "@/types/clientType";
|
||||||
|
import { addClient, updateClient } from "@/store/features/client/thunk";
|
||||||
|
import { clientStatus, getAllClients } from "@/store/features/client/selectors";
|
||||||
|
import { selectClient } from "@/store/features/client/slice";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Progress } from "../ui/Progress";
|
||||||
|
import { Section } from "../ui/Section";
|
||||||
|
import { InputField, InputType, validators } from "../ui/InputValidator";
|
||||||
|
import z from "zod";
|
||||||
|
import { useForm, Controller, type SubmitHandler } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Alert } from "@mui/material";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// HELPERS ZOD
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const createZodValidator = (inputType: InputType, message?: string) => {
|
||||||
|
return z.string().refine(
|
||||||
|
(val) => !val || validators[inputType](val).isValid,
|
||||||
|
(val) => ({ message: message || validators[inputType](val).message })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Schéma optionnel (valide si vide OU si format correct)
|
||||||
|
const optionalZodValidator = (inputType: InputType, message?: string) => {
|
||||||
|
return z.string().refine(
|
||||||
|
(val) => !val || val.trim() === "" || validators[inputType](val).isValid,
|
||||||
|
(val) => ({ message: message || validators[inputType](val).message })
|
||||||
|
).optional().or(z.literal(""));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SCHÉMA DE VALIDATION DU FORMULAIRE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const clientSchema = z.object({
|
||||||
|
// Identification
|
||||||
|
numero: z.string()
|
||||||
|
.min(1, "Le code client est requise")
|
||||||
|
.regex(
|
||||||
|
/^[A-Za-z0-9]+$/,
|
||||||
|
"Le code client ne peut contenir que des lettres et des chiffres"
|
||||||
|
),
|
||||||
|
compte_collectif: z.string().min(1, "Le code collectif est requis"),
|
||||||
|
intitule: z.string().min(1, "L'intitulé est requis"),
|
||||||
|
type_tiers: z.string().default("client"),
|
||||||
|
est_entreprise: z.boolean().default(true),
|
||||||
|
est_particulier: z.boolean().default(false),
|
||||||
|
est_prospect: z.boolean().default(false),
|
||||||
|
est_actif: z.boolean().default(true),
|
||||||
|
forme_juridique: z.string().optional(),
|
||||||
|
secteur: z.string().optional(),
|
||||||
|
|
||||||
|
// Contact
|
||||||
|
civilite: z.string().optional(),
|
||||||
|
nom: z.string().optional(),
|
||||||
|
prenom: z.string().optional(),
|
||||||
|
contact: z.string().optional(),
|
||||||
|
email: optionalZodValidator("email", "Format email invalide"),
|
||||||
|
telephone: optionalZodValidator("phone", "Format téléphone invalide"),
|
||||||
|
portable: optionalZodValidator("phone", "Format téléphone invalide"),
|
||||||
|
telecopie: z.string().optional(),
|
||||||
|
site_web: optionalZodValidator("url", "Format URL invalide"),
|
||||||
|
|
||||||
|
// Adresse
|
||||||
|
adresse: z.string().optional(),
|
||||||
|
complement: z.string().optional(),
|
||||||
|
code_postal: optionalZodValidator("postal_code", "Code postal invalide"),
|
||||||
|
ville: z.string().optional(),
|
||||||
|
region: z.string().optional(),
|
||||||
|
pays: z.string().default("France"),
|
||||||
|
|
||||||
|
// Informations légales
|
||||||
|
siret: optionalZodValidator("siret", "SIRET invalide (14 chiffres)"),
|
||||||
|
siren: optionalZodValidator("siren", "SIREN invalide (9 chiffres)"),
|
||||||
|
tva_intra: optionalZodValidator("tva_intra", "TVA intracommunautaire invalide"),
|
||||||
|
code_naf: z.string().optional(),
|
||||||
|
effectif: z.number().nullable().optional(),
|
||||||
|
ca_annuel: z.number().nullable().optional(),
|
||||||
|
|
||||||
|
// Commercial & Comptabilité
|
||||||
|
commercial_code: z.string().optional(),
|
||||||
|
commercial_nom: z.string().optional(),
|
||||||
|
categorie_tarifaire: z.any().nullable().optional(),
|
||||||
|
categorie_comptable: z.any().nullable().optional(),
|
||||||
|
encours_autorise: z.number().default(0),
|
||||||
|
compte_general: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ClientFormData = z.infer<typeof clientSchema>;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PRINCIPAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export function ModalClient({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
editing
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
editing?: Client | null;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const status = useAppSelector(clientStatus);
|
||||||
|
const [completion, setCompletion] = useState(0);
|
||||||
|
const clients = useAppSelector(getAllClients) as Client[];
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const isEditing = !!editing;
|
||||||
|
|
||||||
|
// React Hook Form
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
formState: { errors, isValid, dirtyFields }
|
||||||
|
} = useForm<ClientFormData>({
|
||||||
|
resolver: zodResolver(clientSchema),
|
||||||
|
mode: "onBlur",
|
||||||
|
defaultValues: {
|
||||||
|
numero: "",
|
||||||
|
compte_collectif: "",
|
||||||
|
intitule: "",
|
||||||
|
type_tiers: "client",
|
||||||
|
est_entreprise: true,
|
||||||
|
est_particulier: false,
|
||||||
|
est_prospect: false,
|
||||||
|
est_actif: true,
|
||||||
|
civilite: "",
|
||||||
|
nom: "",
|
||||||
|
prenom: "",
|
||||||
|
contact: "",
|
||||||
|
adresse: "",
|
||||||
|
complement: "",
|
||||||
|
code_postal: "",
|
||||||
|
ville: "",
|
||||||
|
region: "",
|
||||||
|
pays: "France",
|
||||||
|
telephone: "",
|
||||||
|
portable: "",
|
||||||
|
telecopie: "",
|
||||||
|
email: "",
|
||||||
|
site_web: "",
|
||||||
|
siret: "",
|
||||||
|
siren: "",
|
||||||
|
tva_intra: "",
|
||||||
|
code_naf: "",
|
||||||
|
forme_juridique: "",
|
||||||
|
secteur: "",
|
||||||
|
effectif: null,
|
||||||
|
ca_annuel: null,
|
||||||
|
commercial_code: "",
|
||||||
|
commercial_nom: "",
|
||||||
|
categorie_tarifaire: null,
|
||||||
|
categorie_comptable: null,
|
||||||
|
encours_autorise: 0,
|
||||||
|
compte_general: "",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch pour les valeurs réactives
|
||||||
|
const formValues = watch();
|
||||||
|
const est_entreprise = watch("est_entreprise");
|
||||||
|
|
||||||
|
// Reset form quand le modal s'ouvre
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (editing) {
|
||||||
|
reset({
|
||||||
|
...editing,
|
||||||
|
effectif: editing.effectif ?? null,
|
||||||
|
ca_annuel: editing.ca_annuel ?? null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reset({
|
||||||
|
numero: "",
|
||||||
|
compte_collectif: "",
|
||||||
|
intitule: "",
|
||||||
|
type_tiers: "client",
|
||||||
|
est_entreprise: true,
|
||||||
|
est_particulier: false,
|
||||||
|
est_prospect: false,
|
||||||
|
est_actif: true,
|
||||||
|
civilite: "",
|
||||||
|
nom: "",
|
||||||
|
prenom: "",
|
||||||
|
contact: "",
|
||||||
|
adresse: "",
|
||||||
|
complement: "",
|
||||||
|
code_postal: "",
|
||||||
|
ville: "",
|
||||||
|
region: "",
|
||||||
|
pays: "France",
|
||||||
|
telephone: "",
|
||||||
|
portable: "",
|
||||||
|
telecopie: "",
|
||||||
|
email: "",
|
||||||
|
site_web: "",
|
||||||
|
siret: "",
|
||||||
|
siren: "",
|
||||||
|
tva_intra: "",
|
||||||
|
code_naf: "",
|
||||||
|
forme_juridique: "",
|
||||||
|
secteur: "",
|
||||||
|
effectif: null,
|
||||||
|
ca_annuel: null,
|
||||||
|
commercial_code: "",
|
||||||
|
commercial_nom: "",
|
||||||
|
categorie_tarifaire: null,
|
||||||
|
categorie_comptable: null,
|
||||||
|
encours_autorise: 0,
|
||||||
|
compte_general: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, editing, reset]);
|
||||||
|
|
||||||
|
// Calcul completion
|
||||||
|
useEffect(() => {
|
||||||
|
const requiredFields: (keyof ClientFormData)[] = ['numero', 'intitule', 'email', 'telephone'];
|
||||||
|
const filled = requiredFields.filter(f => !!formValues[f]);
|
||||||
|
setCompletion(Math.round((filled.length / requiredFields.length) * 100));
|
||||||
|
}, [formValues]);
|
||||||
|
|
||||||
|
const canSave = useMemo(() => {
|
||||||
|
return !!formValues.intitule && !!formValues.numero;
|
||||||
|
}, [formValues.intitule, formValues.numero]);
|
||||||
|
|
||||||
|
// Submit handler
|
||||||
|
const onSave: SubmitHandler<ClientFormData> = async (data) => {
|
||||||
|
error && setError(null);
|
||||||
|
try {
|
||||||
|
if (isEditing) {
|
||||||
|
const payload: ClientUpdateRequest = {
|
||||||
|
intitule: data.intitule,
|
||||||
|
adresse: data.adresse,
|
||||||
|
code_postal: data.code_postal,
|
||||||
|
ville: data.ville,
|
||||||
|
email: data.email,
|
||||||
|
telephone: data.telephone,
|
||||||
|
pays: data.pays,
|
||||||
|
siret: data.siret,
|
||||||
|
tva_intra: data.tva_intra || ""
|
||||||
|
};
|
||||||
|
|
||||||
|
const rep = await dispatch(updateClient({
|
||||||
|
numero: data.numero,
|
||||||
|
data: payload
|
||||||
|
})).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"
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
dispatch(selectClient(dataResponse));
|
||||||
|
navigate(`/home/clients/${dataResponse.numero}`);
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const payloadCreate: ClientRequest = {
|
||||||
|
num: data.numero,
|
||||||
|
compte_collectif: data.compte_collectif,
|
||||||
|
intitule: data.intitule,
|
||||||
|
adresse: data.adresse,
|
||||||
|
code_postal: data.code_postal,
|
||||||
|
ville: data.ville,
|
||||||
|
email: data.email,
|
||||||
|
telephone: data.telephone,
|
||||||
|
pays: data.pays,
|
||||||
|
siret: data.siret,
|
||||||
|
tva_intra: data.tva_intra || ""
|
||||||
|
};
|
||||||
|
|
||||||
|
const existingClient = clients.find(client => client.numero === data.numero);
|
||||||
|
if (existingClient) {
|
||||||
|
setError(`Le code client "${data.numero}" est déjà utilisé. Veuillez en choisir un autre.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("payloadCreate : ",payloadCreate);
|
||||||
|
|
||||||
|
|
||||||
|
const rep = await dispatch(addClient(payloadCreate)).unwrap() as any;
|
||||||
|
|
||||||
|
const dataResponse = rep.data as Client;
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Client créé !",
|
||||||
|
description: `Un nouveau client a été créé avec succès.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600"
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
dispatch(selectClient(dataResponse));
|
||||||
|
navigate(`/home/clients/${dataResponse.numero}`);
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Une erreur est survenue.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "100%" }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||||
|
className="fixed inset-y-0 right-0 w-full max-w-xl bg-white dark:bg-gray-950 shadow-2xl z-50 flex flex-col border-l border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-950/80 backdrop-blur-md">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
{title || (isEditing ? "Modifier le client" : "Nouveau client")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500">{completion}% complété</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors">
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Progress value={completion} className="h-4 rounded-none" />
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<form onSubmit={handleSubmit(onSave)} className="flex-1 overflow-y-auto bg-gray-50/50 dark:bg-black/20">
|
||||||
|
{/* Type de client */}
|
||||||
|
<div className="p-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ val: true, label: 'Entreprise', icon: Building2 },
|
||||||
|
{ val: false, label: 'Particulier', icon: User }
|
||||||
|
].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setValue('est_entreprise', opt.val);
|
||||||
|
setValue('est_particulier', !opt.val);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl border text-sm font-medium transition-all",
|
||||||
|
est_entreprise === opt.val
|
||||||
|
? "bg-[#941403] text-white border-[#941403] shadow-lg shadow-red-900/20"
|
||||||
|
: "bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700 hover:border-gray-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<opt.icon className="w-4 h-4" />
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sections */}
|
||||||
|
<Section title="Identification" icon={Hash} defaultOpen={true}>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="numero"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Code client"
|
||||||
|
inputType="text"
|
||||||
|
required
|
||||||
|
disabled={isEditing}
|
||||||
|
placeholder="Ex: CLI001"
|
||||||
|
error={errors.numero?.message}
|
||||||
|
className={cn(isEditing && "bg-gray-100 dark:bg-gray-800 cursor-not-allowed")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="compte_collectif"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Compte collectif"
|
||||||
|
required
|
||||||
|
inputType="text"
|
||||||
|
placeholder="411000"
|
||||||
|
error={errors.compte_collectif?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
name="intitule"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Intitulé / Raison sociale"
|
||||||
|
inputType="text"
|
||||||
|
required
|
||||||
|
placeholder="Nom de l'entreprise ou du client"
|
||||||
|
error={errors.intitule?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{est_entreprise && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="forme_juridique"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Forme juridique"
|
||||||
|
as="select"
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "Sélectionner..." },
|
||||||
|
{ value: "SARL", label: "SARL" },
|
||||||
|
{ value: "SAS", label: "SAS" },
|
||||||
|
{ value: "SA", label: "SA" },
|
||||||
|
{ value: "EURL", label: "EURL" },
|
||||||
|
{ value: "EI", label: "EI" },
|
||||||
|
{ value: "SCI", label: "SCI" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="secteur"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Secteur d'activité"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Ex: Commerce"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Controller
|
||||||
|
name="est_prospect"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value || false}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-[#941403] rounded border-gray-300 focus:ring-[#941403]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Prospect</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="est_actif"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value !== false}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-[#941403] rounded border-gray-300 focus:ring-[#941403]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Actif</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Contact principal" icon={User} defaultOpen={true}>
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="civilite"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Civilité"
|
||||||
|
as="select"
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "-" },
|
||||||
|
{ value: "M.", label: "M." },
|
||||||
|
{ value: "Mme", label: "Mme" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="prenom"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Prénom"
|
||||||
|
inputType="text"
|
||||||
|
containerClassName="col-span-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="nom"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Nom"
|
||||||
|
inputType="text"
|
||||||
|
containerClassName="col-span-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
name="contact"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Fonction / Contact"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Ex: Directeur commercial"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="email"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Email"
|
||||||
|
inputType="email"
|
||||||
|
required
|
||||||
|
placeholder="email@exemple.com"
|
||||||
|
error={errors.email?.message}
|
||||||
|
leftIcon={<Mail className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="telephone"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Téléphone"
|
||||||
|
inputType="phone"
|
||||||
|
placeholder="01 23 45 67 89"
|
||||||
|
error={errors.telephone?.message}
|
||||||
|
leftIcon={<Phone className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="portable"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Portable"
|
||||||
|
inputType="phone"
|
||||||
|
placeholder="06 12 34 56 78"
|
||||||
|
error={errors.portable?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="site_web"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Site web"
|
||||||
|
inputType="url"
|
||||||
|
placeholder="https://..."
|
||||||
|
error={errors.site_web?.message}
|
||||||
|
leftIcon={<Globe className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Adresse" icon={MapPin} defaultOpen={true}>
|
||||||
|
<Controller
|
||||||
|
name="adresse"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Adresse"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Numéro et nom de rue"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="complement"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Complément"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Bâtiment, étage..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="code_postal"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Code postal"
|
||||||
|
required
|
||||||
|
inputType="postal_code"
|
||||||
|
error={errors.code_postal?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="ville"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Ville"
|
||||||
|
required
|
||||||
|
inputType="text"
|
||||||
|
containerClassName="col-span-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="region"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Région"
|
||||||
|
inputType="text"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="pays"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Pays"
|
||||||
|
inputType="text"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{est_entreprise && (
|
||||||
|
<Section title="Informations légales" icon={Building2} defaultOpen={false}>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="siret"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="SIRET"
|
||||||
|
inputType="siret"
|
||||||
|
placeholder="14 chiffres"
|
||||||
|
error={errors.siret?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="siren"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="SIREN"
|
||||||
|
inputType="siren"
|
||||||
|
placeholder="9 chiffres"
|
||||||
|
error={errors.siren?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="tva_intra"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="TVA Intracommunautaire"
|
||||||
|
inputType="tva_intra"
|
||||||
|
placeholder="FR12345678901"
|
||||||
|
error={errors.tva_intra?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="code_naf"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Code NAF"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Ex: 6201Z"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="effectif"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || ""}
|
||||||
|
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
|
label="Effectif"
|
||||||
|
as="select"
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "Sélectionner..." },
|
||||||
|
{ value: "1", label: "1-10" },
|
||||||
|
{ value: "10", label: "11-50" },
|
||||||
|
{ value: "50", label: "51-200" },
|
||||||
|
{ value: "200", label: "200+" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="ca_annuel"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || ""}
|
||||||
|
onChange={(e) => field.onChange(e.target.value ? parseFloat(e.target.value) : null)}
|
||||||
|
label="CA annuel (€)"
|
||||||
|
inputType="number"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title="Commercial & Comptabilité" icon={CreditCard} defaultOpen={false}>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="commercial_nom"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Commercial"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Nom du commercial"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="commercial_code"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Code commercial"
|
||||||
|
inputType="text"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="categorie_tarifaire"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || ""}
|
||||||
|
label="Catégorie tarifaire"
|
||||||
|
inputType="text"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="categorie_comptable"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || ""}
|
||||||
|
label="Catégorie comptable"
|
||||||
|
inputType="text"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="compte_general"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Compte général"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="411000"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="encours_autorise"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || ""}
|
||||||
|
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
label="Encours autorisé (€)"
|
||||||
|
inputType="number"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit(onSave)}
|
||||||
|
disabled={!canSave || status === "loading"}
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-2.5 bg-[#00D639] text-white text-sm font-medium rounded-xl hover:bg-[#7a1002] disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-lg shadow-red-900/20"
|
||||||
|
>
|
||||||
|
{status === "loading" ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
{isEditing ? "Mettre à jour" : "Créer le client"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" onClose={() => setError(null)} className="my-2">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
909
src/components/modal/ModalCommande.tsx
Normal file
909
src/components/modal/ModalCommande.tsx
Normal file
|
|
@ -0,0 +1,909 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import FormModal, { FormSection, FormField } from '@/components/ui/FormModal';
|
||||||
|
import { AlertTriangle, Calendar, FileText, Plus, Save, Trash2, Truck } from "lucide-react";
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/store/hooks";
|
||||||
|
import ClientAutocomplete from "../molecules/ClientAutocomplete";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Client } from "@/types/clientType";
|
||||||
|
import ArticleAutocomplete from "../molecules/ArticleAutocomplete";
|
||||||
|
import { Article } from "@/types/articleType";
|
||||||
|
import { Alert, CircularProgress, Divider } from "@mui/material";
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "../ui/use-toast";
|
||||||
|
import { Commande, CommandeRequest, CommandeResponse, LigneCommande } from '@/types/commandeTypes';
|
||||||
|
import { commandeStatus } from '@/store/features/commande/selectors';
|
||||||
|
import { createCommande, getCommande, updateCommande } from '@/store/features/commande/thunk';
|
||||||
|
import { selectcommande } from '@/store/features/commande/slice';
|
||||||
|
import { Input } from '../ui/ui';
|
||||||
|
import { ModalLoading } from './ModalLoading';
|
||||||
|
import { formatForDateInput } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface LigneForm extends LigneCommande {
|
||||||
|
article?: Article | null;
|
||||||
|
remiseValide?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModalCommande({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
editing
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
editing?: Commande | null;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const statusCommande = useAppSelector(commandeStatus);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
// Nouveaux champs
|
||||||
|
const [dateCommande, setDateCommande] = useState(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
// date livraison
|
||||||
|
const [dateLivraison, setDateLivraison] = useState(() => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + 8);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
const [referenceClient, setReferenceClient] = useState('');
|
||||||
|
const [adresseLivraison, setAdresseLivraison] = useState('');
|
||||||
|
|
||||||
|
const [clientSelectionne, setClientSelectionne] = useState<Client | null>(null);
|
||||||
|
const [lignes, setLignes] = useState<LigneForm[]>([
|
||||||
|
{ article_code: '', quantite: 1, remise_pourcentage: 0, article: null, remiseValide: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isEditing = !!editing;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
setClientSelectionne({
|
||||||
|
numero: editing.client_code,
|
||||||
|
intitule: editing.client_intitule,
|
||||||
|
compte_collectif: "",
|
||||||
|
adresse: "",
|
||||||
|
code_postal: "",
|
||||||
|
ville: "",
|
||||||
|
email: "",
|
||||||
|
telephone: "",
|
||||||
|
} as Client);
|
||||||
|
|
||||||
|
const lignesInitiales: LigneForm[] = editing.lignes!.map(ligne => ({
|
||||||
|
article_code: ligne.article_code,
|
||||||
|
quantite: ligne.quantite,
|
||||||
|
prix_unitaire_ht: ligne.prix_unitaire_ht,
|
||||||
|
remise_pourcentage: 0,
|
||||||
|
article: {
|
||||||
|
reference: ligne.article_code,
|
||||||
|
designation: ligne.designation,
|
||||||
|
prix_vente: ligne.prix_unitaire_ht,
|
||||||
|
} as Article,
|
||||||
|
remiseValide: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setLignes(lignesInitiales.length > 0 ? lignesInitiales : [
|
||||||
|
{ article_code: '', quantite: 1, remise_pourcentage: 0, article: null, remiseValide: true }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (editing.date) setDateCommande(editing.date);
|
||||||
|
if(editing.date_livraison) setDateLivraison(editing.date_livraison);
|
||||||
|
setReferenceClient(editing.reference || '');
|
||||||
|
} else {
|
||||||
|
setClientSelectionne(null);
|
||||||
|
setLignes([
|
||||||
|
{ article_code: '', quantite: 1, remise_pourcentage: 0, article: null, remiseValide: true }
|
||||||
|
]);
|
||||||
|
setDateCommande(new Date().toISOString().split('T')[0]);
|
||||||
|
setDateLivraison(() => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + 8);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
});
|
||||||
|
setReferenceClient('');
|
||||||
|
setAdresseLivraison('');
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
}, [open, editing]);
|
||||||
|
|
||||||
|
const appliquerBareme = async (index: number, articleRef: string) => {
|
||||||
|
if (!clientSelectionne) return;
|
||||||
|
try {
|
||||||
|
// Logique d'application du barème
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur application barème:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ajouterLigne = () => {
|
||||||
|
setLignes([
|
||||||
|
...lignes,
|
||||||
|
{ article_code: '', quantite: 1, remise_pourcentage: 0, article: null, remiseValide: true },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const supprimerLigne = (index: number) => {
|
||||||
|
if (lignes.length > 1) {
|
||||||
|
setLignes(lignes.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLigne = (index: number, field: keyof LigneForm, value: any) => {
|
||||||
|
const nouvelles = [...lignes];
|
||||||
|
|
||||||
|
if (field === 'article' && value) {
|
||||||
|
nouvelles[index].article = value;
|
||||||
|
nouvelles[index].article_code = value.reference;
|
||||||
|
nouvelles[index].prix_unitaire_ht = value.prix_vente;
|
||||||
|
|
||||||
|
if (clientSelectionne) {
|
||||||
|
appliquerBareme(index, value.reference);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(nouvelles[index] as any)[field] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLignes(nouvelles);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculerTotalLigne = (ligne: LigneForm) => {
|
||||||
|
if (!ligne.article) return 0;
|
||||||
|
const prix = ligne.prix_unitaire_ht || ligne.article.prix_vente;
|
||||||
|
const montantBrut = prix * ligne.quantite;
|
||||||
|
const remise = montantBrut * ((ligne.remise_pourcentage || 0) / 100);
|
||||||
|
return montantBrut - remise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculerTotal = () => {
|
||||||
|
return lignes.reduce((total, ligne) => total + calculerTotalLigne(ligne), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toutesRemisesValides = () => {
|
||||||
|
return lignes.every((ligne) => ligne.remiseValide !== false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSave = useMemo(() => {
|
||||||
|
if (!clientSelectionne) return false;
|
||||||
|
const lignesValides = lignes.filter((l) => l.article_code);
|
||||||
|
if (lignesValides.length === 0) return false;
|
||||||
|
if (!toutesRemisesValides()) return false;
|
||||||
|
return true;
|
||||||
|
}, [clientSelectionne, lignes]);
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
if (!clientSelectionne) {
|
||||||
|
setError('Veuillez sélectionner un client');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lignesValides = lignes.filter((l) => l.article_code);
|
||||||
|
|
||||||
|
if (lignesValides.length === 0) {
|
||||||
|
setError('Veuillez ajouter au moins un article');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!toutesRemisesValides()) {
|
||||||
|
setError('Certaines remises ne sont pas autorisées. Veuillez les corriger.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
const payloadUpdate = {
|
||||||
|
client_id: clientSelectionne.numero,
|
||||||
|
reference: referenceClient,
|
||||||
|
date_livraison: (() => {
|
||||||
|
const d = new Date(dateLivraison);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
})(),
|
||||||
|
date_commande: (() => {
|
||||||
|
const d = new Date(dateCommande);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
})(),
|
||||||
|
lignes: lignesValides.map((l) => ({
|
||||||
|
article_code: l.article_code,
|
||||||
|
quantite: l.quantite
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await dispatch(updateCommande({
|
||||||
|
numero: editing!.numero,
|
||||||
|
data: payloadUpdate
|
||||||
|
})).unwrap() as any;
|
||||||
|
|
||||||
|
const numero = result.commande.numero as any;
|
||||||
|
|
||||||
|
setSuccess(true);
|
||||||
|
toast({
|
||||||
|
title: "Commande mise à jour !",
|
||||||
|
description: `La commande a été mise à jour avec succès.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600"
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
const itemCreated = await dispatch(getCommande(numero)).unwrap() as any;
|
||||||
|
const res = itemCreated as Commande;
|
||||||
|
dispatch(selectcommande(res));
|
||||||
|
navigate(`/home/commandes/${numero}`);
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const payloadCreate: CommandeRequest = {
|
||||||
|
client_id: clientSelectionne.numero,
|
||||||
|
reference: referenceClient,
|
||||||
|
date_livraison: (() => {
|
||||||
|
const d = new Date(dateLivraison);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
})(),
|
||||||
|
date_commande: (() => {
|
||||||
|
const d = new Date(dateCommande);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
})(),
|
||||||
|
lignes: lignesValides.map((l) => ({
|
||||||
|
article_code: l.article_code,
|
||||||
|
quantite: l.quantite,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("payloadCreate: ",payloadCreate);
|
||||||
|
|
||||||
|
const result = await dispatch(createCommande(payloadCreate)).unwrap() as CommandeResponse;
|
||||||
|
|
||||||
|
const data = result.data;
|
||||||
|
|
||||||
|
setSuccess(true);
|
||||||
|
toast({
|
||||||
|
title: "Commande créée !",
|
||||||
|
description: `Une nouvelle commande ${data.numero_commande} a été créée avec succès.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600"
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
const itemCreated = await dispatch(getCommande(data.numero_commande)).unwrap() as any;
|
||||||
|
const res = itemCreated as Commande;
|
||||||
|
dispatch(selectcommande(res));
|
||||||
|
navigate(`/home/commandes/${data.numero_commande}`);
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError("Cet article n'est pas disponible pour ce client.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalHT = calculerTotal();
|
||||||
|
const totalTVA = totalHT * 0.20;
|
||||||
|
const totalTTC = totalHT + totalTVA;
|
||||||
|
const remisesNonAutorisees = lignes.filter((l) => l.remiseValide === false).length;
|
||||||
|
const lignesValides = lignes.filter((l) => l.article_code).length;
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title || (isEditing ? "Modifier la commande" : "Créer une commande")}
|
||||||
|
size="xl"
|
||||||
|
onSubmit={onSave}
|
||||||
|
loading={statusCommande === "loading" ? true : false}
|
||||||
|
>
|
||||||
|
{/* Section Client */}
|
||||||
|
<FormSection title="Informations client" description="Sélectionnez le client et les coordonnées">
|
||||||
|
<FormField label="Client" required fullWidth>
|
||||||
|
<ClientAutocomplete
|
||||||
|
value={clientSelectionne}
|
||||||
|
onChange={setClientSelectionne}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="col-span-1 md:col-span-2 grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
|
||||||
|
<FormField label="Date de commande" required>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formatForDateInput(dateCommande)}
|
||||||
|
onChange={(e) => setDateCommande(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Livraison prévue" required>
|
||||||
|
<Input type="date" value={formatForDateInput(dateLivraison)} onChange={(e) => setDateLivraison(e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Référence client">
|
||||||
|
<Input
|
||||||
|
placeholder="Ex: REF-PROJET-123"
|
||||||
|
value={referenceClient}
|
||||||
|
onChange={(e) => setReferenceClient(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Adresse de livraison">
|
||||||
|
<Input
|
||||||
|
placeholder="Adresse par défaut du client"
|
||||||
|
value={adresseLivraison}
|
||||||
|
onChange={(e) => setAdresseLivraison(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Section Lignes */}
|
||||||
|
<FormSection title="Lignes de la commande" description="Ajoutez les produits et services commandés">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full mb-4">
|
||||||
|
<thead className="text-left bg-gray-50 dark:bg-gray-900/50 text-xs uppercase text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 rounded-l-lg" style={{ width: '55%' }}>Article / Description</th>
|
||||||
|
<th className="px-2 py-3 text-center" style={{ width: '80px' }}>Qté</th>
|
||||||
|
<th className="px-2 py-3 text-right" style={{ width: '100px' }}>P.U. HT</th>
|
||||||
|
<th className="px-2 py-3 text-right rounded-r-lg" style={{ width: '100px' }}>Total HT</th>
|
||||||
|
<th style={{ width: '40px' }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{lignes.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 align-top">
|
||||||
|
<ArticleAutocomplete
|
||||||
|
value={ligne.article!}
|
||||||
|
onChange={(article) => updateLigne(index, 'article', article)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-2 py-2 align-top">
|
||||||
|
<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-[#941403] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-2 py-2 align-top">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ligne.prix_unitaire_ht || ligne.article?.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-[#941403] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-2 py-3 text-right align-middle">
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{calculerTotalLigne(ligne).toFixed(2)} €
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-1 py-3 text-center align-middle">
|
||||||
|
<button
|
||||||
|
onClick={() => supprimerLigne(index)}
|
||||||
|
disabled={lignes.length === 1}
|
||||||
|
className="p-2 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 opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-start mt-4">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Récapitulatif */}
|
||||||
|
<div style={{width: "42vh"}} className="w-72 bg-orange-50 dark:bg-orange-900/20 rounded-xl p-4 space-y-3 border border-orange-100 dark:border-orange-800">
|
||||||
|
|
||||||
|
|
||||||
|
<Divider className="!my-3" />
|
||||||
|
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Total HT</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">{totalHT.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Taux TVA </span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">20%</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-3 border-t border-orange-200 dark:border-orange-700 flex justify-between">
|
||||||
|
<span className="font-bold text-gray-900 dark:text-white">Total TTC</span>
|
||||||
|
<span className="font-bold text-[#007E45] text-lg">{totalTTC.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Alertes */}
|
||||||
|
{remisesNonAutorisees > 0 && (
|
||||||
|
<Alert severity="warning" icon={<AlertTriangle size={16} />} className="mb-4">
|
||||||
|
{remisesNonAutorisees} remise(s) non autorisée(s) à corriger
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" className="mb-4">{error}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
loading && (
|
||||||
|
<ModalLoading/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// /* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
// /* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
// import FormModal, { FormSection, FormField, Input, Select, Textarea } from '@/components/ui/FormModal';
|
||||||
|
// import { AlertTriangle, Plus, Save, Trash2 } from "lucide-react";
|
||||||
|
// import { useAppDispatch, useAppSelector } from "@/store/hooks";
|
||||||
|
// import ClientAutocomplete from "../molecules/ClientAutocomplete";
|
||||||
|
// import { useEffect, useMemo, useState } from "react";
|
||||||
|
// import { Client } from "@/types/clientType";
|
||||||
|
// import ArticleAutocomplete from "../molecules/ArticleAutocomplete";
|
||||||
|
// import { Article } from "@/types/articleType";
|
||||||
|
// import { Alert, CircularProgress, Divider, IconButton, TextField, Tooltip } from "@mui/material";
|
||||||
|
// import Button from '@mui/material/Button';
|
||||||
|
// import { useNavigate } from "react-router-dom";
|
||||||
|
// import { toast } from "../ui/use-toast";
|
||||||
|
// import { Commande, CommandeRequest, CommandeResponse, LigneCommande } from '@/types/commandeTypes';
|
||||||
|
// import { commandeStatus } from '@/store/features/commande/selectors';
|
||||||
|
// import { createCommande, getCommande, updateCommande } from '@/store/features/commande/thunk';
|
||||||
|
// import { selectcommande } from '@/store/features/commande/slice';
|
||||||
|
|
||||||
|
// interface LigneForm extends LigneCommande {
|
||||||
|
// article?: Article | null;
|
||||||
|
// remiseValide?: boolean;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export function ModalCommande({
|
||||||
|
// open,
|
||||||
|
// onClose,
|
||||||
|
// title,
|
||||||
|
// editing
|
||||||
|
// }: {
|
||||||
|
// open: boolean;
|
||||||
|
// onClose: () => void;
|
||||||
|
// title?: string;
|
||||||
|
// editing?: Commande | null;
|
||||||
|
// }) {
|
||||||
|
// const navigate = useNavigate();
|
||||||
|
// const dispatch = useAppDispatch();
|
||||||
|
// const statusCommande = useAppSelector(commandeStatus);
|
||||||
|
|
||||||
|
// const [loading, setLoading] = useState(false);
|
||||||
|
// const [error, setError] = useState<string | null>(null);
|
||||||
|
// const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
// const [clientSelectionne, setClientSelectionne] = useState<Client | null>(null);
|
||||||
|
// const [lignes, setLignes] = useState<LigneForm[]>([
|
||||||
|
// { article_code: '', quantite: 1, remise_pourcentage: 0, article: null, remiseValide: true },
|
||||||
|
// ]);
|
||||||
|
|
||||||
|
// const isEditing = !!editing;
|
||||||
|
|
||||||
|
|
||||||
|
// // Initialiser le formulaire avec les données d'édition
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (!open) return;
|
||||||
|
|
||||||
|
// if (editing) {
|
||||||
|
// // Charger les données du devis à modifier
|
||||||
|
// setClientSelectionne({
|
||||||
|
// numero: editing.client_code,
|
||||||
|
// intitule: editing.client_intitule,
|
||||||
|
// compte_collectif: "",
|
||||||
|
// adresse: "",
|
||||||
|
// code_postal: "",
|
||||||
|
// ville: "",
|
||||||
|
// email: "",
|
||||||
|
// telephone: "",
|
||||||
|
// } as Client);
|
||||||
|
// // Charger les lignes du devis
|
||||||
|
// const lignesInitiales: LigneForm[] = editing.lignes!.map(ligne => ({
|
||||||
|
// article_code: ligne.article,
|
||||||
|
// quantite: ligne.quantite,
|
||||||
|
// prix_unitaire_ht: ligne.prix_unitaire,
|
||||||
|
// remise_pourcentage: 0,
|
||||||
|
// article: {
|
||||||
|
// reference: ligne.article,
|
||||||
|
// designation: ligne.designation,
|
||||||
|
// prix_vente: ligne.prix_unitaire,
|
||||||
|
// } as Article,
|
||||||
|
// remiseValide: true,
|
||||||
|
// }));
|
||||||
|
|
||||||
|
// setLignes(lignesInitiales.length > 0 ? lignesInitiales : [
|
||||||
|
// { article_code: '', quantite: 1, remise_pourcentage: 0, article: null, remiseValide: true }
|
||||||
|
// ]);
|
||||||
|
// } else {
|
||||||
|
// // Réinitialiser pour création
|
||||||
|
// setClientSelectionne(null);
|
||||||
|
// setLignes([
|
||||||
|
// { article_code: '', quantite: 1, remise_pourcentage: 0, article: null, remiseValide: true }
|
||||||
|
// ]);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// setError(null);
|
||||||
|
// setSuccess(false);
|
||||||
|
// }, [open, editing]);
|
||||||
|
|
||||||
|
// const appliquerBareme = async (index: number, articleRef: string) => {
|
||||||
|
// if (!clientSelectionne) return;
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// // Logique d'application du barème
|
||||||
|
// } catch (err) {
|
||||||
|
// console.error('Erreur application barème:', err);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const ajouterLigne = () => {
|
||||||
|
// setLignes([
|
||||||
|
// ...lignes,
|
||||||
|
// { article_code: '', quantite: 1, remise_pourcentage: 0, article: null, remiseValide: true },
|
||||||
|
// ]);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const supprimerLigne = (index: number) => {
|
||||||
|
// if (lignes.length > 1) {
|
||||||
|
// setLignes(lignes.filter((_, i) => i !== index));
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const updateLigne = (index: number, field: keyof LigneForm, value: any) => {
|
||||||
|
// const nouvelles = [...lignes];
|
||||||
|
|
||||||
|
// if (field === 'article' && value) {
|
||||||
|
// nouvelles[index].article = value;
|
||||||
|
// nouvelles[index].article_code = value.reference;
|
||||||
|
// nouvelles[index].prix_unitaire_ht = value.prix_vente;
|
||||||
|
|
||||||
|
// if (clientSelectionne) {
|
||||||
|
// appliquerBareme(index, value.reference);
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// (nouvelles[index] as any)[field] = value;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// setLignes(nouvelles);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const calculerTotal = () => {
|
||||||
|
// return lignes.reduce((total, ligne) => {
|
||||||
|
// if (!ligne.article) return total;
|
||||||
|
// const prix = ligne.prix_unitaire_ht || ligne.article.prix_vente;
|
||||||
|
// const montantBrut = prix * ligne.quantite;
|
||||||
|
// const remise = montantBrut * ((ligne.remise_pourcentage || 0) / 100);
|
||||||
|
|
||||||
|
// return total + (montantBrut - remise);
|
||||||
|
// }, 0);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const toutesRemisesValides = () => {
|
||||||
|
// return lignes.every((ligne) => ligne.remiseValide !== false);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const canSave = useMemo(() => {
|
||||||
|
// if (!clientSelectionne) return false;
|
||||||
|
// const lignesValides = lignes.filter((l) => l.article_code);
|
||||||
|
// if (lignesValides.length === 0) return false;
|
||||||
|
// if (!toutesRemisesValides()) return false;
|
||||||
|
// return true;
|
||||||
|
// }, [clientSelectionne, lignes]);
|
||||||
|
|
||||||
|
// const onSave = async () => {
|
||||||
|
// if (!clientSelectionne) {
|
||||||
|
// setError('Veuillez sélectionner un client');
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const lignesValides = lignes.filter((l) => l.article_code);
|
||||||
|
|
||||||
|
// if (lignesValides.length === 0) {
|
||||||
|
// setError('Veuillez ajouter au moins un article');
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (!toutesRemisesValides()) {
|
||||||
|
// setError('Certaines remises ne sont pas autorisées. Veuillez les corriger.');
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// setLoading(true);
|
||||||
|
// setError(null);
|
||||||
|
|
||||||
|
// if (isEditing) {
|
||||||
|
// // ✅ MODE MODIFICATION
|
||||||
|
// const payloadUpdate = {
|
||||||
|
// client_id: clientSelectionne.numero,
|
||||||
|
// date_commande: (() => {
|
||||||
|
// const d = new Date();
|
||||||
|
// d.setDate(d.getDate() + 1);
|
||||||
|
// return d.toISOString().split("T")[0];
|
||||||
|
// })(),
|
||||||
|
// lignes: lignesValides.map((l) => ({
|
||||||
|
// article_code: l.article_code,
|
||||||
|
// quantite: l.quantite
|
||||||
|
// })),
|
||||||
|
// };
|
||||||
|
|
||||||
|
|
||||||
|
// const result = await dispatch(updateCommande({
|
||||||
|
// numero: editing!.numero,
|
||||||
|
// data: payloadUpdate
|
||||||
|
// })).unwrap() as any;
|
||||||
|
|
||||||
|
// console.log("result : ",result.commande);
|
||||||
|
|
||||||
|
|
||||||
|
// const numero = result.commande.numero as any
|
||||||
|
|
||||||
|
// setSuccess(true);
|
||||||
|
// toast({
|
||||||
|
// title: "Commande mis à jour !",
|
||||||
|
// description: `Le devis a été mis à jour avec succès.`,
|
||||||
|
// className: "bg-green-500 text-white border-green-600"
|
||||||
|
// });
|
||||||
|
|
||||||
|
// setTimeout(async () => {
|
||||||
|
// const itemCreated = await dispatch(getCommande(numero)).unwrap() as any
|
||||||
|
// const res = itemCreated as Commande
|
||||||
|
// dispatch(selectcommande(res));
|
||||||
|
// navigate(`/home/commandes/${numero}`);
|
||||||
|
// }, 1500);
|
||||||
|
|
||||||
|
// } else {
|
||||||
|
// // ✅ MODE CRÉATION
|
||||||
|
// const payloadCreate: CommandeRequest = {
|
||||||
|
// client_id: clientSelectionne.numero,
|
||||||
|
// date_commande: (() => {
|
||||||
|
// const d = new Date();
|
||||||
|
// d.setDate(d.getDate() + 1);
|
||||||
|
// return d.toISOString().split("T")[0];
|
||||||
|
// })(),
|
||||||
|
// lignes: lignesValides.map((l) => ({
|
||||||
|
// article_code: l.article_code,
|
||||||
|
// quantite: l.quantite,
|
||||||
|
// })),
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const result = await dispatch(createCommande(payloadCreate)).unwrap() as CommandeResponse
|
||||||
|
|
||||||
|
// const data = result.data
|
||||||
|
|
||||||
|
// setSuccess(true);
|
||||||
|
// toast({
|
||||||
|
// title: "Commande créé !",
|
||||||
|
// description: `Un nouveau commande ${data.numero_commande} a été créé avec succès.`,
|
||||||
|
// className: "bg-green-500 text-white border-green-600"
|
||||||
|
// });
|
||||||
|
|
||||||
|
// setTimeout(async () => {
|
||||||
|
// const itemCreated = await dispatch(getCommande(data.numero_commande)).unwrap() as any
|
||||||
|
// const res = itemCreated as Commande
|
||||||
|
// dispatch(selectcommande(res));
|
||||||
|
// navigate(`/home/commandes/${data.numero_commande}`);
|
||||||
|
// }, 1500);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// onClose();
|
||||||
|
// } catch (err: any) {
|
||||||
|
// setError("Cet article n’est pas disponible pour ce client.");
|
||||||
|
// } finally {
|
||||||
|
// setLoading(false);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const totalHT = calculerTotal();
|
||||||
|
// const totalTTC = totalHT * 1.2;
|
||||||
|
// const remisesNonAutorisees = lignes.filter((l) => l.remiseValide === false).length;
|
||||||
|
|
||||||
|
// if (!open) return null;
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <FormModal
|
||||||
|
// isOpen={open}
|
||||||
|
// onClose={onClose}
|
||||||
|
// title={title || (isEditing ? "Modifier le commande" : "Créer un commande")}
|
||||||
|
// size="xl"
|
||||||
|
// >
|
||||||
|
// <FormSection title="Informations client" description="Sélectionnez le client et les coordonnées">
|
||||||
|
// <FormField label="Client" required fullWidth>
|
||||||
|
// <ClientAutocomplete
|
||||||
|
// value={clientSelectionne}
|
||||||
|
// onChange={setClientSelectionne}
|
||||||
|
// required
|
||||||
|
// />
|
||||||
|
// </FormField>
|
||||||
|
// </FormSection>
|
||||||
|
|
||||||
|
// <FormSection title="Lignes du devis" description="Ajoutez les produits et services">
|
||||||
|
// <div className="col-span-2">
|
||||||
|
// <table className="w-full mb-4">
|
||||||
|
// <tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
// {lignes.map((ligne, index) => (
|
||||||
|
// <tr key={index}>
|
||||||
|
// <td className="py-2 pr-2">
|
||||||
|
// <ArticleAutocomplete
|
||||||
|
// value={ligne.article}
|
||||||
|
// onChange={(article) => updateLigne(index, 'article', article)}
|
||||||
|
// required
|
||||||
|
// />
|
||||||
|
// </td>
|
||||||
|
|
||||||
|
// <td className="py-2 px-1">
|
||||||
|
// <TextField
|
||||||
|
// type="number"
|
||||||
|
// label="Quantité"
|
||||||
|
// value={ligne.quantite}
|
||||||
|
// onChange={(e) =>
|
||||||
|
// updateLigne(index, 'quantite', parseFloat(e.target.value) || 0)
|
||||||
|
// }
|
||||||
|
// inputProps={{ min: 0, step: 1 }}
|
||||||
|
// fullWidth
|
||||||
|
// size="small"
|
||||||
|
// />
|
||||||
|
// </td>
|
||||||
|
|
||||||
|
// <td className="py-2 px-1">
|
||||||
|
// <TextField
|
||||||
|
// type="number"
|
||||||
|
// label="Prix Unit. HT"
|
||||||
|
// value={ligne.prix_unitaire_ht || ligne.article?.prix_vente || 0}
|
||||||
|
// onChange={(e) =>
|
||||||
|
// updateLigne(index, 'prix_unitaire_ht', parseFloat(e.target.value) || 0)
|
||||||
|
// }
|
||||||
|
// inputProps={{ min: 0, step: 0.01 }}
|
||||||
|
// fullWidth
|
||||||
|
// size="small"
|
||||||
|
// />
|
||||||
|
// </td>
|
||||||
|
// <td className="py-2 pl-2">
|
||||||
|
// <IconButton
|
||||||
|
// onClick={() => supprimerLigne(index)}
|
||||||
|
// color="error"
|
||||||
|
// disabled={lignes.length === 1}
|
||||||
|
// size="small"
|
||||||
|
// >
|
||||||
|
// <Trash2 size={18} />
|
||||||
|
// </IconButton>
|
||||||
|
// </td>
|
||||||
|
// </tr>
|
||||||
|
// ))}
|
||||||
|
// </tbody>
|
||||||
|
// </table>
|
||||||
|
// <Button
|
||||||
|
// startIcon={<Plus size={12} />}
|
||||||
|
// onClick={ajouterLigne}
|
||||||
|
// size="small"
|
||||||
|
// sx={{
|
||||||
|
// textTransform: 'capitalize',
|
||||||
|
// color: '#C94635',
|
||||||
|
// fontSize: 12
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// Ajouter une ligne
|
||||||
|
// </Button>
|
||||||
|
// </div>
|
||||||
|
// </FormSection>
|
||||||
|
|
||||||
|
// <FormSection title="Récapitulatif" description="Résumé du devis">
|
||||||
|
// <div className="col-span-2">
|
||||||
|
// <div className="flex flex-col gap-3">
|
||||||
|
// <div className="flex justify-between text-sm">
|
||||||
|
// <span className="text-gray-600">Client :</span>
|
||||||
|
// <span className="font-medium">
|
||||||
|
// {clientSelectionne?.intitule || '-'}
|
||||||
|
// </span>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// <div className="flex justify-between text-sm">
|
||||||
|
// <span className="text-gray-600">Nombre de lignes :</span>
|
||||||
|
// <span className="font-medium">
|
||||||
|
// {lignes.filter((l) => l.article_code).length}
|
||||||
|
// </span>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// {remisesNonAutorisees > 0 && (
|
||||||
|
// <Alert severity="warning" icon={<AlertTriangle size={16} />}>
|
||||||
|
// {remisesNonAutorisees} remise(s) à corriger
|
||||||
|
// </Alert>
|
||||||
|
// )}
|
||||||
|
|
||||||
|
// {error && (
|
||||||
|
// <Alert severity="error">{error}</Alert>
|
||||||
|
// )}
|
||||||
|
|
||||||
|
// <Divider />
|
||||||
|
|
||||||
|
// <div className="flex justify-between">
|
||||||
|
// <span className="font-medium">Total HT :</span>
|
||||||
|
// <span className="font-bold">
|
||||||
|
// {totalHT.toFixed(2)} €
|
||||||
|
// </span>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// <div className="flex justify-between text-sm">
|
||||||
|
// <span className="text-gray-600">TVA (20%) :</span>
|
||||||
|
// <span>{(totalTTC - totalHT).toFixed(2)} €</span>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// <div className="flex justify-between">
|
||||||
|
// <span className="font-medium">Total TTC :</span>
|
||||||
|
// <span className="font-bold text-green-600">
|
||||||
|
// {totalTTC.toFixed(2)} €
|
||||||
|
// </span>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// </FormSection>
|
||||||
|
|
||||||
|
// <div className="flex flex-row items-end justify-end">
|
||||||
|
// <Button
|
||||||
|
// variant="contained"
|
||||||
|
// startIcon={statusCommande === "loading" ? <CircularProgress size={18} /> : <Save size={18} />}
|
||||||
|
// onClick={onSave}
|
||||||
|
// disabled={!canSave || statusCommande === "loading"}
|
||||||
|
// sx={{
|
||||||
|
// textTransform: 'capitalize',
|
||||||
|
// backgroundColor: '#941403',
|
||||||
|
// color: '#fff',
|
||||||
|
// '&:hover': {
|
||||||
|
// backgroundColor: '#941403',
|
||||||
|
// }
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// {statusCommande === "loading"
|
||||||
|
// ? (isEditing ? "Enregistrement..." : "Création...")
|
||||||
|
// : (isEditing ? "Enregistrer" : "Créer")}
|
||||||
|
// </Button>
|
||||||
|
// </div>
|
||||||
|
// </FormModal>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
119
src/components/modal/ModalCommandetoFacture.tsx
Normal file
119
src/components/modal/ModalCommandetoFacture.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/store/hooks";
|
||||||
|
import FormModal, { FormField, Input } from "../ui/FormModal";
|
||||||
|
import { FileText, Hash } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Commande } from "@/types/commandeTypes";
|
||||||
|
import { getcommandeSelected } from "@/store/features/commande/selectors";
|
||||||
|
import { commandeToFacture } from "@/store/features/commande/thunk";
|
||||||
|
import { toast } from "../ui/use-toast";
|
||||||
|
import { Section } from "../ui/Section";
|
||||||
|
import { formatForDateInput } from "@/lib/utils";
|
||||||
|
import { ModalLoading } from "./ModalLoading";
|
||||||
|
import { getFacture } from "@/store/features/factures/thunk";
|
||||||
|
import { selectfacture } from "@/store/features/factures/slice";
|
||||||
|
|
||||||
|
|
||||||
|
export function ModalCommandetoFacture({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
||||||
|
const [dateEmission, setDateEmission] = useState(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + 29);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const commande = useAppSelector(getcommandeSelected) as Commande;
|
||||||
|
const [loadingTransform, setLoadingTransform] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCreateFacture = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingTransform(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await dispatch(commandeToFacture(commande!.numero)).unwrap();
|
||||||
|
const res = await dispatch(getFacture(response.document_cible)).unwrap();
|
||||||
|
dispatch(selectfacture(res));
|
||||||
|
setLoadingTransform(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Facture générée avec succès!',
|
||||||
|
description: `La commande ${commande.numero} a été transformée en facture.`,
|
||||||
|
className: 'bg-green-500 text-white border-green-600',
|
||||||
|
});
|
||||||
|
setTimeout(() => navigate(`/home/factures/${res.numero}`), 1000);
|
||||||
|
} catch (err: any) {
|
||||||
|
onClose();
|
||||||
|
setLoadingTransform(false);
|
||||||
|
setError(err.message);
|
||||||
|
toast({
|
||||||
|
title: 'Impossible de transformer la commande',
|
||||||
|
description: `La commande ${commande.numero} ne peut pas être transformée en facture.`,
|
||||||
|
className: 'bg-red-500 text-white border-red-600',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoadingTransform(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Générer la Facture"
|
||||||
|
onSubmit={handleCreateFacture}
|
||||||
|
>
|
||||||
|
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200 rounded-xl text-sm flex gap-3 border border-blue-100 dark:border-blue-800">
|
||||||
|
<FileText className="w-5 h-5 shrink-0" />
|
||||||
|
<p className="text-sm">
|
||||||
|
Vous allez générer une facture client basée sur la commande <strong>{commande.numero}</strong>.
|
||||||
|
<br />
|
||||||
|
La commande passera au statut <strong>Transformée en facture.</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 mb-6 grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase font-semibold">Montant HT</p>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white text-sm">{commande.total_ht.toFixed(2)} €</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase font-semibold">Montant TTC</p>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white text-sm">{commande.total_ttc.toFixed(2) || '-'} €</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Section title="Paramètres de facturation" icon={Hash} defaultOpen={true}>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField label="Date de la facture" required>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formatForDateInput(commande.date)}
|
||||||
|
onChange={e => setDateEmission(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Date d'échéance" required>
|
||||||
|
<Input type="date" value={dateEmission} onChange={e => setDateEmission(e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{(loadingTransform) && <ModalLoading />}
|
||||||
|
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModalCommandetoFacture;
|
||||||
700
src/components/modal/ModalCommercial.tsx
Normal file
700
src/components/modal/ModalCommercial.tsx
Normal file
|
|
@ -0,0 +1,700 @@
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Save,
|
||||||
|
User,
|
||||||
|
MapPin,
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
Hash,
|
||||||
|
Loader2,
|
||||||
|
Briefcase,
|
||||||
|
Globe,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Progress } from "@/components/ui/Progress";
|
||||||
|
import { Section } from "@/components/ui/Section";
|
||||||
|
import { InputField } from "@/components/ui/InputValidator";
|
||||||
|
import { toast } from "@/components/ui/use-toast";
|
||||||
|
import z from "zod";
|
||||||
|
import { useForm, Controller, type SubmitHandler } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Commercial } from "@/types/commercialType";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SCHÉMA DE VALIDATION
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const commercialSchema = z.object({
|
||||||
|
numero: z.number().optional(),
|
||||||
|
nom: z.string().min(1, "Le nom est requis"),
|
||||||
|
prenom: z.string().min(1, "Le prénom est requis"),
|
||||||
|
fonction: z.string().optional().or(z.literal("")),
|
||||||
|
service: z.string().optional().or(z.literal("")),
|
||||||
|
matricule: z.string().nullable().optional(),
|
||||||
|
|
||||||
|
// Contact
|
||||||
|
email: z.string().email("Email invalide").optional().or(z.literal("")),
|
||||||
|
telephone: z.string().optional().or(z.literal("")),
|
||||||
|
tel_portable: z.string().nullable().optional(),
|
||||||
|
telecopie: z.string().nullable().optional(),
|
||||||
|
|
||||||
|
// Adresse
|
||||||
|
adresse: z.string().optional().or(z.literal("")),
|
||||||
|
complement: z.string().nullable().optional(),
|
||||||
|
code_postal: z.string().optional().or(z.literal("")),
|
||||||
|
ville: z.string().optional().or(z.literal("")),
|
||||||
|
region: z.string().nullable().optional(),
|
||||||
|
pays: z.string().nullable().optional(),
|
||||||
|
|
||||||
|
// Réseaux sociaux
|
||||||
|
facebook: z.string().nullable().optional(),
|
||||||
|
linkedin: z.string().nullable().optional(),
|
||||||
|
skype: z.string().nullable().optional(),
|
||||||
|
|
||||||
|
// Rôles
|
||||||
|
est_vendeur: z.boolean().default(true),
|
||||||
|
est_acheteur: z.boolean().default(false),
|
||||||
|
est_caissier: z.boolean().default(false),
|
||||||
|
est_chef_ventes: z.boolean().default(false),
|
||||||
|
chef_ventes_numero: z.number().optional(),
|
||||||
|
|
||||||
|
// Statut
|
||||||
|
est_actif: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
type CommercialFormData = z.infer<typeof commercialSchema>;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface ModalCommercialProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
editing?: Commercial | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PRINCIPAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export function ModalCommercial({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
editing,
|
||||||
|
}: ModalCommercialProps) {
|
||||||
|
const [completion, setCompletion] = useState(0);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const isEditing = !!editing;
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<CommercialFormData>({
|
||||||
|
resolver: zodResolver(commercialSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: {
|
||||||
|
numero: undefined,
|
||||||
|
nom: "",
|
||||||
|
prenom: "",
|
||||||
|
fonction: "",
|
||||||
|
service: "",
|
||||||
|
matricule: "",
|
||||||
|
email: "",
|
||||||
|
telephone: "",
|
||||||
|
tel_portable: "",
|
||||||
|
telecopie: "",
|
||||||
|
adresse: "",
|
||||||
|
complement: "",
|
||||||
|
code_postal: "",
|
||||||
|
ville: "",
|
||||||
|
region: "",
|
||||||
|
pays: "France",
|
||||||
|
facebook: "",
|
||||||
|
linkedin: "",
|
||||||
|
skype: "",
|
||||||
|
est_vendeur: true,
|
||||||
|
est_acheteur: false,
|
||||||
|
est_caissier: false,
|
||||||
|
est_chef_ventes: false,
|
||||||
|
chef_ventes_numero: undefined,
|
||||||
|
est_actif: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formValues = watch();
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
reset({
|
||||||
|
numero: editing.numero,
|
||||||
|
nom: editing.nom || "",
|
||||||
|
prenom: editing.prenom || "",
|
||||||
|
fonction: editing.fonction || "",
|
||||||
|
service: editing.service || "",
|
||||||
|
matricule: editing.matricule || "",
|
||||||
|
email: editing.email || "",
|
||||||
|
telephone: editing.telephone || "",
|
||||||
|
tel_portable: editing.tel_portable || "",
|
||||||
|
telecopie: editing.telecopie || "",
|
||||||
|
adresse: editing.adresse || "",
|
||||||
|
complement: editing.complement || "",
|
||||||
|
code_postal: editing.code_postal || "",
|
||||||
|
ville: editing.ville || "",
|
||||||
|
region: editing.region || "",
|
||||||
|
pays: editing.pays || "France",
|
||||||
|
facebook: editing.facebook || "",
|
||||||
|
linkedin: editing.linkedin || "",
|
||||||
|
skype: editing.skype || "",
|
||||||
|
est_vendeur: editing.vendeur ?? true,
|
||||||
|
est_acheteur: editing.acheteur ?? false,
|
||||||
|
est_caissier: editing.caissier ?? false,
|
||||||
|
est_chef_ventes: editing.chef_ventes ?? false,
|
||||||
|
chef_ventes_numero: editing.chef_ventes_numero,
|
||||||
|
est_actif: editing.est_actif ?? true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reset({
|
||||||
|
numero: undefined,
|
||||||
|
nom: "",
|
||||||
|
prenom: "",
|
||||||
|
fonction: "",
|
||||||
|
service: "",
|
||||||
|
matricule: "",
|
||||||
|
email: "",
|
||||||
|
telephone: "",
|
||||||
|
tel_portable: "",
|
||||||
|
telecopie: "",
|
||||||
|
adresse: "",
|
||||||
|
complement: "",
|
||||||
|
code_postal: "",
|
||||||
|
ville: "",
|
||||||
|
region: "",
|
||||||
|
pays: "France",
|
||||||
|
facebook: "",
|
||||||
|
linkedin: "",
|
||||||
|
skype: "",
|
||||||
|
est_vendeur: true,
|
||||||
|
est_acheteur: false,
|
||||||
|
est_caissier: false,
|
||||||
|
est_chef_ventes: false,
|
||||||
|
chef_ventes_numero: undefined,
|
||||||
|
est_actif: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, editing, reset]);
|
||||||
|
|
||||||
|
// Calcul completion
|
||||||
|
useEffect(() => {
|
||||||
|
const requiredFields: (keyof CommercialFormData)[] = ["nom", "prenom", "email", "telephone"];
|
||||||
|
const optionalFields: (keyof CommercialFormData)[] = ["fonction", "service", "adresse", "ville"];
|
||||||
|
|
||||||
|
const filledRequired = requiredFields.filter((f) => !!formValues[f]);
|
||||||
|
const filledOptional = optionalFields.filter((f) => !!formValues[f]);
|
||||||
|
|
||||||
|
const completion = Math.round(
|
||||||
|
((filledRequired.length / requiredFields.length) * 70) +
|
||||||
|
((filledOptional.length / optionalFields.length) * 30)
|
||||||
|
);
|
||||||
|
setCompletion(completion);
|
||||||
|
}, [formValues]);
|
||||||
|
|
||||||
|
const canSave = useMemo(() => {
|
||||||
|
return !!formValues.nom && !!formValues.prenom;
|
||||||
|
}, [formValues.nom, formValues.prenom]);
|
||||||
|
|
||||||
|
// Submit handler
|
||||||
|
const onSave: SubmitHandler<CommercialFormData> = async (data) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEditing) {
|
||||||
|
// ✅ UPDATE - Console.log pour debug
|
||||||
|
console.log("=== UPDATE COMMERCIAL ===");
|
||||||
|
console.log("Numero:", editing?.numero);
|
||||||
|
console.log("Data:", data);
|
||||||
|
console.log("========================");
|
||||||
|
|
||||||
|
// TODO: Implémenter le dispatch UPDATE ici
|
||||||
|
// await dispatch(updateCommercial({ numero: editing.numero, data })).unwrap();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Commercial mis à jour !",
|
||||||
|
description: `${data.prenom} ${data.nom} a été mis à jour avec succès.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// ✅ CREATE - Console.log pour debug
|
||||||
|
console.log("=== CREATE COMMERCIAL ===");
|
||||||
|
console.log("Data:", data);
|
||||||
|
console.log("=========================");
|
||||||
|
|
||||||
|
// TODO: Implémenter le dispatch CREATE ici
|
||||||
|
// await dispatch(addCommercial(data)).unwrap();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Commercial créé !",
|
||||||
|
description: `${data.prenom} ${data.nom} a été créé avec succès.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur:", error);
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Une erreur est survenue.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (errors: any) => {
|
||||||
|
console.log("Erreurs de validation:", errors);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "100%" }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||||
|
className="fixed inset-y-0 right-0 w-full max-w-xl bg-white dark:bg-gray-950 shadow-2xl z-50 flex flex-col border-l border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-950/80 backdrop-blur-md">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-[#007E45] text-white flex items-center justify-center">
|
||||||
|
<User className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
{title || (isEditing ? "Modifier le commercial" : "Nouveau commercial")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500">{completion}% complété</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Progress value={completion} className="h-1 rounded-none" />
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSave, onError)}
|
||||||
|
className="flex-1 flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Body scrollable */}
|
||||||
|
<div className="flex-1 overflow-y-auto bg-gray-50/50 dark:bg-black/20">
|
||||||
|
{/* Statut & Rôles */}
|
||||||
|
<div className="p-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<Controller
|
||||||
|
name="est_actif"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-[#007E45] rounded border-gray-300 focus:ring-[#007E45]"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Actif</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="est_vendeur"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Vendeur</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="est_acheteur"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Acheteur</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="est_caissier"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-orange-500 rounded border-gray-300 focus:ring-orange-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Caissier</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="est_chef_ventes"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-yellow-500 rounded border-gray-300 focus:ring-yellow-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Chef des ventes</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section: Identification */}
|
||||||
|
<Section title="Identification" icon={Hash} defaultOpen={true}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="prenom"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Prénom"
|
||||||
|
inputType="text"
|
||||||
|
required
|
||||||
|
placeholder="Jean"
|
||||||
|
error={errors.prenom?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="nom"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Nom"
|
||||||
|
inputType="text"
|
||||||
|
required
|
||||||
|
placeholder="Dupont"
|
||||||
|
error={errors.nom?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="fonction"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Fonction"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Responsable commercial"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="service"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Service"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Commercial"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
name="matricule"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
label="Matricule"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="MAT001"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Section: Contact */}
|
||||||
|
<Section title="Contact" icon={Phone} defaultOpen={true}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="email"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Email"
|
||||||
|
inputType="email"
|
||||||
|
placeholder="jean.dupont@exemple.com"
|
||||||
|
error={errors.email?.message}
|
||||||
|
leftIcon={<Mail className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="telephone"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Téléphone"
|
||||||
|
inputType="phone"
|
||||||
|
placeholder="01 23 45 67 89"
|
||||||
|
leftIcon={<Phone className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="tel_portable"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
label="Portable"
|
||||||
|
inputType="phone"
|
||||||
|
placeholder="06 12 34 56 78"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="telecopie"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
label="Fax"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="01 23 45 67 90"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Section: Adresse */}
|
||||||
|
<Section title="Adresse" icon={MapPin} defaultOpen={false}>
|
||||||
|
<Controller
|
||||||
|
name="adresse"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Adresse"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Numéro et nom de rue"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="complement"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
label="Complément"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Bâtiment, étage..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="code_postal"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Code postal"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="75001"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Controller
|
||||||
|
name="ville"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Ville"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Paris"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="region"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
label="Région"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Île-de-France"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="pays"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
label="Pays"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="France"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Section: Réseaux sociaux */}
|
||||||
|
<Section title="Réseaux sociaux" icon={Globe} defaultOpen={false}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="linkedin"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
label="LinkedIn"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="URL ou identifiant"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="facebook"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
label="Facebook"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="URL ou identifiant"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="skype"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
label="Skype"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Identifiant Skype"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSave || isSubmitting}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-6 py-2.5 text-white text-sm font-medium rounded-xl shadow-lg shadow-emerald-900/20 transition-colors",
|
||||||
|
canSave && !isSubmitting
|
||||||
|
? "bg-[#007E45] hover:bg-[#006838]"
|
||||||
|
: "bg-gray-400 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{isEditing ? "Mettre à jour" : "Créer le commercial"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModalCommercial;
|
||||||
560
src/components/modal/ModalContact.tsx
Normal file
560
src/components/modal/ModalContact.tsx
Normal file
|
|
@ -0,0 +1,560 @@
|
||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useForm, Controller, type SubmitHandler } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import FormModal from '@/components/ui/FormModal';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Client, Contacts } from '@/types/clientType';
|
||||||
|
import { Fournisseur } from '@/types/fournisseurType';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||||
|
import { addContactClient, addContactFournisseur, updateContactClient, updateContactFournisseur } from '@/store/features/contact/thunk';
|
||||||
|
import { toast } from '../ui/use-toast';
|
||||||
|
import { contactStatus } from '@/store/features/contact/selectors';
|
||||||
|
import { getClientSelected } from '@/store/features/client/selectors';
|
||||||
|
import { getfournisseurSelected } from '@/store/features/fournisseur/selectors';
|
||||||
|
import { InternationalPhoneInput } from '../ui/InternationalPhoneInput';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SCHÉMA DE VALIDATION ZOD
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const contactSchema = z.object({
|
||||||
|
numero: z.string().optional().or(z.literal("")),
|
||||||
|
civilite: z.string().optional().or(z.literal("")),
|
||||||
|
nom: z.string().min(1, "Le nom est requis"),
|
||||||
|
prenom: z.string().min(1, "Le prénom est requis"),
|
||||||
|
fonction: z.string().optional().or(z.literal("")),
|
||||||
|
service_code: z.number().optional(),
|
||||||
|
telephone: z.string().optional().or(z.literal("")),
|
||||||
|
portable: z.string().optional().or(z.literal("")),
|
||||||
|
telecopie: z.string().optional().or(z.literal("")),
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.refine(
|
||||||
|
(val) => !val || val.trim() === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val),
|
||||||
|
{ message: "Format email invalide" }
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.or(z.literal("")),
|
||||||
|
facebook: z.string().optional().or(z.literal("")),
|
||||||
|
linkedin: z.string().optional().or(z.literal("")),
|
||||||
|
skype: z.string().optional().or(z.literal("")),
|
||||||
|
est_defaut: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ContactFormData = z.infer<typeof contactSchema>;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
type EntityType = 'client' | 'fournisseur';
|
||||||
|
|
||||||
|
interface ModalContactProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
editing?: Contacts | null;
|
||||||
|
entityType: EntityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// OPTIONS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const CIVILITE_OPTIONS = [
|
||||||
|
{ value: "", label: "-- Sélectionner --" },
|
||||||
|
{ value: "M.", label: "M." },
|
||||||
|
{ value: "Mme", label: "Mme" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SERVICE_OPTIONS = [
|
||||||
|
{ value: undefined, label: "-- Sélectionner --" },
|
||||||
|
{ value: 1, label: "Direction" },
|
||||||
|
{ value: 2, label: "Commercial" },
|
||||||
|
{ value: 3, label: "Achats" },
|
||||||
|
{ value: 4, label: "Comptabilité" },
|
||||||
|
{ value: 5, label: "Technique" },
|
||||||
|
{ value: 6, label: "SAV" },
|
||||||
|
{ value: 7, label: "RH" },
|
||||||
|
{ value: 8, label: "Logistique" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// LABELS PAR TYPE D'ENTITÉ
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const ENTITY_LABELS: Record<EntityType, { singular: string; addTitle: string; editTitle: string }> = {
|
||||||
|
client: {
|
||||||
|
singular: "client",
|
||||||
|
addTitle: "Ajouter un contact",
|
||||||
|
editTitle: "Modifier le contact",
|
||||||
|
},
|
||||||
|
fournisseur: {
|
||||||
|
singular: "fournisseur",
|
||||||
|
addTitle: "Ajouter un contact",
|
||||||
|
editTitle: "Modifier le contact",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PRINCIPAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export function ModalContact({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
editing,
|
||||||
|
entityType,
|
||||||
|
}: ModalContactProps) {
|
||||||
|
const isEditing = !!editing;
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const statusContact = useAppSelector(contactStatus);
|
||||||
|
|
||||||
|
// Sélectionner l'entité selon le type
|
||||||
|
const client = useAppSelector(getClientSelected) as Client | null;
|
||||||
|
const fournisseur = useAppSelector(getfournisseurSelected) as Fournisseur | null;
|
||||||
|
|
||||||
|
// Récupérer le numéro selon le type d'entité
|
||||||
|
const entityNumero = useMemo(() => {
|
||||||
|
if (entityType === 'client') {
|
||||||
|
return client?.numero || "";
|
||||||
|
}
|
||||||
|
return fournisseur?.numero || "";
|
||||||
|
}, [entityType, client, fournisseur]);
|
||||||
|
|
||||||
|
const labels = ENTITY_LABELS[entityType];
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<ContactFormData>({
|
||||||
|
resolver: zodResolver(contactSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: {
|
||||||
|
numero: entityNumero,
|
||||||
|
civilite: "",
|
||||||
|
nom: "",
|
||||||
|
prenom: "",
|
||||||
|
fonction: "",
|
||||||
|
service_code: undefined,
|
||||||
|
telephone: "",
|
||||||
|
portable: "",
|
||||||
|
telecopie: "",
|
||||||
|
email: "",
|
||||||
|
facebook: "",
|
||||||
|
linkedin: "",
|
||||||
|
skype: "",
|
||||||
|
est_defaut: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const nom = watch("nom");
|
||||||
|
const prenom = watch("prenom");
|
||||||
|
|
||||||
|
// Reset form quand le contact est chargé ou modal ouverte
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (isEditing && editing) {
|
||||||
|
reset({
|
||||||
|
numero: entityNumero,
|
||||||
|
civilite: editing.civilite || "",
|
||||||
|
nom: editing.nom || "",
|
||||||
|
prenom: editing.prenom || "",
|
||||||
|
fonction: editing.fonction || "",
|
||||||
|
service_code: editing.service_code || undefined,
|
||||||
|
telephone: editing.telephone || "",
|
||||||
|
portable: editing.portable || "",
|
||||||
|
telecopie: editing.telecopie || "",
|
||||||
|
email: editing.email || "",
|
||||||
|
facebook: editing.facebook || "",
|
||||||
|
linkedin: editing.linkedin || "",
|
||||||
|
skype: editing.skype || "",
|
||||||
|
est_defaut: editing.est_defaut ?? false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reset({
|
||||||
|
numero: entityNumero,
|
||||||
|
civilite: "",
|
||||||
|
nom: "",
|
||||||
|
prenom: "",
|
||||||
|
fonction: "",
|
||||||
|
service_code: undefined,
|
||||||
|
telephone: "",
|
||||||
|
portable: "",
|
||||||
|
telecopie: "",
|
||||||
|
email: "",
|
||||||
|
facebook: "",
|
||||||
|
linkedin: "",
|
||||||
|
skype: "",
|
||||||
|
est_defaut: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [editing, isEditing, reset, open, entityNumero]);
|
||||||
|
|
||||||
|
const canSave = useMemo(() => {
|
||||||
|
const hasNom = !!nom && nom.trim().length > 0;
|
||||||
|
const hasPrenom = !!prenom && prenom.trim().length > 0;
|
||||||
|
const hasNoErrors = Object.keys(errors).length === 0;
|
||||||
|
return hasNom && hasPrenom && hasNoErrors;
|
||||||
|
}, [nom, prenom, errors]);
|
||||||
|
|
||||||
|
const onSubmit: SubmitHandler<ContactFormData> = async (data) => {
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
const res = entityType === "client"
|
||||||
|
? (await dispatch(updateContactClient({contact_numero: editing.contact_numero,data,})).unwrap()) as Contacts
|
||||||
|
: (await dispatch(updateContactFournisseur({contact_numero: editing.contact_numero,data,})).unwrap()) as Contacts;
|
||||||
|
toast({
|
||||||
|
title: "Contact mis à jour avec succès !",
|
||||||
|
description: `Le contact a été modifié pour le ${labels.singular} ${res.numero}.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600",
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
const res = entityType === "client"
|
||||||
|
? (await dispatch(addContactClient(data)).unwrap()) as Contacts
|
||||||
|
: (await dispatch(addContactFournisseur(data)).unwrap()) as Contacts;
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Contact ajouté avec succès !",
|
||||||
|
description: `Un nouveau contact a été ajouté au ${labels.singular} ${res.numero}.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600",
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Une erreur est survenue lors de l'enregistrement.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClass = (error?: unknown) =>
|
||||||
|
cn(
|
||||||
|
"w-full px-3 py-2.5 bg-white dark:bg-gray-900 border rounded-xl text-sm transition-all focus:outline-none focus:ring-2 focus:ring-[#338660]/20 focus:border-[#338660]",
|
||||||
|
error
|
||||||
|
? "border-red-300 focus:border-red-500 focus:ring-red-200"
|
||||||
|
: "border-gray-200 dark:border-gray-700"
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectClass = cn(
|
||||||
|
"w-full px-3 py-2.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl text-sm transition-all focus:outline-none focus:ring-2 focus:ring-[#338660]/20 focus:border-[#338660]"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title || (isEditing ? labels.editTitle : labels.addTitle)}
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
submitLabel={isEditing ? "Enregistrer" : "Ajouter"}
|
||||||
|
size="md"
|
||||||
|
loading={statusContact === "loading"}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Civilité */}
|
||||||
|
<Controller
|
||||||
|
name="civilite"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Civilité
|
||||||
|
</label>
|
||||||
|
<select {...field} className={selectClass}>
|
||||||
|
{CIVILITE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Nom & Prénom */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="prenom"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Prénom <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...field}
|
||||||
|
placeholder="Pierre"
|
||||||
|
className={inputClass(errors.prenom)}
|
||||||
|
/>
|
||||||
|
{errors.prenom && (
|
||||||
|
<p className="text-xs text-red-500">{errors.prenom.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="nom"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Nom <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...field}
|
||||||
|
placeholder="Martin"
|
||||||
|
className={inputClass(errors.nom)}
|
||||||
|
/>
|
||||||
|
{errors.nom && (
|
||||||
|
<p className="text-xs text-red-500">{errors.nom.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fonction & Service */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="fonction"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Fonction
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...field}
|
||||||
|
placeholder="Directeur des Ventes"
|
||||||
|
className={inputClass()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="service_code"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Service
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
field.onChange(val === "" ? undefined : parseInt(val));
|
||||||
|
}}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
{SERVICE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value ?? "empty"} value={opt.value ?? ""}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email & Téléphone */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="email"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...field}
|
||||||
|
type="email"
|
||||||
|
placeholder="p.martin@exemple.com"
|
||||||
|
className={inputClass(errors.email)}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-xs text-red-500">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* <Controller
|
||||||
|
name="telephone"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Téléphone fixe
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...field}
|
||||||
|
type="tel"
|
||||||
|
placeholder="01 23 45 67 89"
|
||||||
|
className={inputClass()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/> */}
|
||||||
|
<Controller
|
||||||
|
name="telephone"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<InternationalPhoneInput
|
||||||
|
{...field}
|
||||||
|
label="Téléphone"
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
required={false}
|
||||||
|
defaultCountry="FR"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Portable & Télécopie */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* <Controller
|
||||||
|
name="portable"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Portable
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...field}
|
||||||
|
type="tel"
|
||||||
|
placeholder="06 12 34 56 78"
|
||||||
|
className={inputClass()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/> */}
|
||||||
|
<Controller
|
||||||
|
name="portable"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<InternationalPhoneInput
|
||||||
|
{...field}
|
||||||
|
label="Portable"
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
required={false}
|
||||||
|
defaultCountry="FR" // ou "MG" pour Madagascar
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="telecopie"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Télécopie (Fax)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...field}
|
||||||
|
type="tel"
|
||||||
|
placeholder="01 23 45 67 90"
|
||||||
|
className={inputClass()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Réseaux sociaux */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="linkedin"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
LinkedIn
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...field}
|
||||||
|
placeholder="URL ou identifiant"
|
||||||
|
className={inputClass()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* <Controller
|
||||||
|
name="facebook"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Facebook
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...field}
|
||||||
|
placeholder="URL ou identifiant"
|
||||||
|
className={inputClass()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="skype"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Skype
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...field}
|
||||||
|
placeholder="Identifiant Skype"
|
||||||
|
className={inputClass()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact par défaut */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<Controller
|
||||||
|
name="est_defaut"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<label className="flex items-center gap-3 p-3 border border-gray-200 dark:border-gray-800 rounded-xl cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value || false}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-[#338660] rounded border-gray-300 focus:ring-[#338660]"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Définir comme contact par défaut
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Ce contact sera utilisé pour les envois automatiques
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModalContact;
|
||||||
491
src/components/modal/ModalFacture.tsx
Normal file
491
src/components/modal/ModalFacture.tsx
Normal file
|
|
@ -0,0 +1,491 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import FormModal, { FormSection, FormField } from '@/components/ui/FormModal';
|
||||||
|
import { Calendar, FileText, Plus, Save, Trash2 } from "lucide-react";
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/store/hooks";
|
||||||
|
import ClientAutocomplete from "../molecules/ClientAutocomplete";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Client } from "@/types/clientType";
|
||||||
|
import ArticleAutocomplete from "../molecules/ArticleAutocomplete";
|
||||||
|
import { Article } from "@/types/articleType";
|
||||||
|
import { Alert, CircularProgress, Divider } from "@mui/material";
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "../ui/use-toast";
|
||||||
|
import { Facture, FactureRequest } from '@/types/factureType';
|
||||||
|
import { factureStatus } from '@/store/features/factures/selectors';
|
||||||
|
import { createFacture, getFacture, updateFacture } from '@/store/features/factures/thunk';
|
||||||
|
import { selectfacture } from '@/store/features/factures/slice';
|
||||||
|
import { ModalLoading } from "./ModalLoading";
|
||||||
|
import { formatForDateInput } from '@/lib/utils';
|
||||||
|
import { Input } from '../ui/ui';
|
||||||
|
|
||||||
|
// ✅ Interface corrigée avec les bons noms de champs
|
||||||
|
interface LigneForm {
|
||||||
|
article_code: string;
|
||||||
|
quantite: number;
|
||||||
|
prix_unitaire_ht: number;
|
||||||
|
articles: Article | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModalFacture({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
editing,
|
||||||
|
client
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
editing?: Facture | null;
|
||||||
|
client?: Client | null;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const statusFacture = useAppSelector(factureStatus);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const [dateFacture, setDateFacture] = useState(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
});
|
||||||
|
const [dateEcheance, setDateEcheance] = useState('');
|
||||||
|
const [referenceClient, setReferenceClient] = useState('');
|
||||||
|
|
||||||
|
const [clientSelectionne, setClientSelectionne] = useState<Client | null>(client ?? null);
|
||||||
|
|
||||||
|
// du le
|
||||||
|
const [dateLivraison, setDateLivraison] = useState(() => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + 8);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ✅ Ligne par défaut avec articles: null
|
||||||
|
const [lignes, setLignes] = useState<LigneForm[]>([
|
||||||
|
{ article_code: '', quantite: 1, prix_unitaire_ht: 0, articles: null }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isEditing = !!editing;
|
||||||
|
|
||||||
|
// Calcul date d'échéance par défaut (30 jours)
|
||||||
|
useEffect(() => {
|
||||||
|
if (dateFacture && !dateEcheance) {
|
||||||
|
const date = new Date(dateFacture);
|
||||||
|
date.setDate(date.getDate() + 30);
|
||||||
|
setDateEcheance(date.toISOString().split('T')[0]);
|
||||||
|
}
|
||||||
|
}, [dateFacture]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
setClientSelectionne({
|
||||||
|
numero: editing.client_code,
|
||||||
|
intitule: editing.client_intitule,
|
||||||
|
compte_collectif: "",
|
||||||
|
adresse: "",
|
||||||
|
code_postal: "",
|
||||||
|
ville: "",
|
||||||
|
email: "",
|
||||||
|
telephone: "",
|
||||||
|
} as Client);
|
||||||
|
|
||||||
|
const lignesInitiales: LigneForm[] = editing.lignes?.map(ligne => ({
|
||||||
|
article_code: ligne.article_code,
|
||||||
|
quantite: ligne.quantite,
|
||||||
|
prix_unitaire_ht: ligne.prix_unitaire_ht ?? 0,
|
||||||
|
articles: {
|
||||||
|
reference: ligne.article_code,
|
||||||
|
designation: ligne.designation ?? '',
|
||||||
|
prix_vente: ligne.prix_unitaire_ht ?? 0,
|
||||||
|
stock_reel: 0,
|
||||||
|
} as Article,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
setLignes(lignesInitiales.length > 0 ? lignesInitiales : [
|
||||||
|
{ article_code: '', quantite: 1, prix_unitaire_ht: 0, articles: null }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (editing.date) setDateFacture(editing.date);
|
||||||
|
if(editing.date_livraison) setDateLivraison(editing.date_livraison);
|
||||||
|
setReferenceClient(editing.reference || '');
|
||||||
|
} else {
|
||||||
|
if (!client) {
|
||||||
|
setClientSelectionne(null);
|
||||||
|
} else {
|
||||||
|
setClientSelectionne(client);
|
||||||
|
}
|
||||||
|
setLignes([
|
||||||
|
{ article_code: '', quantite: 1, prix_unitaire_ht: 0, articles: null }
|
||||||
|
]);
|
||||||
|
setDateFacture(new Date().toISOString().split('T')[0]);
|
||||||
|
setDateLivraison(() => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + 8);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
});
|
||||||
|
setReferenceClient('');
|
||||||
|
setDateEcheance('');
|
||||||
|
setReferenceClient('');
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
}, [open, editing, client]);
|
||||||
|
|
||||||
|
const appliquerBareme = async (index: number, articleRef: string) => {
|
||||||
|
if (!clientSelectionne) return;
|
||||||
|
try {
|
||||||
|
// Logique d'application du barème
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur application barème:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ajouterLigne = () => {
|
||||||
|
setLignes([
|
||||||
|
...lignes,
|
||||||
|
{ article_code: '', quantite: 1, prix_unitaire_ht: 0, articles: null },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const supprimerLigne = (index: number) => {
|
||||||
|
if (lignes.length > 1) {
|
||||||
|
setLignes(lignes.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Fonction updateLigne corrigée
|
||||||
|
const updateLigne = (index: number, field: keyof LigneForm, value: any) => {
|
||||||
|
const nouvelles = [...lignes];
|
||||||
|
|
||||||
|
if (field === 'articles' && value) {
|
||||||
|
const article = value as Article;
|
||||||
|
nouvelles[index] = {
|
||||||
|
...nouvelles[index],
|
||||||
|
articles: article,
|
||||||
|
article_code: article.reference,
|
||||||
|
prix_unitaire_ht: article.prix_vente,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (clientSelectionne) {
|
||||||
|
appliquerBareme(index, article.reference);
|
||||||
|
}
|
||||||
|
} else if (field === 'articles' && !value) {
|
||||||
|
nouvelles[index] = {
|
||||||
|
...nouvelles[index],
|
||||||
|
articles: null,
|
||||||
|
article_code: '',
|
||||||
|
prix_unitaire_ht: 0,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
(nouvelles[index] as any)[field] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLignes(nouvelles);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculerTotalLigne = (ligne: LigneForm) => {
|
||||||
|
if (!ligne.articles) return 0;
|
||||||
|
const prix = ligne.prix_unitaire_ht || ligne.articles.prix_vente;
|
||||||
|
return prix * ligne.quantite;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculerTotal = () => {
|
||||||
|
return lignes.reduce((acc, ligne) => acc + calculerTotalLigne(ligne), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSave = useMemo(() => {
|
||||||
|
if (!clientSelectionne) return false;
|
||||||
|
const lignesValides = lignes.filter((l) => l.article_code);
|
||||||
|
if (lignesValides.length === 0) return false;
|
||||||
|
return true;
|
||||||
|
}, [clientSelectionne, lignes]);
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
if (!clientSelectionne) {
|
||||||
|
setError('Veuillez sélectionner un client');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lignesValides = lignes.filter((l) => l.article_code);
|
||||||
|
|
||||||
|
if (lignesValides.length === 0) {
|
||||||
|
setError('Veuillez ajouter au moins un article');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
const payloadUpdate = {
|
||||||
|
client_id: clientSelectionne.numero,
|
||||||
|
date_facture: (() => {
|
||||||
|
const d = new Date(dateFacture);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
})(),
|
||||||
|
reference: referenceClient,
|
||||||
|
date_livraison: (() => {
|
||||||
|
const d = new Date(dateLivraison);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
})(),
|
||||||
|
lignes: lignesValides.map((l) => ({
|
||||||
|
article_code: l.article_code,
|
||||||
|
quantite: l.quantite
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await dispatch(updateFacture({
|
||||||
|
numero: editing!.numero,
|
||||||
|
data: payloadUpdate
|
||||||
|
})).unwrap() as any;
|
||||||
|
|
||||||
|
const numero = result.facture.numero as any;
|
||||||
|
|
||||||
|
setSuccess(true);
|
||||||
|
toast({
|
||||||
|
title: "Facture mise à jour !",
|
||||||
|
description: `La facture a été mise à jour avec succès.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600"
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
const itemCreated = await dispatch(getFacture(numero)).unwrap() as any;
|
||||||
|
const res = itemCreated as Facture;
|
||||||
|
dispatch(selectfacture(res));
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
onClose();
|
||||||
|
navigate(`/home/factures/${numero}`);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const payloadCreate: FactureRequest = {
|
||||||
|
client_id: clientSelectionne.numero,
|
||||||
|
date_facture: (() => {
|
||||||
|
const d = new Date(dateFacture);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
})(),
|
||||||
|
reference: referenceClient,
|
||||||
|
date_livraison: (() => {
|
||||||
|
const d = new Date(dateLivraison);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
})(),
|
||||||
|
lignes: lignesValides.map((l) => ({
|
||||||
|
article_code: l.article_code,
|
||||||
|
quantite: l.quantite
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await dispatch(createFacture(payloadCreate)).unwrap();
|
||||||
|
|
||||||
|
const data = result.data;
|
||||||
|
setSuccess(true);
|
||||||
|
toast({
|
||||||
|
title: "Facture créée !",
|
||||||
|
description: `Une nouvelle facture ${data.numero_facture} a été créée avec succès.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600"
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
const itemCreated = await dispatch(getFacture(data.numero_facture)).unwrap() as any;
|
||||||
|
const res = itemCreated as Facture;
|
||||||
|
dispatch(selectfacture(res));
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
onClose();
|
||||||
|
navigate(`/home/factures/${data.numero_facture}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
setError("Cet article n'est pas disponible pour ce client.");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalHT = calculerTotal();
|
||||||
|
const totalTVA = totalHT * 0.20;
|
||||||
|
const totalTTC = totalHT + totalTVA;
|
||||||
|
const lignesValides = lignes.filter((l) => l.article_code).length;
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title || (isEditing ? "Modifier la facture" : "Créer une facture")}
|
||||||
|
size="xl"
|
||||||
|
onSubmit={onSave}
|
||||||
|
loading={statusFacture === "loading" ? true : false}
|
||||||
|
>
|
||||||
|
{/* Section Client */}
|
||||||
|
<FormSection title="Informations client" description="Sélectionnez le client et les coordonnées">
|
||||||
|
<FormField label="Client" required fullWidth>
|
||||||
|
<ClientAutocomplete
|
||||||
|
value={clientSelectionne}
|
||||||
|
onChange={setClientSelectionne}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="col-span-1 md:col-span-2 grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||||
|
<FormField label="Date d'émission" required>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formatForDateInput(dateFacture)}
|
||||||
|
onChange={(e) => setDateFacture(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Dû le" required>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formatForDateInput(dateLivraison)}
|
||||||
|
onChange={(e) => setDateLivraison(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Référence client">
|
||||||
|
<Input
|
||||||
|
placeholder="Ex: REF-FAC-123"
|
||||||
|
value={referenceClient}
|
||||||
|
onChange={(e) => setReferenceClient(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Section Lignes */}
|
||||||
|
<FormSection title="Lignes de la facture" description="Ajoutez les produits et services facturés">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full mb-4">
|
||||||
|
<thead className="text-left bg-gray-50 dark:bg-gray-900/50 text-xs uppercase text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 rounded-l-lg" style={{ width: '50%' }}>Article / Description</th>
|
||||||
|
<th className="px-2 py-3 text-center" style={{ width: '80px' }}>Qté</th>
|
||||||
|
<th className="px-2 py-3 text-right" style={{ width: '100px' }}>P.U. HT</th>
|
||||||
|
<th className="px-2 py-3 text-right rounded-r-lg" style={{ width: '100px' }}>Total HT</th>
|
||||||
|
<th style={{ width: '40px' }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{lignes.map((ligne, index) => (
|
||||||
|
<tr key={index} className="group hover:bg-gray-50/50 dark:hover:bg-gray-900/20">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{/* ✅ Correction: passer articles et onChange sur 'articles' */}
|
||||||
|
<ArticleAutocomplete
|
||||||
|
value={ligne.articles}
|
||||||
|
onChange={(article) => updateLigne(index, 'articles', article)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-2 py-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ligne.quantite}
|
||||||
|
onChange={(e) => updateLigne(index, '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-[#941403] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-2 py-3">
|
||||||
|
<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-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-[#941403] focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-2 py-3 text-right">
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white text-sm">
|
||||||
|
{calculerTotalLigne(ligne).toFixed(2)} €
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-1 py-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => supprimerLigne(index)}
|
||||||
|
disabled={lignes.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 opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-start mt-4">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Récapitulatif */}
|
||||||
|
<div style={{width: "42vh"}} className="w-72 bg-blue-50 dark:bg-blue-900/20 rounded-xl p-4 space-y-3 border border-blue-100 dark:border-blue-800">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Client</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white truncate max-w-[150px]">
|
||||||
|
{clientSelectionne?.intitule || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Lignes</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{lignesValides}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider className="!my-3" />
|
||||||
|
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Total HT</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">{totalHT.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Taux TVA </span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">20%</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-3 border-t border-blue-200 dark:border-blue-700 flex justify-between">
|
||||||
|
<span className="font-bold text-gray-900 dark:text-white">Total TTC</span>
|
||||||
|
<span className="font-bold text-blue-600 dark:text-blue-400 text-lg">{totalTTC.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Erreurs */}
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" className="mb-4">{error}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && <ModalLoading />}
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
282
src/components/modal/ModalFamille.tsx
Normal file
282
src/components/modal/ModalFamille.tsx
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useForm, Controller, type SubmitHandler } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import z from "zod";
|
||||||
|
import FormModal, { FormField, FormSection, Select } from '@/components/ui/FormModal';
|
||||||
|
import { InputField } from "../ui/InputValidator";
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/store/hooks";
|
||||||
|
import { articleStatus } from "@/store/features/article/selectors";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { getAllfamilles } from "@/store/features/famille/selectors";
|
||||||
|
import { Famille, FamilleRequest } from "@/types/familleType";
|
||||||
|
import { createFamille } from "@/store/features/famille/thunk";
|
||||||
|
import { selectfamille } from "@/store/features/famille/slice";
|
||||||
|
import { toast } from "../ui/use-toast";
|
||||||
|
import { Alert } from "@mui/material";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SCHÉMA DE VALIDATION ZOD
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const itemSchema = z.object({
|
||||||
|
code: z.string().min(1, "La code est requise"),
|
||||||
|
intitule: z.string().min(1, "L'intitule est requise"),
|
||||||
|
compte_achat: z.string().optional(),
|
||||||
|
compte_vente: z.string().optional(),
|
||||||
|
unite_vente: z.string().default("pcs"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ItemFormData = z.infer<typeof itemSchema>;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// INTERFACE PROPS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface FormModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
editing?: Famille | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PRINCIPAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export function ModalFamille({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
editing
|
||||||
|
}: FormModalProps) {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const isEditing = !!editing;
|
||||||
|
const status = useAppSelector(articleStatus);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const familles = useAppSelector(getAllfamilles) as Famille[];
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isValid }
|
||||||
|
} = useForm<ItemFormData>({
|
||||||
|
resolver: zodResolver(itemSchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: {
|
||||||
|
code: "",
|
||||||
|
intitule: "",
|
||||||
|
compte_achat: "",
|
||||||
|
compte_vente: "",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setIsSubmitting(false);
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
reset({
|
||||||
|
code: editing.code || "",
|
||||||
|
intitule: editing.intitule || "",
|
||||||
|
compte_achat: editing.compte_achat || "",
|
||||||
|
compte_vente: editing.compte_vente || "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reset({
|
||||||
|
code: "",
|
||||||
|
intitule: "",
|
||||||
|
compte_achat: "",
|
||||||
|
compte_vente: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, editing, reset]);
|
||||||
|
|
||||||
|
// ✅ Fonction de soumission
|
||||||
|
const onSave: SubmitHandler<ItemFormData> = async (data) => {
|
||||||
|
setError('')
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
if(isEditing){
|
||||||
|
const payload: FamilleRequest = {
|
||||||
|
code: data.code,
|
||||||
|
intitule: data.intitule,
|
||||||
|
compte_achat: data.compte_achat || "",
|
||||||
|
compte_vente: data.compte_vente || "",
|
||||||
|
type: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("payload update: ",payload)
|
||||||
|
|
||||||
|
|
||||||
|
}else{
|
||||||
|
const payload: FamilleRequest = {
|
||||||
|
code: data.code,
|
||||||
|
intitule: data.intitule,
|
||||||
|
compte_achat: data.compte_achat || "",
|
||||||
|
compte_vente: data.compte_vente || "",
|
||||||
|
type: 0
|
||||||
|
};
|
||||||
|
const familleExist = familles.findIndex((item) => item.code === payload.code)
|
||||||
|
if(familleExist !== -1){
|
||||||
|
console.log("ato");
|
||||||
|
|
||||||
|
setError("Une famille avec ce code existe déjà.")
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await dispatch(createFamille(payload)).unwrap() as Famille
|
||||||
|
dispatch(selectfamille(result))
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Famille d'article bien créée !",
|
||||||
|
description: `Une nouvelle famille d'article ${result.code} a été créée avec succès.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600"
|
||||||
|
});
|
||||||
|
|
||||||
|
navigate(`/home/familles-articles/${result.code}`)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
setError("Une erreur est survenue lors de la sauvegarde.");
|
||||||
|
console.error("Erreur lors de la sauvegarde:", error);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Vérifier si le formulaire peut être soumis
|
||||||
|
const canSave = isValid && !isSubmitting && status !== "loading";
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title || (isEditing ? `Modifier ${editing.code} famille` : "Nouvelle famille d'article")}
|
||||||
|
size="xl"
|
||||||
|
onSubmit={handleSubmit(onSave)}
|
||||||
|
loading={ isSubmitting || status === "loading" ? true : false}
|
||||||
|
>
|
||||||
|
|
||||||
|
{/* Section: Informations générales */}
|
||||||
|
<FormSection title="Informations générales" description="Identification de la famille">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="code"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Code famille"
|
||||||
|
inputType="text"
|
||||||
|
required
|
||||||
|
disabled={isEditing}
|
||||||
|
placeholder="Ex: FAM001"
|
||||||
|
error={errors.code?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="intitule"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Intitulé"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Ex: Ordinateur"
|
||||||
|
error={errors.intitule?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Section: regle par défault */}
|
||||||
|
<FormSection title="Règles par défaut" description="Appliquées aux nouveaux articles">
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<FormField label="Taux TVA par défaut">
|
||||||
|
<Select defaultValue="20">
|
||||||
|
<option value="20">20%</option>
|
||||||
|
<option value="10">10%</option>
|
||||||
|
<option value="5.5">5.5%</option>
|
||||||
|
<option value="0">0%</option>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<Controller
|
||||||
|
name="compte_vente"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || ""}
|
||||||
|
label="Compte comptable vente"
|
||||||
|
inputType="text"
|
||||||
|
required
|
||||||
|
placeholder="707..."
|
||||||
|
error={errors.compte_vente?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="compte_achat"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || ""}
|
||||||
|
label="Compte comptable achat"
|
||||||
|
inputType="text"
|
||||||
|
required
|
||||||
|
placeholder="404..."
|
||||||
|
error={errors.compte_achat?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="unite_vente"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Unité"
|
||||||
|
as="select"
|
||||||
|
options={[
|
||||||
|
{ value: "pcs", label: "Pièce" },
|
||||||
|
{ value: "h", label: "Heure" },
|
||||||
|
{ value: "kg", label: "Kg" },
|
||||||
|
{ value: "l", label: "Litre" },
|
||||||
|
{ value: "m", label: "Mètre" },
|
||||||
|
{ value: "m2", label: "M²" },
|
||||||
|
{ value: "m3", label: "M³" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</FormSection>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" onClose={() => setError(null)} className="my-2">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
2234
src/components/modal/ModalFournisseur.tsx
Normal file
2234
src/components/modal/ModalFournisseur.tsx
Normal file
File diff suppressed because it is too large
Load diff
431
src/components/modal/ModalGateway.tsx
Normal file
431
src/components/modal/ModalGateway.tsx
Normal file
|
|
@ -0,0 +1,431 @@
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Save,
|
||||||
|
Server,
|
||||||
|
Link,
|
||||||
|
Key,
|
||||||
|
Database,
|
||||||
|
Building2,
|
||||||
|
Loader2,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Progress } from "@/components/ui/Progress";
|
||||||
|
import { Section } from "../ui/Section";
|
||||||
|
import { InputField } from "../ui/InputValidator";
|
||||||
|
import z from "zod";
|
||||||
|
import { useForm, Controller, type SubmitHandler } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Gateways, GatewaysRequest } from "@/types/gateways";
|
||||||
|
import { toast } from "../ui/use-toast";
|
||||||
|
import { useAppDispatch } from "@/store/hooks";
|
||||||
|
import { addGateways } from "@/store/features/gateways/thunk";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SCHÉMA DE VALIDATION DU FORMULAIRE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const gatewaySchema = z.object({
|
||||||
|
name: z.string().min(1, "Le nom est requis").max(100, "100 caractères maximum"),
|
||||||
|
description: z.string().max(500, "500 caractères maximum").nullable().optional(),
|
||||||
|
gateway_url: z
|
||||||
|
.string()
|
||||||
|
.min(1, "L'URL est requise")
|
||||||
|
.url("Format URL invalide (ex: https://exemple.com)"),
|
||||||
|
token_preview: z.string().min(1, "Le token est requis"),
|
||||||
|
sage_database: z.string().nullable().optional(),
|
||||||
|
sage_company: z.string().nullable().optional(),
|
||||||
|
is_active: z.boolean().default(true),
|
||||||
|
is_default: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
type GatewayFormData = z.infer<typeof gatewaySchema>;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PROPS INTERFACE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface ModalGatewayProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
editing?: Gateways | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PRINCIPAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export function ModalGateway({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
editing,
|
||||||
|
}: ModalGatewayProps) {
|
||||||
|
const [completion, setCompletion] = useState(0);
|
||||||
|
const [showToken, setShowToken] = useState(false);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const isEditing = !!editing;
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<GatewayFormData>({
|
||||||
|
resolver: zodResolver(gatewaySchema),
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
gateway_url: "",
|
||||||
|
token_preview: "",
|
||||||
|
sage_database: "",
|
||||||
|
sage_company: "",
|
||||||
|
is_active: true,
|
||||||
|
is_default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formValues = watch();
|
||||||
|
|
||||||
|
// Reset form when modal opens/closes or editing changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (editing) {
|
||||||
|
reset({
|
||||||
|
name: editing.name || "",
|
||||||
|
description: editing.description || "",
|
||||||
|
gateway_url: editing.gateway_url || "",
|
||||||
|
token_preview: editing.token_preview || "",
|
||||||
|
sage_database: editing.sage_database || "",
|
||||||
|
sage_company: editing.sage_company || "",
|
||||||
|
is_active: editing.is_active ?? true,
|
||||||
|
is_default: editing.is_default ?? false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reset({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
gateway_url: "",
|
||||||
|
token_preview: "",
|
||||||
|
sage_database: "",
|
||||||
|
sage_company: "",
|
||||||
|
is_active: true,
|
||||||
|
is_default: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setShowToken(false);
|
||||||
|
}, [open, editing, reset]);
|
||||||
|
|
||||||
|
// Calculate completion percentage
|
||||||
|
useEffect(() => {
|
||||||
|
const requiredFields: (keyof GatewayFormData)[] = ["name", "gateway_url", "token_preview"];
|
||||||
|
const optionalFields: (keyof GatewayFormData)[] = ["description", "sage_database", "sage_company"];
|
||||||
|
|
||||||
|
const filledRequired = requiredFields.filter((f) => !!formValues[f]);
|
||||||
|
const filledOptional = optionalFields.filter((f) => !!formValues[f]);
|
||||||
|
|
||||||
|
const requiredWeight = 0.7;
|
||||||
|
const optionalWeight = 0.3;
|
||||||
|
|
||||||
|
const requiredCompletion = (filledRequired.length / requiredFields.length) * requiredWeight;
|
||||||
|
const optionalCompletion = (filledOptional.length / optionalFields.length) * optionalWeight;
|
||||||
|
|
||||||
|
setCompletion(Math.round((requiredCompletion + optionalCompletion) * 100));
|
||||||
|
}, [formValues]);
|
||||||
|
|
||||||
|
const canSave = useMemo(() => {
|
||||||
|
return !!formValues.name && !!formValues.gateway_url && !!formValues.token_preview;
|
||||||
|
}, [formValues.name, formValues.gateway_url, formValues.token_preview]);
|
||||||
|
|
||||||
|
// Submit handler
|
||||||
|
const handleFormSubmit: SubmitHandler<GatewayFormData> = async (data) => {
|
||||||
|
const payload: GatewaysRequest = {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description || null,
|
||||||
|
gateway_url: data.gateway_url,
|
||||||
|
gateway_token: data.token_preview,
|
||||||
|
is_active: data.is_active,
|
||||||
|
is_default: data.is_default,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rep = await dispatch(addGateways(payload)).unwrap() as Gateways;
|
||||||
|
console.log("rep : ",rep);
|
||||||
|
|
||||||
|
onClose
|
||||||
|
toast({
|
||||||
|
title: "Entreprise bien créé !",
|
||||||
|
description: `Votre nouvelle entreprise a été créé avec succès.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Erreur du creation d'entreprise !",
|
||||||
|
className: "bg-red-500 text-white border-green-600"
|
||||||
|
});
|
||||||
|
onClose
|
||||||
|
}
|
||||||
|
|
||||||
|
// await onSave(payload, isEditing, editing?.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debug: Show validation errors
|
||||||
|
const onError = (validationErrors: typeof errors) => {
|
||||||
|
console.log("Erreurs de validation:", validationErrors);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute inset-0 z-[9999] overflow-hidden">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Panel */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "100%" }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||||
|
className="absolute top-0 right-0 h-full w-full max-w-xl bg-white dark:bg-gray-950 shadow-2xl flex flex-col border-l border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-950/80 backdrop-blur-md">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-emerald-600 text-white flex items-center justify-center">
|
||||||
|
<Server className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
{title || (isEditing ? "Modifier l'entreprise" : "Nouvelle entreprise")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500">{completion}% complété</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<Progress value={completion} className="h-1 rounded-none" />
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(handleFormSubmit, onError)}
|
||||||
|
className="flex-1 flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Body scrollable */}
|
||||||
|
<div className="flex-1 overflow-y-auto bg-gray-50/50 dark:bg-black/20">
|
||||||
|
{/* Status toggles */}
|
||||||
|
<div className="p-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<Controller
|
||||||
|
name="is_active"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value !== false}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-emerald-600 rounded border-gray-300 focus:ring-emerald-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="is_default"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.value || false}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Par défaut
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section: Identification */}
|
||||||
|
<Section title="Identification" icon={Server} defaultOpen={true}>
|
||||||
|
<Controller
|
||||||
|
name="name"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Nom de la gateway"
|
||||||
|
inputType="text"
|
||||||
|
required
|
||||||
|
placeholder="Ex: Gateway Production"
|
||||||
|
error={errors.name?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="description"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
label="Description"
|
||||||
|
inputType="text"
|
||||||
|
// as="textarea"
|
||||||
|
placeholder="Description optionnelle de la gateway..."
|
||||||
|
error={errors.description?.message}
|
||||||
|
// rows={3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Section: Connexion */}
|
||||||
|
<Section title="Connexion" icon={Link} defaultOpen={true}>
|
||||||
|
<Controller
|
||||||
|
name="gateway_url"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="URL de la gateway"
|
||||||
|
inputType="url"
|
||||||
|
required
|
||||||
|
placeholder="https://gateway.exemple.com"
|
||||||
|
error={errors.gateway_url?.message}
|
||||||
|
leftIcon={<Link className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="token_preview"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="relative">
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Token d'authentification"
|
||||||
|
inputType={showToken ? "text" : "password"}
|
||||||
|
required
|
||||||
|
placeholder="Votre token secret..."
|
||||||
|
error={errors.token_preview?.message}
|
||||||
|
leftIcon={<Key className="w-4 h-4" />}
|
||||||
|
className="pr-10 font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowToken(!showToken)}
|
||||||
|
className="absolute right-3 top-[34px] p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
{showToken ? (
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Section: Configuration Sage */}
|
||||||
|
<Section title="Configuration Sage" icon={Database} defaultOpen={false}>
|
||||||
|
<Controller
|
||||||
|
name="sage_database"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
label="Base de données Sage"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Ex: SAGE_PROD"
|
||||||
|
error={errors.sage_database?.message}
|
||||||
|
leftIcon={<Database className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="sage_company"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
label="Société Sage"
|
||||||
|
inputType="text"
|
||||||
|
placeholder="Ex: Ma Société SARL"
|
||||||
|
error={errors.sage_company?.message}
|
||||||
|
leftIcon={<Building2 className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSave || isSubmitting}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-6 py-2.5 text-white text-sm font-medium rounded-xl shadow-lg shadow-emerald-900/20 transition-colors",
|
||||||
|
canSave && !isSubmitting
|
||||||
|
? "bg-[#007E45] hover:bg-[#006838]"
|
||||||
|
: "bg-gray-400 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{isEditing ? "Mettre à jour" : "Créer la gateway"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModalGateway;
|
||||||
43
src/components/modal/ModalLoading.tsx
Normal file
43
src/components/modal/ModalLoading.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { CircularProgress, Dialog, Box } from "@mui/material";
|
||||||
|
import { Building } from "lucide-react";
|
||||||
|
|
||||||
|
export function ModalLoading() {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={true}
|
||||||
|
PaperProps={{
|
||||||
|
style: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
boxShadow: 'none',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
BackdropProps={{
|
||||||
|
style: {
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
gap={1}
|
||||||
|
width={100}
|
||||||
|
height={100}
|
||||||
|
borderRadius={4}
|
||||||
|
bgcolor="#338660"
|
||||||
|
p={3}
|
||||||
|
>
|
||||||
|
<Building className="w-10 h-10 text-white" />
|
||||||
|
<CircularProgress
|
||||||
|
size={18}
|
||||||
|
sx={{ color: '#fff' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* <span className="text-[10px] font-bold text-white">Chargement...</span> */}
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
src/components/modal/ModalPDFPreview.tsx
Normal file
151
src/components/modal/ModalPDFPreview.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Download, Loader2, ZoomIn, ZoomOut } from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
interface PDFPreviewModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
pdfUrl: string | null;
|
||||||
|
fileName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PDFPreviewModal: React.FC<PDFPreviewModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
pdfUrl,
|
||||||
|
fileName = 'document.pdf'
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pdfUrl) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [pdfUrl]);
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (!pdfUrl) return;
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = pdfUrl;
|
||||||
|
a.download = fileName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
className="relative bg-white dark:bg-gray-950 w-full max-w-6xl h-screen shadow-2xl flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex-shrink-0 px-6 py-4 border-b border-gray-200 dark:border-gray-800 flex justify-between items-center bg-gray-50/50 dark:bg-gray-900/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||||
|
Prévisualisation PDF
|
||||||
|
</h3>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{fileName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-[#941403] text-white rounded-xl text-sm font-medium hover:bg-[#7a1002] transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Télécharger
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PDF Viewer */}
|
||||||
|
<div className="flex-1 overflow-hidden bg-gray-100 dark:bg-gray-900 relative">
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 text-[#941403] animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pdfUrl && (
|
||||||
|
<iframe
|
||||||
|
src={pdfUrl}
|
||||||
|
className="w-full h-full border-0"
|
||||||
|
title="PDF Preview"
|
||||||
|
onLoad={() => setLoading(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Hook personnalisé pour gérer la prévisualisation PDF
|
||||||
|
// =============================================
|
||||||
|
export const usePDFPreview = () => {
|
||||||
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [fileName, setFileName] = useState('document.pdf');
|
||||||
|
|
||||||
|
const openPreview = (blob: Blob, name?: string) => {
|
||||||
|
// Révoquer l'ancienne URL si elle existe
|
||||||
|
if (pdfUrl) {
|
||||||
|
window.URL.revokeObjectURL(pdfUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
setPdfUrl(url);
|
||||||
|
setFileName(name || 'document.pdf');
|
||||||
|
setIsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePreview = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup à la destruction du composant
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (pdfUrl) {
|
||||||
|
window.URL.revokeObjectURL(pdfUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [pdfUrl]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pdfUrl,
|
||||||
|
isOpen,
|
||||||
|
fileName,
|
||||||
|
openPreview,
|
||||||
|
closePreview,
|
||||||
|
PDFPreviewModal,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PDFPreviewModal;
|
||||||
639
src/components/modal/ModalPaymentPanel.tsx
Normal file
639
src/components/modal/ModalPaymentPanel.tsx
Normal file
|
|
@ -0,0 +1,639 @@
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { X, Calendar, Wallet, CheckCircle, AlertTriangle, Coins, AlertCircle, Building, CreditCard } from 'lucide-react';
|
||||||
|
import { cn, formatCurrency } from '@/lib/utils';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { toast } from '@/components/ui/use-toast';
|
||||||
|
import { Facture } from '@/types/factureType';
|
||||||
|
import PrimaryButton_v2 from '../PrimaryButton_v2';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||||
|
import { getAllModes, getAllDevises, getAllTresoreries, getAllEncaissements, reglementStatus } from '@/store/features/reglement/selectors';
|
||||||
|
import { ReglementRequest } from '@/types/reglementType';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { getReglementId, reglerFacture } from '@/store/features/reglement/thunk';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
type PaymentStep = 'input' | 'confirmation';
|
||||||
|
|
||||||
|
interface Allocation {
|
||||||
|
[invoiceId: string]: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModalPaymentPanelProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
selectedInvoices: Facture[];
|
||||||
|
totalAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PRINCIPAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const ModalPaymentPanel: React.FC<ModalPaymentPanelProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
selectedInvoices,
|
||||||
|
totalAmount
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
// Redux selectors
|
||||||
|
const modes = useAppSelector(getAllModes);
|
||||||
|
const devises = useAppSelector(getAllDevises);
|
||||||
|
const tresoreries = useAppSelector(getAllTresoreries);
|
||||||
|
const encaissements = useAppSelector(getAllEncaissements);
|
||||||
|
const statsReglement = useAppSelector(reglementStatus);
|
||||||
|
|
||||||
|
|
||||||
|
const [step, setStep] = useState<PaymentStep>('input');
|
||||||
|
const [allocations, setAllocations] = useState<Allocation>({});
|
||||||
|
|
||||||
|
// Form state pour ReglementRequest
|
||||||
|
const [reglementData, setReglementData] = useState<ReglementRequest>({
|
||||||
|
client_id: '',
|
||||||
|
code_journal: '',
|
||||||
|
cours_devise: 1,
|
||||||
|
date_echeance: new Date().toISOString().split('T')[0],
|
||||||
|
date_reglement: new Date().toISOString().split('T')[0],
|
||||||
|
devise_code: 0,
|
||||||
|
libelle: 'Reglement multiple',
|
||||||
|
mode_reglement: 2,
|
||||||
|
montant_total: 0,
|
||||||
|
numeros_factures: [],
|
||||||
|
reference: '',
|
||||||
|
tva_encaissement: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Devise principale par défaut
|
||||||
|
const devisePrincipale = useMemo(() => {
|
||||||
|
return devises?.find(d => d.est_principale) || devises?.[0];
|
||||||
|
}, [devises]);
|
||||||
|
|
||||||
|
// Initial setup when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && selectedInvoices.length > 0) {
|
||||||
|
const totalDue = selectedInvoices.reduce((sum, inv) => sum + (inv.total_ttc_calcule || 0), 0);
|
||||||
|
|
||||||
|
// Auto-allocate full amounts initially
|
||||||
|
const initialAllocations: Allocation = {};
|
||||||
|
const numerosFactures: string[] = [];
|
||||||
|
selectedInvoices.forEach(inv => {
|
||||||
|
initialAllocations[inv.numero] = inv.total_ttc_calcule || 0;
|
||||||
|
numerosFactures.push(inv.numero);
|
||||||
|
});
|
||||||
|
setAllocations(initialAllocations);
|
||||||
|
|
||||||
|
// Initialize reglement data
|
||||||
|
const clientId = selectedInvoices[0]?.client_code || selectedInvoices[0]?.client_code || '';
|
||||||
|
|
||||||
|
setReglementData({
|
||||||
|
client_id: clientId,
|
||||||
|
code_journal: tresoreries?.[0]?.code || '',
|
||||||
|
cours_devise: devisePrincipale?.cours_actuel || 1,
|
||||||
|
date_echeance: new Date().toISOString().split('T')[0],
|
||||||
|
date_reglement: new Date().toISOString().split('T')[0],
|
||||||
|
devise_code: devisePrincipale?.code || 0,
|
||||||
|
libelle: 'Reglement multiple',
|
||||||
|
mode_reglement: modes?.[0]?.code || 2,
|
||||||
|
montant_total: totalDue,
|
||||||
|
numeros_factures: numerosFactures,
|
||||||
|
reference: '',
|
||||||
|
tva_encaissement: encaissements?.tva_encaissement_actif || false
|
||||||
|
});
|
||||||
|
|
||||||
|
setStep('input');
|
||||||
|
}
|
||||||
|
}, [isOpen, selectedInvoices, modes, devises, tresoreries, encaissements, devisePrincipale]);
|
||||||
|
|
||||||
|
// Update numeros_factures based on allocations
|
||||||
|
useEffect(() => {
|
||||||
|
const activeInvoices = Object.entries(allocations)
|
||||||
|
.filter(([_, amount]) => amount > 0)
|
||||||
|
.map(([numero]) => numero);
|
||||||
|
|
||||||
|
setReglementData(prev => ({
|
||||||
|
...prev,
|
||||||
|
numeros_factures: activeInvoices
|
||||||
|
}));
|
||||||
|
}, [allocations]);
|
||||||
|
|
||||||
|
const totalSelectedAmount = useMemo(() => {
|
||||||
|
return selectedInvoices.reduce((sum, inv) => sum + (inv.total_ttc_calcule || 0), 0);
|
||||||
|
}, [selectedInvoices]);
|
||||||
|
|
||||||
|
const totalAllocated = useMemo(() => {
|
||||||
|
return Object.values(allocations).reduce((sum, val) => sum + (parseFloat(val.toString()) || 0), 0);
|
||||||
|
}, [allocations]);
|
||||||
|
|
||||||
|
const remainingToAllocate = reglementData.montant_total - totalAllocated;
|
||||||
|
// const isOverAllocated = totalAllocated < reglementData.montant_total + 0.01;
|
||||||
|
const isUnderAllocated = remainingToAllocate > 0.01;
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleFieldChange = <K extends keyof ReglementRequest>(field: K, value: ReglementRequest[K]) => {
|
||||||
|
setReglementData(prev => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeviseChange = (deviseCode: number) => {
|
||||||
|
const devise = devises?.find(d => d.code === deviseCode);
|
||||||
|
setReglementData(prev => ({
|
||||||
|
...prev,
|
||||||
|
devise_code: deviseCode,
|
||||||
|
cours_devise: devise?.cours_actuel || 1
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAllocationChange = (invoiceId: string, value: string) => {
|
||||||
|
const numValue = parseFloat(value) || 0;
|
||||||
|
const invoice = selectedInvoices.find(inv => inv.numero === invoiceId);
|
||||||
|
const maxAllocation = invoice?.total_ttc_calcule || 0;
|
||||||
|
const finalValue = Math.min(Math.max(0, numValue), maxAllocation);
|
||||||
|
|
||||||
|
setAllocations(prev => ({ ...prev, [invoiceId]: finalValue }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFullAllocation = (invoiceId: string, isChecked: boolean) => {
|
||||||
|
const invoice = selectedInvoices.find(inv => inv.numero === invoiceId);
|
||||||
|
if (!invoice) return;
|
||||||
|
|
||||||
|
setAllocations(prev => ({
|
||||||
|
...prev,
|
||||||
|
[invoiceId]: isChecked ? (invoice.total_ttc_calcule || 0) : 0
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValidateClick = () => {
|
||||||
|
// if (isOverAllocated) {
|
||||||
|
// toast({
|
||||||
|
// title: "Erreur de ventilation",
|
||||||
|
// description: "Le montant ventilé ne peut pas dépasser le montant du règlement.",
|
||||||
|
// variant: "destructive"
|
||||||
|
// });
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
if(!reglementData.reference){
|
||||||
|
toast({
|
||||||
|
title: "Champ requis",
|
||||||
|
description: "Veuillez ajouter la référence.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!reglementData.code_journal) {
|
||||||
|
toast({
|
||||||
|
title: "Champ requis",
|
||||||
|
description: "Veuillez sélectionner un journal de trésorerie.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStep('confirmation');
|
||||||
|
};
|
||||||
|
|
||||||
|
// const handleConfirm = () => {
|
||||||
|
// console.log("reglementData : ",reglementData);
|
||||||
|
// onValidate(reglementData);
|
||||||
|
// };
|
||||||
|
|
||||||
|
const handlePaymentValidate = async() => {
|
||||||
|
try {
|
||||||
|
console.log('Règlement validé:', reglementData);
|
||||||
|
const result = await dispatch(reglerFacture(reglementData)).unwrap();
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
const success = result.success
|
||||||
|
const data = result.data
|
||||||
|
|
||||||
|
if(success){
|
||||||
|
for (let index = 0; index < data.reglements.length; index++) {
|
||||||
|
const numero_facture = data.reglements[index].numero_facture
|
||||||
|
const factureRegle = await dispatch(getReglementId(numero_facture)).unwrap();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: `Règlement du facture ${numero_facture} effectué`,
|
||||||
|
description: "Les factures ont été réglées avec succès.",
|
||||||
|
className: "bg-green-500 text-white border-green-600"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(data.reglements.length > 1) navigate(`/home/reglements`);
|
||||||
|
else navigate(`/home/reglements`); //navigate(`/home/reglements/${data.reglements[0].numero_facture}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Impossible de réglé ces factures.",
|
||||||
|
className: "bg-red-500 text-white border-red-600",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const clientName = selectedInvoices[0]?.client_intitule || "Client Inconnu";
|
||||||
|
const selectedMode = modes?.find(m => m.code === reglementData.mode_reglement);
|
||||||
|
const selectedDevise = devises?.find(d => d.code === reglementData.devise_code);
|
||||||
|
const selectedTresorerie = tresoreries?.find(t => t.code === reglementData.code_journal);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[60]"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "100%" }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||||
|
className="fixed inset-y-0 right-0 w-full max-w-2xl bg-white dark:bg-gray-950 shadow-2xl z-[70] flex flex-col border-l border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-950/80 backdrop-blur-md">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Wallet className="w-5 h-5 text-[#338660]" />
|
||||||
|
Saisie du règlement
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Client : <span className="font-medium text-gray-900 dark:text-white">{clientName}</span>
|
||||||
|
<span className="ml-2 text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{reglementData.client_id}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors">
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 bg-gray-50/50 dark:bg-black/20">
|
||||||
|
|
||||||
|
{/* STEP 1: INPUT */}
|
||||||
|
{step === 'input' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* General Info Card */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-5 shadow-sm space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-3">
|
||||||
|
<Calendar className="w-4 h-4 text-gray-500" /> Informations générales
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-gray-600 dark:text-gray-400">Date de règlement *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={reglementData.date_reglement}
|
||||||
|
onChange={(e) => handleFieldChange('date_reglement', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#338660]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-gray-600 dark:text-gray-400">Date d'échéance *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={reglementData.date_echeance}
|
||||||
|
onChange={(e) => handleFieldChange('date_echeance', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#338660]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-gray-600 dark:text-gray-400">Mode de paiement *</label>
|
||||||
|
<select
|
||||||
|
value={reglementData.mode_reglement}
|
||||||
|
onChange={(e) => handleFieldChange('mode_reglement', Number(e.target.value))}
|
||||||
|
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#338660]"
|
||||||
|
>
|
||||||
|
{modes?.map(mode => (
|
||||||
|
<option key={mode.code} value={mode.code}>
|
||||||
|
{mode.intitule}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-gray-600 dark:text-gray-400">Journal de trésorerie *</label>
|
||||||
|
<select
|
||||||
|
value={reglementData.code_journal}
|
||||||
|
onChange={(e) => handleFieldChange('code_journal', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#338660]"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner...</option>
|
||||||
|
{tresoreries?.map(tres => (
|
||||||
|
<option key={tres.code} value={tres.code}>
|
||||||
|
{tres.code} - {tres.intitule}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-gray-600 dark:text-gray-400">Devise</label>
|
||||||
|
<select
|
||||||
|
value={reglementData.devise_code}
|
||||||
|
onChange={(e) => handleDeviseChange(Number(e.target.value))}
|
||||||
|
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#338660]"
|
||||||
|
>
|
||||||
|
{devises?.map(devise => (
|
||||||
|
<option key={devise.code} value={devise.code}>
|
||||||
|
{devise.intitule} ({devise.sigle}) {devise.est_principale && '★'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-gray-600 dark:text-gray-400">Cours devise</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
value={reglementData.cours_devise}
|
||||||
|
onChange={(e) => handleFieldChange('cours_devise', parseFloat(e.target.value) || 1)}
|
||||||
|
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#338660]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-gray-600 dark:text-gray-400">Libellé</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Reglement multiple"
|
||||||
|
value={reglementData.libelle}
|
||||||
|
onChange={(e) => handleFieldChange('libelle', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#338660]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-gray-600 dark:text-gray-400">Référence <span className='font-bold'>*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex: CHQ-001, VIR-2025-00123"
|
||||||
|
required
|
||||||
|
value={reglementData.reference}
|
||||||
|
onChange={(e) => handleFieldChange('reference', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#338660]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex items-center gap-3 pt-2">
|
||||||
|
<Checkbox
|
||||||
|
id="tva_encaissement"
|
||||||
|
checked={reglementData.tva_encaissement}
|
||||||
|
onCheckedChange={(checked) => handleFieldChange('tva_encaissement', checked as boolean)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="tva_encaissement" className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||||
|
TVA sur encaissement
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amounts & Ventilation Card */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-5 shadow-sm space-y-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Coins className="w-4 h-4 text-gray-500" /> Montants & Ventilation
|
||||||
|
</h3>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
Total sélectionné : {formatCurrency(totalSelectedAmount)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Payment Amount Input */}
|
||||||
|
<div className="bg-[#338660]/5 dark:bg-[#338660]/10 border border-[#338660]/20 rounded-lg p-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-[#338660]">Montant total du règlement *</label>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
Saisissez le montant total reçu
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-40">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={reglementData.montant_total}
|
||||||
|
onChange={(e) => handleFieldChange('montant_total', parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full pl-4 pr-8 py-2 bg-white dark:bg-gray-950 border border-[#338660]/30 rounded-lg text-lg font-bold text-right focus:outline-none focus:ring-2 focus:ring-[#338660]"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 font-medium">
|
||||||
|
{selectedDevise?.sigle || '€'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Indicators */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3 border border-gray-100 dark:border-gray-800">
|
||||||
|
<span className="text-xs text-gray-500 block">Montant ventilé</span>
|
||||||
|
<span className={cn(
|
||||||
|
"text-lg font-bold block text-gray-900 dark:text-white",
|
||||||
|
// isOverAllocated ? "text-red-500" : "text-gray-900 dark:text-white"
|
||||||
|
)}>
|
||||||
|
{formatCurrency(totalAllocated)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3 border border-gray-100 dark:border-gray-800">
|
||||||
|
<span className="text-xs text-gray-500 block">Reste à ventiler</span>
|
||||||
|
<span className={cn(
|
||||||
|
"text-lg font-bold block",
|
||||||
|
remainingToAllocate < 0 ? "text-red-500" : (remainingToAllocate === 0 ? "text-green-500" : "text-amber-500")
|
||||||
|
)}>
|
||||||
|
{formatCurrency(remainingToAllocate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Messages */}
|
||||||
|
{/* {isOverAllocated && (
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 text-sm rounded-lg border border-red-100 dark:border-red-900/50">
|
||||||
|
<AlertCircle className="w-5 h-5 shrink-0" />
|
||||||
|
<p>Attention : Le montant ventilé est supérieur au montant du règlement.</p>
|
||||||
|
</div>
|
||||||
|
)} */}
|
||||||
|
{isUnderAllocated && (
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-900/20 text-amber-600 text-sm rounded-lg border border-amber-100 dark:border-amber-900/50">
|
||||||
|
<AlertTriangle className="w-5 h-5 shrink-0" />
|
||||||
|
<p>Note : Une partie du règlement n'est pas affectée ({formatCurrency(remainingToAllocate)}).</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ventilation Table */}
|
||||||
|
<div className="mt-4 border rounded-lg border-gray-200 dark:border-gray-800 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-950 text-gray-500 font-medium border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left">Facture</th>
|
||||||
|
<th className="px-4 py-2 text-right">Solde Dû</th>
|
||||||
|
<th className="px-4 py-2 text-center w-24">Tout régler</th>
|
||||||
|
<th className="px-4 py-2 text-right w-40">Montant affecté</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{selectedInvoices.map(invoice => (
|
||||||
|
<tr key={invoice.numero} className="group hover:bg-gray-50/50 dark:hover:bg-gray-900/20">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{invoice.numero}</div>
|
||||||
|
<div className="text-xs text-gray-500">{new Date(invoice.date).toLocaleDateString()}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-600 dark:text-gray-300">
|
||||||
|
{formatCurrency(invoice.total_ttc_calcule || 0)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={Math.abs((allocations[invoice.numero] || 0) - (invoice.total_ttc_calcule || 0)) < 0.01 && (invoice.total_ttc_calcule > 0)}
|
||||||
|
onCheckedChange={(checked) => toggleFullAllocation(invoice.numero, checked as boolean)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
max={invoice.total_ttc_calcule}
|
||||||
|
value={allocations[invoice.numero] || 0}
|
||||||
|
onChange={(e) => handleAllocationChange(invoice.numero, e.target.value)}
|
||||||
|
className="w-full text-right bg-transparent border-b border-gray-200 dark:border-gray-700 focus:border-[#338660] focus:ring-0 px-1 py-1 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* STEP 2: CONFIRMATION */}
|
||||||
|
{step === 'confirmation' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/10 border border-green-100 dark:border-green-900/30 rounded-xl p-6 text-center">
|
||||||
|
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||||
|
<CheckCircle className="w-6 h-6 text-[#338660]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white">Confirmer le règlement</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Veuillez vérifier les informations ci-dessous avant de valider.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-950">
|
||||||
|
<h4 className="font-semibold text-gray-900 dark:text-white text-sm">Récapitulatif</h4>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">Client</span>
|
||||||
|
<span className="font-medium">{clientName} ({reglementData.client_id})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">Date règlement</span>
|
||||||
|
<span className="font-medium">{new Date(reglementData.date_reglement).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">Date échéance</span>
|
||||||
|
<span className="font-medium">{new Date(reglementData.date_echeance).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">Mode de paiement</span>
|
||||||
|
<span className="font-medium">{selectedMode?.intitule || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">Journal</span>
|
||||||
|
<span className="font-medium">{selectedTresorerie?.code} - {selectedTresorerie?.intitule}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">Devise</span>
|
||||||
|
<span className="font-medium">{selectedDevise?.intitule} (cours: {reglementData.cours_devise})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">Libellé</span>
|
||||||
|
<span className="font-medium">{reglementData.libelle}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">Référence</span>
|
||||||
|
<span className="font-medium">{reglementData.reference || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">TVA sur encaissement</span>
|
||||||
|
<span className="font-medium">{reglementData.tva_encaissement ? 'Oui' : 'Non'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-gray-100 dark:bg-gray-800 my-2" />
|
||||||
|
<div className="flex justify-between text-base font-bold">
|
||||||
|
<span className="text-gray-900 dark:text-white">Montant Total</span>
|
||||||
|
<span className="text-[#338660]">{formatCurrency(reglementData.montant_total)} {selectedDevise?.sigle}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm text-gray-500">
|
||||||
|
<span>Montant non affecté (acompte/crédit)</span>
|
||||||
|
<span>{formatCurrency(remainingToAllocate > 0 ? remainingToAllocate : 0)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white px-1">
|
||||||
|
Factures concernées ({reglementData.numeros_factures.length})
|
||||||
|
</h4>
|
||||||
|
{selectedInvoices.filter(inv => (allocations[inv.numero] || 0) > 0).map(inv => (
|
||||||
|
<div key={inv.numero} className="flex justify-between items-center p-3 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">{inv.numero}</span>
|
||||||
|
<div className="text-xs text-gray-500">Solde avant: {formatCurrency(inv.total_ttc_calcule || 0)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="font-bold text-[#338660]">-{formatCurrency(allocations[inv.numero] || 0)}</span>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
Reste: {formatCurrency((inv.total_ttc_calcule || 0) - (allocations[inv.numero] || 0))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 z-10 flex justify-end gap-3">
|
||||||
|
{step === 'input' ? (
|
||||||
|
<>
|
||||||
|
<button onClick={onClose} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<PrimaryButton_v2 onClick={handleValidateClick} disabled={reglementData.montant_total <= 0}>
|
||||||
|
Suivant
|
||||||
|
</PrimaryButton_v2>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setStep('input')} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
|
<PrimaryButton_v2 disabled={statsReglement === "loading"} onClick={handlePaymentValidate} className="bg-[#338660] hover:bg-[#2A6F4F]">
|
||||||
|
|
||||||
|
{statsReglement === "loading" ? (
|
||||||
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
):(
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Valider le règlement
|
||||||
|
</PrimaryButton_v2>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalPaymentPanel;
|
||||||
649
src/components/modal/ModalQuote.tsx
Normal file
649
src/components/modal/ModalQuote.tsx
Normal file
|
|
@ -0,0 +1,649 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { DevisListItem, DevisRequest, DevisResponse, LigneDevis } from "@/types/devisType";
|
||||||
|
import FormModal, { FormSection, FormField, Input } from '@/components/ui/FormModal';
|
||||||
|
import { AlertTriangle, Package, PenLine, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/store/hooks";
|
||||||
|
import ClientAutocomplete from "../molecules/ClientAutocomplete";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Client } from "@/types/clientType";
|
||||||
|
import ArticleAutocomplete from "../molecules/ArticleAutocomplete";
|
||||||
|
import { Article } from "@/types/articleType";
|
||||||
|
import { Alert, Divider } from "@mui/material";
|
||||||
|
import { createDevis, getDevisById, updateDevis } from "@/store/features/devis/thunk";
|
||||||
|
import { selectDevis } from "@/store/features/devis/slice";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "../ui/use-toast";
|
||||||
|
import { devisStatus } from "@/store/features/devis/selectors";
|
||||||
|
import { ModalLoading } from "./ModalLoading";
|
||||||
|
import { formatForDateInput, cn } from "@/lib/utils";
|
||||||
|
import { ModalArticle } from "./ModalArticle";
|
||||||
|
import { Tooltip, TooltipTrigger, TooltipProvider, TooltipContent } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface LigneDevisForm extends LigneDevis {
|
||||||
|
id: string;
|
||||||
|
article?: Article | null;
|
||||||
|
remiseValide?: boolean;
|
||||||
|
isManual: boolean;
|
||||||
|
designation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer un ID unique
|
||||||
|
const generateLineId = () => `line_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PRINCIPAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export function ModalQuote({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
editing,
|
||||||
|
client
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
editing?: DevisListItem | null;
|
||||||
|
client?: Client | null
|
||||||
|
}) {
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const statusDevis = useAppSelector(devisStatus);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
// États pour le modal article
|
||||||
|
const [isArticleModalOpen, setIsArticleModalOpen] = useState(false);
|
||||||
|
const [currentLineId, setCurrentLineId] = useState<string | null>(null);
|
||||||
|
const [activeLineId, setActiveLineId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Nouveaux champs
|
||||||
|
const [dateEmission, setDateEmission] = useState(() => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
const [dateLivraison, setDateLivraison] = useState(() => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + 8);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
const [validiteJours, setValiditeJours] = useState(30);
|
||||||
|
const [referenceClient, setReferenceClient] = useState('');
|
||||||
|
|
||||||
|
const [clientSelectionne, setClientSelectionne] = useState<Client | null>(client ?? null);
|
||||||
|
|
||||||
|
const [lignes, setLignes] = useState<LigneDevisForm[]>([
|
||||||
|
{ id: generateLineId(), article_code: '', quantite: 1, remise_pourcentage: 0, article: null, remiseValide: true, isManual: false, designation: '' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isEditing = !!editing;
|
||||||
|
|
||||||
|
// Initialiser le formulaire avec les données d'édition
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
setClientSelectionne({
|
||||||
|
numero: editing.client_code,
|
||||||
|
intitule: editing.client_intitule,
|
||||||
|
compte_collectif: "",
|
||||||
|
adresse: "",
|
||||||
|
code_postal: "",
|
||||||
|
ville: "",
|
||||||
|
email: "",
|
||||||
|
telephone: "",
|
||||||
|
} as Client);
|
||||||
|
|
||||||
|
const lignesInitiales: LigneDevisForm[] = editing.lignes.map(ligne => ({
|
||||||
|
id: generateLineId(),
|
||||||
|
article_code: ligne.article_code,
|
||||||
|
quantite: ligne.quantite,
|
||||||
|
prix_unitaire_ht: ligne.prix_unitaire_ht,
|
||||||
|
remise_pourcentage: 0,
|
||||||
|
article: ligne.article_code ? {
|
||||||
|
reference: ligne.article_code,
|
||||||
|
designation: ligne.designation,
|
||||||
|
prix_vente: ligne.prix_unitaire_ht,
|
||||||
|
} as Article : null,
|
||||||
|
remiseValide: true,
|
||||||
|
isManual: !ligne.article_code,
|
||||||
|
designation: ligne.designation || '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
setLignes(lignesInitiales.length > 0 ? lignesInitiales : [
|
||||||
|
{ id: generateLineId(), article_code: '', quantite: 1, remise_pourcentage: 0, article: null, remiseValide: true, isManual: false, designation: '' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (editing.date) setDateEmission(editing.date);
|
||||||
|
if (editing.date_livraison) setDateLivraison(editing.date_livraison);
|
||||||
|
setReferenceClient(editing.reference || '');
|
||||||
|
} else {
|
||||||
|
if (!client) {
|
||||||
|
setClientSelectionne(null);
|
||||||
|
} else {
|
||||||
|
setClientSelectionne(client);
|
||||||
|
}
|
||||||
|
setLignes([
|
||||||
|
{ id: generateLineId(), article_code: '', quantite: 1, remise_pourcentage: 0, article: null, remiseValide: true, isManual: false, designation: '' }
|
||||||
|
]);
|
||||||
|
setDateEmission(new Date().toISOString().split('T')[0]);
|
||||||
|
setDateLivraison(() => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + 8);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
});
|
||||||
|
setReferenceClient('');
|
||||||
|
setValiditeJours(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
}, [open, editing, client]);
|
||||||
|
|
||||||
|
// Toggle mode Article/Libre
|
||||||
|
const toggleLineMode = (lineId: string) => {
|
||||||
|
setLignes(prev => prev.map(ligne => {
|
||||||
|
if (ligne.id === lineId) {
|
||||||
|
const newIsManual = !ligne.isManual;
|
||||||
|
return {
|
||||||
|
...ligne,
|
||||||
|
isManual: newIsManual,
|
||||||
|
article_code: newIsManual ? '' : ligne.article_code,
|
||||||
|
article: newIsManual ? null : ligne.article,
|
||||||
|
designation: '',
|
||||||
|
prix_unitaire_ht: newIsManual ? 0 : ligne.prix_unitaire_ht,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return ligne;
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const appliquerBareme = async (lineId: string, articleRef: string) => {
|
||||||
|
if (!clientSelectionne) return;
|
||||||
|
try {
|
||||||
|
// Logique d'application du barème
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur application barème:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ajouterLigne = () => {
|
||||||
|
setLignes([
|
||||||
|
...lignes,
|
||||||
|
{ id: generateLineId(), article_code: '', quantite: 1, remise_pourcentage: 0, article: null, remiseValide: true, isManual: false, designation: '' },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const supprimerLigne = (lineId: string) => {
|
||||||
|
if (lignes.length > 1) {
|
||||||
|
setLignes(lignes.filter(l => l.id !== lineId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLigne = (lineId: string, field: keyof LigneDevisForm, value: any) => {
|
||||||
|
setLignes(prev => prev.map(ligne => {
|
||||||
|
if (ligne.id !== lineId) return ligne;
|
||||||
|
|
||||||
|
if (field === 'article' && value) {
|
||||||
|
const article = value as Article;
|
||||||
|
if (clientSelectionne) {
|
||||||
|
appliquerBareme(lineId, article.reference);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...ligne,
|
||||||
|
article: article,
|
||||||
|
article_code: article.reference,
|
||||||
|
designation: article.designation,
|
||||||
|
prix_unitaire_ht: article.prix_vente,
|
||||||
|
};
|
||||||
|
} else if (field === 'article' && !value) {
|
||||||
|
return {
|
||||||
|
...ligne,
|
||||||
|
article: null,
|
||||||
|
article_code: '',
|
||||||
|
designation: '',
|
||||||
|
prix_unitaire_ht: 0,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return { ...ligne, [field]: value };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ouvrir le modal de création d'article
|
||||||
|
const handleOpenArticleModal = (lineId: string) => {
|
||||||
|
setCurrentLineId(lineId);
|
||||||
|
setIsArticleModalOpen(true);
|
||||||
|
setActiveLineId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Callback après création d'article
|
||||||
|
const handleArticleCreated = (article: Article) => {
|
||||||
|
if (currentLineId) {
|
||||||
|
updateLigne(currentLineId, 'article', article);
|
||||||
|
// Repasser en mode article catalogue
|
||||||
|
setLignes(prev => prev.map(ligne => {
|
||||||
|
if (ligne.id === currentLineId) {
|
||||||
|
return { ...ligne, isManual: false };
|
||||||
|
}
|
||||||
|
return ligne;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setIsArticleModalOpen(false);
|
||||||
|
setCurrentLineId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const calculerTotalLigne = (ligne: LigneDevisForm) => {
|
||||||
|
const prix = ligne.prix_unitaire_ht || ligne.article?.prix_vente || 0;
|
||||||
|
const montantBrut = prix * ligne.quantite;
|
||||||
|
const remise = montantBrut * ((ligne.remise_pourcentage || 0) / 100);
|
||||||
|
return montantBrut - remise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculerTotal = () => {
|
||||||
|
return lignes.reduce((total, ligne) => total + calculerTotalLigne(ligne), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toutesRemisesValides = () => {
|
||||||
|
return lignes.every((ligne) => ligne.remiseValide !== false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSave = useMemo(() => {
|
||||||
|
if (!clientSelectionne) return false;
|
||||||
|
// Accepter les lignes avec article OU les lignes manuelles avec désignation
|
||||||
|
const lignesValides = lignes.filter((l) => l.article_code || (l.isManual && l.designation));
|
||||||
|
if (lignesValides.length === 0) return false;
|
||||||
|
if (!toutesRemisesValides()) return false;
|
||||||
|
return true;
|
||||||
|
}, [clientSelectionne, lignes]);
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
if (!clientSelectionne) {
|
||||||
|
setError('Veuillez sélectionner un client');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lignesValides = lignes.filter((l) => l.article_code || (l.isManual && l.designation));
|
||||||
|
|
||||||
|
if (lignesValides.length === 0) {
|
||||||
|
setError('Veuillez ajouter au moins un article ou une ligne');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!toutesRemisesValides()) {
|
||||||
|
setError('Certaines remises ne sont pas autorisées. Veuillez les corriger.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const lignesPayload = lignesValides.map((l) => ({
|
||||||
|
article_code: l.article_code || 'DIVERS',
|
||||||
|
quantite: l.quantite,
|
||||||
|
remise_pourcentage: l.remise_pourcentage,
|
||||||
|
// Ajouter la désignation pour les lignes manuelles
|
||||||
|
...(l.isManual && l.designation && { designation: l.designation })
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
const payloadUpdate = {
|
||||||
|
client_id: clientSelectionne.numero,
|
||||||
|
date_devis: (() => {
|
||||||
|
const d = new Date(dateEmission);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
})(),
|
||||||
|
reference: referenceClient,
|
||||||
|
date_livraison: (() => {
|
||||||
|
const d = new Date(dateLivraison);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
})(),
|
||||||
|
lignes: lignesPayload,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await dispatch(updateDevis({
|
||||||
|
numero: editing!.numero,
|
||||||
|
data: payloadUpdate
|
||||||
|
})).unwrap() as any;
|
||||||
|
|
||||||
|
const numero = result.devis.numero as any;
|
||||||
|
|
||||||
|
setSuccess(true);
|
||||||
|
toast({
|
||||||
|
title: "Devis mis à jour !",
|
||||||
|
description: `Le devis a été mis à jour avec succès.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600"
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
const devisCreated = await dispatch(getDevisById(numero)).unwrap() as any;
|
||||||
|
const res = devisCreated.data as DevisListItem;
|
||||||
|
dispatch(selectDevis(res));
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
onClose();
|
||||||
|
navigate(`/home/devis/${numero}`);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const payloadCreate: DevisRequest = {
|
||||||
|
client_id: clientSelectionne.numero,
|
||||||
|
date_devis: (() => {
|
||||||
|
const d = new Date(dateEmission);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
})(),
|
||||||
|
reference: referenceClient,
|
||||||
|
date_livraison: (() => {
|
||||||
|
const d = new Date(dateLivraison);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
})(),
|
||||||
|
lignes: lignesPayload,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await dispatch(createDevis(payloadCreate)).unwrap() as DevisResponse;
|
||||||
|
|
||||||
|
setSuccess(true);
|
||||||
|
|
||||||
|
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éé !",
|
||||||
|
description: `Un nouveau devis ${result.id} a été créé avec succès.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600"
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
onClose();
|
||||||
|
navigate(`/home/devis/${result.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
setError("Cet article n'est pas disponible pour ce client.");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalHT = calculerTotal();
|
||||||
|
const totalTVA = totalHT * 0.20;
|
||||||
|
const totalTTC = totalHT + totalTVA;
|
||||||
|
const remisesNonAutorisees = lignes.filter((l) => l.remiseValide === false).length;
|
||||||
|
const lignesValides = lignes.filter((l) => l.article_code || (l.isManual && l.designation)).length;
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormModal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title || (isEditing ? "Modifier le devis" : "Créer un devis")}
|
||||||
|
size="xl"
|
||||||
|
onSubmit={onSave}
|
||||||
|
loading={statusDevis === "loading"}
|
||||||
|
>
|
||||||
|
{/* Section Client */}
|
||||||
|
<FormSection title="Informations client" description="Sélectionnez le client et les coordonnées">
|
||||||
|
<FormField label="Client" required fullWidth>
|
||||||
|
<ClientAutocomplete
|
||||||
|
value={clientSelectionne}
|
||||||
|
onChange={setClientSelectionne}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="col-span-1 md:col-span-2 grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||||
|
<FormField label="Date d'émission" required>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formatForDateInput(dateEmission)}
|
||||||
|
onChange={(e) => setDateEmission(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Date d'échéance" required>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formatForDateInput(dateLivraison)}
|
||||||
|
onChange={(e) => setDateLivraison(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Référence">
|
||||||
|
<Input
|
||||||
|
placeholder="Ex: REF-PROJET-123"
|
||||||
|
value={referenceClient}
|
||||||
|
onChange={(e) => setReferenceClient(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Section Lignes */}
|
||||||
|
<FormSection title="Lignes du devis" description="Ajoutez les produits et services">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full mb-4">
|
||||||
|
<thead className="text-left bg-gray-50 dark:bg-gray-900/50 text-xs uppercase text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-3 w-10 text-center rounded-l-lg">Mode</th>
|
||||||
|
<th className="px-4 py-3" style={{ width: '45%' }}>Article / Description</th>
|
||||||
|
<th className="px-2 py-3 text-center" style={{ width: '70px' }}>Qté</th>
|
||||||
|
<th className="px-2 py-3 text-right" style={{ width: '90px' }}>P.U. HT</th>
|
||||||
|
<th className="px-2 py-3 text-right" style={{ width: '80px' }}>Remise</th>
|
||||||
|
<th className="px-2 py-3 text-right" style={{ width: '90px' }}>Total HT</th>
|
||||||
|
<th className="rounded-r-lg" style={{ width: '40px' }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{lignes.map((ligne) => (
|
||||||
|
<tr key={ligne.id} className="group hover:bg-gray-50/50 dark:hover:bg-gray-900/20">
|
||||||
|
{/* Mode Toggle */}
|
||||||
|
<td className="px-2 py-2 align-middle">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleLineMode(ligne.id)}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded-md transition-all duration-200",
|
||||||
|
ligne.isManual
|
||||||
|
? "text-amber-600 bg-amber-50 hover:bg-amber-100"
|
||||||
|
: "text-[#007E45] bg-green-50 hover:bg-green-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ligne.isManual ? (
|
||||||
|
<PenLine className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Package className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{/* <TooltipContent side="right">
|
||||||
|
<p>Mode: {ligne.isManual ? 'Saisie libre' : 'Article catalogue'}</p>
|
||||||
|
<p className="text-xs text-gray-400">Cliquer pour changer</p>
|
||||||
|
</TooltipContent> */}
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Article / Désignation */}
|
||||||
|
<td className="px-4 py-2 align-top">
|
||||||
|
{ligne.isManual ? (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ligne.designation || ''}
|
||||||
|
onChange={(e) => updateLigne(ligne.id, 'designation', e.target.value)}
|
||||||
|
onFocus={() => setActiveLineId(ligne.id)}
|
||||||
|
onBlur={() => setTimeout(() => setActiveLineId(null), 200)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#007E45]/20 focus:border-[#007E45]"
|
||||||
|
placeholder="Saisir une désignation..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Menu dropdown */}
|
||||||
|
{activeLineId === ligne.id && ligne.designation && (
|
||||||
|
<div className="overflow-hidden mt-1 w-full bg-white rounded-xl border border-gray-200 shadow-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => handleOpenArticleModal(ligne.id)}
|
||||||
|
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-green-50 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<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]">{ligne.designation}</span>"
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className="border-t border-gray-100" />
|
||||||
|
<div className="w-full py-2 text-xs text-gray-400 flex flex-row items-center justify-center gap-3 text-center">
|
||||||
|
<PenLine className="w-4 h-4" />
|
||||||
|
<span className="text-xs font-medium">Texte libre accepté</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ArticleAutocomplete
|
||||||
|
value={ligne.article!}
|
||||||
|
onChange={(article) => updateLigne(ligne.id, 'article', article)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Quantité */}
|
||||||
|
<td className="px-2 py-2 align-top">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={ligne.quantite}
|
||||||
|
onChange={(e) => updateLigne(ligne.id, 'quantite', parseFloat(e.target.value) || 0)}
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Prix Unitaire HT */}
|
||||||
|
<td className="px-2 py-2 align-top">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={ligne.prix_unitaire_ht || ligne.article?.prix_vente || 0}
|
||||||
|
onChange={(e) => updateLigne(ligne.id, 'prix_unitaire_ht', parseFloat(e.target.value) || 0)}
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Remise */}
|
||||||
|
<td className="px-2 py-2 align-top">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={ligne.remise_pourcentage || 0}
|
||||||
|
onChange={(e) => updateLigne(ligne.id, 'remise_pourcentage', parseFloat(e.target.value) || 0)}
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Total HT */}
|
||||||
|
<td className="px-2 py-3 text-right align-middle">
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white text-sm">
|
||||||
|
{calculerTotalLigne(ligne).toFixed(2)} €
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Bouton Supprimer */}
|
||||||
|
<td className="px-1 py-3 text-center align-middle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => supprimerLigne(ligne.id)}
|
||||||
|
disabled={lignes.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 opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-start mt-4">
|
||||||
|
<button
|
||||||
|
type="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>
|
||||||
|
|
||||||
|
{/* Récapitulatif */}
|
||||||
|
<div style={{ width: "42vh" }} className="w-72 bg-gray-50 dark:bg-gray-900 rounded-xl p-4 space-y-3">
|
||||||
|
<Divider className="!my-3" />
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Total HT</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">{totalHT.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Taux TVA </span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">20%</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-3 border-t border-gray-200 dark:border-gray-700 flex justify-between">
|
||||||
|
<span className="font-bold text-gray-900 dark:text-white">Total TTC</span>
|
||||||
|
<span className="font-bold text-[#007E45] text-lg">{totalTTC.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
{/* Alertes */}
|
||||||
|
{remisesNonAutorisees > 0 && (
|
||||||
|
<Alert severity="warning" icon={<AlertTriangle size={16} />} className="mb-4">
|
||||||
|
{remisesNonAutorisees} remise(s) non autorisée(s) à corriger
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" className="mb-4">{error}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && <ModalLoading />}
|
||||||
|
</FormModal>
|
||||||
|
|
||||||
|
{/* Modal création article */}
|
||||||
|
<ModalArticle
|
||||||
|
open={isArticleModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsArticleModalOpen(false);
|
||||||
|
}}
|
||||||
|
// defaultDesignation={currentLineId ? lignes.find(l => l.id === currentLineId)?.designation : undefined}
|
||||||
|
// onArticleCreated={handleArticleCreated}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
309
src/components/modal/ModalSendSignatureRequest.tsx
Normal file
309
src/components/modal/ModalSendSignatureRequest.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { User, Mail, Phone, Send, ChevronRight, Check, Edit3 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Client, Contacts } from '@/types/clientType';
|
||||||
|
import { DevisListItem } from '@/types/devisType';
|
||||||
|
import FormModal, { FormField, FormSection } from '../ui/FormModal';
|
||||||
|
|
||||||
|
interface SignatureData {
|
||||||
|
emailSignataire: string;
|
||||||
|
nomSignataire: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendSignatureRequestModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
client: Client;
|
||||||
|
quote: DevisListItem;
|
||||||
|
onConfirm: (data: SignatureData) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModalSendSignatureRequest: React.FC<SendSignatureRequestModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
client,
|
||||||
|
quote,
|
||||||
|
onConfirm,
|
||||||
|
loading
|
||||||
|
}) => {
|
||||||
|
const [step, setStep] = useState<1 | 2>(1);
|
||||||
|
const [selectedContact, setSelectedContact] = useState<Contacts | null>(null)
|
||||||
|
const [useManualEntry, setUseManualEntry] = useState(false);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [nomSignataire, setNomSignataire] = useState('');
|
||||||
|
|
||||||
|
// Réinitialiser quand le modal s'ouvre
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setStep(1);
|
||||||
|
setSelectedContact(null);
|
||||||
|
setUseManualEntry(false);
|
||||||
|
setEmail(client?.email || '');
|
||||||
|
setNomSignataire(client?.intitule || '');
|
||||||
|
}
|
||||||
|
}, [isOpen, client]);
|
||||||
|
|
||||||
|
// Mettre à jour les champs quand un contact est sélectionné
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedContact) {
|
||||||
|
setEmail(selectedContact.email || '');
|
||||||
|
setNomSignataire(`${selectedContact.nom || ''} ${selectedContact.prenom || ''}`.trim());
|
||||||
|
setUseManualEntry(false);
|
||||||
|
}
|
||||||
|
}, [selectedContact]);
|
||||||
|
|
||||||
|
const isValidEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
||||||
|
|
||||||
|
const canProceed = email.trim() && nomSignataire.trim() && isValidEmail(email);
|
||||||
|
|
||||||
|
const handleSelectContact = (contact: Contacts) => {
|
||||||
|
if (selectedContact?.contact_numero === contact.contact_numero) {
|
||||||
|
// Désélectionner si on clique sur le même contact
|
||||||
|
setSelectedContact(null);
|
||||||
|
} else {
|
||||||
|
setSelectedContact(contact);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualEntry = () => {
|
||||||
|
setSelectedContact(null);
|
||||||
|
setUseManualEntry(true);
|
||||||
|
setEmail('');
|
||||||
|
setNomSignataire('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (canProceed) {
|
||||||
|
setStep(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setStep(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirm({ emailSignataire: email, nomSignataire });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setStep(1);
|
||||||
|
setSelectedContact(null);
|
||||||
|
setUseManualEntry(false);
|
||||||
|
setEmail(client?.email || '');
|
||||||
|
setNomSignataire(client?.intitule || '');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasContacts = client?.contacts && client.contacts.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
title={step === 1 ? "Envoyer le devis pour signature" : "Confirmer l'envoi"}
|
||||||
|
size="md"
|
||||||
|
onSubmit={step === 1 ? handleNext : handleConfirm}
|
||||||
|
submitLabel={step === 1 ? "Suivant" : "Envoyer pour signature"}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!canProceed}
|
||||||
|
showBackButton={step === 2}
|
||||||
|
onBack={handleBack}
|
||||||
|
>
|
||||||
|
{step === 1 ? (
|
||||||
|
<>
|
||||||
|
{/* Section Sélection de contact */}
|
||||||
|
{hasContacts && (
|
||||||
|
<FormSection
|
||||||
|
title="Sélectionner un contact"
|
||||||
|
description={`Choisissez le contact de ${client?.intitule} qui signera ce devis`}
|
||||||
|
>
|
||||||
|
<div className="col-span-2 space-y-2 max-h-[200px] overflow-y-auto custom-scrollbar">
|
||||||
|
{client.contacts!.map((contact, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
onClick={() => handleSelectContact(contact)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-4 p-3 rounded-xl border cursor-pointer transition-all",
|
||||||
|
selectedContact?.contact_numero === contact.contact_numero
|
||||||
|
? "border-[#007E45] bg-green-50 dark:bg-green-900/20 ring-1 ring-[#007E45]"
|
||||||
|
: "border-gray-200 dark:border-gray-800 hover:border-gray-300 dark:hover:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0",
|
||||||
|
selectedContact?.contact_numero === contact.contact_numero
|
||||||
|
? "bg-[#007E45] text-white"
|
||||||
|
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||||
|
)}>
|
||||||
|
{contact.nom?.charAt(0)}{contact.prenom?.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
{contact.nom} {contact.prenom}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">
|
||||||
|
{contact.email || 'Aucun email'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{selectedContact?.contact_numero === contact.contact_numero && (
|
||||||
|
<div className="w-6 h-6 rounded-full bg-[#007E45] flex items-center justify-center">
|
||||||
|
<Check className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Séparateur OU */}
|
||||||
|
{hasContacts && (
|
||||||
|
<div className="flex items-center gap-4 my-2">
|
||||||
|
<div className="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
|
||||||
|
<span className="text-sm text-gray-500 font-medium">OU</span>
|
||||||
|
<div className="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section Saisie manuelle */}
|
||||||
|
<FormSection
|
||||||
|
title={hasContacts ? "Saisir manuellement" : "Informations du signataire"}
|
||||||
|
description={hasContacts
|
||||||
|
? "Entrez les coordonnées d'un nouveau signataire"
|
||||||
|
: `Renseignez les coordonnées du signataire pour le devis de ${client?.intitule}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasContacts && !useManualEntry && !selectedContact && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleManualEntry}
|
||||||
|
className="w-full flex items-center justify-center gap-2 p-4 rounded-xl border-2 border-dashed border-gray-200 dark:border-gray-700 hover:border-[#007E45] hover:bg-green-50 dark:hover:bg-green-900/10 transition-all text-gray-500 hover:text-[#007E45]"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-5 h-5" />
|
||||||
|
<span className="font-medium">Saisir un nouveau contact</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(useManualEntry || !hasContacts || selectedContact) && (
|
||||||
|
<>
|
||||||
|
<FormField label="Nom du signataire" required>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={nomSignataire}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNomSignataire(e.target.value);
|
||||||
|
if (selectedContact) setSelectedContact(null);
|
||||||
|
}}
|
||||||
|
placeholder="Nom complet du signataire"
|
||||||
|
className={cn(
|
||||||
|
"w-full pl-10 pr-4 text-sm py-2.5 rounded-lg border bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400",
|
||||||
|
"focus:outline-none focus:ring-2 focus:ring-[#007E45] focus:border-transparent",
|
||||||
|
"border-gray-200 dark:border-gray-700"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Email du signataire" required>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEmail(e.target.value);
|
||||||
|
if (selectedContact) setSelectedContact(null);
|
||||||
|
}}
|
||||||
|
placeholder="email@exemple.com"
|
||||||
|
className={cn(
|
||||||
|
"w-full pl-10 pr-4 py-2.5 text-sm rounded-lg border bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400",
|
||||||
|
"focus:outline-none focus:ring-2 focus:ring-[#007E45] focus:border-transparent",
|
||||||
|
email && !isValidEmail(email)
|
||||||
|
? "border-red-300 dark:border-red-700"
|
||||||
|
: "border-gray-200 dark:border-gray-700"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{email && !isValidEmail(email) && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">Veuillez entrer un email valide</p>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormSection>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* Step 2: Confirmation */
|
||||||
|
<FormSection
|
||||||
|
title="Vérification avant envoi"
|
||||||
|
description="Confirmez les informations du signataire"
|
||||||
|
>
|
||||||
|
<div className="col-span-2 space-y-4">
|
||||||
|
{/* Info banner */}
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-xl p-4 flex gap-4">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-800 rounded-full flex items-center justify-center flex-shrink-0 text-blue-600 dark:text-blue-200">
|
||||||
|
<Send className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-1">Prêt à envoyer</h4>
|
||||||
|
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
Le devis <strong>{quote?.numero}</strong> sera envoyé par email pour signature électronique.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Récapitulatif signataire */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-3 mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-[#007E45] text-white flex items-center justify-center font-bold text-sm">
|
||||||
|
{nomSignataire.split(' ').map(n => n.charAt(0)).join('').slice(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-white text-lg">{nomSignataire}</p>
|
||||||
|
<p className="text-sm text-gray-500">{client?.intitule}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||||
|
<Mail className="w-4 h-4 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide">Email</p>
|
||||||
|
<p className="text-gray-900 dark:text-white font-medium">{email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{client?.telephone && (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||||
|
<Phone className="w-4 h-4 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide">Téléphone</p>
|
||||||
|
<p className="text-gray-900 dark:text-white font-medium">{client.telephone}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avertissement */}
|
||||||
|
<p className="text-xs text-gray-500 text-center">
|
||||||
|
En cliquant sur "Envoyer pour signature", un email sera envoyé au signataire avec un lien sécurisé pour signer le devis.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
)}
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalSendSignatureRequest;
|
||||||
406
src/components/modal/ModalStatus.tsx
Normal file
406
src/components/modal/ModalStatus.tsx
Normal file
|
|
@ -0,0 +1,406 @@
|
||||||
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { X, ArrowRight, Save, ShieldAlert, History } from "lucide-react";
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/store/hooks";
|
||||||
|
import { changerStatutDevis } from "@/store/features/devis/thunk";
|
||||||
|
import { changerStatutCommande } from "@/store/features/commande/thunk";
|
||||||
|
import { DevisListItem } from "@/types/devisType";
|
||||||
|
import { toast } from "../ui/use-toast";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { SageDocumentType, StatusRequest } from "@/types/sageTypes";
|
||||||
|
import { devisStatus, getDevisSelected } from "@/store/features/devis/selectors";
|
||||||
|
import { commandeStatus, getcommandeSelected } from "@/store/features/commande/selectors";
|
||||||
|
import { Commande } from "@/types/commandeTypes";
|
||||||
|
import { factureStatus, getfactureSelected } from "@/store/features/factures/selectors";
|
||||||
|
import { Facture } from "@/types/factureType";
|
||||||
|
import { BL } from "@/types/BL_Types";
|
||||||
|
import { BLStatus, getBLSelected } from "@/store/features/bl/selectors";
|
||||||
|
import { avoirStatus, getavoirSelected } from "@/store/features/avoir/selectors";
|
||||||
|
import { Avoir } from "@/types/avoirType";
|
||||||
|
import StatusBadge from "../ui/StatusBadge";
|
||||||
|
import { changerStatutBL } from "@/store/features/bl/thunk";
|
||||||
|
import { changerStatutFacture } from "@/store/features/factures/thunk";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONFIGURATION DES STATUTS PAR TYPE DE DOCUMENT
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface StatusOption {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: Record<SageDocumentType, StatusOption[]> = {
|
||||||
|
[SageDocumentType.DEVIS]: [
|
||||||
|
{ value: 0, label: "Saisi" },
|
||||||
|
{ value: 1, label: "Confirmé" },
|
||||||
|
{ value: 2, label: "Accepté" },
|
||||||
|
{ value: 3, label: "Perdu" },
|
||||||
|
{ value: 4, label: "Archivé" },
|
||||||
|
],
|
||||||
|
[SageDocumentType.BON_COMMANDE]: [
|
||||||
|
{ value: 0, label: "Saisi" },
|
||||||
|
{ value: 1, label: "Confirmé" },
|
||||||
|
{ value: 2, label: "A préparer" },
|
||||||
|
],
|
||||||
|
[SageDocumentType.PREPARATION]: [
|
||||||
|
{ value: 0, label: "Saisi" },
|
||||||
|
{ value: 1, label: "Confirmé" },
|
||||||
|
],
|
||||||
|
[SageDocumentType.BON_LIVRAISON]: [
|
||||||
|
{ value: 0, label: "Saisi" },
|
||||||
|
{ value: 1, label: "Confirmé" },
|
||||||
|
{ value: 2, label: "A Facturer" },
|
||||||
|
],
|
||||||
|
[SageDocumentType.BON_RETOUR]: [
|
||||||
|
{ value: 0, label: "Saisi" },
|
||||||
|
{ value: 1, label: "Confirmé" },
|
||||||
|
],
|
||||||
|
[SageDocumentType.BON_AVOIR]: [
|
||||||
|
{ value: 0, label: "Saisi" },
|
||||||
|
{ value: 1, label: "Confirmé" },
|
||||||
|
{ value: 2, label: "A Facturer" },
|
||||||
|
],
|
||||||
|
[SageDocumentType.FACTURE]: [
|
||||||
|
{ value: 0, label: "Saisi" },
|
||||||
|
{ value: 1, label: "Confirmé" },
|
||||||
|
{ value: 2, label: "A comptabiliser" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOCUMENT_LABELS: Record<SageDocumentType, string> = {
|
||||||
|
[SageDocumentType.DEVIS]: "Devis",
|
||||||
|
[SageDocumentType.BON_COMMANDE]: "Bon de commande",
|
||||||
|
[SageDocumentType.PREPARATION]: "Préparation",
|
||||||
|
[SageDocumentType.BON_LIVRAISON]: "Bon de livraison",
|
||||||
|
[SageDocumentType.BON_RETOUR]: "Bon de retour",
|
||||||
|
[SageDocumentType.BON_AVOIR]: "Avoir",
|
||||||
|
[SageDocumentType.FACTURE]: "Facture",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOCUMENT_ROUTES: Record<SageDocumentType, string> = {
|
||||||
|
[SageDocumentType.DEVIS]: "/home/devis",
|
||||||
|
[SageDocumentType.BON_COMMANDE]: "/home/commandes",
|
||||||
|
[SageDocumentType.PREPARATION]: "/home/preparations",
|
||||||
|
[SageDocumentType.BON_LIVRAISON]: "/home/bons-livraison",
|
||||||
|
[SageDocumentType.BON_RETOUR]: "/home/retours",
|
||||||
|
[SageDocumentType.BON_AVOIR]: "/home/avoirs",
|
||||||
|
[SageDocumentType.FACTURE]: "/home/factures",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TYPE DOCUMENT GÉNÉRIQUE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
type DocumentBase = {
|
||||||
|
numero: string;
|
||||||
|
statut: number;
|
||||||
|
client_intitule?: string;
|
||||||
|
client_code?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT MODAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export function ModalStatus({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
type_doc,
|
||||||
|
document,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
type_doc: SageDocumentType;
|
||||||
|
document?: DocumentBase | null;
|
||||||
|
}) {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Sélecteurs par type de document
|
||||||
|
const devisSelected = useAppSelector(getDevisSelected) as DevisListItem | null;
|
||||||
|
const commandeSelected = useAppSelector(getcommandeSelected) as Commande | null;
|
||||||
|
const factureSelected = useAppSelector(getfactureSelected) as Facture | null;
|
||||||
|
const livraisonSelected = useAppSelector(getBLSelected) as BL | null;
|
||||||
|
const avoirSelected = useAppSelector(getavoirSelected) as Avoir | null;
|
||||||
|
|
||||||
|
// Status loading par type de document
|
||||||
|
const devisLoading = useAppSelector(devisStatus);
|
||||||
|
const commandeLoading = useAppSelector(commandeStatus);
|
||||||
|
const factureLoading = useAppSelector(factureStatus);
|
||||||
|
const livraisonLoading = useAppSelector(BLStatus);
|
||||||
|
const avoirLoading = useAppSelector(avoirStatus);
|
||||||
|
|
||||||
|
// Document sélectionné dynamique
|
||||||
|
const currentDocument = useMemo((): DocumentBase | null => {
|
||||||
|
if (document) return document;
|
||||||
|
|
||||||
|
switch (type_doc) {
|
||||||
|
case SageDocumentType.DEVIS:
|
||||||
|
return devisSelected;
|
||||||
|
case SageDocumentType.BON_COMMANDE:
|
||||||
|
return commandeSelected;
|
||||||
|
case SageDocumentType.FACTURE:
|
||||||
|
return factureSelected;
|
||||||
|
case SageDocumentType.BON_LIVRAISON:
|
||||||
|
return livraisonSelected;
|
||||||
|
case SageDocumentType.BON_AVOIR:
|
||||||
|
return avoirSelected;
|
||||||
|
default:
|
||||||
|
return devisSelected;
|
||||||
|
}
|
||||||
|
}, [document, type_doc, devisSelected, commandeSelected, factureSelected, livraisonSelected, avoirSelected]);
|
||||||
|
|
||||||
|
// Loading dynamique
|
||||||
|
const isLoading = useMemo(() => {
|
||||||
|
switch (type_doc) {
|
||||||
|
case SageDocumentType.DEVIS:
|
||||||
|
return devisLoading === "loading";
|
||||||
|
case SageDocumentType.BON_COMMANDE:
|
||||||
|
return commandeLoading === "loading";
|
||||||
|
case SageDocumentType.FACTURE:
|
||||||
|
return factureLoading === "loading";
|
||||||
|
case SageDocumentType.BON_LIVRAISON:
|
||||||
|
return livraisonLoading === "loading";
|
||||||
|
case SageDocumentType.BON_AVOIR:
|
||||||
|
return avoirLoading === "loading";
|
||||||
|
default:
|
||||||
|
return devisLoading === "loading";
|
||||||
|
}
|
||||||
|
}, [type_doc, devisLoading, commandeLoading, factureLoading, livraisonLoading, avoirLoading]);
|
||||||
|
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<number>(0);
|
||||||
|
|
||||||
|
// Options dynamiques selon le type de document
|
||||||
|
const statusOptions = STATUS_OPTIONS[type_doc] || STATUS_OPTIONS[SageDocumentType.DEVIS];
|
||||||
|
const documentLabel = DOCUMENT_LABELS[type_doc] || "Document";
|
||||||
|
const documentRoute = DOCUMENT_ROUTES[type_doc] || "/home/devis";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setSelectedStatus(currentDocument?.statut ?? 0);
|
||||||
|
setError(null);
|
||||||
|
}, [open, currentDocument]);
|
||||||
|
|
||||||
|
// Dispatch dynamique selon le type de document
|
||||||
|
const dispatchStatusChange = async (payload: StatusRequest) => {
|
||||||
|
switch (type_doc) {
|
||||||
|
case SageDocumentType.DEVIS:
|
||||||
|
return dispatch(changerStatutDevis(payload)).unwrap();
|
||||||
|
case SageDocumentType.BON_COMMANDE:
|
||||||
|
return dispatch(changerStatutCommande(payload)).unwrap();
|
||||||
|
case SageDocumentType.FACTURE:
|
||||||
|
return dispatch(changerStatutFacture(payload)).unwrap();
|
||||||
|
case SageDocumentType.BON_LIVRAISON:
|
||||||
|
return dispatch(changerStatutBL(payload)).unwrap();
|
||||||
|
default:
|
||||||
|
return dispatch(changerStatutDevis(payload)).unwrap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
if (selectedStatus === currentDocument?.statut) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const payload: StatusRequest = {
|
||||||
|
numero: currentDocument!.numero,
|
||||||
|
status: selectedStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
const rep = await dispatchStatusChange(payload);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Statut mis à jour !",
|
||||||
|
description: `Le statut du ${documentLabel.toLowerCase()} ${rep.document_id} a été modifié avec succès.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600",
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
navigate(`${documentRoute}/${rep.document_id}`);
|
||||||
|
}, 1500);
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Ce ${documentLabel.toLowerCase()} ne peut pas être modifié dans ce statut`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trouver le label du statut actuel et sélectionné
|
||||||
|
const currentStatusLabel = statusOptions.find(s => s.value === currentDocument?.statut)?.label || "Inconnu";
|
||||||
|
const selectedStatusLabel = statusOptions.find(s => s.value === selectedStatus)?.label || "Inconnu";
|
||||||
|
|
||||||
|
const hasChanged = selectedStatus !== currentDocument?.statut;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && currentDocument && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[70]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Panel - Slide from right */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "100%" }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||||
|
className="fixed inset-y-0 right-0 w-full max-w-md bg-white dark:bg-gray-950 shadow-2xl z-[80] flex flex-col border-l border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Changer le statut</h2>
|
||||||
|
<p className="text-sm text-gray-500">Mise à jour du cycle de vie</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
||||||
|
|
||||||
|
{/* Document Info Card */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-xl p-5 border border-gray-100 dark:border-gray-800 space-y-4">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="p-2 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<ShieldAlert className="w-5 h-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Document</p>
|
||||||
|
<p className="font-bold text-gray-900 dark:text-white">{documentLabel} #{currentDocument.numero}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-2 border-t border-gray-200 dark:border-gray-700/50">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Client</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white truncate" title={currentDocument.client_intitule}>
|
||||||
|
{currentDocument.client_intitule || 'N/A'}
|
||||||
|
</p>
|
||||||
|
{currentDocument.client_code && (
|
||||||
|
<p className="text-xs text-gray-400">{currentDocument.client_code}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-1">Statut actuel</p>
|
||||||
|
<StatusBadge status={currentDocument.statut} type_doc={type_doc} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Selection */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Nouveau statut
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={selectedStatus}
|
||||||
|
onChange={(e) => setSelectedStatus(Number(e.target.value))}
|
||||||
|
className="w-full pl-4 pr-10 py-3 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl appearance-none focus:outline-none focus:ring-2 focus:ring-[#007E45] focus:border-transparent text-sm text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{/* Custom Arrow */}
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||||
|
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Preview - Only show if status changed */}
|
||||||
|
{hasChanged && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex items-center justify-between p-4 bg-[#007E45]/5 rounded-xl border border-[#007E45]/20"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-1 flex-1">
|
||||||
|
<span className="text-xs text-gray-500">Avant</span>
|
||||||
|
<StatusBadge status={currentDocument.statut} type_doc={type_doc} />
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="w-5 h-5 text-[#007E45] mx-4" />
|
||||||
|
<div className="flex flex-col items-center gap-1 flex-1">
|
||||||
|
<span className="text-xs text-[#007E45] font-medium">Après</span>
|
||||||
|
<StatusBadge status={selectedStatus} type_doc={type_doc} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl text-sm text-red-700 dark:text-red-400"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audit Info Note */}
|
||||||
|
<div className="flex gap-3 p-4 bg-blue-50 dark:bg-blue-900/10 rounded-xl text-xs text-blue-700 dark:text-blue-400">
|
||||||
|
<History className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
<p>
|
||||||
|
Ce changement de statut sera enregistré dans l'historique du document.
|
||||||
|
Cette action n'entraîne pas automatiquement de mouvement comptable ou de stock.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-900/50 flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-white dark:hover:bg-gray-800 hover:shadow-sm border border-transparent hover:border-gray-200 dark:hover:border-gray-700 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!hasChanged || isLoading}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white rounded-xl transition-all shadow-lg",
|
||||||
|
"bg-[#007E45] hover:bg-[#006837]",
|
||||||
|
"disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Appliquer le statut
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModalStatus;
|
||||||
247
src/components/modal/ModalStock.tsx
Normal file
247
src/components/modal/ModalStock.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm, Controller, type SubmitHandler } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import z from "zod";
|
||||||
|
import FormModal, { FormSection } from '@/components/ui/FormModal';
|
||||||
|
import { InputField } from "../ui/InputValidator";
|
||||||
|
import { Article, StockRequest } from '@/types/articleType';
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/store/hooks";
|
||||||
|
import { articleStatus } from "@/store/features/article/selectors";
|
||||||
|
import { addStock } from "@/store/features/article/thunk";
|
||||||
|
import { toast } from "../ui/use-toast";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { getAllfamilles } from "@/store/features/famille/selectors";
|
||||||
|
import { Famille } from "@/types/familleType";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SCHÉMA DE VALIDATION ZOD
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const articleSchema = z.object({
|
||||||
|
reference: z.string().min(1, "La référence est requise"),
|
||||||
|
stock_reel: z.coerce.number().default(0),
|
||||||
|
stock_mini: z.coerce.number().default(5),
|
||||||
|
stock_maxi: z.coerce.number().default(10),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ArticleFormData = z.infer<typeof articleSchema>;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// INTERFACE PROPS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface ArticleFormModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
editing?: Article | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PRINCIPAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export function ModalStock({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
editing
|
||||||
|
}: ArticleFormModalProps) {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const isEditing = !!editing;
|
||||||
|
const status = useAppSelector(articleStatus);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isValid }
|
||||||
|
} = useForm<ArticleFormData>({
|
||||||
|
resolver: zodResolver(articleSchema),
|
||||||
|
mode: "onChange", // ✅ Validation en temps réel pour activer/désactiver le bouton
|
||||||
|
defaultValues: {
|
||||||
|
reference: "",
|
||||||
|
stock_reel: 0,
|
||||||
|
stock_mini: 5,
|
||||||
|
stock_maxi: 10,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setIsSubmitting(false);
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
reset({
|
||||||
|
reference: editing.reference || "",
|
||||||
|
stock_reel: 0,
|
||||||
|
stock_mini: editing.stock_mini || 5,
|
||||||
|
stock_maxi: editing.stock_maxi || 10,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reset({
|
||||||
|
reference: "",
|
||||||
|
stock_reel: 0,
|
||||||
|
stock_mini: 5,
|
||||||
|
stock_maxi: 10
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, editing, reset]);
|
||||||
|
|
||||||
|
// ✅ Fonction de soumission
|
||||||
|
const onSave: SubmitHandler<ArticleFormData> = async (data) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
console.log("ato");
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
if(isEditing){
|
||||||
|
const payload: StockRequest = {
|
||||||
|
commentaire: "",
|
||||||
|
date_entree: (() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
})(),
|
||||||
|
lignes: [
|
||||||
|
{
|
||||||
|
article_ref: data.reference,
|
||||||
|
quantite: data.stock_reel,
|
||||||
|
stock_mini: data.stock_mini,
|
||||||
|
stock_maxi: data.stock_maxi
|
||||||
|
}
|
||||||
|
],
|
||||||
|
reference:""
|
||||||
|
};
|
||||||
|
|
||||||
|
await dispatch(addStock(payload)).unwrap()
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Stock bien ajouté!",
|
||||||
|
description: `Le stock de votre article a été bien modifié.`,
|
||||||
|
className: "bg-green-500 text-white border-green-600"
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la sauvegarde:", error);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Vérifier si le formulaire peut être soumis
|
||||||
|
const canSave = isValid && !isSubmitting && status !== "loading";
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title || (isEditing ? "Modifier l'article" : "Créer un nouvel article")}
|
||||||
|
size="md"
|
||||||
|
onSubmit={handleSubmit(onSave)}
|
||||||
|
loading={ isSubmitting || status === "loading" ? true : false}
|
||||||
|
>
|
||||||
|
{/* Section: Informations générales */}
|
||||||
|
<FormSection
|
||||||
|
title="Informations générales"
|
||||||
|
description="Identification du produit"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="reference"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
label="Référence"
|
||||||
|
inputType="text"
|
||||||
|
required
|
||||||
|
disabled={isEditing}
|
||||||
|
placeholder="Ex: ART-2025-001"
|
||||||
|
error={errors.reference?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
{/* Section: Logistique & Stock */}
|
||||||
|
<FormSection
|
||||||
|
title="Logistique & Stock"
|
||||||
|
description="Gestion des stocks"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="stock_reel"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={editing?.stock_reel}
|
||||||
|
label="Stock actuel"
|
||||||
|
disabled={isEditing}
|
||||||
|
inputType="number"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="stock_reel"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || "0"}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||||
|
label="Stock ajouté"
|
||||||
|
inputType="number"
|
||||||
|
error={errors.stock_reel?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="stock_maxi"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || "10"}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||||
|
label="Stock maximum"
|
||||||
|
inputType="number"
|
||||||
|
error={errors.stock_maxi?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="stock_mini"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<InputField
|
||||||
|
{...field}
|
||||||
|
value={field.value?.toString() || "5"}
|
||||||
|
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||||
|
label="Stock minimum"
|
||||||
|
inputType="number"
|
||||||
|
error={errors.stock_mini?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
src/components/modal/ModalValidationWarningCard.tsx
Normal file
243
src/components/modal/ModalValidationWarningCard.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { AlertTriangle, ArrowRight, LucideIcon, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// VARIANTES DE COULEURS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
type ColorVariant = 'warning' | 'danger' | 'info' | 'success';
|
||||||
|
|
||||||
|
const colorVariants: Record<ColorVariant, {
|
||||||
|
border: string;
|
||||||
|
bg: string;
|
||||||
|
accent: string;
|
||||||
|
iconBg: string;
|
||||||
|
iconColor: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
hoverBorder: string;
|
||||||
|
hoverBg: string;
|
||||||
|
}> = {
|
||||||
|
warning: {
|
||||||
|
border: 'border-amber-200 dark:border-amber-800',
|
||||||
|
bg: 'bg-amber-50 dark:bg-amber-900/20',
|
||||||
|
accent: 'bg-amber-500',
|
||||||
|
iconBg: 'bg-amber-100 dark:bg-amber-900/50',
|
||||||
|
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||||
|
title: 'text-amber-900 dark:text-amber-100',
|
||||||
|
description: 'text-amber-700 dark:text-amber-300',
|
||||||
|
hoverBorder: 'hover:border-amber-400 dark:hover:border-amber-600',
|
||||||
|
hoverBg: 'hover:bg-amber-100/50 dark:hover:bg-amber-900/30',
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
border: 'border-red-200 dark:border-red-800',
|
||||||
|
bg: 'bg-red-50 dark:bg-red-900/20',
|
||||||
|
accent: 'bg-red-500',
|
||||||
|
iconBg: 'bg-red-100 dark:bg-red-900/50',
|
||||||
|
iconColor: 'text-red-600 dark:text-red-400',
|
||||||
|
title: 'text-red-900 dark:text-red-100',
|
||||||
|
description: 'text-red-700 dark:text-red-300',
|
||||||
|
hoverBorder: 'hover:border-red-400 dark:hover:border-red-600',
|
||||||
|
hoverBg: 'hover:bg-red-100/50 dark:hover:bg-red-900/30',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
border: 'border-blue-200 dark:border-blue-800',
|
||||||
|
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||||
|
accent: 'bg-blue-500',
|
||||||
|
iconBg: 'bg-blue-100 dark:bg-blue-900/50',
|
||||||
|
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||||
|
title: 'text-blue-900 dark:text-blue-100',
|
||||||
|
description: 'text-blue-700 dark:text-blue-300',
|
||||||
|
hoverBorder: 'hover:border-blue-400 dark:hover:border-blue-600',
|
||||||
|
hoverBg: 'hover:bg-blue-100/50 dark:hover:bg-blue-900/30',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
border: 'border-emerald-200 dark:border-emerald-800',
|
||||||
|
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||||
|
accent: 'bg-emerald-500',
|
||||||
|
iconBg: 'bg-emerald-100 dark:bg-emerald-900/50',
|
||||||
|
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||||||
|
title: 'text-emerald-900 dark:text-emerald-100',
|
||||||
|
description: 'text-emerald-700 dark:text-emerald-300',
|
||||||
|
hoverBorder: 'hover:border-emerald-400 dark:hover:border-emerald-600',
|
||||||
|
hoverBg: 'hover:bg-emerald-100/50 dark:hover:bg-emerald-900/30',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// INTERFACES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface ValidationWarningCardProps {
|
||||||
|
icon?: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
variant?: ColorVariant;
|
||||||
|
onClick?: () => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
loadingText?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
actionLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PRINCIPAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const ModalValidationWarningCard: React.FC<ValidationWarningCardProps> = ({
|
||||||
|
icon: Icon = AlertTriangle,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
variant = 'warning',
|
||||||
|
onClick,
|
||||||
|
isLoading = false,
|
||||||
|
loadingText = 'Chargement...',
|
||||||
|
disabled = false,
|
||||||
|
actionLabel,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const colors = colorVariants[variant];
|
||||||
|
const isClickable = !!onClick && !disabled && !isLoading;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{/* Accent gauche */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute left-0 top-0 h-full w-1 rounded-l-2xl transition-all duration-200",
|
||||||
|
colors.accent,
|
||||||
|
isClickable && "group-hover:w-1.5"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* Icône */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl",
|
||||||
|
"transition-all duration-200",
|
||||||
|
colors.iconBg,
|
||||||
|
isClickable && "group-hover:scale-110"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className={cn("h-6 w-6 animate-spin", colors.iconColor)} />
|
||||||
|
) : (
|
||||||
|
<Icon className={cn("h-6 w-6", colors.iconColor)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenu */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className={cn(
|
||||||
|
"flex items-center gap-2 font-semibold",
|
||||||
|
colors.title
|
||||||
|
)}>
|
||||||
|
{isLoading ? loadingText : title}
|
||||||
|
{isClickable && (
|
||||||
|
<ArrowRight className={cn(
|
||||||
|
"h-4 w-4 flex-shrink-0 transition-all duration-200",
|
||||||
|
"translate-x-[-4px] opacity-0",
|
||||||
|
"group-hover:translate-x-0 group-hover:opacity-100",
|
||||||
|
colors.iconColor
|
||||||
|
)} />
|
||||||
|
)}
|
||||||
|
</h4>
|
||||||
|
<p className={cn(
|
||||||
|
"mt-1 text-sm leading-relaxed",
|
||||||
|
colors.description
|
||||||
|
)}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Action label optionnel */}
|
||||||
|
{actionLabel && isClickable && (
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1 mt-2 text-xs font-medium",
|
||||||
|
colors.iconColor,
|
||||||
|
"opacity-0 translate-y-1 transition-all duration-200",
|
||||||
|
"group-hover:opacity-100 group-hover:translate-y-0"
|
||||||
|
)}>
|
||||||
|
{actionLabel}
|
||||||
|
<ArrowRight className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Si cliquable, utiliser un bouton
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
className={cn(
|
||||||
|
"group relative w-full text-left rounded-2xl border p-4",
|
||||||
|
"transition-all duration-200",
|
||||||
|
colors.border,
|
||||||
|
colors.bg,
|
||||||
|
isClickable && colors.hoverBorder,
|
||||||
|
isClickable && colors.hoverBg,
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||||
|
variant === 'warning' && "focus-visible:ring-amber-500",
|
||||||
|
variant === 'danger' && "focus-visible:ring-red-500",
|
||||||
|
variant === 'info' && "focus-visible:ring-blue-500",
|
||||||
|
variant === 'success' && "focus-visible:ring-emerald-500",
|
||||||
|
(disabled || isLoading) && "opacity-60 cursor-not-allowed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sinon, utiliser une div
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative w-full rounded-2xl border p-4",
|
||||||
|
colors.border,
|
||||||
|
colors.bg,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANTS PRÉ-CONFIGURÉS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface SimpleWarningProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
loadingText?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WarningCard: React.FC<SimpleWarningProps> = (props) => (
|
||||||
|
<ModalValidationWarningCard {...props} variant="warning" icon={AlertTriangle} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DangerCard: React.FC<SimpleWarningProps> = (props) => (
|
||||||
|
<ModalValidationWarningCard {...props} variant="danger" icon={AlertTriangle} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const InfoCard: React.FC<SimpleWarningProps & { icon?: LucideIcon }> = (props) => (
|
||||||
|
<ModalValidationWarningCard {...props} variant="info" />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SuccessCard: React.FC<SimpleWarningProps & { icon?: LucideIcon }> = (props) => (
|
||||||
|
<ModalValidationWarningCard {...props} variant="success" />
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ModalValidationWarningCard;
|
||||||
121
src/components/modal/ModalWorkflowToBL.tsx
Normal file
121
src/components/modal/ModalWorkflowToBL.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAppDispatch, useAppSelector } from "@/store/hooks";
|
||||||
|
import FormModal, { FormField, FormSection, Input } from "../ui/FormModal";
|
||||||
|
import { Hash, Truck } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Commande } from "@/types/commandeTypes";
|
||||||
|
import { getcommandeSelected } from "@/store/features/commande/selectors";
|
||||||
|
import { commandeToBL } from "@/store/features/commande/thunk";
|
||||||
|
import { getBL } from "@/store/features/bl/thunk";
|
||||||
|
import { selectBL } from "@/store/features/bl/slice";
|
||||||
|
import { toast } from "../ui/use-toast";
|
||||||
|
import { Section } from "../ui/Section";
|
||||||
|
import { formatForDateInput } from "@/lib/utils";
|
||||||
|
import { BL } from "@/types/BL_Types";
|
||||||
|
import { ModalLoading } from "./ModalLoading";
|
||||||
|
|
||||||
|
|
||||||
|
export function ModalWorkflowToBL({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
||||||
|
const [dateEmission, setDateEmission] = useState(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + 29);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const commande = useAppSelector(getcommandeSelected) as Commande;
|
||||||
|
const [loadingTransform, setLoadingTransform] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCreateBL = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingTransform(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await dispatch(commandeToBL(commande!.numero)).unwrap();
|
||||||
|
const res = (await dispatch(getBL(response.document_cible)).unwrap()) as BL;
|
||||||
|
dispatch(selectBL(res));
|
||||||
|
setLoadingTransform(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'BL généré avec succès!',
|
||||||
|
description: `La commande a été transformée en bon de livraison.`,
|
||||||
|
className: 'bg-green-500 text-white border-green-600',
|
||||||
|
});
|
||||||
|
setTimeout(() => navigate(`/home/bons-livraison/${res.numero}`), 1000);
|
||||||
|
onClose()
|
||||||
|
} catch (err: any) {
|
||||||
|
setLoadingTransform(false);
|
||||||
|
setError(err.message);
|
||||||
|
toast({
|
||||||
|
title: 'Impossible de transformer la commande',
|
||||||
|
description: `La commande ${commande.numero} ne peut pas être transformée en BL.`,
|
||||||
|
className: 'bg-red-500 text-white border-red-600',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoadingTransform(false);
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
isOpen={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Transformer en Bon de Livraison"
|
||||||
|
onSubmit={handleCreateBL}
|
||||||
|
submitLabel="Générer BL"
|
||||||
|
>
|
||||||
|
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200 rounded-xl text-sm flex gap-3 border border-blue-100 dark:border-blue-800">
|
||||||
|
<Truck className="w-5 h-5 shrink-0" />
|
||||||
|
<p className="text-sm">
|
||||||
|
Vous allez générer un Bon de Livraison à partir de la commande <strong>{commande.numero}</strong>.
|
||||||
|
<br />
|
||||||
|
Les stocks seront mis à jour et la commande passera au statut <strong>Livrée</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4 mb-6 grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase font-semibold">Client</p>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white text-sm">{commande.client_intitule}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase font-semibold">Référence</p>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white text-sm">{commande.reference || '-'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Section title="Détails de l'expédition" icon={Hash} defaultOpen={true}>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField label="Date d'expédition" required>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formatForDateInput(commande.date)}
|
||||||
|
onChange={e => setDateEmission(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Date de livraison prévue" required>
|
||||||
|
<Input type="date" value={dateEmission} onChange={e => setDateEmission(e.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{(loadingTransform) && <ModalLoading />}
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModalWorkflowToBL;
|
||||||
425
src/components/modal/PDFPreview.tsx
Normal file
425
src/components/modal/PDFPreview.tsx
Normal file
|
|
@ -0,0 +1,425 @@
|
||||||
|
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { X, Download, Printer, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
|
||||||
|
import html2canvas from 'html2canvas';
|
||||||
|
import jsPDF from 'jspdf';
|
||||||
|
import logo from '../../assets/logo/logo.png'
|
||||||
|
import { Societe } from '@/types/societeType';
|
||||||
|
import { getSociete } from '@/store/features/gateways/selectors';
|
||||||
|
import { useAppSelector } from '@/store/hooks';
|
||||||
|
|
||||||
|
interface LigneDocument {
|
||||||
|
article: string;
|
||||||
|
designation?: string;
|
||||||
|
quantite: number;
|
||||||
|
prix_unitaire?: number;
|
||||||
|
tva?: number;
|
||||||
|
remise?: number;
|
||||||
|
total_ht?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentData {
|
||||||
|
numero: string;
|
||||||
|
type: 'devis' | 'facture' | 'bl';
|
||||||
|
date: string;
|
||||||
|
date_echeance?: string;
|
||||||
|
client: {
|
||||||
|
code: string;
|
||||||
|
nom: string;
|
||||||
|
adresse?: string;
|
||||||
|
code_postal?: string;
|
||||||
|
ville?: string;
|
||||||
|
email?: string;
|
||||||
|
telephone?: string;
|
||||||
|
};
|
||||||
|
reference_externe?: string;
|
||||||
|
lignes: LigneDocument[];
|
||||||
|
total_ht: number;
|
||||||
|
total_tva: number;
|
||||||
|
total_ttc: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PDFPreviewProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
data: DocumentData;
|
||||||
|
entreprise?: {
|
||||||
|
name: string;
|
||||||
|
adresse: string;
|
||||||
|
code_postal: string;
|
||||||
|
ville: string;
|
||||||
|
siret: string;
|
||||||
|
tva_intra: string;
|
||||||
|
telephone: string;
|
||||||
|
email: string;
|
||||||
|
logo?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PDFPreview({ open, onClose, data, entreprise }: PDFPreviewProps) {
|
||||||
|
const documentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [zoom, setZoom] = useState(100);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const societe = useAppSelector(getSociete) as Societe;
|
||||||
|
const ZOOM_MIN = 50;
|
||||||
|
const ZOOM_MAX = 200;
|
||||||
|
const ZOOM_STEP = 10;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const defaultEntreprise = {
|
||||||
|
name: "Bijou SA",
|
||||||
|
adresse: "123 rue de la Joaillerie",
|
||||||
|
code_postal: "75001",
|
||||||
|
ville: "Paris",
|
||||||
|
siret: "123 456 789 00012",
|
||||||
|
tva_intra: "FR12345678901",
|
||||||
|
telephone: "01 23 45 67 89",
|
||||||
|
email: "contact@bijou.fr",
|
||||||
|
...entreprise
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDocumentTitle = () => {
|
||||||
|
switch (data.type) {
|
||||||
|
case 'devis': return 'DEVIS';
|
||||||
|
case 'facture': return 'FACTURE';
|
||||||
|
case 'bl': return 'BON DE LIVRAISON';
|
||||||
|
default: return 'DOCUMENT';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('fr-FR');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return amount.toFixed(2) + ' €';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomIn = () => {
|
||||||
|
setZoom(prev => Math.min(prev + ZOOM_STEP, ZOOM_MAX));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomOut = () => {
|
||||||
|
setZoom(prev => Math.max(prev - ZOOM_STEP, ZOOM_MIN));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetZoom = () => {
|
||||||
|
setZoom(100);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
} else if (e.key === '+' || e.key === '=') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleZoomIn();
|
||||||
|
} else if (e.key === '-') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleZoomOut();
|
||||||
|
} else if (e.key === '0') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleResetZoom();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.deltaY < 0) {
|
||||||
|
handleZoomIn();
|
||||||
|
} else {
|
||||||
|
handleZoomOut();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDownloadPDF = async () => {
|
||||||
|
if (!documentRef.current || isGenerating) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsGenerating(true);
|
||||||
|
|
||||||
|
const originalTransform = documentRef.current.style.transform;
|
||||||
|
documentRef.current.style.transform = 'scale(1)';
|
||||||
|
|
||||||
|
const canvas = await html2canvas(documentRef.current, {
|
||||||
|
scale: 2,
|
||||||
|
useCORS: true,
|
||||||
|
logging: false,
|
||||||
|
backgroundColor: '#ffffff'
|
||||||
|
});
|
||||||
|
|
||||||
|
documentRef.current.style.transform = originalTransform;
|
||||||
|
|
||||||
|
const imgData = canvas.toDataURL('image/png');
|
||||||
|
const pdf = new jsPDF({
|
||||||
|
orientation: 'portrait',
|
||||||
|
unit: 'mm',
|
||||||
|
format: 'a4'
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfWidth = pdf.internal.pageSize.getWidth();
|
||||||
|
const pdfHeight = pdf.internal.pageSize.getHeight();
|
||||||
|
const imgWidth = canvas.width;
|
||||||
|
const imgHeight = canvas.height;
|
||||||
|
const ratio = Math.min(pdfWidth / imgWidth, pdfHeight / imgHeight);
|
||||||
|
const imgX = (pdfWidth - imgWidth * ratio) / 2;
|
||||||
|
const imgY = 0;
|
||||||
|
|
||||||
|
pdf.addImage(imgData, 'PNG', imgX, imgY, imgWidth * ratio, imgHeight * ratio);
|
||||||
|
pdf.save(`${data.type}_${data.numero}.pdf`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la génération du PDF:', error);
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Slide Panel */}
|
||||||
|
<div className="fixed inset-y-0 right-0 w-full max-w-4xl bg-[#525659] shadow-2xl z-50 flex flex-col">
|
||||||
|
{/* Header Toolbar */}
|
||||||
|
<div className="bg-[#323639] shadow-md px-6 py-3 flex items-center justify-between border-b border-gray-700">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-700 rounded-lg transition-colors text-gray-300 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<h2 className="font-medium text-gray-200 text-sm uppercase tracking-wider">
|
||||||
|
Aperçu PDF
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zoom Controls */}
|
||||||
|
<div className="flex items-center gap-2 bg-gray-700 rounded-lg p-1">
|
||||||
|
<button
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
disabled={zoom <= ZOOM_MIN}
|
||||||
|
className="p-1.5 hover:bg-gray-600 rounded-md transition-colors disabled:opacity-40 text-gray-300 hover:text-white"
|
||||||
|
>
|
||||||
|
<ZoomOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleResetZoom}
|
||||||
|
className="px-3 py-1 text-sm font-bold text-white hover:bg-gray-600 rounded-md min-w-[60px]"
|
||||||
|
>
|
||||||
|
{zoom}%
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
disabled={zoom >= ZOOM_MAX}
|
||||||
|
className="p-1.5 hover:bg-gray-600 rounded-md transition-colors disabled:opacity-40 text-gray-300 hover:text-white"
|
||||||
|
>
|
||||||
|
<ZoomIn className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-gray-600 mx-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleResetZoom}
|
||||||
|
className="p-1.5 hover:bg-gray-600 rounded-md transition-colors text-gray-300 hover:text-white"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handlePrint}
|
||||||
|
className="flex items-center gap-2 px-4 py-1.5 text-sm text-gray-300 hover:bg-gray-700 rounded-lg transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
<Printer className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Imprimer</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadPDF}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="flex items-center gap-2 px-4 py-1.5 text-sm text-gray-300 hover:bg-gray-700 rounded-lg transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">{isGenerating ? 'Génération...' : 'Télécharger'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document Preview Container */}
|
||||||
|
<div
|
||||||
|
className="flex-1 overflow-auto p-8 flex justify-center items-start bg-[#525659]"
|
||||||
|
onWheel={handleWheel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="transition-all duration-200 ease-out"
|
||||||
|
style={{
|
||||||
|
width: `${zoom}%`,
|
||||||
|
minWidth: '50mm',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={documentRef}
|
||||||
|
className="bg-white shadow-2xl border border-gray-300"
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1 / 1.314',
|
||||||
|
padding: '8%',
|
||||||
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-start mb-12">
|
||||||
|
{/* Logo placeholder */}
|
||||||
|
<div><img src={logo} alt="" className='w-25 h-20'/></div>
|
||||||
|
|
||||||
|
{/* Document Info */}
|
||||||
|
<div className="text-right flex flex-col gap-2">
|
||||||
|
<h1 className="text-3xl font-bold uppercase text-gray-900">
|
||||||
|
{data.numero || 'BROUILLON'}
|
||||||
|
</h1>
|
||||||
|
<div className="text-sm text-gray-500 flex flex-col gap-1">
|
||||||
|
<p>Date : {formatDate(data.date)}</p>
|
||||||
|
<p>Validité : {formatDate(data.date_echeance || data.date)}</p>
|
||||||
|
<p>Réf : {data.reference_externe || '—'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Client Info */}
|
||||||
|
<div className="flex justify-between gap-12 mb-16">
|
||||||
|
<div className="w-1/2">
|
||||||
|
<p className="text-xs font-bold text-gray-400 uppercase mb-2">Émetteur</p>
|
||||||
|
<div className="text-sm text-gray-800 font-medium">
|
||||||
|
<p>{societe?.raison_sociale}</p>
|
||||||
|
<p className="font-normal text-gray-600">{societe?.adresse}</p>
|
||||||
|
<p className="font-normal text-gray-600">{societe?.code_postal} {societe?.ville}, {societe?.pays}</p>
|
||||||
|
<p className="font-normal text-gray-600 mt-1">{societe?.email_societe}</p>
|
||||||
|
<p className="font-normal text-gray-600 mt-1">Tel : +{societe?.telephone}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-1/2 bg-gray-50 p-6 rounded-xl border border-gray-100">
|
||||||
|
<p className="text-xs font-bold text-gray-400 uppercase mb-2">Destinataire</p>
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
<p className="font-bold text-sm mb-1">{data.client.nom || 'Client'}</p>
|
||||||
|
<p className="text-gray-600">10 rue des Clients</p>
|
||||||
|
<p className="text-gray-600">75001 Paris</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* info list */}
|
||||||
|
<div className="flex text-sm border-b border-gray-200 pb-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-bold">Désignation</p>
|
||||||
|
</div>
|
||||||
|
<div className="font-bold w-16 text-right">Qté</div>
|
||||||
|
<div className="font-bold w-24 text-right">
|
||||||
|
Prix Unit.HT
|
||||||
|
</div>
|
||||||
|
<div className="font-bold w-16 text-right">Remise</div>
|
||||||
|
<div className="font-bold w-16 text-right">TVA</div>
|
||||||
|
<div className="font-bold w-24 text-right ">
|
||||||
|
Montant HT
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lines */}
|
||||||
|
<div className="flex-1 space-y-3 mb-8">
|
||||||
|
{data.lignes.length > 0 ? (
|
||||||
|
data.lignes.map((ligne, i) => {
|
||||||
|
const prixUnitaire = ligne.prix_unitaire ?? 0;
|
||||||
|
const totalHT = ligne.total_ht ?? (ligne.quantite * prixUnitaire);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex text-sm border-b border-gray-200 pb-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-bold">{ligne.article}</p>
|
||||||
|
{ligne.designation && (
|
||||||
|
<p className="text-gray-600 text-xs">{ligne.designation}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-16 text-right text-gray-600">{ligne.quantite}</div>
|
||||||
|
<div className="w-24 text-right text-gray-600">
|
||||||
|
{formatCurrency(prixUnitaire)}
|
||||||
|
</div>
|
||||||
|
<div className="w-16 text-right text-gray-400">{ligne.remise}%</div>
|
||||||
|
<div className="w-16 text-right text-gray-400">{ligne.tva}%</div>
|
||||||
|
<div className="w-24 text-right font-medium">
|
||||||
|
{formatCurrency(totalHT)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-400 italic py-8">
|
||||||
|
Aucune ligne
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div className="flex justify-end mb-8">
|
||||||
|
<div className="w-64 space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between text-gray-600">
|
||||||
|
<span>Total HT</span>
|
||||||
|
<span>{formatCurrency(data.total_ht)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-gray-600">
|
||||||
|
<span>TVA (20%)</span>
|
||||||
|
<span>{formatCurrency(data.total_tva)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-t-2 border-[#2A6F4F] pt-2 font-bold text-base text-[#2A6F4F]">
|
||||||
|
<span>Net à payer</span>
|
||||||
|
<span>{formatCurrency(data.total_ttc)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{data.notes && (
|
||||||
|
<div className="mt-10 border-t border-gray-300 pt-4">
|
||||||
|
<p className="text-xs font-bold uppercase text-gray-400 mb-2">
|
||||||
|
Notes & Conditions
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600 whitespace-pre-wrap">
|
||||||
|
{data.notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-auto pt-8 text-center">
|
||||||
|
<p className="text-[10px] text-gray-400">Page 1 / 1</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PDFPreview;
|
||||||
429
src/components/molecules/ArticleAutocomplete.tsx
Normal file
429
src/components/molecules/ArticleAutocomplete.tsx
Normal file
|
|
@ -0,0 +1,429 @@
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Autocomplete, TextField, CircularProgress, Box, Typography, Chip, Popper, PopperProps } from '@mui/material';
|
||||||
|
import { styled } from '@mui/material/styles';
|
||||||
|
import { Package } from 'lucide-react';
|
||||||
|
import { Article } from '@/types/articleType';
|
||||||
|
import { useAppSelector } from '@/store/hooks';
|
||||||
|
import { getAllArticles } from '@/store/features/article/selectors';
|
||||||
|
import { hasSpecialChars } from '../filter/ItemsFilter';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CUSTOM POPPER - Largeur indépendante de l'input
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const StyledPopper = styled(Popper)(({ theme }) => ({
|
||||||
|
'& .MuiPaper-root': {
|
||||||
|
minWidth: '400px', // Largeur minimale fixe
|
||||||
|
maxWidth: '500px', // Largeur maximale
|
||||||
|
width: 'auto !important', // Override la largeur automatique
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CustomPopper = (props: PopperProps) => {
|
||||||
|
return (
|
||||||
|
<StyledPopper
|
||||||
|
{...props}
|
||||||
|
placement="bottom-start"
|
||||||
|
style={{
|
||||||
|
...props.style,
|
||||||
|
width: 'auto',
|
||||||
|
minWidth: '400px',
|
||||||
|
maxWidth: '500px',
|
||||||
|
}}
|
||||||
|
modifiers={[
|
||||||
|
{
|
||||||
|
name: 'preventOverflow',
|
||||||
|
enabled: true,
|
||||||
|
options: {
|
||||||
|
altAxis: true,
|
||||||
|
altBoundary: true,
|
||||||
|
tether: true,
|
||||||
|
rootBoundary: 'viewport',
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'flip',
|
||||||
|
enabled: true,
|
||||||
|
options: {
|
||||||
|
fallbackPlacements: ['top-start', 'bottom-end', 'top-end'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface ArticleSearchInputProps {
|
||||||
|
value: Article | null;
|
||||||
|
onChange: (article: Article | null) => void;
|
||||||
|
onArticleSelect?: (article: Article | null) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
error?: string;
|
||||||
|
required?: boolean;
|
||||||
|
helperText?: string;
|
||||||
|
dropdownWidth?: number | string;
|
||||||
|
className?: string; // Classes Tailwind pour le conteneur
|
||||||
|
inputClassName?: string; // Classes Tailwind pour l'input
|
||||||
|
sx?: object; // Styles MUI personnalisés pour l'Autocomplete
|
||||||
|
inputSx?: object; // Styles MUI personnalisés pour l'input
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PRINCIPAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const ArticleSearchInput = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onArticleSelect,
|
||||||
|
placeholder = "Rechercher un article...",
|
||||||
|
disabled = false,
|
||||||
|
error,
|
||||||
|
required = false,
|
||||||
|
helperText,
|
||||||
|
dropdownWidth = 450,
|
||||||
|
className,
|
||||||
|
inputClassName,
|
||||||
|
sx: customSx,
|
||||||
|
inputSx,
|
||||||
|
}: ArticleSearchInputProps) => {
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [loading] = useState(false);
|
||||||
|
const options = useAppSelector(getAllArticles) as Article[];
|
||||||
|
|
||||||
|
const filteredOptions = useMemo(() => {
|
||||||
|
return options
|
||||||
|
.filter(
|
||||||
|
(article) =>
|
||||||
|
!hasSpecialChars(article.reference ?? "") && article.stock_reel > 0
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.stock_reel - a.stock_reel);
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
const handleInputChange = (_: unknown, newInputValue: string) => {
|
||||||
|
setInputValue(newInputValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (_: unknown, newValue: Article | null) => {
|
||||||
|
onChange(newValue);
|
||||||
|
if (onArticleSelect) {
|
||||||
|
onArticleSelect(newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOptionLabel = (option: Article) => {
|
||||||
|
return `${option.reference} - ${option.designation}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterOptions = (
|
||||||
|
options: Article[],
|
||||||
|
{ inputValue }: { inputValue: string }
|
||||||
|
) => {
|
||||||
|
if (!inputValue) return options.slice(0, 10);
|
||||||
|
|
||||||
|
const lowerTerm = inputValue.toLowerCase();
|
||||||
|
return options
|
||||||
|
.filter(
|
||||||
|
(article) =>
|
||||||
|
(article.reference &&
|
||||||
|
article.reference.toLowerCase().includes(lowerTerm)) ||
|
||||||
|
(article.designation &&
|
||||||
|
article.designation.toLowerCase().includes(lowerTerm)) ||
|
||||||
|
(article.description &&
|
||||||
|
article.description.toLowerCase().includes(lowerTerm))
|
||||||
|
)
|
||||||
|
.slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
inputValue={inputValue}
|
||||||
|
onInputChange={handleInputChange}
|
||||||
|
options={filteredOptions}
|
||||||
|
getOptionLabel={getOptionLabel}
|
||||||
|
filterOptions={filterOptions}
|
||||||
|
loading={loading}
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
noOptionsText="Aucun article trouvé"
|
||||||
|
loadingText="Recherche..."
|
||||||
|
clearText="Effacer"
|
||||||
|
openText="Ouvrir"
|
||||||
|
closeText="Fermer"
|
||||||
|
className={className}
|
||||||
|
isOptionEqualToValue={(option, val) => option.reference === val.reference}
|
||||||
|
// ✅ Custom Popper pour largeur indépendante
|
||||||
|
PopperComponent={(props) => (
|
||||||
|
<Popper
|
||||||
|
{...props}
|
||||||
|
placement="bottom-start"
|
||||||
|
style={{
|
||||||
|
...props.style,
|
||||||
|
width: typeof dropdownWidth === 'number' ? `${dropdownWidth}px` : dropdownWidth,
|
||||||
|
minWidth: typeof dropdownWidth === 'number' ? `${dropdownWidth}px` : dropdownWidth,
|
||||||
|
}}
|
||||||
|
modifiers={[
|
||||||
|
{
|
||||||
|
name: 'preventOverflow',
|
||||||
|
enabled: true,
|
||||||
|
options: {
|
||||||
|
altAxis: true,
|
||||||
|
rootBoundary: 'viewport',
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'flip',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
sx={{
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
borderRadius: '0',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: '1px solid #007E45',
|
||||||
|
borderRadius: '0',
|
||||||
|
},
|
||||||
|
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: '2px solid #007E45',
|
||||||
|
},
|
||||||
|
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: '2px solid #007E45',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'& .MuiInputLabel-root': {
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
'&.Mui-focused': {
|
||||||
|
color: '#007E45',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...customSx, // ✅ Merge avec les styles personnalisés
|
||||||
|
}}
|
||||||
|
componentsProps={{
|
||||||
|
paper: {
|
||||||
|
sx: {
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
boxShadow: '0 10px 40px rgba(0,0,0,0.1)',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
marginTop: '8px',
|
||||||
|
// ✅ Largeur fixe pour le Paper aussi
|
||||||
|
width: typeof dropdownWidth === 'number' ? `${dropdownWidth}px` : dropdownWidth,
|
||||||
|
minWidth: typeof dropdownWidth === 'number' ? `${dropdownWidth}px` : dropdownWidth,
|
||||||
|
'& .MuiAutocomplete-listbox': {
|
||||||
|
maxHeight: '300px',
|
||||||
|
padding: '4px',
|
||||||
|
'& .MuiAutocomplete-option': {
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
minHeight: 'auto',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
margin: '2px 0',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: '#e6f4ee',
|
||||||
|
},
|
||||||
|
'&[aria-selected="true"]': {
|
||||||
|
backgroundColor: '#cce9dc',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: '#b3decf',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'&.Mui-focused': {
|
||||||
|
backgroundColor: '#e6f4ee',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'& .MuiAutocomplete-noOptions': {
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: '#6b7280',
|
||||||
|
padding: '16px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
renderOption={(props, option) => {
|
||||||
|
const { key, ...restProps } = props;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="li"
|
||||||
|
key={option.reference}
|
||||||
|
{...restProps}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icône Article */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
backgroundColor: '#e6f4ee',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#007E45',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
'.MuiAutocomplete-option:hover &, .MuiAutocomplete-option[aria-selected="true"] &':
|
||||||
|
{
|
||||||
|
backgroundColor: '#007E45',
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Package size={20} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Infos Article */}
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{/* Ligne 1: Code + Prix */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 1,
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#007E45',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.reference}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ textAlign: 'right' }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.prix_vente.toFixed(2).replace('.', ',')} €
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: '#9ca3af',
|
||||||
|
fontSize: '0.625rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
HT
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Ligne 2: Nom */}
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
color: '#374151',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.designation}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Ligne 3: Famille + stock */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 1,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.famille_libelle && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.famille_libelle}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Chip
|
||||||
|
label={`Stock réel: ${option.stock_reel}`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: '18px',
|
||||||
|
fontSize: '0.625rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
backgroundColor: '#f3f4f6',
|
||||||
|
color: '#6b7280',
|
||||||
|
'& .MuiChip-label': {
|
||||||
|
px: 0.75,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
error={!!error}
|
||||||
|
helperText={error || helperText}
|
||||||
|
className={inputClassName}
|
||||||
|
FormHelperTextProps={{
|
||||||
|
sx: {
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
marginLeft: '4px',
|
||||||
|
color: error ? '#dc2626' : '#9ca3af',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={inputSx}
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
endAdornment: (
|
||||||
|
<>
|
||||||
|
{loading ? (
|
||||||
|
<CircularProgress size={18} sx={{ color: '#007E45' }} />
|
||||||
|
) : null}
|
||||||
|
{params.InputProps.endAdornment}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ArticleSearchInput;
|
||||||
262
src/components/molecules/ClientAutocomplete.tsx
Normal file
262
src/components/molecules/ClientAutocomplete.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Autocomplete, TextField, CircularProgress, Box, Typography, Chip } from '@mui/material';
|
||||||
|
import { Client } from '@/types/clientType';
|
||||||
|
import { getAllClients } from '@/store/features/client/selectors';
|
||||||
|
import { useAppSelector } from '@/store/hooks';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
interface ClientSearchInputProps {
|
||||||
|
value: Client | null;
|
||||||
|
onChange: (client: Client | null) => void;
|
||||||
|
onClientSelect?: (client: Client | null) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
error?: string;
|
||||||
|
required?: boolean;
|
||||||
|
helperText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClientSearchInput = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onClientSelect,
|
||||||
|
placeholder = "Rechercher un client (Numéro, Intitule, email, adresse, ...)",
|
||||||
|
disabled = false,
|
||||||
|
error,
|
||||||
|
required = false,
|
||||||
|
helperText
|
||||||
|
}: ClientSearchInputProps) => {
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const options = useAppSelector(getAllClients).filter((item) => item.est_actif === true) as Client[];
|
||||||
|
|
||||||
|
// Simuler un chargement (à remplacer par votre logique réelle)
|
||||||
|
const handleInputChange = (_: any, newInputValue: string) => {
|
||||||
|
setInputValue(newInputValue);
|
||||||
|
// Simuler un délai de recherche si nécessaire
|
||||||
|
// setLoading(true);
|
||||||
|
// setTimeout(() => setLoading(false), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (_: any, newValue: Client | null) => {
|
||||||
|
onChange(newValue);
|
||||||
|
if (onClientSelect) {
|
||||||
|
onClientSelect(newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOptionLabel = (option: Client) => {
|
||||||
|
return `${option.numero || ''} ${option.intitule || ''}`.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterOptions = (options: Client[], { inputValue }: { inputValue: string }) => {
|
||||||
|
if (!inputValue) return options.slice(0, 10);
|
||||||
|
|
||||||
|
const lowerTerm = inputValue.toLowerCase();
|
||||||
|
return options.filter(client =>
|
||||||
|
(client.numero && client.numero.toLowerCase().includes(lowerTerm)) ||
|
||||||
|
(client.intitule && client.intitule.toLowerCase().includes(lowerTerm)) ||
|
||||||
|
(client.adresse && client.adresse.toLowerCase().includes(lowerTerm)) ||
|
||||||
|
(client.email && client.email.toLowerCase().includes(lowerTerm))
|
||||||
|
).slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
inputValue={inputValue}
|
||||||
|
onInputChange={handleInputChange}
|
||||||
|
options={options}
|
||||||
|
getOptionLabel={getOptionLabel}
|
||||||
|
filterOptions={filterOptions}
|
||||||
|
loading={loading}
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
noOptionsText="Aucun client trouvé"
|
||||||
|
loadingText="Recherche..."
|
||||||
|
clearText="Effacer"
|
||||||
|
openText="Ouvrir"
|
||||||
|
closeText="Fermer"
|
||||||
|
isOptionEqualToValue={(option, val) => option.numero === val.numero}
|
||||||
|
sx={{
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: '#007E45',
|
||||||
|
},
|
||||||
|
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: '#007E45',
|
||||||
|
borderWidth: '2px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'& .MuiInputLabel-root': {
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
'&.Mui-focused': {
|
||||||
|
color: '#007E45',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
componentsProps={{
|
||||||
|
paper: {
|
||||||
|
sx: {
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
boxShadow: '0 10px 40px rgba(0,0,0,0.1)',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
marginTop: '8px',
|
||||||
|
'& .MuiAutocomplete-listbox': {
|
||||||
|
maxHeight: '300px',
|
||||||
|
padding: '4px',
|
||||||
|
'& .MuiAutocomplete-option': {
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
minHeight: 'auto',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
margin: '2px 0',
|
||||||
|
|
||||||
|
/* petit vert (hover) */
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: '#e6f4ee',
|
||||||
|
},
|
||||||
|
|
||||||
|
/* vert principal (selected) */
|
||||||
|
'&[aria-selected="true"]': {
|
||||||
|
backgroundColor: '#cce9dc',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: '#b3decf',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/* focus clavier */
|
||||||
|
'&.Mui-focused': {
|
||||||
|
backgroundColor: '#e6f4ee',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'& .MuiAutocomplete-noOptions': {
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: '#6b7280',
|
||||||
|
padding: '16px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
renderOption={(props, option) => {
|
||||||
|
const { key, ...restProps } = props;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="li"
|
||||||
|
key={option.numero}
|
||||||
|
{...restProps}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: '#f3f4f6',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#4b5563',
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
'.MuiAutocomplete-option:hover &, .MuiAutocomplete-option[aria-selected="true"] &': {
|
||||||
|
backgroundColor: '#007E45',
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(option.numero || option.intitule || '?').charAt(0).toUpperCase()}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Infos */}
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#111827',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`${option.numero} ${option.intitule}`}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={option.est_actif ? 'Actif' : 'Inactif'}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: '20px',
|
||||||
|
fontSize: '0.625rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
backgroundColor: option.est_actif ? '#dcfce7' : '#f3f4f6',
|
||||||
|
color: option.est_actif ? '#166534' : '#6b7280',
|
||||||
|
'& .MuiChip-label': {
|
||||||
|
px: 1,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: '#6b7280',
|
||||||
|
display: 'block',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.email}
|
||||||
|
{option.telephone && ` • ${option.telephone}`}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
error={!!error}
|
||||||
|
FormHelperTextProps={{
|
||||||
|
sx: {
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
marginLeft: '4px',
|
||||||
|
color: error ? '#dc2626' : '#9ca3af',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
endAdornment: (
|
||||||
|
<>
|
||||||
|
{loading ? (
|
||||||
|
<CircularProgress size={18} sx={{ color: '#941403' }} />
|
||||||
|
) : null}
|
||||||
|
{params.InputProps.endAdornment}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClientSearchInput;
|
||||||
207
src/components/page/client/ClientContactsList.tsx
Normal file
207
src/components/page/client/ClientContactsList.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Plus, User, Mail, Phone, Building2, Edit, Trash2 } from "lucide-react";
|
||||||
|
import PrimaryButton_v2 from "@/components/PrimaryButton_v2";
|
||||||
|
import { Contacts } from "@/types/clientType";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface ClientContactsListProps {
|
||||||
|
contacts: Contacts[];
|
||||||
|
onCreateContact: () => void;
|
||||||
|
onEditContact: (contact: Contacts) => void;
|
||||||
|
onDeleteContact?: (contactId: string | number) => void;
|
||||||
|
showDeleteButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SOUS-COMPOSANTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const EmptyState: React.FC = () => (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContactAvatar: React.FC<{ nom: string; prenom: string }> = ({ nom, prenom }) => (
|
||||||
|
<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">
|
||||||
|
{prenom?.[0] || ""}{nom?.[0] || ""}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DefaultBadge: React.FC = () => (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ContactInfoRowProps {
|
||||||
|
icon: React.ElementType;
|
||||||
|
value: string;
|
||||||
|
href?: string;
|
||||||
|
type?: "email" | "tel";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContactInfoRow: React.FC<ContactInfoRowProps> = ({ icon: Icon, value, href, type }) => {
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{value}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={type === "email" ? `mailto:${href}` : type === "tel" ? `tel:${href}` : href}
|
||||||
|
className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-[#007E45] transition-colors"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ContactCardProps {
|
||||||
|
contact: Contacts;
|
||||||
|
onEdit: (contact: Contacts) => void;
|
||||||
|
onDelete?: (contactId: string | number) => void;
|
||||||
|
showDeleteButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContactCard: React.FC<ContactCardProps> = ({
|
||||||
|
contact,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
showDeleteButton = false,
|
||||||
|
}) => (
|
||||||
|
<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 relative group">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ContactAvatar nom={contact.nom} prenom={contact.prenom} />
|
||||||
|
<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 && <DefaultBadge />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Info */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{contact.email && (
|
||||||
|
<ContactInfoRow
|
||||||
|
icon={Mail}
|
||||||
|
value={contact.email}
|
||||||
|
href={contact.email}
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{contact.telephone && (
|
||||||
|
<ContactInfoRow
|
||||||
|
icon={Phone}
|
||||||
|
value={contact.telephone}
|
||||||
|
href={contact.telephone}
|
||||||
|
type="tel"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ContactInfoRow
|
||||||
|
icon={Building2}
|
||||||
|
value={contact.fonction || "Non spécifié"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="pt-4 border-t border-gray-100 dark:border-gray-800 flex justify-end gap-2">
|
||||||
|
<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>
|
||||||
|
{showDeleteButton && onDelete && contact.numero && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(contact.numero!)}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PRINCIPAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const ClientContactsList: React.FC<ClientContactsListProps> = ({
|
||||||
|
contacts,
|
||||||
|
onCreateContact,
|
||||||
|
onEditContact,
|
||||||
|
onDeleteContact,
|
||||||
|
showDeleteButton = false,
|
||||||
|
}) => {
|
||||||
|
const contactCount = contacts?.length || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="font-bold text-gray-900 dark:text-white">
|
||||||
|
Liste des contacts ({contactCount})
|
||||||
|
</h3>
|
||||||
|
<PrimaryButton_v2 icon={Plus} onClick={onCreateContact}>
|
||||||
|
Ajouter un contact
|
||||||
|
</PrimaryButton_v2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{contactCount === 0 ? (
|
||||||
|
<EmptyState />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{contacts?.map((contact, index) => (
|
||||||
|
<ContactCard
|
||||||
|
key={index}
|
||||||
|
contact={contact}
|
||||||
|
onEdit={onEditContact}
|
||||||
|
onDelete={onDeleteContact}
|
||||||
|
showDeleteButton={showDeleteButton}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClientContactsList;
|
||||||
|
|
||||||
|
// Export des sous-composants si besoin
|
||||||
|
export {
|
||||||
|
EmptyState,
|
||||||
|
ContactAvatar,
|
||||||
|
DefaultBadge,
|
||||||
|
ContactInfoRow,
|
||||||
|
ContactCard,
|
||||||
|
};
|
||||||
324
src/components/page/client/ClientDetailsGrid.tsx
Normal file
324
src/components/page/client/ClientDetailsGrid.tsx
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
User,
|
||||||
|
MapPin,
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
Globe,
|
||||||
|
FileText,
|
||||||
|
CreditCard,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Client } from "@/types/clientType";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface ClientDetailsGridProps {
|
||||||
|
client: Client;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionCardProps {
|
||||||
|
icon: React.ElementType;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InfoRowProps {
|
||||||
|
label: string;
|
||||||
|
value?: string | number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// HELPERS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const getTypeTiersLabel = (type: number): string => {
|
||||||
|
switch (type) {
|
||||||
|
case 0:
|
||||||
|
return "Client";
|
||||||
|
case 1:
|
||||||
|
return "Fournisseur";
|
||||||
|
case 2:
|
||||||
|
return "Client & Fournisseur";
|
||||||
|
case 3:
|
||||||
|
return "Autre";
|
||||||
|
default:
|
||||||
|
return "Non défini";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SOUS-COMPOSANTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const SectionCard: React.FC<SectionCardProps> = ({ icon: Icon, title, children }) => (
|
||||||
|
<div className="bg-white dark:bg-gray-950 rounded-xl border border-gray-200 dark:border-gray-800 overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100 dark:border-gray-800 flex items-center gap-2">
|
||||||
|
<Icon className="w-4 h-4 text-[#007E45]" />
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white text-sm">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const InfoRow: React.FC<InfoRowProps> = ({ label, value }) => {
|
||||||
|
if (!value && value !== 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-start gap-4">
|
||||||
|
<span className="text-sm text-gray-500 flex-shrink-0">{label}</span>
|
||||||
|
<span className="text-sm text-gray-900 dark:text-white text-right font-medium">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContactLink: React.FC<{
|
||||||
|
icon: React.ElementType;
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
suffix?: string;
|
||||||
|
isExternal?: boolean;
|
||||||
|
}> = ({ icon: Icon, href, label, suffix, isExternal = false }) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Icon className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target={isExternal ? "_blank" : undefined}
|
||||||
|
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||||
|
className="text-blue-600 hover:underline break-all"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
{suffix && <span className="text-xs text-gray-500">{suffix}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const StatusBadge: React.FC<{ label: string; color: "blue" | "purple" | "red" }> = ({
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
}) => {
|
||||||
|
const colorClasses = {
|
||||||
|
blue: "bg-blue-100 text-blue-700",
|
||||||
|
purple: "bg-purple-100 text-purple-700",
|
||||||
|
red: "bg-red-100 text-red-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-1 ${colorClasses[color]} rounded-full text-xs font-medium`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SECTIONS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const InformationsPrincipales: React.FC<{ client: Client }> = ({ client }) => (
|
||||||
|
<SectionCard icon={Building2} title="Informations principales">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<InfoRow label="Compte Tiers" value={client.numero} />
|
||||||
|
<InfoRow label="Raison sociale" value={client.intitule} />
|
||||||
|
<InfoRow label="Type de tiers" value={getTypeTiersLabel(client.type_tiers || 0)} />
|
||||||
|
<InfoRow label="Qualité" value={client.qualite} />
|
||||||
|
<InfoRow label="Classement" value={client.classement} />
|
||||||
|
|
||||||
|
{/* Statuts */}
|
||||||
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
|
{client.est_prospect && <StatusBadge label="Prospect" color="blue" />}
|
||||||
|
{(client.type_tiers === 1 || client.type_tiers === 2) && (
|
||||||
|
<StatusBadge label="Fournisseur" color="purple" />
|
||||||
|
)}
|
||||||
|
{!client.est_actif && <StatusBadge label="Inactif" color="red" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContactSection: React.FC<{ client: Client }> = ({ client }) => (
|
||||||
|
<SectionCard icon={User} title="Contact">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{client.contact && <InfoRow label="Contact principal" value={client.contact} />}
|
||||||
|
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
{client.telephone && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Phone className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<a
|
||||||
|
href={`tel:${client.telephone}`}
|
||||||
|
className="text-gray-900 dark:text-white hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{client.telephone}
|
||||||
|
</a>
|
||||||
|
<span className="text-xs text-gray-500">Fixe</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{client.telecopie && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<span className="text-gray-900 dark:text-white">{client.telecopie}</span>
|
||||||
|
<span className="text-xs text-gray-500">Fax</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{client.email && (
|
||||||
|
<ContactLink
|
||||||
|
icon={Mail}
|
||||||
|
href={`mailto:${client.email}`}
|
||||||
|
label={client.email}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{client.site_web && (
|
||||||
|
<ContactLink
|
||||||
|
icon={Globe}
|
||||||
|
href={client.site_web.startsWith("http") ? client.site_web : `https://${client.site_web}`}
|
||||||
|
label={client.site_web}
|
||||||
|
isExternal
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{client.facebook && (
|
||||||
|
<ContactLink
|
||||||
|
icon={Globe}
|
||||||
|
href={client.facebook.startsWith("http") ? client.facebook : `https://${client.facebook}`}
|
||||||
|
label="Facebook"
|
||||||
|
isExternal
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{client.linkedin && (
|
||||||
|
<ContactLink
|
||||||
|
icon={Globe}
|
||||||
|
href={client.linkedin.startsWith("http") ? client.linkedin : `https://${client.linkedin}`}
|
||||||
|
label="LinkedIn"
|
||||||
|
isExternal
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AdresseSection: React.FC<{ client: Client }> = ({ client }) => (
|
||||||
|
<SectionCard icon={MapPin} title="Adresse">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{client.adresse && (
|
||||||
|
<p className="text-gray-900 dark:text-white">{client.adresse}</p>
|
||||||
|
)}
|
||||||
|
{client.complement && (
|
||||||
|
<p className="text-gray-700 dark:text-gray-300">{client.complement}</p>
|
||||||
|
)}
|
||||||
|
{(client.code_postal || client.ville) && (
|
||||||
|
<p className="text-gray-900 dark:text-white">
|
||||||
|
{client.code_postal} {client.ville}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{client.region && (
|
||||||
|
<p className="text-gray-700 dark:text-gray-300">{client.region}</p>
|
||||||
|
)}
|
||||||
|
{client.pays && (
|
||||||
|
<p className="text-gray-700 dark:text-gray-300 font-medium">{client.pays}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
const InformationsLegales: React.FC<{ client: Client }> = ({ client }) => (
|
||||||
|
<SectionCard icon={FileText} title="Informations légales">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<InfoRow label="SIRET" value={client.siret} />
|
||||||
|
<InfoRow label="TVA Intracommunautaire" value={client.tva_intra} />
|
||||||
|
<InfoRow label="Code NAF" value={client.code_naf} />
|
||||||
|
<InfoRow label="Forme juridique" value={client.forme_juridique} />
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DonneesFinancieres: React.FC<{ client: Client }> = ({ client }) => (
|
||||||
|
<SectionCard icon={CreditCard} title="Données financières">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<InfoRow label="Compte général" value={client.compte_general} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{client.categorie_tarif !== null && client.categorie_tarif !== undefined && (
|
||||||
|
<InfoRow label="Catégorie tarifaire" value={client.categorie_tarif.toString()} />
|
||||||
|
)}
|
||||||
|
{client.categorie_compta !== null && client.categorie_compta !== undefined && (
|
||||||
|
<InfoRow label="Catégorie comptable" value={client.categorie_compta.toString()} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{client.encours_autorise !== undefined && client.encours_autorise !== 0 && (
|
||||||
|
<InfoRow
|
||||||
|
label="Encours autorisé"
|
||||||
|
value={client.encours_autorise.toLocaleString("fr-FR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{client.assurance_credit !== undefined && client.assurance_credit !== 0 && (
|
||||||
|
<InfoRow
|
||||||
|
label="Assurance crédit"
|
||||||
|
value={client.assurance_credit.toLocaleString("fr-FR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CommentaireSection: React.FC<{ commentaire: string }> = ({ commentaire }) => (
|
||||||
|
<SectionCard icon={FileText} title="Commentaire">
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||||
|
{commentaire}
|
||||||
|
</p>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PRINCIPAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const ClientDetailsGrid: React.FC<ClientDetailsGridProps> = ({ client }) => {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Colonne gauche */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<InformationsPrincipales client={client} />
|
||||||
|
<ContactSection client={client} />
|
||||||
|
<AdresseSection client={client} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Colonne droite */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<InformationsLegales client={client} />
|
||||||
|
<DonneesFinancieres client={client} />
|
||||||
|
{client.commentaire && <CommentaireSection commentaire={client.commentaire} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClientDetailsGrid;
|
||||||
|
|
||||||
|
// Export des sous-composants si besoin de les réutiliser ailleurs
|
||||||
|
export {
|
||||||
|
SectionCard,
|
||||||
|
InfoRow,
|
||||||
|
ContactLink,
|
||||||
|
StatusBadge,
|
||||||
|
InformationsPrincipales,
|
||||||
|
ContactSection,
|
||||||
|
AdresseSection,
|
||||||
|
InformationsLegales,
|
||||||
|
DonneesFinancieres,
|
||||||
|
CommentaireSection,
|
||||||
|
getTypeTiersLabel,
|
||||||
|
};
|
||||||
269
src/components/page/client/ClientInfoVerifier.tsx
Normal file
269
src/components/page/client/ClientInfoVerifier.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { AlertTriangle, RefreshCw, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
import { useAppDispatch } from '@/store/hooks';
|
||||||
|
import { searchEntreprise } from '@/store/features/entreprise/thunk';
|
||||||
|
import { Client } from '@/types/clientType';
|
||||||
|
import { EntrepriseResponse } from '@/types/entreprise';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface ClientInfoVerifierProps {
|
||||||
|
client: Client;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerificationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
issues: string[];
|
||||||
|
hasOfficialData: boolean;
|
||||||
|
needsUpdate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClientInfoVerifier: React.FC<ClientInfoVerifierProps> = ({ client }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
const [verificationResult, setVerificationResult] = useState<VerificationResult | null>(null);
|
||||||
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
|
|
||||||
|
// Vérifier automatiquement au chargement
|
||||||
|
useEffect(() => {
|
||||||
|
checkClientInfo();
|
||||||
|
}, [client.numero]);
|
||||||
|
|
||||||
|
const checkClientInfo = async () => {
|
||||||
|
setIsChecking(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const issues: string[] = [];
|
||||||
|
let hasOfficialData = false;
|
||||||
|
|
||||||
|
// 1. Vérifier les champs requis
|
||||||
|
if (!client.intitule || client.intitule.trim().length === 0) {
|
||||||
|
issues.push("Raison sociale manquante");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client.siret || client.siret.length !== 14) {
|
||||||
|
issues.push("SIRET invalide ou manquant");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client.adresse || client.adresse.trim().length === 0) {
|
||||||
|
issues.push("Adresse manquante");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client.code_postal || client.code_postal.length !== 5) {
|
||||||
|
issues.push("Code postal invalide ou manquant");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client.ville || client.ville.trim().length === 0) {
|
||||||
|
issues.push("Ville manquante");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Rechercher l'entreprise dans la base officielle
|
||||||
|
let searchQuery = "";
|
||||||
|
|
||||||
|
if (client.siret && client.siret.length === 14) {
|
||||||
|
searchQuery = client.siret;
|
||||||
|
} else if (client.intitule) {
|
||||||
|
searchQuery = client.intitule;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
try {
|
||||||
|
const response = await dispatch(searchEntreprise(searchQuery)).unwrap() as EntrepriseResponse;
|
||||||
|
|
||||||
|
if (response.results && response.results.length > 0) {
|
||||||
|
hasOfficialData = true;
|
||||||
|
const officialData = response.results[0];
|
||||||
|
|
||||||
|
// Vérifier les divergences avec les données officielles
|
||||||
|
if (client.siret && officialData.siret_siege) {
|
||||||
|
if (client.siret !== officialData.siret_siege.replace(/\s/g, '')) {
|
||||||
|
issues.push("SIRET différent des données officielles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.intitule && officialData.company_name) {
|
||||||
|
// Vérifier si les noms sont très différents
|
||||||
|
const similarity = calculateSimilarity(
|
||||||
|
client.intitule.toLowerCase(),
|
||||||
|
officialData.company_name.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (similarity < 0.6) {
|
||||||
|
issues.push("Raison sociale différente des données officielles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.adresse && officialData.address) {
|
||||||
|
const similarity = calculateSimilarity(
|
||||||
|
client.adresse.toLowerCase(),
|
||||||
|
officialData.address.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (similarity < 0.5) {
|
||||||
|
issues.push("Adresse différente des données officielles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.code_postal && officialData.code_postal) {
|
||||||
|
if (client.code_postal !== officialData.code_postal) {
|
||||||
|
issues.push("Code postal différent des données officielles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.ville && officialData.ville) {
|
||||||
|
if (client.ville.toLowerCase() !== officialData.ville.toLowerCase()) {
|
||||||
|
issues.push("Ville différente des données officielles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si l'entreprise est active
|
||||||
|
if (officialData.is_active === false && client.est_actif === true) {
|
||||||
|
issues.push("L'entreprise est marquée inactive dans les registres officiels");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
issues.push("Entreprise non trouvée dans les registres officiels");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la vérification:", error);
|
||||||
|
issues.push("Impossible de vérifier les données officielles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Déterminer si une mise à jour est nécessaire
|
||||||
|
const needsUpdate = issues.length > 0;
|
||||||
|
|
||||||
|
setVerificationResult({
|
||||||
|
isValid: !needsUpdate,
|
||||||
|
issues,
|
||||||
|
hasOfficialData,
|
||||||
|
needsUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la vérification:", error);
|
||||||
|
setVerificationResult({
|
||||||
|
isValid: false,
|
||||||
|
issues: ["Erreur lors de la vérification des informations"],
|
||||||
|
hasOfficialData: false,
|
||||||
|
needsUpdate: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour calculer la similarité entre deux chaînes (algorithme de Levenshtein simplifié)
|
||||||
|
const calculateSimilarity = (str1: string, str2: string): number => {
|
||||||
|
const longer = str1.length > str2.length ? str1 : str2;
|
||||||
|
const shorter = str1.length > str2.length ? str2 : str1;
|
||||||
|
|
||||||
|
if (longer.length === 0) return 1.0;
|
||||||
|
|
||||||
|
const editDistance = levenshteinDistance(longer, shorter);
|
||||||
|
return (longer.length - editDistance) / longer.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const levenshteinDistance = (str1: string, str2: string): number => {
|
||||||
|
const matrix: number[][] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i <= str2.length; i++) {
|
||||||
|
matrix[i] = [i];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let j = 0; j <= str1.length; j++) {
|
||||||
|
matrix[0][j] = j;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i <= str2.length; i++) {
|
||||||
|
for (let j = 1; j <= str1.length; j++) {
|
||||||
|
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
|
||||||
|
matrix[i][j] = matrix[i - 1][j - 1];
|
||||||
|
} else {
|
||||||
|
matrix[i][j] = Math.min(
|
||||||
|
matrix[i - 1][j - 1] + 1,
|
||||||
|
matrix[i][j - 1] + 1,
|
||||||
|
matrix[i - 1][j] + 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matrix[str2.length][str1.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateInfo = () => {
|
||||||
|
navigate(`/home/clients/${client.numero}/edit`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ne rien afficher si la vérification n'est pas terminée ou si tout est OK
|
||||||
|
if (isChecking || !verificationResult) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verificationResult.isValid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{/* Bouton principal */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl text-sm font-medium text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
<span>Informations à vérifier ({verificationResult.issues.length})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Détails des problèmes */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showDetails && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="bg-white dark:bg-gray-950 border border-amber-200 dark:border-amber-800 rounded-xl p-4 space-y-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
Problèmes détectés :
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{verificationResult.issues.map((issue, index) => (
|
||||||
|
<li key={index} className="flex items-start gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<XCircle className="w-3 h-3 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>{issue}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2 border-t border-amber-100 dark:border-amber-900">
|
||||||
|
<button
|
||||||
|
onClick={handleUpdateInfo}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-[#007E45] text-white rounded-lg text-sm font-medium hover:bg-[#006837] transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Mettre à jour les informations
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDetails(false)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Ignorer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClientInfoVerifier;
|
||||||
460
src/components/page/devis/DevisContent.tsx
Normal file
460
src/components/page/devis/DevisContent.tsx
Normal file
|
|
@ -0,0 +1,460 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Plus, Trash2, PenLine, Package, Lock, EyeOff } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Tooltip, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { Textarea } from "@/components/ui/FormModal";
|
||||||
|
import ArticleAutocomplete from "@/components/molecules/ArticleAutocomplete";
|
||||||
|
import StickyTotals from "@/components/document-entry/StickyTotals";
|
||||||
|
import { DevisListItem } from "@/types/devisType";
|
||||||
|
import { LigneForm, Note } from "@/pages/sales/QuoteDetailPage";
|
||||||
|
|
||||||
|
interface DevisContentProps {
|
||||||
|
// Données
|
||||||
|
devis: DevisListItem;
|
||||||
|
editLignes: LigneForm[];
|
||||||
|
note: Note;
|
||||||
|
|
||||||
|
// États
|
||||||
|
isEditing: boolean;
|
||||||
|
isPdfPreviewVisible: boolean;
|
||||||
|
activeLineId: string | null;
|
||||||
|
description: string | null;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Totaux
|
||||||
|
editTotalHT: number;
|
||||||
|
editTotalTVA: number;
|
||||||
|
editTotalTTC: number;
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
onToggleLineMode: (lineId: string) => void;
|
||||||
|
onUpdateLigne: (lineId: string, field: keyof LigneForm, value: any) => void;
|
||||||
|
onAjouterLigne: () => void;
|
||||||
|
onSupprimerLigne: (lineId: string) => void;
|
||||||
|
onSetActiveLineId: (lineId: string | null) => void;
|
||||||
|
onSetDescription: (value: string | null) => void;
|
||||||
|
onSetNote: (note: Note) => void;
|
||||||
|
onOpenArticleModal: (lineId: string) => void;
|
||||||
|
|
||||||
|
// Fonction de calcul
|
||||||
|
calculerTotalLigne: (ligne: LigneForm) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DevisContent: React.FC<DevisContentProps> = ({
|
||||||
|
devis,
|
||||||
|
editLignes,
|
||||||
|
note,
|
||||||
|
isEditing,
|
||||||
|
isPdfPreviewVisible,
|
||||||
|
activeLineId,
|
||||||
|
description,
|
||||||
|
error,
|
||||||
|
editTotalHT,
|
||||||
|
editTotalTVA,
|
||||||
|
editTotalTTC,
|
||||||
|
onToggleLineMode,
|
||||||
|
onUpdateLigne,
|
||||||
|
onAjouterLigne,
|
||||||
|
onSupprimerLigne,
|
||||||
|
onSetActiveLineId,
|
||||||
|
onSetDescription,
|
||||||
|
onSetNote,
|
||||||
|
onOpenArticleModal,
|
||||||
|
calculerTotalLigne,
|
||||||
|
}) => {
|
||||||
|
const textSizeClass = isPdfPreviewVisible ? "text-xs" : "text-sm";
|
||||||
|
// console.log("devis : ",devis);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col p-2 ${isEditing ? "h-full": "h-[60vh]"}`}>
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 mx-auto w-full sm:px-6 lg:px-8 scroll-smooth">
|
||||||
|
{/* Tableau des lignes */}
|
||||||
|
<div className="bg-white rounded-2xl border bg-red 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={isEditing ? "text-[10px]" : ""}>
|
||||||
|
{isEditing && <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-0 py-3 text-left w-[20%]">
|
||||||
|
Description {!isEditing && "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%]">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="px-2 py-3 w-12"></th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{isEditing
|
||||||
|
? editLignes.map((item) => (
|
||||||
|
<EditableLigneRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isPdfPreviewVisible={isPdfPreviewVisible}
|
||||||
|
activeLineId={activeLineId}
|
||||||
|
description={description}
|
||||||
|
onToggleLineMode={onToggleLineMode}
|
||||||
|
onUpdateLigne={onUpdateLigne}
|
||||||
|
onSupprimerLigne={onSupprimerLigne}
|
||||||
|
onSetActiveLineId={onSetActiveLineId}
|
||||||
|
onSetDescription={onSetDescription}
|
||||||
|
onOpenArticleModal={onOpenArticleModal}
|
||||||
|
calculerTotalLigne={calculerTotalLigne}
|
||||||
|
canDelete={editLignes.length > 1}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: devis.lignes.map((line, index) => (
|
||||||
|
<ReadOnlyLigneRow
|
||||||
|
key={index}
|
||||||
|
line={line}
|
||||||
|
textSizeClass={textSizeClass}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bouton ajouter ligne */}
|
||||||
|
{isEditing && (
|
||||||
|
<div className="m-6">
|
||||||
|
<button
|
||||||
|
onClick={onAjouterLigne}
|
||||||
|
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 && isEditing && (
|
||||||
|
<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 */}
|
||||||
|
<NotesSection
|
||||||
|
note={note}
|
||||||
|
isEditing={isEditing}
|
||||||
|
onSetNote={onSetNote}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* StickyTotals fixé en bas */}
|
||||||
|
<div className="flex-none mt-5 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={isEditing ? editTotalHT : devis.total_ht_calcule}
|
||||||
|
total_taxes_calcule={isEditing ? editTotalTVA : devis.total_taxes_calcule}
|
||||||
|
total_ttc_calcule={isEditing ? editTotalTTC : devis.total_ttc_calcule}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SOUS-COMPOSANTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface EditableLigneRowProps {
|
||||||
|
item: LigneForm;
|
||||||
|
isPdfPreviewVisible: boolean;
|
||||||
|
activeLineId: string | null;
|
||||||
|
description: string | null;
|
||||||
|
canDelete: boolean;
|
||||||
|
onToggleLineMode: (lineId: string) => void;
|
||||||
|
onUpdateLigne: (lineId: string, field: keyof LigneForm, value: any) => void;
|
||||||
|
onSupprimerLigne: (lineId: string) => void;
|
||||||
|
onSetActiveLineId: (lineId: string | null) => void;
|
||||||
|
onSetDescription: (value: string | null) => void;
|
||||||
|
onOpenArticleModal: (lineId: string) => void;
|
||||||
|
calculerTotalLigne: (ligne: LigneForm) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditableLigneRow: React.FC<EditableLigneRowProps> = ({
|
||||||
|
item,
|
||||||
|
isPdfPreviewVisible,
|
||||||
|
activeLineId,
|
||||||
|
description,
|
||||||
|
canDelete,
|
||||||
|
onToggleLineMode,
|
||||||
|
onUpdateLigne,
|
||||||
|
onSupprimerLigne,
|
||||||
|
onSetActiveLineId,
|
||||||
|
onSetDescription,
|
||||||
|
onOpenArticleModal,
|
||||||
|
calculerTotalLigne,
|
||||||
|
}) => {
|
||||||
|
const textSizeClass = isPdfPreviewVisible ? "text-xs" : "text-sm";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="group hover:bg-gray-50/50 dark:hover:bg-gray-900/20">
|
||||||
|
{/* Mode Toggle */}
|
||||||
|
<td className="px-2 py-3">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleLineMode(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) => onUpdateLigne(item.id, "designation", e.target.value)}
|
||||||
|
onFocus={() => onSetActiveLineId(item.id)}
|
||||||
|
onBlur={() => setTimeout(() => onSetActiveLineId(null), 150)}
|
||||||
|
className={cn(
|
||||||
|
`w-full border-0 ${textSizeClass} focus:outline-none bg-white text-gray-900`
|
||||||
|
)}
|
||||||
|
placeholder="Saisir une désignation..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Menu dropdown */}
|
||||||
|
{activeLineId === item.id && item.designation && (
|
||||||
|
<ArticleCreationDropdown
|
||||||
|
designation={item.designation}
|
||||||
|
onCreateArticle={() => onOpenArticleModal(item.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<ArticleAutocomplete
|
||||||
|
value={item.articles}
|
||||||
|
onChange={(article) => onUpdateLigne(item.id, "articles", article)}
|
||||||
|
required
|
||||||
|
className={textSizeClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<td className="px-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.isManual ? description! : item.designation || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
item.isManual
|
||||||
|
? onSetDescription(e.target.value)
|
||||||
|
: onUpdateLigne(item.id, "designation", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Description détaillée..."
|
||||||
|
className={cn(
|
||||||
|
`w-full border-0 ${textSizeClass} focus:outline-none bg-white text-gray-900 text-left`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Quantité */}
|
||||||
|
<td className="px-1" style={{ width: "100px" }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={item.quantite}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateLigne(item.id, "quantite", parseFloat(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
className={cn(
|
||||||
|
`w-full border-0 ${textSizeClass} focus:outline-none bg-white 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) =>
|
||||||
|
onUpdateLigne(item.id, "prix_unitaire_ht", parseFloat(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
className={cn(
|
||||||
|
`w-full border-0 ${textSizeClass} focus:outline-none bg-white 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) =>
|
||||||
|
onUpdateLigne(item.id, "remise_pourcentage", parseFloat(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
className={cn(
|
||||||
|
`w-full border-0 ${textSizeClass} focus:outline-none bg-white text-gray-900 text-right`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* TVA */}
|
||||||
|
<td className={cn("px-1 text-center text-gray-500 text-right", textSizeClass)}>
|
||||||
|
{item.taux_taxe1}%
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<td className="px-1 text-right">
|
||||||
|
<span className={cn("font-bold font-mono text-gray-900", textSizeClass)}>
|
||||||
|
{calculerTotalLigne(item).toFixed(2)} €
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<td className="px-1 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => onSupprimerLigne(item.id)}
|
||||||
|
disabled={!canDelete}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ReadOnlyLigneRowProps {
|
||||||
|
line: any;
|
||||||
|
textSizeClass: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReadOnlyLigneRow: React.FC<ReadOnlyLigneRowProps> = ({ line, textSizeClass }) => (
|
||||||
|
<tr className="group hover:bg-gray-50/50">
|
||||||
|
<td className={cn("px-4 py-3 font-bold font-mono", textSizeClass)}>
|
||||||
|
{line.article_code}
|
||||||
|
</td>
|
||||||
|
<td className={cn("px-4 py-3 text-gray-600", textSizeClass)}>
|
||||||
|
{line.designation}
|
||||||
|
</td>
|
||||||
|
<td className={cn("px-4 py-3 text-right", textSizeClass)}>
|
||||||
|
{line.quantite}
|
||||||
|
</td>
|
||||||
|
<td className={cn("px-4 py-3 text-right font-mono", textSizeClass)}>
|
||||||
|
{line.prix_unitaire_ht?.toFixed(2)} €
|
||||||
|
</td>
|
||||||
|
<td className={cn("px-4 py-3 text-right text-gray-500", textSizeClass)}>
|
||||||
|
{line.remise_valeur1}%
|
||||||
|
</td>
|
||||||
|
<td className={cn("px-4 py-3 text-right text-gray-500", textSizeClass)}>
|
||||||
|
{line.taux_taxe1}%
|
||||||
|
</td>
|
||||||
|
<td className={cn("px-4 py-3 text-right font-bold font-mono", textSizeClass)}>
|
||||||
|
{line.montant_ligne_ht?.toFixed(2)} €
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ArticleCreationDropdownProps {
|
||||||
|
designation: string;
|
||||||
|
onCreateArticle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArticleCreationDropdown: React.FC<ArticleCreationDropdownProps> = ({
|
||||||
|
designation,
|
||||||
|
onCreateArticle,
|
||||||
|
}) => (
|
||||||
|
<div className="overflow-hidden mt-1 w-full bg-white rounded-xl border border-gray-200 shadow-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={onCreateArticle}
|
||||||
|
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]">{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">
|
||||||
|
<div className="p-1.5 rounded-lg">
|
||||||
|
<PenLine className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium">Texte libre accepté</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface NotesSectionProps {
|
||||||
|
note: Note;
|
||||||
|
isEditing: boolean;
|
||||||
|
onSetNote: (note: Note) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotesSection: React.FC<NotesSectionProps> = ({ note, isEditing, onSetNote }) => (
|
||||||
|
<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}
|
||||||
|
disabled={!isEditing}
|
||||||
|
placeholder="Conditions de paiement, délais de livraison..."
|
||||||
|
className="bg-white resize-none dark:bg-gray-950"
|
||||||
|
value={note.publique}
|
||||||
|
onChange={(e) => onSetNote({ ...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}
|
||||||
|
disabled={!isEditing}
|
||||||
|
placeholder="Notes internes, marge de négociation..."
|
||||||
|
className="bg-white border-yellow-200 resize-none dark:bg-gray-950 focus:border-yellow-400"
|
||||||
|
value={note.prive}
|
||||||
|
onChange={(e) => onSetNote({ ...note, prive: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DevisContent;
|
||||||
532
src/components/page/devis/DevisHeader.tsx
Normal file
532
src/components/page/devis/DevisHeader.tsx
Normal file
|
|
@ -0,0 +1,532 @@
|
||||||
|
import React from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
FileText,
|
||||||
|
FileSignature,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Loader2,
|
||||||
|
Save,
|
||||||
|
Settings,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn, formatDateFRCourt, formatForDateInput } from "@/lib/utils";
|
||||||
|
import { Input } from "@/components/ui/FormModal";
|
||||||
|
import { DevisListItem } from "@/types/devisType";
|
||||||
|
import { Client } from "@/types/clientType";
|
||||||
|
import { UniversignType } from "@/types/sageTypes";
|
||||||
|
import StatusBadge from "@/components/ui/StatusBadge";
|
||||||
|
import ClientAutocomplete from "@/components/molecules/ClientAutocomplete";
|
||||||
|
import PrimaryButton_v2 from "@/components/PrimaryButton_v2";
|
||||||
|
import { ActionButton } from "@/components/ribbons/ActionButton";
|
||||||
|
import { UserInterface } from "@/types/userInterface";
|
||||||
|
|
||||||
|
interface DevisHeaderProps {
|
||||||
|
// Données
|
||||||
|
devis: DevisListItem;
|
||||||
|
client: Client;
|
||||||
|
devisSigned: UniversignType | null;
|
||||||
|
|
||||||
|
userConnected: UserInterface
|
||||||
|
|
||||||
|
// États d'édition
|
||||||
|
isEditing: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
canSave: boolean;
|
||||||
|
|
||||||
|
// Champs éditables
|
||||||
|
editClient: Client | null;
|
||||||
|
editDateEmission: string;
|
||||||
|
editDateLivraison: string;
|
||||||
|
editReference: string;
|
||||||
|
|
||||||
|
// États UI
|
||||||
|
isPdfPreviewVisible: boolean;
|
||||||
|
|
||||||
|
// Handlers d'édition
|
||||||
|
onSetEditClient: (client: Client | null) => void;
|
||||||
|
onSetEditDateEmission: (date: string) => void;
|
||||||
|
onSetEditDateLivraison: (date: string) => void;
|
||||||
|
onSetEditReference: (ref: string) => void;
|
||||||
|
|
||||||
|
// Handlers d'actions
|
||||||
|
onStartEdit: () => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onSaveEdit: () => void;
|
||||||
|
onTogglePdfPreview: () => void;
|
||||||
|
onOpenStatusModal: () => void;
|
||||||
|
onOpenSignatureModal: () => void;
|
||||||
|
onOpenTransformModal: () => void;
|
||||||
|
onNavigateToClient: (clientCode: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DevisHeader: React.FC<DevisHeaderProps> = ({
|
||||||
|
devis,
|
||||||
|
client,
|
||||||
|
devisSigned,
|
||||||
|
isEditing,
|
||||||
|
isSaving,
|
||||||
|
canSave,
|
||||||
|
editClient,
|
||||||
|
editDateEmission,
|
||||||
|
editDateLivraison,
|
||||||
|
editReference,
|
||||||
|
isPdfPreviewVisible,
|
||||||
|
userConnected,
|
||||||
|
onSetEditClient,
|
||||||
|
onSetEditDateEmission,
|
||||||
|
onSetEditDateLivraison,
|
||||||
|
onSetEditReference,
|
||||||
|
onStartEdit,
|
||||||
|
onCancelEdit,
|
||||||
|
onSaveEdit,
|
||||||
|
onTogglePdfPreview,
|
||||||
|
onOpenStatusModal,
|
||||||
|
onOpenSignatureModal,
|
||||||
|
onOpenTransformModal,
|
||||||
|
onNavigateToClient,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isSigned = devisSigned?.local_status === "SIGNE";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"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)] transition-all duration-300 shrink-0 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">
|
||||||
|
{/* Main Info Area */}
|
||||||
|
<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 button + Title */}
|
||||||
|
<HeaderTitle
|
||||||
|
devis={devis}
|
||||||
|
devisSigned={devisSigned}
|
||||||
|
isEditing={isEditing}
|
||||||
|
onBack={() => navigate("/home/devis")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="hidden mx-2 w-px h-8 bg-gray-200 dark:bg-gray-800 sm:block" />
|
||||||
|
|
||||||
|
{/* Client Field */}
|
||||||
|
<ClientField
|
||||||
|
isEditing={isEditing}
|
||||||
|
editClient={editClient}
|
||||||
|
clientIntitule={devis.client_intitule}
|
||||||
|
onSetEditClient={onSetEditClient}
|
||||||
|
onNavigateToClient={() => onNavigateToClient(devis.client_code)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Date Fields (editing mode) */}
|
||||||
|
{isEditing && (
|
||||||
|
<EditingDateFields
|
||||||
|
editDateEmission={editDateEmission}
|
||||||
|
onSetEditDateEmission={onSetEditDateEmission}
|
||||||
|
userConnected={userConnected}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2 */}
|
||||||
|
{isEditing ? (
|
||||||
|
<EditingRow2
|
||||||
|
editReference={editReference}
|
||||||
|
editDateLivraison={editDateLivraison}
|
||||||
|
onSetEditReference={onSetEditReference}
|
||||||
|
onSetEditDateLivraison={onSetEditDateLivraison}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ReadOnlyRow2
|
||||||
|
devis={devis}
|
||||||
|
userConnected={userConnected}
|
||||||
|
devisSigned={devisSigned}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Section - Actions */}
|
||||||
|
<HeaderActions
|
||||||
|
isEditing={isEditing}
|
||||||
|
isSaving={isSaving}
|
||||||
|
canSave={canSave}
|
||||||
|
isSigned={isSigned}
|
||||||
|
devisStatut={devis.statut}
|
||||||
|
isPdfPreviewVisible={isPdfPreviewVisible}
|
||||||
|
onStartEdit={onStartEdit}
|
||||||
|
onCancelEdit={onCancelEdit}
|
||||||
|
onSaveEdit={onSaveEdit}
|
||||||
|
onTogglePdfPreview={onTogglePdfPreview}
|
||||||
|
onOpenStatusModal={onOpenStatusModal}
|
||||||
|
onOpenSignatureModal={onOpenSignatureModal}
|
||||||
|
onOpenTransformModal={onOpenTransformModal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SOUS-COMPOSANTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface HeaderTitleProps {
|
||||||
|
devis: DevisListItem;
|
||||||
|
devisSigned: UniversignType | null;
|
||||||
|
isEditing: boolean;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeaderTitle: React.FC<HeaderTitleProps> = ({
|
||||||
|
devis,
|
||||||
|
devisSigned,
|
||||||
|
isEditing,
|
||||||
|
onBack,
|
||||||
|
}) => (
|
||||||
|
<div className="flex gap-3 items-center mr-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
disabled={isEditing}
|
||||||
|
className="p-1.5 -ml-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors text-gray-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{devis.numero}
|
||||||
|
</h1>
|
||||||
|
<StatusBadge
|
||||||
|
status={devisSigned?.local_status === "SIGNE" ? 6 : devis.statut}
|
||||||
|
type_doc={0}
|
||||||
|
/>
|
||||||
|
{isEditing && (
|
||||||
|
<span className="px-2 py-1 text-xs font-medium text-amber-800 bg-amber-100 rounded-full">
|
||||||
|
Mode édition
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ClientFieldProps {
|
||||||
|
isEditing: boolean;
|
||||||
|
editClient: Client | null;
|
||||||
|
clientIntitule: string;
|
||||||
|
onSetEditClient: (client: Client | null) => void;
|
||||||
|
onNavigateToClient: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClientField: React.FC<ClientFieldProps> = ({
|
||||||
|
isEditing,
|
||||||
|
editClient,
|
||||||
|
clientIntitule,
|
||||||
|
onSetEditClient,
|
||||||
|
onNavigateToClient,
|
||||||
|
}) => (
|
||||||
|
<div className="flex-1 min-w-[250px] max-w-[400px]">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="relative z-20">
|
||||||
|
<ClientAutocomplete
|
||||||
|
value={editClient}
|
||||||
|
onChange={onSetEditClient}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="flex flex-col p-2 -m-2 rounded-lg transition-colors cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900"
|
||||||
|
onClick={onNavigateToClient}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium text-gray-500">Client</span>
|
||||||
|
<span className="font-semibold text-gray-900 truncate dark:text-white">
|
||||||
|
{clientIntitule || "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface EditingDateFieldsProps {
|
||||||
|
editDateEmission: string;
|
||||||
|
onSetEditDateEmission: (date: string) => void;
|
||||||
|
userConnected: UserInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditingDateFields: React.FC<EditingDateFieldsProps> = ({
|
||||||
|
editDateEmission,
|
||||||
|
onSetEditDateEmission,
|
||||||
|
userConnected
|
||||||
|
}) => (
|
||||||
|
<div className="flex gap-4 shrink-0">
|
||||||
|
<div className="w-auto">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formatForDateInput(editDateEmission)}
|
||||||
|
onChange={(e) => onSetEditDateEmission(e.target.value)}
|
||||||
|
className="h-10 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-[140px]">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface EditingRow2Props {
|
||||||
|
editReference: string;
|
||||||
|
editDateLivraison: string;
|
||||||
|
onSetEditReference: (ref: string) => void;
|
||||||
|
onSetEditDateLivraison: (date: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditingRow2: React.FC<EditingRow2Props> = ({
|
||||||
|
editReference,
|
||||||
|
editDateLivraison,
|
||||||
|
onSetEditReference,
|
||||||
|
onSetEditDateLivraison,
|
||||||
|
}) => (
|
||||||
|
<div className="flex flex-wrap gap-4 items-center pt-1 animate-in fade-in slide-in-from-top-1">
|
||||||
|
<div className="w-[250px]">
|
||||||
|
<Input
|
||||||
|
value={editReference}
|
||||||
|
onChange={(e) => onSetEditReference(e.target.value)}
|
||||||
|
placeholder="REF-EXT-123 ..."
|
||||||
|
className="h-9 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formatForDateInput(editDateLivraison)}
|
||||||
|
onChange={(e) => onSetEditDateLivraison(e.target.value)}
|
||||||
|
className="h-10 text-xs w-[140px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ReadOnlyRow2Props {
|
||||||
|
devis: DevisListItem;
|
||||||
|
userConnected: UserInterface;
|
||||||
|
devisSigned: UniversignType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReadOnlyRow2: React.FC<ReadOnlyRow2Props> = ({ devis, userConnected, devisSigned }) => (
|
||||||
|
<div className="flex flex-row gap-8 items-center">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-medium text-gray-500">Date</span>
|
||||||
|
<span className="text-sm text-gray-900 dark:text-white">
|
||||||
|
{formatDateFRCourt(devis.date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-medium text-gray-500">Commercial</span>
|
||||||
|
<span className="text-sm text-gray-900 dark:text-white">
|
||||||
|
{userConnected
|
||||||
|
? `${userConnected.prenom} ${userConnected.nom}`
|
||||||
|
: "_"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{devisSigned?.signers && devisSigned.signers.length > 0 && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-medium text-gray-500">Signataire</span>
|
||||||
|
<span className="text-sm text-gray-900 dark:text-white">
|
||||||
|
{devisSigned.signers[0].name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface HeaderActionsProps {
|
||||||
|
isEditing: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
canSave: boolean;
|
||||||
|
isSigned: boolean;
|
||||||
|
devisStatut: number;
|
||||||
|
isPdfPreviewVisible: boolean;
|
||||||
|
onStartEdit: () => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onSaveEdit: () => void;
|
||||||
|
onTogglePdfPreview: () => void;
|
||||||
|
onOpenStatusModal: () => void;
|
||||||
|
onOpenSignatureModal: () => void;
|
||||||
|
onOpenTransformModal: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeaderActions: React.FC<HeaderActionsProps> = ({
|
||||||
|
isEditing,
|
||||||
|
isSaving,
|
||||||
|
canSave,
|
||||||
|
isSigned,
|
||||||
|
devisStatut,
|
||||||
|
isPdfPreviewVisible,
|
||||||
|
onStartEdit,
|
||||||
|
onCancelEdit,
|
||||||
|
onSaveEdit,
|
||||||
|
onTogglePdfPreview,
|
||||||
|
onOpenStatusModal,
|
||||||
|
onOpenSignatureModal,
|
||||||
|
onOpenTransformModal,
|
||||||
|
}) => (
|
||||||
|
<div className="flex gap-3 items-center self-start mt-2 ml-auto shrink-0 lg:self-center lg:mt-0">
|
||||||
|
{/* Toggle PDF Preview */}
|
||||||
|
<div className="flex items-center p-1 mr-2 bg-gray-100 rounded-lg dark:bg-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={onTogglePdfPreview}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded-md transition-all",
|
||||||
|
isPdfPreviewVisible
|
||||||
|
? "bg-white dark:bg-gray-700 text-[#2A6F4F] shadow-sm"
|
||||||
|
: "text-gray-500 hover:text-gray-900 hover:bg-white"
|
||||||
|
)}
|
||||||
|
title="Aperçu PDF"
|
||||||
|
>
|
||||||
|
{isPdfPreviewVisible ? (
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<EditingActions
|
||||||
|
isSaving={isSaving}
|
||||||
|
canSave={canSave}
|
||||||
|
onCancelEdit={onCancelEdit}
|
||||||
|
onSaveEdit={onSaveEdit}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ReadOnlyActions
|
||||||
|
isSigned={isSigned}
|
||||||
|
devisStatut={devisStatut}
|
||||||
|
onStartEdit={onStartEdit}
|
||||||
|
onOpenStatusModal={onOpenStatusModal}
|
||||||
|
onOpenSignatureModal={onOpenSignatureModal}
|
||||||
|
onOpenTransformModal={onOpenTransformModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface EditingActionsProps {
|
||||||
|
isSaving: boolean;
|
||||||
|
canSave: boolean;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onSaveEdit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditingActions: React.FC<EditingActionsProps> = ({
|
||||||
|
isSaving,
|
||||||
|
canSave,
|
||||||
|
onCancelEdit,
|
||||||
|
onSaveEdit,
|
||||||
|
}) => (
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<button
|
||||||
|
onClick={onCancelEdit}
|
||||||
|
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={onSaveEdit}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ReadOnlyActionsProps {
|
||||||
|
isSigned: boolean;
|
||||||
|
devisStatut: number;
|
||||||
|
onStartEdit: () => void;
|
||||||
|
onOpenStatusModal: () => void;
|
||||||
|
onOpenSignatureModal: () => void;
|
||||||
|
onOpenTransformModal: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReadOnlyActions: React.FC<ReadOnlyActionsProps> = ({
|
||||||
|
isSigned,
|
||||||
|
devisStatut,
|
||||||
|
onStartEdit,
|
||||||
|
onOpenStatusModal,
|
||||||
|
onOpenSignatureModal,
|
||||||
|
onOpenTransformModal,
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
{(devisStatut === 0 || devisStatut === 1) && (
|
||||||
|
<button
|
||||||
|
onClick={onOpenStatusModal}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
<PrimaryButton_v2
|
||||||
|
icon={FileSignature}
|
||||||
|
disabled={isSigned}
|
||||||
|
onClick={onOpenSignatureModal}
|
||||||
|
className="bg-white text-[#2A6F4F] border border-[#2A6F4F] hover:bg-green-50"
|
||||||
|
>
|
||||||
|
<span className="hidden lg:inline">
|
||||||
|
Envoyer pour signature électronique
|
||||||
|
</span>
|
||||||
|
</PrimaryButton_v2>
|
||||||
|
{
|
||||||
|
devisStatut === 2 && (
|
||||||
|
<ActionButton
|
||||||
|
onClick={onOpenTransformModal}
|
||||||
|
icon={FileText}
|
||||||
|
label="Transformer"
|
||||||
|
className="bg-[#2A6F4F] text-white hover:bg-[#235c41]"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
onClick={onStartEdit}
|
||||||
|
disabled={isSigned}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 border border-gray-300 rounded-xl hover:bg-gray-50 transition-colors bg-white shadow-sm",
|
||||||
|
isSigned && "opacity-60 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DevisHeader;
|
||||||
41
src/components/page/devis/TransformOption.tsx
Normal file
41
src/components/page/devis/TransformOption.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ArrowRight, LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface TransformOptionProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
colorClass: string;
|
||||||
|
}
|
||||||
|
export const TransformOption: React.FC<TransformOptionProps> = ({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onClick,
|
||||||
|
colorClass,
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="w-full text-left p-4 rounded-xl border border-gray-200 hover:border-[#007E45] hover:bg-gray-50 transition-all group relative overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className={cn("absolute top-0 left-0 w-1 h-full", colorClass)} />
|
||||||
|
<div className="flex gap-4 items-start">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"p-3 rounded-xl bg-gray-100 group-hover:scale-110 transition-transform",
|
||||||
|
colorClass.replace("bg-", "text-")
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="flex gap-2 items-center font-semibold text-gray-900">
|
||||||
|
{title}
|
||||||
|
<ArrowRight className="w-4 h-4 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
|
</h4>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
480
src/components/page/facture/FactureContent.tsx
Normal file
480
src/components/page/facture/FactureContent.tsx
Normal file
|
|
@ -0,0 +1,480 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Plus, Trash2, PenLine, Package, Lock, EyeOff } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Tooltip, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { Textarea } from "@/components/ui/FormModal";
|
||||||
|
import ArticleAutocomplete from "@/components/molecules/ArticleAutocomplete";
|
||||||
|
import StickyTotals from "@/components/document-entry/StickyTotals";
|
||||||
|
import { Facture } from "@/types/factureType";
|
||||||
|
|
||||||
|
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: any | null;
|
||||||
|
isManual: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Note {
|
||||||
|
publique: string;
|
||||||
|
prive: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FactureContentProps {
|
||||||
|
// Données
|
||||||
|
facture: Facture;
|
||||||
|
editLignes: LigneForm[];
|
||||||
|
note: Note;
|
||||||
|
|
||||||
|
// États
|
||||||
|
isEditing: boolean;
|
||||||
|
isPdfPreviewVisible: boolean;
|
||||||
|
activeLineId: string | null;
|
||||||
|
description: string | null;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Totaux
|
||||||
|
editTotalHT: number;
|
||||||
|
editTotalTVA: number;
|
||||||
|
editTotalTTC: number;
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
onToggleLineMode: (lineId: string) => void;
|
||||||
|
onUpdateLigne: (lineId: string, field: keyof LigneForm, value: any) => void;
|
||||||
|
onAjouterLigne: () => void;
|
||||||
|
onSupprimerLigne: (lineId: string) => void;
|
||||||
|
onSetActiveLineId: (lineId: string | null) => void;
|
||||||
|
onSetDescription: (value: string | null) => void;
|
||||||
|
onSetNote: (note: Note) => void;
|
||||||
|
onOpenArticleModal: (lineId: string) => void;
|
||||||
|
|
||||||
|
// Fonction de calcul
|
||||||
|
calculerTotalLigne: (ligne: LigneForm) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FactureContent: React.FC<FactureContentProps> = ({
|
||||||
|
facture,
|
||||||
|
editLignes,
|
||||||
|
note,
|
||||||
|
isEditing,
|
||||||
|
isPdfPreviewVisible,
|
||||||
|
activeLineId,
|
||||||
|
description,
|
||||||
|
error,
|
||||||
|
editTotalHT,
|
||||||
|
editTotalTVA,
|
||||||
|
editTotalTTC,
|
||||||
|
onToggleLineMode,
|
||||||
|
onUpdateLigne,
|
||||||
|
onAjouterLigne,
|
||||||
|
onSupprimerLigne,
|
||||||
|
onSetActiveLineId,
|
||||||
|
onSetDescription,
|
||||||
|
onSetNote,
|
||||||
|
onOpenArticleModal,
|
||||||
|
calculerTotalLigne,
|
||||||
|
}) => {
|
||||||
|
const textSizeClass = isPdfPreviewVisible ? "text-xs" : "text-sm";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full p-2 ">
|
||||||
|
{/* Zone scrollable - SEULEMENT ici le scroll s'active */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 mx-auto w-full sm:px-6 lg:px-8 scroll-smooth">
|
||||||
|
<div className="mt-6 pb-4 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={isEditing ? "text-[10px]" : ""}>
|
||||||
|
{isEditing && <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-0 py-3 text-left w-[20%]">
|
||||||
|
Description {!isEditing && "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%]">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="px-2 py-3 w-12"></th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{isEditing
|
||||||
|
? editLignes.map((item) => (
|
||||||
|
<EditableLigneRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isPdfPreviewVisible={isPdfPreviewVisible}
|
||||||
|
activeLineId={activeLineId}
|
||||||
|
description={description}
|
||||||
|
onToggleLineMode={onToggleLineMode}
|
||||||
|
onUpdateLigne={onUpdateLigne}
|
||||||
|
onSupprimerLigne={onSupprimerLigne}
|
||||||
|
onSetActiveLineId={onSetActiveLineId}
|
||||||
|
onSetDescription={onSetDescription}
|
||||||
|
onOpenArticleModal={onOpenArticleModal}
|
||||||
|
calculerTotalLigne={calculerTotalLigne}
|
||||||
|
canDelete={editLignes.length > 1}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: facture.lignes?.map((line, index) => (
|
||||||
|
<ReadOnlyLigneRow
|
||||||
|
key={index}
|
||||||
|
line={line}
|
||||||
|
textSizeClass={textSizeClass}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bouton ajouter ligne */}
|
||||||
|
{isEditing && (
|
||||||
|
<div className="m-6">
|
||||||
|
<button
|
||||||
|
onClick={onAjouterLigne}
|
||||||
|
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 && isEditing && (
|
||||||
|
<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 */}
|
||||||
|
<NotesSection
|
||||||
|
note={note}
|
||||||
|
isEditing={isEditing}
|
||||||
|
onSetNote={onSetNote}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Totaux - Reste fixe naturellement en bas */}
|
||||||
|
<div className="flex-none mt-5 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={isEditing ? editTotalHT : facture.total_ht_calcule}
|
||||||
|
total_taxes_calcule={isEditing ? editTotalTVA : facture.total_taxes_calcule}
|
||||||
|
total_ttc_calcule={isEditing ? editTotalTTC : facture.total_ttc_calcule}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SOUS-COMPOSANTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface EditableLigneRowProps {
|
||||||
|
item: LigneForm;
|
||||||
|
isPdfPreviewVisible: boolean;
|
||||||
|
activeLineId: string | null;
|
||||||
|
description: string | null;
|
||||||
|
canDelete: boolean;
|
||||||
|
onToggleLineMode: (lineId: string) => void;
|
||||||
|
onUpdateLigne: (lineId: string, field: keyof LigneForm, value: any) => void;
|
||||||
|
onSupprimerLigne: (lineId: string) => void;
|
||||||
|
onSetActiveLineId: (lineId: string | null) => void;
|
||||||
|
onSetDescription: (value: string | null) => void;
|
||||||
|
onOpenArticleModal: (lineId: string) => void;
|
||||||
|
calculerTotalLigne: (ligne: LigneForm) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditableLigneRow: React.FC<EditableLigneRowProps> = ({
|
||||||
|
item,
|
||||||
|
isPdfPreviewVisible,
|
||||||
|
activeLineId,
|
||||||
|
description,
|
||||||
|
canDelete,
|
||||||
|
onToggleLineMode,
|
||||||
|
onUpdateLigne,
|
||||||
|
onSupprimerLigne,
|
||||||
|
onSetActiveLineId,
|
||||||
|
onSetDescription,
|
||||||
|
onOpenArticleModal,
|
||||||
|
calculerTotalLigne,
|
||||||
|
}) => {
|
||||||
|
const textSizeClass = isPdfPreviewVisible ? "text-xs" : "text-sm";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="group hover:bg-gray-50/50 dark:hover:bg-gray-900/20">
|
||||||
|
{/* Mode Toggle */}
|
||||||
|
<td className="px-2 py-3">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleLineMode(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) => onUpdateLigne(item.id, "designation", e.target.value)}
|
||||||
|
onFocus={() => onSetActiveLineId(item.id)}
|
||||||
|
onBlur={() => setTimeout(() => onSetActiveLineId(null), 150)}
|
||||||
|
className={cn(
|
||||||
|
`w-full border-0 ${textSizeClass} focus:outline-none bg-white text-gray-900`
|
||||||
|
)}
|
||||||
|
placeholder="Saisir une désignation..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Menu dropdown */}
|
||||||
|
{activeLineId === item.id && item.designation && (
|
||||||
|
<ArticleCreationDropdown
|
||||||
|
designation={item.designation}
|
||||||
|
onCreateArticle={() => onOpenArticleModal(item.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<ArticleAutocomplete
|
||||||
|
value={item.articles}
|
||||||
|
onChange={(article) => onUpdateLigne(item.id, "articles", article)}
|
||||||
|
required
|
||||||
|
className={textSizeClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<td className="px-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.isManual ? description! : item.designation || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
item.isManual
|
||||||
|
? onSetDescription(e.target.value)
|
||||||
|
: onUpdateLigne(item.id, "designation", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Description détaillée..."
|
||||||
|
className={cn(
|
||||||
|
`w-full border-0 ${textSizeClass} focus:outline-none bg-white text-gray-900 text-left`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Quantité */}
|
||||||
|
<td className="px-1" style={{ width: "100px" }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={item.quantite}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateLigne(item.id, "quantite", parseFloat(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
className={cn(
|
||||||
|
`w-full border-0 ${textSizeClass} focus:outline-none bg-white 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) =>
|
||||||
|
onUpdateLigne(item.id, "prix_unitaire_ht", parseFloat(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
className={cn(
|
||||||
|
`w-full border-0 ${textSizeClass} focus:outline-none bg-white 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) =>
|
||||||
|
onUpdateLigne(item.id, "remise_pourcentage", parseFloat(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
className={cn(
|
||||||
|
`w-full border-0 ${textSizeClass} focus:outline-none bg-white text-gray-900 text-right`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* TVA */}
|
||||||
|
<td className={cn("px-1 text-center text-gray-500 text-right", textSizeClass)}>
|
||||||
|
{item.taux_taxe1}%
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<td className="px-1 text-right">
|
||||||
|
<span className={cn("font-bold font-mono text-gray-900", textSizeClass)}>
|
||||||
|
{calculerTotalLigne(item).toFixed(2)} €
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<td className="px-1 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => onSupprimerLigne(item.id)}
|
||||||
|
disabled={!canDelete}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ReadOnlyLigneRowProps {
|
||||||
|
line: any;
|
||||||
|
textSizeClass: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReadOnlyLigneRow: React.FC<ReadOnlyLigneRowProps> = ({ line, textSizeClass }) => (
|
||||||
|
<tr className="group hover:bg-gray-50/50">
|
||||||
|
<td className={cn("px-4 py-3 font-bold font-mono", textSizeClass)}>
|
||||||
|
{line.article_code}
|
||||||
|
</td>
|
||||||
|
<td className={cn("px-4 py-3 text-gray-600", textSizeClass)}>
|
||||||
|
{line.designation}
|
||||||
|
</td>
|
||||||
|
<td className={cn("px-4 py-3 text-right", textSizeClass)}>
|
||||||
|
{line.quantite}
|
||||||
|
</td>
|
||||||
|
<td className={cn("px-4 py-3 text-right font-mono", textSizeClass)}>
|
||||||
|
{line.prix_unitaire_ht?.toFixed(2)} €
|
||||||
|
</td>
|
||||||
|
<td className={cn("px-4 py-3 text-right text-gray-500", textSizeClass)}>
|
||||||
|
{line.remise_valeur1 || 0}%
|
||||||
|
</td>
|
||||||
|
<td className={cn("px-4 py-3 text-right text-gray-500", textSizeClass)}>
|
||||||
|
{line.taux_taxe1}%
|
||||||
|
</td>
|
||||||
|
<td className={cn("px-4 py-3 text-right font-bold font-mono", textSizeClass)}>
|
||||||
|
{line.montant_ligne_ht?.toFixed(2)} €
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ArticleCreationDropdownProps {
|
||||||
|
designation: string;
|
||||||
|
onCreateArticle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArticleCreationDropdown: React.FC<ArticleCreationDropdownProps> = ({
|
||||||
|
designation,
|
||||||
|
onCreateArticle,
|
||||||
|
}) => (
|
||||||
|
<div className="overflow-hidden mt-1 w-full bg-white rounded-xl border border-gray-200 shadow-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={onCreateArticle}
|
||||||
|
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]">{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">
|
||||||
|
<div className="p-1.5 rounded-lg">
|
||||||
|
<PenLine className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium">Texte libre accepté</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface NotesSectionProps {
|
||||||
|
note: Note;
|
||||||
|
isEditing: boolean;
|
||||||
|
onSetNote: (note: Note) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotesSection: React.FC<NotesSectionProps> = ({ note, isEditing, onSetNote }) => (
|
||||||
|
<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}
|
||||||
|
disabled={!isEditing}
|
||||||
|
placeholder="Conditions de paiement, délais de livraison..."
|
||||||
|
className="bg-white resize-none dark:bg-gray-950"
|
||||||
|
value={note.publique}
|
||||||
|
onChange={(e) => onSetNote({ ...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}
|
||||||
|
disabled={!isEditing}
|
||||||
|
placeholder="Notes internes, marge de négociation..."
|
||||||
|
className="bg-white border-yellow-200 resize-none dark:bg-gray-950 focus:border-yellow-400"
|
||||||
|
value={note.prive}
|
||||||
|
onChange={(e) => onSetNote({ ...note, prive: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default FactureContent;
|
||||||
566
src/components/page/facture/FactureHeader.tsx
Normal file
566
src/components/page/facture/FactureHeader.tsx
Normal file
|
|
@ -0,0 +1,566 @@
|
||||||
|
import React from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Undo2,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Loader2,
|
||||||
|
Save,
|
||||||
|
Settings,
|
||||||
|
Download,
|
||||||
|
AlertTriangle,
|
||||||
|
Lock,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn, formatDateFRCourt, formatForDateInput } from "@/lib/utils";
|
||||||
|
import { Input } from "@/components/ui/FormModal";
|
||||||
|
import { Facture } from "@/types/factureType";
|
||||||
|
import { Client } from "@/types/clientType";
|
||||||
|
import StatusBadge from "@/components/ui/StatusBadge";
|
||||||
|
import ClientAutocomplete from "@/components/molecules/ClientAutocomplete";
|
||||||
|
import PrimaryButton_v2 from "@/components/PrimaryButton_v2";
|
||||||
|
import { UserInterface } from "@/types/userInterface";
|
||||||
|
import { ActionButton } from "@/components/ribbons/ActionButton";
|
||||||
|
|
||||||
|
interface FactureHeaderProps {
|
||||||
|
// Données
|
||||||
|
facture: Facture;
|
||||||
|
client: Client | null;
|
||||||
|
userConnected: UserInterface;
|
||||||
|
|
||||||
|
// États d'édition
|
||||||
|
isEditing: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
canSave: boolean;
|
||||||
|
|
||||||
|
// Champs éditables
|
||||||
|
editClient: Client | null;
|
||||||
|
editDateFacture: string;
|
||||||
|
editDateLivraison: string;
|
||||||
|
editReference: string;
|
||||||
|
|
||||||
|
// États UI
|
||||||
|
isPdfPreviewVisible: boolean;
|
||||||
|
|
||||||
|
setIsValid: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
|
||||||
|
// Handlers d'édition
|
||||||
|
onSetEditClient: (client: Client | null) => void;
|
||||||
|
onSetEditDateFacture: (date: string) => void;
|
||||||
|
onSetEditDateLivraison: (date: string) => void;
|
||||||
|
onSetEditReference: (ref: string) => void;
|
||||||
|
|
||||||
|
// Handlers d'actions
|
||||||
|
onStartEdit: () => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onSaveEdit: () => void;
|
||||||
|
onTogglePdfPreview: () => void;
|
||||||
|
onOpenStatusModal: () => void;
|
||||||
|
onOpenCreateAvoir: () => void;
|
||||||
|
onNavigateToClient: (clientCode: string) => void;
|
||||||
|
onDownloadPdf?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FactureHeader: React.FC<FactureHeaderProps> = ({
|
||||||
|
facture,
|
||||||
|
client,
|
||||||
|
userConnected,
|
||||||
|
isEditing,
|
||||||
|
isSaving,
|
||||||
|
canSave,
|
||||||
|
setIsValid,
|
||||||
|
editClient,
|
||||||
|
editDateFacture,
|
||||||
|
editDateLivraison,
|
||||||
|
editReference,
|
||||||
|
isPdfPreviewVisible,
|
||||||
|
onSetEditClient,
|
||||||
|
onSetEditDateFacture,
|
||||||
|
onSetEditDateLivraison,
|
||||||
|
onSetEditReference,
|
||||||
|
onStartEdit,
|
||||||
|
onCancelEdit,
|
||||||
|
onSaveEdit,
|
||||||
|
onTogglePdfPreview,
|
||||||
|
onOpenStatusModal,
|
||||||
|
onOpenCreateAvoir,
|
||||||
|
onNavigateToClient,
|
||||||
|
onDownloadPdf,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isPaid = facture.statut === 2;
|
||||||
|
const isLocked = facture.valide === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"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)] transition-all duration-300 shrink-0 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">
|
||||||
|
{/* Main Info Area */}
|
||||||
|
<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 button + Title */}
|
||||||
|
<HeaderTitle
|
||||||
|
facture={facture}
|
||||||
|
isEditing={isEditing}
|
||||||
|
onBack={() => navigate("/home/factures")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="hidden mx-2 w-px h-8 bg-gray-200 dark:bg-gray-800 sm:block" />
|
||||||
|
|
||||||
|
{/* Client Field */}
|
||||||
|
<ClientField
|
||||||
|
isEditing={isEditing}
|
||||||
|
editClient={editClient}
|
||||||
|
clientIntitule={facture.client_intitule}
|
||||||
|
onSetEditClient={onSetEditClient}
|
||||||
|
onNavigateToClient={() => onNavigateToClient(facture.client_code)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Date Fields (editing mode) */}
|
||||||
|
{isEditing && (
|
||||||
|
<EditingDateFields
|
||||||
|
editDateFacture={editDateFacture}
|
||||||
|
onSetEditDateFacture={onSetEditDateFacture}
|
||||||
|
userConnected={userConnected}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2 */}
|
||||||
|
{isEditing ? (
|
||||||
|
<EditingRow2
|
||||||
|
editReference={editReference}
|
||||||
|
editDateLivraison={editDateLivraison}
|
||||||
|
onSetEditReference={onSetEditReference}
|
||||||
|
onSetEditDateLivraison={onSetEditDateLivraison}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ReadOnlyRow2
|
||||||
|
facture={facture}
|
||||||
|
userConnected={userConnected}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Section - Actions */}
|
||||||
|
<HeaderActions
|
||||||
|
isEditing={isEditing}
|
||||||
|
isSaving={isSaving}
|
||||||
|
canSave={canSave}
|
||||||
|
isPaid={isPaid}
|
||||||
|
isLocked={isLocked}
|
||||||
|
factureStatut={facture.statut}
|
||||||
|
isPdfPreviewVisible={isPdfPreviewVisible}
|
||||||
|
onStartEdit={onStartEdit}
|
||||||
|
onCancelEdit={onCancelEdit}
|
||||||
|
onSaveEdit={onSaveEdit}
|
||||||
|
setIsValid={setIsValid}
|
||||||
|
onTogglePdfPreview={onTogglePdfPreview}
|
||||||
|
onOpenStatusModal={onOpenStatusModal}
|
||||||
|
onOpenCreateAvoir={onOpenCreateAvoir}
|
||||||
|
onDownloadPdf={onDownloadPdf}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SOUS-COMPOSANTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface HeaderTitleProps {
|
||||||
|
facture: Facture;
|
||||||
|
isEditing: boolean;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeaderTitle: React.FC<HeaderTitleProps> = ({
|
||||||
|
facture,
|
||||||
|
isEditing,
|
||||||
|
onBack,
|
||||||
|
}) => (
|
||||||
|
<div className="flex gap-3 items-center mr-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
disabled={isEditing}
|
||||||
|
className="p-1.5 -ml-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors text-gray-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{facture.numero}
|
||||||
|
</h1>
|
||||||
|
<StatusBadge status={facture.valide === 1 ? 5 : facture.statut} type_doc={60} />
|
||||||
|
{isEditing && (
|
||||||
|
<span className="px-2 py-1 text-xs font-medium text-amber-800 bg-amber-100 rounded-full">
|
||||||
|
Mode édition
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ClientFieldProps {
|
||||||
|
isEditing: boolean;
|
||||||
|
editClient: Client | null;
|
||||||
|
clientIntitule: string;
|
||||||
|
onSetEditClient: (client: Client | null) => void;
|
||||||
|
onNavigateToClient: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClientField: React.FC<ClientFieldProps> = ({
|
||||||
|
isEditing,
|
||||||
|
editClient,
|
||||||
|
clientIntitule,
|
||||||
|
onSetEditClient,
|
||||||
|
onNavigateToClient,
|
||||||
|
}) => (
|
||||||
|
<div className="flex-1 min-w-[250px] max-w-[400px]">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="relative z-20">
|
||||||
|
<ClientAutocomplete
|
||||||
|
value={editClient}
|
||||||
|
onChange={onSetEditClient}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="flex flex-col p-2 -m-2 rounded-lg transition-colors cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900"
|
||||||
|
onClick={onNavigateToClient}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium text-gray-500">Client</span>
|
||||||
|
<span className="font-semibold text-gray-900 truncate dark:text-white">
|
||||||
|
{clientIntitule || "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface EditingDateFieldsProps {
|
||||||
|
editDateFacture: string;
|
||||||
|
onSetEditDateFacture: (date: string) => void;
|
||||||
|
userConnected?: UserInterface;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditingDateFields: React.FC<EditingDateFieldsProps> = ({
|
||||||
|
editDateFacture,
|
||||||
|
onSetEditDateFacture,
|
||||||
|
userConnected,
|
||||||
|
}) => (
|
||||||
|
<div className="flex gap-4 shrink-0">
|
||||||
|
<div className="w-auto">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formatForDateInput(editDateFacture)}
|
||||||
|
onChange={(e) => onSetEditDateFacture(e.target.value)}
|
||||||
|
className="h-10 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{userConnected && (
|
||||||
|
<div className="w-[140px]">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface EditingRow2Props {
|
||||||
|
editReference: string;
|
||||||
|
editDateLivraison: string;
|
||||||
|
onSetEditReference: (ref: string) => void;
|
||||||
|
onSetEditDateLivraison: (date: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditingRow2: React.FC<EditingRow2Props> = ({
|
||||||
|
editReference,
|
||||||
|
editDateLivraison,
|
||||||
|
onSetEditReference,
|
||||||
|
onSetEditDateLivraison,
|
||||||
|
}) => (
|
||||||
|
<div className="flex flex-wrap gap-4 items-center pt-1 animate-in fade-in slide-in-from-top-1">
|
||||||
|
<div className="w-[250px]">
|
||||||
|
<Input
|
||||||
|
value={editReference}
|
||||||
|
onChange={(e) => onSetEditReference(e.target.value)}
|
||||||
|
placeholder="REF-EXT-123 ..."
|
||||||
|
className="h-9 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formatForDateInput(editDateLivraison)}
|
||||||
|
onChange={(e) => onSetEditDateLivraison(e.target.value)}
|
||||||
|
className="h-10 text-xs w-[140px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ReadOnlyRow2Props {
|
||||||
|
facture: Facture;
|
||||||
|
userConnected: UserInterface;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReadOnlyRow2: React.FC<ReadOnlyRow2Props> = ({
|
||||||
|
facture,
|
||||||
|
userConnected,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-8 items-center">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-medium text-gray-500">
|
||||||
|
Date d'émission
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-900 dark:text-white">
|
||||||
|
{formatDateFRCourt(facture.date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-medium text-gray-500">Commercial</span>
|
||||||
|
<span className="text-sm text-gray-900 dark:text-white">
|
||||||
|
{userConnected
|
||||||
|
? `${userConnected.prenom} ${userConnected.nom}`
|
||||||
|
: "_"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{facture.date_livraison && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-medium text-red-500 flex items-center gap-1">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Date d'échéance
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-red-600">
|
||||||
|
{formatDateFRCourt(facture.date_livraison)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{facture.reference && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-medium text-gray-500">Référence</span>
|
||||||
|
<span className="text-sm text-gray-900 dark:text-white">
|
||||||
|
{facture.reference}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeaderActionsProps {
|
||||||
|
isEditing: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
canSave: boolean;
|
||||||
|
isPaid: boolean;
|
||||||
|
isLocked: boolean;
|
||||||
|
factureStatut: number;
|
||||||
|
isPdfPreviewVisible: boolean;
|
||||||
|
setIsValid: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
onStartEdit: () => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onSaveEdit: () => void;
|
||||||
|
onTogglePdfPreview: () => void;
|
||||||
|
onOpenStatusModal: () => void;
|
||||||
|
onOpenCreateAvoir: () => void;
|
||||||
|
onDownloadPdf?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeaderActions: React.FC<HeaderActionsProps> = ({
|
||||||
|
isEditing,
|
||||||
|
isSaving,
|
||||||
|
canSave,
|
||||||
|
isPaid,
|
||||||
|
isLocked,
|
||||||
|
factureStatut,
|
||||||
|
isPdfPreviewVisible,
|
||||||
|
setIsValid,
|
||||||
|
onStartEdit,
|
||||||
|
onCancelEdit,
|
||||||
|
onSaveEdit,
|
||||||
|
onTogglePdfPreview,
|
||||||
|
onOpenStatusModal,
|
||||||
|
onOpenCreateAvoir,
|
||||||
|
onDownloadPdf,
|
||||||
|
}) => (
|
||||||
|
<div className="flex gap-3 items-center self-start mt-2 ml-auto shrink-0 lg:self-center lg:mt-0">
|
||||||
|
{/* Toggle PDF Preview */}
|
||||||
|
<div className="flex items-center p-1 mr-2 bg-gray-100 rounded-lg dark:bg-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={onTogglePdfPreview}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded-md transition-all",
|
||||||
|
isPdfPreviewVisible
|
||||||
|
? "bg-white dark:bg-gray-700 text-[#007E45] shadow-sm"
|
||||||
|
: "text-gray-500 hover:text-gray-900 hover:bg-white"
|
||||||
|
)}
|
||||||
|
title="Aperçu PDF"
|
||||||
|
>
|
||||||
|
{isPdfPreviewVisible ? (
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<EditingActions
|
||||||
|
isSaving={isSaving}
|
||||||
|
canSave={canSave}
|
||||||
|
onCancelEdit={onCancelEdit}
|
||||||
|
onSaveEdit={onSaveEdit}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ReadOnlyActions
|
||||||
|
isPaid={isPaid}
|
||||||
|
isLocked={isLocked}
|
||||||
|
setIsValid={setIsValid}
|
||||||
|
factureStatut={factureStatut}
|
||||||
|
onStartEdit={onStartEdit}
|
||||||
|
onOpenStatusModal={onOpenStatusModal}
|
||||||
|
onOpenCreateAvoir={onOpenCreateAvoir}
|
||||||
|
onDownloadPdf={onDownloadPdf}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface EditingActionsProps {
|
||||||
|
isSaving: boolean;
|
||||||
|
canSave: boolean;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onSaveEdit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditingActions: React.FC<EditingActionsProps> = ({
|
||||||
|
isSaving,
|
||||||
|
canSave,
|
||||||
|
onCancelEdit,
|
||||||
|
onSaveEdit,
|
||||||
|
}) => (
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<button
|
||||||
|
onClick={onCancelEdit}
|
||||||
|
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={onSaveEdit}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ReadOnlyActionsProps {
|
||||||
|
isPaid: boolean;
|
||||||
|
isLocked: boolean;
|
||||||
|
setIsValid: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
factureStatut: number;
|
||||||
|
onStartEdit: () => void;
|
||||||
|
onOpenStatusModal: () => void;
|
||||||
|
onOpenCreateAvoir: () => void;
|
||||||
|
onDownloadPdf?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReadOnlyActions: React.FC<ReadOnlyActionsProps> = ({
|
||||||
|
isPaid,
|
||||||
|
isLocked,
|
||||||
|
factureStatut,
|
||||||
|
setIsValid,
|
||||||
|
onStartEdit,
|
||||||
|
onOpenStatusModal,
|
||||||
|
onOpenCreateAvoir,
|
||||||
|
onDownloadPdf,
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
{onDownloadPdf && (
|
||||||
|
<button
|
||||||
|
onClick={onDownloadPdf}
|
||||||
|
className="p-2 text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
|
title="Télécharger PDF"
|
||||||
|
>
|
||||||
|
<Download className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPaid ? (
|
||||||
|
<PrimaryButton_v2
|
||||||
|
icon={Undo2}
|
||||||
|
onClick={onOpenCreateAvoir}
|
||||||
|
className="bg-green-50 text-green-700 border border-green-100 hover:bg-green-100"
|
||||||
|
>
|
||||||
|
<span className="hidden lg:inline">Créer Avoir</span>
|
||||||
|
</PrimaryButton_v2>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onOpenStatusModal}
|
||||||
|
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>
|
||||||
|
{isLocked && (
|
||||||
|
<ActionButton
|
||||||
|
onClick={() => setIsValid(true) }
|
||||||
|
icon={Lock}
|
||||||
|
label="Valider"
|
||||||
|
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onStartEdit}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 border border-gray-300 rounded-xl hover:bg-gray-50 transition-colors bg-white shadow-sm"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default FactureHeader;
|
||||||
66
src/components/page/facture/ValiderFacture.tsx
Normal file
66
src/components/page/facture/ValiderFacture.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ArrowRight, LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface OptionProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
colorClass: string; // ex: "bg-emerald-500"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ValiderFacture: React.FC<OptionProps> = ({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onClick,
|
||||||
|
colorClass,
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"group relative w-full text-left rounded-2xl border",
|
||||||
|
"border-gray-200 bg-white p-4",
|
||||||
|
"transition-all duration-200",
|
||||||
|
"hover:border-emerald-500 hover:bg-gray-50",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Accent gauche */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute left-0 top-0 h-full w-1 rounded-l-2xl",
|
||||||
|
colorClass
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* Icône */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-11 w-11 items-center justify-center rounded-xl",
|
||||||
|
"bg-gray-100 transition-transform duration-200",
|
||||||
|
"group-hover:scale-105"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-6",
|
||||||
|
colorClass.replace("bg-", "text-")
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenu */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="flex items-center gap-2 font-semibold text-gray-900">
|
||||||
|
{title}
|
||||||
|
<ArrowRight className="h-4 w-4 translate-x-[-4px] opacity-0 transition-all duration-200 group-hover:translate-x-0 group-hover:opacity-100" />
|
||||||
|
</h4>
|
||||||
|
<p className="mt-1 text-sm leading-relaxed text-gray-500">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
810
src/components/panels/PDFPreviewPanel.tsx
Normal file
810
src/components/panels/PDFPreviewPanel.tsx
Normal file
|
|
@ -0,0 +1,810 @@
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { Download, ZoomIn, ZoomOut } from "lucide-react";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import PrimaryButton_v2 from "../PrimaryButton_v2";
|
||||||
|
import logo from "../../assets/logo/logo.png";
|
||||||
|
import { Societe } from "@/types/societeType";
|
||||||
|
import { getSociete } from "@/store/features/gateways/selectors";
|
||||||
|
import { useAppSelector } from "@/store/hooks";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type DocumentType = "devis" | "facture" | "commande" | "bl" | "avoir";
|
||||||
|
|
||||||
|
export interface LigneDocument {
|
||||||
|
id?: string;
|
||||||
|
article_code: string;
|
||||||
|
designation: string;
|
||||||
|
quantite: number;
|
||||||
|
prix_unitaire_ht: number;
|
||||||
|
remise_pourcentage?: number;
|
||||||
|
taux_taxe1: number;
|
||||||
|
montant_ligne_ht: number;
|
||||||
|
articles?: any;
|
||||||
|
isManual?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Note {
|
||||||
|
publique: string;
|
||||||
|
prive: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseDocument {
|
||||||
|
numero: string;
|
||||||
|
date: string;
|
||||||
|
date_livraison?: string;
|
||||||
|
reference?: string;
|
||||||
|
client_code: string;
|
||||||
|
client_intitule: string;
|
||||||
|
client_adresse?: string;
|
||||||
|
client_code_postal?: string;
|
||||||
|
client_ville?: string;
|
||||||
|
statut?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONFIGURATION PAR TYPE DE DOCUMENT
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const DOCUMENT_CONFIG: Record<
|
||||||
|
DocumentType,
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
dateLabel: string;
|
||||||
|
validityLabel?: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
devis: {
|
||||||
|
title: "DEVIS",
|
||||||
|
dateLabel: "Date",
|
||||||
|
validityLabel: "Validité",
|
||||||
|
color: "#2A6F4F",
|
||||||
|
},
|
||||||
|
facture: {
|
||||||
|
title: "FACTURE",
|
||||||
|
dateLabel: "Date d'émission",
|
||||||
|
validityLabel: "Date d'échéance",
|
||||||
|
color: "#007E45",
|
||||||
|
},
|
||||||
|
commande: {
|
||||||
|
title: "BON DE COMMANDE",
|
||||||
|
dateLabel: "Date de commande",
|
||||||
|
validityLabel: "Date de livraison souhaitée",
|
||||||
|
color: "#1E40AF",
|
||||||
|
},
|
||||||
|
bl: {
|
||||||
|
title: "BON DE LIVRAISON",
|
||||||
|
dateLabel: "Date de livraison",
|
||||||
|
color: "#7C3AED",
|
||||||
|
},
|
||||||
|
avoir: {
|
||||||
|
title: "AVOIR",
|
||||||
|
dateLabel: "Date d'avoir",
|
||||||
|
color: "#DC2626",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PDFContent
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface PDFContentProps {
|
||||||
|
documentType: DocumentType;
|
||||||
|
document: BaseDocument;
|
||||||
|
data: LigneDocument[];
|
||||||
|
zoom?: number;
|
||||||
|
page?: number;
|
||||||
|
total_ht: number;
|
||||||
|
total_taxes_calcule: number;
|
||||||
|
total_ttc_calcule: number;
|
||||||
|
totalPages?: number;
|
||||||
|
notes: Note;
|
||||||
|
isEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PDFContent: React.FC<PDFContentProps> = ({
|
||||||
|
documentType,
|
||||||
|
document,
|
||||||
|
data,
|
||||||
|
notes,
|
||||||
|
total_ht,
|
||||||
|
total_taxes_calcule,
|
||||||
|
total_ttc_calcule,
|
||||||
|
zoom = 100,
|
||||||
|
page = 1,
|
||||||
|
totalPages = 1,
|
||||||
|
isEdit,
|
||||||
|
}) => {
|
||||||
|
const societe = useAppSelector(getSociete) as Societe;
|
||||||
|
const config = DOCUMENT_CONFIG[documentType];
|
||||||
|
|
||||||
|
const calculerTotalLigne = (ligne: LigneDocument) => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-white text-black shadow-lg border border-[#E0E0E0] overflow-hidden mx-auto transition-all duration-200 origin-top"
|
||||||
|
style={{
|
||||||
|
width: `${zoom}%`,
|
||||||
|
minWidth: "50mm",
|
||||||
|
aspectRatio: "1 / 1.314",
|
||||||
|
transform: `scale(${zoom / 100})`,
|
||||||
|
transformOrigin: "top center",
|
||||||
|
marginBottom: "2rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="p-[8%] h-full flex flex-col font-sans relative">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-start mb-12">
|
||||||
|
<div>
|
||||||
|
<img src={logo} alt="Logo" className="h-20 w-25" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 text-right">
|
||||||
|
<h1
|
||||||
|
className="text-3xl font-bold uppercase"
|
||||||
|
style={{ color: config.color }}
|
||||||
|
>
|
||||||
|
{config.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl font-semibold text-gray-700">
|
||||||
|
{document.numero ?? "BROUILLON"}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-1 text-sm text-gray-500">
|
||||||
|
<p>
|
||||||
|
{config.dateLabel} :{" "}
|
||||||
|
{new Date(document.date ?? Date.now()).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
{config.validityLabel && document.date_livraison && (
|
||||||
|
<p>
|
||||||
|
{config.validityLabel} :{" "}
|
||||||
|
{new Date(document.date_livraison).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{document.reference && <p>Réf : {document.reference}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Addresses */}
|
||||||
|
<div className="flex gap-12 justify-between mb-16">
|
||||||
|
<div className="w-1/2">
|
||||||
|
<p className="mb-2 text-xs font-bold text-gray-400 uppercase">
|
||||||
|
Émetteur
|
||||||
|
</p>
|
||||||
|
<div className="text-sm font-medium text-gray-800">
|
||||||
|
<p>{societe?.raison_sociale}</p>
|
||||||
|
<p className="font-normal text-gray-600">{societe?.adresse}</p>
|
||||||
|
<p className="font-normal text-gray-600">
|
||||||
|
{societe?.code_postal} {societe?.ville}, {societe?.pays}
|
||||||
|
</p>
|
||||||
|
<p className="font-normal text-gray-600 mt-1">
|
||||||
|
{societe?.email_societe}
|
||||||
|
</p>
|
||||||
|
<p className="font-normal text-gray-600 mt-1">
|
||||||
|
Tel : +{societe?.telephone}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 w-1/2 bg-gray-50 rounded-xl border border-gray-100">
|
||||||
|
<p className="mb-2 text-xs font-bold text-gray-400 uppercase">
|
||||||
|
{documentType === "avoir" ? "Client crédité" : "Destinataire"}
|
||||||
|
</p>
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
<p className="mb-1 text-sm font-bold">
|
||||||
|
{document.client_intitule || "Client"}
|
||||||
|
</p>
|
||||||
|
{document.client_adresse && (
|
||||||
|
<p className="text-gray-600">{document.client_adresse}</p>
|
||||||
|
)}
|
||||||
|
{(document.client_code_postal || document.client_ville) && (
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{document.client_code_postal} {document.client_ville}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Header */}
|
||||||
|
<div className="flex text-sm border-b border-gray-200 pb-2 mb-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-bold">Désignation</p>
|
||||||
|
</div>
|
||||||
|
<div className="font-bold w-16 text-right">Qté</div>
|
||||||
|
<div className="font-bold w-24 text-right">Prix Unit.HT</div>
|
||||||
|
<div className="font-bold w-16 text-right">TVA</div>
|
||||||
|
<div className="font-bold w-24 text-right">Montant HT</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lines */}
|
||||||
|
<div className="flex-1 space-y-3 mb-6">
|
||||||
|
{data.length > 0 ? (
|
||||||
|
data.map((line, i) => (
|
||||||
|
<div key={line.id || i} className="flex text-xs">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-bold">
|
||||||
|
{line.article_code || "Article libre"}
|
||||||
|
</p>
|
||||||
|
{line.designation && (
|
||||||
|
<p className="text-gray-600 text-[10px]">
|
||||||
|
{line.designation}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-16 text-right">{line.quantite}</div>
|
||||||
|
<div className="w-24 text-right">
|
||||||
|
{line.prix_unitaire_ht.toFixed(2)} €
|
||||||
|
</div>
|
||||||
|
<div className="w-16 text-right">{line.taux_taxe1}%</div>
|
||||||
|
<div className="w-24 font-medium text-right">
|
||||||
|
{isEdit
|
||||||
|
? calculerTotalLigne(line).toFixed(2)
|
||||||
|
: line.montant_ligne_ht.toFixed(2)}
|
||||||
|
€
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="italic text-center text-gray-400 py-8">
|
||||||
|
Aucune ligne
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Totaux */}
|
||||||
|
<div className="flex justify-end mb-8">
|
||||||
|
<div className="space-y-2 w-64 text-sm">
|
||||||
|
<div className="flex justify-between opacity-70">
|
||||||
|
<span>Total HT</span>
|
||||||
|
<span className="font-mono">{total_ht.toFixed(2)} €</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between opacity-70">
|
||||||
|
<span>TVA</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{total_taxes_calcule.toFixed(2)} €
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex justify-between border-t-2 pt-2 font-bold text-base"
|
||||||
|
style={{ borderColor: config.color, color: config.color }}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{documentType === "avoir" ? "Montant à rembourser" : "Net à payer"}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{total_ttc_calcule.toFixed(2)} €
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{notes?.publique && (
|
||||||
|
<div className="pt-4 border-t border-gray-200">
|
||||||
|
<p className="text-xs font-bold text-gray-400 uppercase mb-2">
|
||||||
|
Notes & Conditions
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600 whitespace-pre-wrap leading-relaxed">
|
||||||
|
{notes.publique}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="absolute bottom-8 left-0 right-0 text-center text-[10px] text-gray-400">
|
||||||
|
<p>
|
||||||
|
Page {page} / {totalPages}
|
||||||
|
</p>
|
||||||
|
{societe && (
|
||||||
|
<p className="mt-1">
|
||||||
|
{societe.raison_sociale} - SIRET: {societe.siret || "N/A"} - TVA:{" "}
|
||||||
|
{societe.numero_tva || "N/A"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PDFPreviewPanel
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
interface PDFPreviewPanelProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
documentType: DocumentType;
|
||||||
|
document: BaseDocument;
|
||||||
|
data: LigneDocument[];
|
||||||
|
notes: Note;
|
||||||
|
total_ht: number;
|
||||||
|
total_taxes_calcule: number;
|
||||||
|
total_ttc_calcule: number;
|
||||||
|
variant?: "modal" | "inline";
|
||||||
|
isEdit: boolean;
|
||||||
|
onDownload?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PDFPreviewPanel: React.FC<PDFPreviewPanelProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
documentType,
|
||||||
|
document,
|
||||||
|
data,
|
||||||
|
total_ht,
|
||||||
|
total_taxes_calcule,
|
||||||
|
total_ttc_calcule,
|
||||||
|
notes,
|
||||||
|
variant = "modal",
|
||||||
|
isEdit,
|
||||||
|
onDownload,
|
||||||
|
}) => {
|
||||||
|
const [zoom, setZoom] = useState<number>(100);
|
||||||
|
const config = DOCUMENT_CONFIG[documentType];
|
||||||
|
|
||||||
|
if (variant === "inline") {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col bg-[#525659] overflow-hidden border-l border-gray-200 dark:border-gray-800">
|
||||||
|
{/* Simple Toolbar */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 bg-[#323639] border-b border-gray-700 shadow-md z-10">
|
||||||
|
<span className="text-xs font-medium tracking-wider text-gray-200 uppercase">
|
||||||
|
Aperçu PDF en direct - {config.title}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setZoom((z) => Math.max(50, z - 10))}
|
||||||
|
className="p-1 text-gray-300 hover:text-white transition-colors"
|
||||||
|
title="Zoom arrière"
|
||||||
|
>
|
||||||
|
<ZoomOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-gray-400 font-mono w-12 text-center">
|
||||||
|
{zoom}%
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setZoom((z) => Math.min(150, z + 10))}
|
||||||
|
className="p-1 text-gray-300 hover:text-white transition-colors"
|
||||||
|
title="Zoom avant"
|
||||||
|
>
|
||||||
|
<ZoomIn className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar bg-[#525659] flex justify-center items-start">
|
||||||
|
<PDFContent
|
||||||
|
documentType={documentType}
|
||||||
|
isEdit={isEdit}
|
||||||
|
notes={notes}
|
||||||
|
document={document}
|
||||||
|
data={data}
|
||||||
|
zoom={zoom}
|
||||||
|
total_ht={total_ht}
|
||||||
|
total_taxes_calcule={total_taxes_calcule}
|
||||||
|
total_ttc_calcule={total_ttc_calcule}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-y-0 right-0 z-50 w-full max-w-4xl bg-[#525659] shadow-2xl"
|
||||||
|
initial={{ x: "100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "100%" }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||||
|
>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 bg-[#323639] border-b border-gray-700 shadow-sm">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h2 className="text-sm font-semibold text-white">
|
||||||
|
Aperçu {config.title}
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-2 items-center p-1 bg-gray-700 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setZoom(Math.max(50, zoom - 10))}
|
||||||
|
className="p-1.5 hover:bg-gray-600 rounded text-gray-300 hover:text-white transition-colors"
|
||||||
|
title="Zoom arrière"
|
||||||
|
>
|
||||||
|
<ZoomOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span className="w-12 text-sm font-bold text-center text-white font-mono">
|
||||||
|
{zoom}%
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setZoom(Math.min(200, zoom + 10))}
|
||||||
|
className="p-1.5 hover:bg-gray-600 rounded text-gray-300 hover:text-white transition-colors"
|
||||||
|
title="Zoom avant"
|
||||||
|
>
|
||||||
|
<ZoomIn className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
{onDownload && (
|
||||||
|
<PrimaryButton_v2
|
||||||
|
onClick={onDownload}
|
||||||
|
className="py-1.5 text-sm h-auto px-4 bg-white text-[#2A6F4F] hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<Download className="mr-2 w-4 h-4" /> Télécharger
|
||||||
|
</PrimaryButton_v2>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-1.5 text-sm font-medium text-gray-300 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-[calc(100vh-60px)] overflow-y-auto p-8 custom-scrollbar bg-[#525659] flex justify-center items-start">
|
||||||
|
<PDFContent
|
||||||
|
documentType={documentType}
|
||||||
|
isEdit={isEdit}
|
||||||
|
notes={notes}
|
||||||
|
document={document}
|
||||||
|
data={data}
|
||||||
|
zoom={zoom}
|
||||||
|
total_ht={total_ht}
|
||||||
|
total_taxes_calcule={total_taxes_calcule}
|
||||||
|
total_ttc_calcule={total_ttc_calcule}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PDFPreviewPanel;
|
||||||
|
|
||||||
|
// import { LigneForm, Note } from "@/pages/sales/QuoteDetailPage";
|
||||||
|
// import { DevisListItem } from "@/types/devisType";
|
||||||
|
// import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
// import { Download, ZoomIn, ZoomOut } from "lucide-react";
|
||||||
|
// import React, { useState } from "react";
|
||||||
|
// import PrimaryButton_v2 from "../PrimaryButton_v2";
|
||||||
|
// import logo from "../../assets/logo/logo.png";
|
||||||
|
// import { Societe } from "@/types/societeType";
|
||||||
|
// import { getSociete } from "@/store/features/gateways/selectors";
|
||||||
|
// import { useAppSelector } from "@/store/hooks";
|
||||||
|
|
||||||
|
// interface PDFContentProps {
|
||||||
|
// devis: DevisListItem;
|
||||||
|
// data: LigneForm[];
|
||||||
|
// zoom?: number;
|
||||||
|
// page?: number;
|
||||||
|
// total_ht: number;
|
||||||
|
// total_taxes_calcule: number;
|
||||||
|
// total_ttc_calcule: number;
|
||||||
|
// totalPages?: number;
|
||||||
|
// notes: Note;
|
||||||
|
// isEdit: boolean;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const PDFContent: React.FC<PDFContentProps> = ({
|
||||||
|
// data,
|
||||||
|
// notes,
|
||||||
|
// total_ht,
|
||||||
|
// total_taxes_calcule,
|
||||||
|
// total_ttc_calcule,
|
||||||
|
// devis,
|
||||||
|
// zoom = 10,
|
||||||
|
// page = 1,
|
||||||
|
// totalPages = 1,
|
||||||
|
// isEdit,
|
||||||
|
// }) => {
|
||||||
|
// 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 societe = useAppSelector(getSociete) as Societe;
|
||||||
|
|
||||||
|
// console.log("data : ",data);
|
||||||
|
// console.log("deeivs : ",devis);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <div
|
||||||
|
// className="bg-white text-black shadow-lg border border-[#E0E0E0] overflow-hidden mx-auto transition-all duration-200 origin-top"
|
||||||
|
// style={{
|
||||||
|
// width: `${zoom}%`,
|
||||||
|
// minWidth: "50mm",
|
||||||
|
// aspectRatio: "1 / 1.314",
|
||||||
|
// transform: `scale(${zoom / 100})`,
|
||||||
|
// transformOrigin: "top center",
|
||||||
|
// marginBottom: "2rem",
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <div className="p-[8%] h-full flex flex-col font-sans relative">
|
||||||
|
// {/* Header */}
|
||||||
|
// <div className="flex justify-between items-start mb-12">
|
||||||
|
// <div>
|
||||||
|
// <img src={logo} alt="" className="h-20 w-25" />
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// <div className="flex flex-col gap-2 text-right">
|
||||||
|
// <h1 className="text-3xl font-bold uppercase">
|
||||||
|
// {devis.numero ?? "BROUILLON"}
|
||||||
|
// </h1>
|
||||||
|
// <div className="flex flex-col gap-1 text-sm text-gray-500">
|
||||||
|
// <p>
|
||||||
|
// Date : {new Date(devis.date ?? Date.now()).toLocaleDateString()}
|
||||||
|
// </p>
|
||||||
|
// <p>
|
||||||
|
// Validité :{" "}
|
||||||
|
// {new Date(
|
||||||
|
// devis.date_livraison ?? Date.now()
|
||||||
|
// ).toLocaleDateString()}
|
||||||
|
// </p>
|
||||||
|
// <p>Réf : {devis.reference ?? "—"}</p>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// {/* Addresses */}
|
||||||
|
// <div className="flex gap-12 justify-between mb-16">
|
||||||
|
// <div className="w-1/2">
|
||||||
|
// <p className="mb-2 text-xs font-bold text-gray-400 uppercase">
|
||||||
|
// Émetteur
|
||||||
|
// </p>
|
||||||
|
// <div className="text-sm font-medium text-gray-800">
|
||||||
|
// <p>{societe?.raison_sociale}</p>
|
||||||
|
// <p className="font-normal text-gray-600">{societe?.adresse}</p>
|
||||||
|
// <p className="font-normal text-gray-600">{societe?.code_postal} {societe?.ville}, {societe?.pays}</p>
|
||||||
|
// <p className="font-normal text-gray-600 mt-1">{societe?.email_societe}</p>
|
||||||
|
// <p className="font-normal text-gray-600 mt-1">Tel : +{societe?.telephone}</p>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// <div className="p-6 w-1/2 bg-gray-50 rounded-xl border border-gray-100">
|
||||||
|
// <p className="mb-2 text-xs font-bold text-gray-400 uppercase">
|
||||||
|
// Destinataire
|
||||||
|
// </p>
|
||||||
|
// <div className="text-sm text-gray-900">
|
||||||
|
// <p className="mb-1 text-sm font-bold">
|
||||||
|
// {devis.client_intitule || "Client"}
|
||||||
|
// </p>
|
||||||
|
// <p className="text-gray-600">10 rue des Clients</p>
|
||||||
|
// <p className="text-gray-600">75001 Paris</p>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// {/* info list */}
|
||||||
|
// <div className="flex text-sm border-b border-gray-200 pb-2">
|
||||||
|
// <div className="flex-1">
|
||||||
|
// <p className="font-bold">Désignation</p>
|
||||||
|
// </div>
|
||||||
|
// <div className="font-bold w-16 text-right">Qté</div>
|
||||||
|
// <div className="font-bold w-24 text-right">
|
||||||
|
// Prix Unit.HT
|
||||||
|
// </div>
|
||||||
|
// {/* <div className="font-bold w-16 text-right">Remise</div> */}
|
||||||
|
// <div className="font-bold w-16 text-right">TVA</div>
|
||||||
|
// <div className="font-bold w-24 text-right ">
|
||||||
|
// Montant HT
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// {/* Lines */}
|
||||||
|
// <div className="flex-1 space-y-4">
|
||||||
|
// {data.length > 0 ? (
|
||||||
|
// data.map((line, i) => (
|
||||||
|
// <div key={i} className="flex text-xs">
|
||||||
|
// <div className="flex-1">
|
||||||
|
// <p className="font-bold">{line.designation}</p>
|
||||||
|
// {line.designation && (
|
||||||
|
// <p className="text-gray-600">{line.designation}</p>
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
// <div className="w-16 text-right">{line.quantite}</div>
|
||||||
|
// <div className="w-24 text-right">
|
||||||
|
// {line.prix_unitaire_ht.toFixed(2)} €
|
||||||
|
// </div>
|
||||||
|
// {/* <div className="w-16 text-right">{line.remise_pourcentage}%</div> */}
|
||||||
|
// <div className="w-16 text-right">{line.taux_taxe1}%</div>
|
||||||
|
// <div className="w-24 font-medium text-right">
|
||||||
|
// {isEdit
|
||||||
|
// ? calculerTotalLigne(line).toFixed(2)
|
||||||
|
// : line.montant_ligne_ht}
|
||||||
|
// €
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// ))
|
||||||
|
// ) : (
|
||||||
|
// <div className="italic text-center text-gray-400">Aucune ligne</div>
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// {/* Totaux */}
|
||||||
|
// <div className="flex justify-end">
|
||||||
|
// <div className="space-y-2 w-64 text-sm">
|
||||||
|
// <div className="flex justify-between opacity-70">
|
||||||
|
// <span>Total HT</span>
|
||||||
|
// <span>{total_ht.toFixed(2)} €</span>
|
||||||
|
// </div>
|
||||||
|
// <div className="flex justify-between opacity-70">
|
||||||
|
// <span>TVA</span>
|
||||||
|
// <span>{total_taxes_calcule.toFixed(2)} €</span>
|
||||||
|
// </div>
|
||||||
|
// <div className="flex justify-between border-t font-bold mt-2 text-md text-[#2A6F4F]">
|
||||||
|
// <span>Net à payer</span>
|
||||||
|
// <span>{total_ttc_calcule.toFixed(2)} €</span>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// {/* Notes */}
|
||||||
|
// {notes?.publique && (
|
||||||
|
// <div className="pt-4 mt-10 border-t">
|
||||||
|
// <p className="text-xs font-bold text-gray-400 uppercase">
|
||||||
|
// Notes & Conditions
|
||||||
|
// </p>
|
||||||
|
// <p className="text-xs text-gray-600 whitespace-pre-wrap">
|
||||||
|
// {notes.publique}
|
||||||
|
// </p>
|
||||||
|
// </div>
|
||||||
|
// )}
|
||||||
|
|
||||||
|
// <div className="absolute bottom-8 w-full text-center text-[10px] text-gray-400">
|
||||||
|
// Page {page} / {totalPages}
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
|
||||||
|
// interface PDFPreviewPanelProps {
|
||||||
|
// isOpen: boolean;
|
||||||
|
// onClose: () => void;
|
||||||
|
// devis: DevisListItem;
|
||||||
|
// title?: string;
|
||||||
|
// data: LigneForm[];
|
||||||
|
// notes: Note;
|
||||||
|
// total_ht: number;
|
||||||
|
// total_taxes_calcule: number;
|
||||||
|
// total_ttc_calcule: number;
|
||||||
|
// variant?: "modal" | "inline";
|
||||||
|
// isEdit: boolean;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const PDFPreviewPanel: React.FC<PDFPreviewPanelProps> = ({
|
||||||
|
// isOpen,
|
||||||
|
// onClose,
|
||||||
|
// devis,
|
||||||
|
// data,
|
||||||
|
// total_ht,
|
||||||
|
// total_taxes_calcule,
|
||||||
|
// total_ttc_calcule,
|
||||||
|
// notes,
|
||||||
|
// variant = "modal",
|
||||||
|
// isEdit,
|
||||||
|
// }) => {
|
||||||
|
// const [zoom, setZoom] = useState<number>(100);
|
||||||
|
|
||||||
|
// if (variant === "inline") {
|
||||||
|
// if (!isOpen) return null;
|
||||||
|
// return (
|
||||||
|
// <div className="h-full flex flex-col bg-[#525659] overflow-hidden border-l border-gray-200 dark:border-gray-800">
|
||||||
|
// {/* Simple Toolbar */}
|
||||||
|
// <div className="flex items-center justify-between px-4 py-2 bg-[#323639] border-b border-gray-700 shadow-md z-10">
|
||||||
|
// <span className="text-xs font-medium tracking-wider text-gray-200 uppercase">
|
||||||
|
// Aperçu PDF en direct
|
||||||
|
// </span>
|
||||||
|
// <div className="flex gap-2 items-center">
|
||||||
|
// <button
|
||||||
|
// onClick={() => setZoom((z) => Math.max(50, z - 10))}
|
||||||
|
// className="p-1 text-gray-300 hover:text-white"
|
||||||
|
// >
|
||||||
|
// <ZoomOut className="w-4 h-4" />
|
||||||
|
// </button>
|
||||||
|
// <button
|
||||||
|
// onClick={() => setZoom((z) => Math.min(150, z + 10))}
|
||||||
|
// className="p-1 text-gray-300 hover:text-white"
|
||||||
|
// >
|
||||||
|
// <ZoomIn className="w-4 h-4" />
|
||||||
|
// </button>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// <div className="flex-1 overflow-y-auto p-8 custom-scrollbar bg-[#525659] flex justify-center items-start">
|
||||||
|
// <PDFContent
|
||||||
|
// isEdit={isEdit}
|
||||||
|
// notes={notes}
|
||||||
|
// devis={devis}
|
||||||
|
// data={data}
|
||||||
|
// zoom={zoom}
|
||||||
|
// total_ht={total_ht}
|
||||||
|
// total_taxes_calcule={total_taxes_calcule}
|
||||||
|
// total_ttc_calcule={total_ttc_calcule}
|
||||||
|
// />
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <AnimatePresence>
|
||||||
|
// {isOpen && (
|
||||||
|
// <>
|
||||||
|
// <motion.div
|
||||||
|
// className="fixed inset-0 z-50 bg-black/50"
|
||||||
|
// onClick={onClose}
|
||||||
|
// />
|
||||||
|
// <motion.div
|
||||||
|
// className="fixed inset-y-0 right-0 z-60 w-full max-w-4xl bg-[#525659]"
|
||||||
|
// initial={{ x: "100%" }}
|
||||||
|
// animate={{ x: 0 }}
|
||||||
|
// exit={{ x: "100%" }}
|
||||||
|
// >
|
||||||
|
// {/* Toolbar */}
|
||||||
|
// <div className="flex items-center justify-between px-6 py-3 bg-[#323639] border-b border-gray-700 shadow-sm">
|
||||||
|
// <div className="flex gap-2 items-center p-1 bg-gray-700 rounded-lg">
|
||||||
|
// <button
|
||||||
|
// onClick={() => setZoom(Math.max(50, zoom - 10))}
|
||||||
|
// className="p-1.5 hover:bg-gray-600 rounded text-gray-300 hover:text-white"
|
||||||
|
// >
|
||||||
|
// <ZoomOut className="w-4 h-4" />
|
||||||
|
// </button>
|
||||||
|
// <span className="w-12 text-sm font-bold text-center text-white">
|
||||||
|
// {zoom}%
|
||||||
|
// </span>
|
||||||
|
// <button
|
||||||
|
// onClick={() => setZoom(Math.min(200, zoom + 10))}
|
||||||
|
// className="p-1.5 hover:bg-gray-600 rounded text-gray-300 hover:text-white"
|
||||||
|
// >
|
||||||
|
// <ZoomIn className="w-4 h-4" />
|
||||||
|
// </button>
|
||||||
|
// </div>
|
||||||
|
// <div className="flex gap-2 items-center">
|
||||||
|
// <PrimaryButton_v2 className="py-1.5 text-sm h-auto px-4 bg-white text-[#2A6F4F] hover:bg-gray-100">
|
||||||
|
// <Download className="mr-2 w-4 h-4" /> Télécharger
|
||||||
|
// </PrimaryButton_v2>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
|
||||||
|
// <PDFContent
|
||||||
|
// isEdit={isEdit}
|
||||||
|
// notes={notes}
|
||||||
|
// devis={devis}
|
||||||
|
// data={data}
|
||||||
|
// zoom={zoom}
|
||||||
|
// total_ht={total_ht}
|
||||||
|
// total_taxes_calcule={total_taxes_calcule}
|
||||||
|
// total_ttc_calcule={total_ttc_calcule}
|
||||||
|
// />
|
||||||
|
// </motion.div>
|
||||||
|
// </>
|
||||||
|
// )}
|
||||||
|
// </AnimatePresence>
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
|
||||||
|
// export default PDFPreviewPanel;
|
||||||
40
src/components/ribbons/ActionButton.tsx
Normal file
40
src/components/ribbons/ActionButton.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ActionButtonProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
variant?: 'primary' | 'secondary';
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActionButton: React.FC<ActionButtonProps> = ({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
variant = 'secondary',
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
title
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={title || label}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all shadow-sm active:scale-95",
|
||||||
|
variant === 'primary'
|
||||||
|
? "bg-[#941403] text-white hover:bg-[#7a1002] shadow-red-900/10"
|
||||||
|
: "bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750",
|
||||||
|
disabled && "opacity-50 cursor-not-allowed grayscale",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
107
src/components/ribbons/QuoteActionRibbon.jsx
Normal file
107
src/components/ribbons/QuoteActionRibbon.jsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Edit, Send, FileSignature, Download, Eye, FileText,
|
||||||
|
Trash2, Copy, CheckCircle, XCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import PrimaryButton from '@/components/PrimaryButton';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const ActionButton = ({ icon: Icon, label, onClick, variant = 'secondary', disabled, className, title }) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={title || label}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all shadow-sm active:scale-95",
|
||||||
|
variant === 'primary'
|
||||||
|
? "bg-[#941403] text-white hover:bg-[#7a1002] shadow-red-900/10"
|
||||||
|
: "bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750",
|
||||||
|
disabled && "opacity-50 cursor-not-allowed grayscale",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const QuoteActionRibbon = ({ quote, onAction, onTransform, isEditMode }) => {
|
||||||
|
if (!quote) return null;
|
||||||
|
|
||||||
|
const isSigned = quote.status === 'Signé' || quote.status === 'Transformé en commande';
|
||||||
|
const isCancelled = quote.status === 'Annulé' || quote.status === 'Refusé';
|
||||||
|
|
||||||
|
// For new quotes, we don't show the ribbon actions yet
|
||||||
|
if (quote.id === 'new') return null;
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-800 rounded-xl p-3 flex items-center gap-3 text-red-700 dark:text-red-300">
|
||||||
|
<XCircle className="w-5 h-5" />
|
||||||
|
<span className="font-medium">Ce devis est annulé. Aucune action n'est possible.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 py-2">
|
||||||
|
{/* Main Actions */}
|
||||||
|
{!isSigned && (
|
||||||
|
<>
|
||||||
|
<PrimaryButton icon={FileSignature} onClick={() => onAction('sign')} disabled={isEditMode}>
|
||||||
|
Signature électronique
|
||||||
|
</PrimaryButton>
|
||||||
|
<ActionButton icon={Send} label="Envoyer" onClick={() => onAction('send')} disabled={isEditMode} />
|
||||||
|
|
||||||
|
{!isEditMode && (
|
||||||
|
<ActionButton icon={Edit} label="Modifier" onClick={() => onAction('edit')} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Signed Actions */}
|
||||||
|
{isSigned && (
|
||||||
|
<div className="flex items-center gap-2 bg-green-50 dark:bg-green-900/20 px-3 py-1.5 rounded-lg border border-green-100 dark:border-green-800 mr-auto">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||||
|
<span className="text-sm font-medium text-green-700 dark:text-green-300">Document verrouillé – signé</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Common Actions */}
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
{!isSigned && (
|
||||||
|
<ActionButton
|
||||||
|
icon={FileText}
|
||||||
|
label="Transformer"
|
||||||
|
onClick={onTransform}
|
||||||
|
disabled={isEditMode}
|
||||||
|
className="text-blue-600 dark:text-blue-400 border-blue-100 dark:border-blue-900 bg-blue-50 dark:bg-blue-900/10 hover:bg-blue-100"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ActionButton icon={Download} label="PDF" onClick={() => onAction('download')} />
|
||||||
|
<ActionButton icon={Eye} label="Aperçu" onClick={() => onAction('preview')} />
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-gray-200 dark:bg-gray-700 mx-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onAction('copy')}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
title="Dupliquer le devis"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{!isSigned && (
|
||||||
|
<button
|
||||||
|
onClick={() => onAction('delete')}
|
||||||
|
className="p-2 text-gray-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||||
|
title="Supprimer le devis"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuoteActionRibbon;
|
||||||
80
src/components/signature/SignatureChart.jsx
Normal file
80
src/components/signature/SignatureChart.jsx
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
const SignatureChart = ({ data }) => {
|
||||||
|
if (!data || data.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-950 p-6 rounded-2xl border border-gray-200 dark:border-gray-800 shadow-sm h-[400px]">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="font-bold text-lg text-gray-900 dark:text-white">Consommation des crédits</h3>
|
||||||
|
<select className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg px-3 py-1 text-sm">
|
||||||
|
<option>30 derniers jours</option>
|
||||||
|
<option>90 derniers jours</option>
|
||||||
|
<option>Cette année</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveContainer width="100%" height="85%">
|
||||||
|
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E5E7EB" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(date) => {
|
||||||
|
const d = new Date(date);
|
||||||
|
return `${d.getDate()}/${d.getMonth()+1}`;
|
||||||
|
}}
|
||||||
|
stroke="#9CA3AF"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="#9CA3AF"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
border: '1px solid #E5E7EB',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="daily"
|
||||||
|
name="Consommation journalière"
|
||||||
|
stroke="#338660"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3, fill: '#338660' }}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="cumulative"
|
||||||
|
name="Consommation cumulée"
|
||||||
|
stroke="#3B82F6"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SignatureChart;
|
||||||
63
src/components/signature/SignatureCreditAlert.jsx
Normal file
63
src/components/signature/SignatureCreditAlert.jsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { AlertTriangle, AlertCircle, ShoppingCart } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
const SignatureCreditAlert = ({ credits }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (!credits || credits.remaining > 20) return null;
|
||||||
|
|
||||||
|
const isCritical = credits.remaining < 10;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className={`mb-6 p-4 rounded-xl border ${
|
||||||
|
isCritical
|
||||||
|
? 'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800'
|
||||||
|
: 'bg-orange-50 border-orange-200 dark:bg-orange-900/20 dark:border-orange-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${
|
||||||
|
isCritical ? 'bg-red-100 text-red-600' : 'bg-orange-100 text-orange-600'
|
||||||
|
}`}>
|
||||||
|
{isCritical ? <AlertCircle className="w-5 h-5" /> : <AlertTriangle className="w-5 h-5" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className={`font-bold ${
|
||||||
|
isCritical ? 'text-red-900 dark:text-red-200' : 'text-orange-900 dark:text-orange-200'
|
||||||
|
}`}>
|
||||||
|
{isCritical ? 'Stock de signatures critique !' : 'Stock de signatures faible'}
|
||||||
|
</h3>
|
||||||
|
<p className={`text-sm ${
|
||||||
|
isCritical ? 'text-red-700 dark:text-red-300' : 'text-orange-700 dark:text-orange-300'
|
||||||
|
}`}>
|
||||||
|
Il ne vous reste que <strong>{credits.remaining} crédits</strong>.
|
||||||
|
{isCritical ? ' Rechargez maintenant pour éviter tout blocage.' : ' Pensez à recharger votre compte.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/signature/purchase')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors ${
|
||||||
|
isCritical
|
||||||
|
? 'bg-red-600 hover:bg-red-700 text-white shadow-md shadow-red-600/20'
|
||||||
|
: 'bg-orange-500 hover:bg-orange-600 text-white shadow-md shadow-orange-500/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShoppingCart className="w-4 h-4" />
|
||||||
|
Acheter des crédits
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SignatureCreditAlert;
|
||||||
93
src/components/signature/SignatureKPIs.jsx
Normal file
93
src/components/signature/SignatureKPIs.jsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
CreditCard, CheckCircle2, Wallet, Send, Check, Clock,
|
||||||
|
XCircle, Bell, TrendingUp, Calendar
|
||||||
|
} from 'lucide-react';
|
||||||
|
import KpiCard from '@/components/KpiCard';
|
||||||
|
|
||||||
|
const SignatureKPIs = ({ stats }) => {
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
const { credits, signatures } = stats;
|
||||||
|
|
||||||
|
const kpis = [
|
||||||
|
{
|
||||||
|
title: "Crédits Achetés",
|
||||||
|
value: credits.total,
|
||||||
|
icon: CreditCard,
|
||||||
|
tooltip: { content: "Nombre total de crédits achetés depuis le début" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Crédits Consommés",
|
||||||
|
value: credits.consumed,
|
||||||
|
icon: CheckCircle2,
|
||||||
|
trend: 'up',
|
||||||
|
change: `${Math.round((credits.consumed / credits.total) * 100)}%`,
|
||||||
|
tooltip: { content: "Crédits utilisés pour les signatures validées" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Crédits Restants",
|
||||||
|
value: credits.remaining,
|
||||||
|
icon: Wallet,
|
||||||
|
trend: credits.remaining < 10 ? 'down' : 'neutral',
|
||||||
|
tooltip: { content: "Crédits disponibles pour nouvelles signatures" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Projection (Jours)",
|
||||||
|
value: credits.projectionDays,
|
||||||
|
icon: Calendar,
|
||||||
|
trend: credits.projectionDays < 30 ? 'down' : 'neutral',
|
||||||
|
tooltip: { content: "Estimation durée du stock au rythme actuel" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Demandes Envoyées",
|
||||||
|
value: signatures.total,
|
||||||
|
icon: Send,
|
||||||
|
tooltip: { content: "Nombre total de demandes de signature" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Signées",
|
||||||
|
value: signatures.signed,
|
||||||
|
icon: Check,
|
||||||
|
change: "+2 cette semaine",
|
||||||
|
trend: "up",
|
||||||
|
tooltip: { content: "Documents signés avec succès" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "En Attente",
|
||||||
|
value: signatures.pending,
|
||||||
|
icon: Clock,
|
||||||
|
tooltip: { content: "En attente de signature par le client" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Relançables",
|
||||||
|
value: signatures.remindable,
|
||||||
|
icon: Bell,
|
||||||
|
tooltip: { content: "Signatures pouvant être relancées" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Refusés / Expirés",
|
||||||
|
value: signatures.refused,
|
||||||
|
icon: XCircle,
|
||||||
|
trend: "down",
|
||||||
|
tooltip: { content: "Documents refusés ou délai dépassé" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Taux Conversion",
|
||||||
|
value: `${signatures.conversionRate}%`,
|
||||||
|
icon: TrendingUp,
|
||||||
|
trend: signatures.conversionRate > 70 ? "up" : "down",
|
||||||
|
tooltip: { content: "Pourcentage de demandes aboutissant à une signature" }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
|
||||||
|
{kpis.map((kpi, index) => (
|
||||||
|
<KpiCard key={index} {...kpi} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SignatureKPIs;
|
||||||
79
src/components/signature/SignatureReminderModal.jsx
Normal file
79
src/components/signature/SignatureReminderModal.jsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import FormModal, { FormField, Textarea } from '@/components/FormModal';
|
||||||
|
import { toast } from '@/components/ui/use-toast';
|
||||||
|
import { Mail, AlertCircle } from 'lucide-react';
|
||||||
|
import { useSignature } from '@/contexts/SignatureContext';
|
||||||
|
|
||||||
|
const SignatureReminderModal = ({ isOpen, onClose, signature }) => {
|
||||||
|
const { sendReminder } = useSignature();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [message, setMessage] = useState(
|
||||||
|
"Bonjour,\n\nSauf erreur de notre part, nous sommes toujours en attente de votre signature pour le document référencé.\n\nPouvez-vous procéder à la signature dès que possible ?\n\nCordialement,"
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const success = await sendReminder(signature.id);
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast({
|
||||||
|
title: "Relance envoyée",
|
||||||
|
description: `Un email de rappel a été envoyé à ${signature.signerEmail}`,
|
||||||
|
variant: "success"
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Impossible d'envoyer la relance.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!signature) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Relancer la signature"
|
||||||
|
submitLabel="Envoyer la relance"
|
||||||
|
onSubmit={handleSend}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-xl flex items-start gap-3">
|
||||||
|
<Mail className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-blue-900 dark:text-blue-200">Détails de l'envoi</h4>
|
||||||
|
<ul className="text-sm text-blue-800 dark:text-blue-300 mt-1 space-y-1">
|
||||||
|
<li><strong>Document :</strong> {signature.documentType.toUpperCase()} {signature.documentNumber}</li>
|
||||||
|
<li><strong>Client :</strong> {signature.clientName}</li>
|
||||||
|
<li><strong>Destinataire :</strong> {signature.signerEmail}</li>
|
||||||
|
<li><strong>Relances déjà effectuées :</strong> {signature.remindersSent} / 3</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{signature.remindersSent >= 3 && (
|
||||||
|
<div className="bg-orange-50 dark:bg-orange-900/20 p-4 rounded-xl flex items-center gap-3 text-orange-800 dark:text-orange-200">
|
||||||
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
<p className="text-sm">Attention : Vous avez déjà atteint le nombre recommandé de relances.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField label="Message personnalisé">
|
||||||
|
<Textarea
|
||||||
|
rows={6}
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SignatureReminderModal;
|
||||||
169
src/components/system/SystemStatusDrawer.jsx
Normal file
169
src/components/system/SystemStatusDrawer.jsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
X, CheckCircle2, Server, ShieldCheck, Activity,
|
||||||
|
Users, FileText, Truck, BarChart3, PenTool, RefreshCw,
|
||||||
|
Lock, FileJson, Info
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const StatusItem = ({ icon: Icon, label, status, subtext }) => (
|
||||||
|
<div className="flex items-center justify-between p-4 bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors cursor-default group">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-gray-50 dark:bg-gray-800 rounded-lg group-hover:bg-white dark:group-hover:bg-gray-700 transition-colors">
|
||||||
|
<Icon className="w-5 h-5 text-[#338660]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{label}</p>
|
||||||
|
{subtext && <p className="text-xs text-gray-500 dark:text-gray-400">{subtext}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="relative flex h-2.5 w-2.5">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-[#338660]"></span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-[#338660]">{status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MetricCard = ({ label, value, icon: Icon, tooltip }) => (
|
||||||
|
<div className="flex flex-col p-4 bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800 rounded-xl hover:shadow-sm transition-shadow">
|
||||||
|
<div className="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400">
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
<span className="text-xs font-medium">{label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-gray-900 dark:text-white">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SecurityItem = ({ label, active = true }) => (
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-green-50/50 dark:bg-green-900/10 border border-green-100 dark:border-green-900/20">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-[#338660]" />
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{label}</span>
|
||||||
|
{active && <span className="ml-auto text-xs text-[#338660] font-bold">Actif</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SystemStatusDrawer = ({ isOpen, onClose }) => {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[60]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drawer */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: '100%' }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: '100%' }}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||||
|
className="fixed inset-y-0 right-0 z-[70] w-full sm:w-[500px] bg-white dark:bg-[#0A0A0A] shadow-2xl flex flex-col border-l border-gray-200 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-800 bg-white/50 dark:bg-[#0A0A0A]/50 backdrop-blur-xl sticky top-0 z-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-[#338660]/10 rounded-xl">
|
||||||
|
<Server className="w-6 h-6 text-[#338660]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 dark:text-white">État du système</h2>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Statut opérationnel en temps réel</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" onClick={onClose} className="rounded-full hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-8 custom-scrollbar">
|
||||||
|
|
||||||
|
{/* Global Status */}
|
||||||
|
<div className="p-5 rounded-2xl bg-gradient-to-br from-green-50 to-white dark:from-[#338660]/10 dark:to-gray-900 border border-green-100 dark:border-[#338660]/20 shadow-sm">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 bg-green-400 rounded-full animate-ping opacity-20"></div>
|
||||||
|
<CheckCircle2 className="w-12 h-12 text-[#338660] relative z-10" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-[#338660]">Système Opérationnel</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">Tous les services Dataven fonctionnent normalement.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modules */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Activity className="w-4 h-4 text-gray-400" />
|
||||||
|
<h3 className="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider">Modules Applicatifs</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<StatusItem icon={Users} label="CRM & Opportunités" status="Opérationnel" />
|
||||||
|
<StatusItem icon={FileText} label="Devis & Facturation" status="Opérationnel" />
|
||||||
|
<StatusItem icon={Truck} label="Livraisons & Stocks" status="Opérationnel" />
|
||||||
|
<StatusItem icon={BarChart3} label="Comptabilité & Reporting" status="Opérationnel" />
|
||||||
|
<StatusItem icon={PenTool} label="Signature Électronique" status="Opérationnel" />
|
||||||
|
<StatusItem icon={RefreshCw} label="Connecteurs ERP (Sage 100)" status="Sync active" subtext="Dernière synchro: 2 min" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Activity Metrics */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<BarChart3 className="w-4 h-4 text-gray-400" />
|
||||||
|
<h3 className="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider">Activité Système</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<MetricCard icon={RefreshCw} label="Dernière synchro" value="il y a 2 min" />
|
||||||
|
<MetricCard icon={FileJson} label="Documents traités (24h)" value="247 documents" />
|
||||||
|
<MetricCard icon={Activity} label="Écritures analysées" value="1,842 lignes" />
|
||||||
|
<MetricCard icon={Users} label="Utilisateurs actifs" value="12 connectés" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Security */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Lock className="w-4 h-4 text-gray-400" />
|
||||||
|
<h3 className="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider">Sécurité & Conformité</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<SecurityItem label="Connexions SSL/TLS" />
|
||||||
|
<SecurityItem label="Journalisation active" />
|
||||||
|
<SecurityItem label="Contrôle d'accès RBAC" />
|
||||||
|
<SecurityItem label="Chiffrement des données" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* SaaS Position */}
|
||||||
|
<div className="p-4 bg-blue-50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-900/20 rounded-xl flex items-start gap-3">
|
||||||
|
<Info className="w-5 h-5 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-300 italic">
|
||||||
|
"Dataven fonctionne comme une surcouche intelligente, en continu, sans interruption des systèmes existants."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center text-xs text-gray-400 pt-4">
|
||||||
|
Version système: 3.0.0-stable • Dataven Cloud Regions (EU-West)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SystemStatusDrawer;
|
||||||
88
src/components/ui/AuthInput.tsx
Normal file
88
src/components/ui/AuthInput.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface AuthInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
icon?: React.ElementType;
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
rightElement?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
containerClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthInput = React.forwardRef<HTMLInputElement, AuthInputProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
rightElement,
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-1.5", containerClassName)}>
|
||||||
|
<label className="text-sm font-semibold text-gray-700 dark:text-gray-300 ml-1">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex items-center transition-all duration-200 border rounded-xl overflow-hidden bg-white dark:bg-gray-900",
|
||||||
|
error
|
||||||
|
? "border-red-300 focus-within:border-red-500 focus-within:ring-4 focus-within:ring-red-100"
|
||||||
|
: isFocused
|
||||||
|
? "border-[#338660] ring-4 ring-[#338660]/10 shadow-sm"
|
||||||
|
: "border-gray-200 dark:border-gray-800 hover:border-gray-300 dark:hover:border-gray-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Icon && (
|
||||||
|
<div className="pl-4 pr-2 text-gray-400">
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"w-full py-3 pr-4 bg-transparent border-none focus:ring-0 text-gray-900 dark:text-white placeholder-gray-400 text-base",
|
||||||
|
!Icon && "pl-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{rightElement && (
|
||||||
|
<div className="pr-4 pl-2">
|
||||||
|
{rightElement}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: -5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="text-xs text-red-500 font-medium ml-1 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
{error}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
AuthInput.displayName = "AuthInput";
|
||||||
|
|
||||||
|
export default AuthInput;
|
||||||
263
src/components/ui/DataTable.tsx
Normal file
263
src/components/ui/DataTable.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { ChevronDown, ChevronUp, Search } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import Pagination from "@/components/Pagination";
|
||||||
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
import { Checkbox } from "@mui/material";
|
||||||
|
|
||||||
|
// ------------------------
|
||||||
|
// TYPES
|
||||||
|
// ------------------------
|
||||||
|
export interface TableColumn<T> {
|
||||||
|
key: keyof T;
|
||||||
|
label: string;
|
||||||
|
sortable?: boolean;
|
||||||
|
render?: (value: any, row: T) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataTableProps<T> {
|
||||||
|
columns: any[];
|
||||||
|
data: T[];
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
actions?: (row: T) => React.ReactNode;
|
||||||
|
status?: boolean;
|
||||||
|
selectable?: boolean;
|
||||||
|
selectableFactures?: T[];
|
||||||
|
selectedIds?: string[];
|
||||||
|
onSelectRow?: (id: string) => void;
|
||||||
|
onSelectAll?: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------
|
||||||
|
// COMPONENT
|
||||||
|
// ------------------------
|
||||||
|
const DataTable = <T extends Record<string, any>>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
onRowClick,
|
||||||
|
selectable = false,
|
||||||
|
selectableFactures = [],
|
||||||
|
selectedIds = [],
|
||||||
|
onSelectRow,
|
||||||
|
onSelectAll,
|
||||||
|
actions,
|
||||||
|
status = false,
|
||||||
|
}: DataTableProps<T>) => {
|
||||||
|
const [sortColumn, setSortColumn] = useState<keyof T | null>(null);
|
||||||
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
const itemsPerPage = 10;
|
||||||
|
|
||||||
|
const handleSort = (column: keyof T) => {
|
||||||
|
if (sortColumn === column) {
|
||||||
|
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
||||||
|
} else {
|
||||||
|
setSortColumn(column);
|
||||||
|
setSortDirection("asc");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🔍 Recherche
|
||||||
|
const filteredData = data.filter((row) =>
|
||||||
|
Object.values(row).some((value) =>
|
||||||
|
String(value).toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ↕️ Tri
|
||||||
|
const sortedData = [...filteredData].sort((a, b) => {
|
||||||
|
if (!sortColumn) return 0;
|
||||||
|
|
||||||
|
const aVal = a[sortColumn];
|
||||||
|
const bVal = b[sortColumn];
|
||||||
|
|
||||||
|
if (aVal < bVal) return sortDirection === "asc" ? -1 : 1;
|
||||||
|
if (aVal > bVal) return sortDirection === "asc" ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 📄 Pagination
|
||||||
|
const paginatedData = sortedData.slice(
|
||||||
|
(currentPage - 1) * itemsPerPage,
|
||||||
|
currentPage * itemsPerPage
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
|
||||||
|
|
||||||
|
// ✅ Vérifier si une facture est sélectionnable
|
||||||
|
const isSelectable = (row: T) => {
|
||||||
|
return selectableFactures.some(f => (f as any).numero === (row as any).numero);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Vérifier si toutes les factures sélectionnables sont sélectionnées
|
||||||
|
const allSelectableSelected = selectableFactures.length > 0 &&
|
||||||
|
selectableFactures.every(f => selectedIds.includes((f as any).numero));
|
||||||
|
|
||||||
|
// ------------------------
|
||||||
|
// JSX
|
||||||
|
// ------------------------
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 🔍 Barre de recherche */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Rechercher..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-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-[#007E45] text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TABLE */}
|
||||||
|
<div className="bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-2xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<tr>
|
||||||
|
{/* ✅ Colonne de sélection */}
|
||||||
|
{selectable && (
|
||||||
|
<th className="px-6 py-3 w-10">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelectableSelected}
|
||||||
|
indeterminate={selectedIds.length > 0 && !allSelectableSelected}
|
||||||
|
onChange={(e) => onSelectAll?.(e.target.checked)}
|
||||||
|
disabled={selectableFactures.length === 0}
|
||||||
|
sx={{
|
||||||
|
color: '#007E45',
|
||||||
|
'&.Mui-checked': {
|
||||||
|
color: '#007E45',
|
||||||
|
},
|
||||||
|
'&.MuiCheckbox-indeterminate': {
|
||||||
|
color: '#007E45',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th
|
||||||
|
key={String(column.key)}
|
||||||
|
onClick={() =>
|
||||||
|
column.sortable && handleSort(column.key)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"px-6 py-3 text-left text-xs text-gray-900 font-bold dark:text-gray-400 uppercase tracking-wider",
|
||||||
|
column.sortable &&
|
||||||
|
"cursor-pointer hover:text-gray-900 dark:hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{column.label}
|
||||||
|
{column.sortable &&
|
||||||
|
sortColumn === column.key &&
|
||||||
|
(sortDirection === "asc" ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{actions && (
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||||
|
{/* LOADING */}
|
||||||
|
{status ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={100} className="p-5">
|
||||||
|
<div className="flex justify-center items-center py-10">
|
||||||
|
<CircularProgress size={48} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
paginatedData.map((row, rowIndex) => {
|
||||||
|
const rowId = (row as any).numero;
|
||||||
|
const isRowSelectable = isSelectable(row);
|
||||||
|
const isSelected = selectedIds.includes(rowId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={rowIndex}
|
||||||
|
onClick={() => !selectable && onRowClick?.(row)}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors",
|
||||||
|
!selectable && "cursor-pointer",
|
||||||
|
isSelected && "bg-green-50 dark:bg-green-900/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* ✅ Checkbox de sélection */}
|
||||||
|
{selectable && (
|
||||||
|
<td className="px-6 py-4" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
disabled={!isRowSelectable}
|
||||||
|
onChange={() => isRowSelectable && onSelectRow?.(rowId)}
|
||||||
|
sx={{
|
||||||
|
color: '#007E45',
|
||||||
|
'&.Mui-checked': {
|
||||||
|
color: '#007E45',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{columns.map((column) => (
|
||||||
|
<td
|
||||||
|
key={String(column.key)}
|
||||||
|
className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white"
|
||||||
|
onClick={() => selectable && onRowClick?.(row)}
|
||||||
|
>
|
||||||
|
{column.render
|
||||||
|
? column.render(row[column.key], row)
|
||||||
|
: row[column.key]}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{actions && (
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-end gap-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{actions(row)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PAGINATION */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DataTable;
|
||||||
280
src/components/ui/FormDynamique.tsx
Normal file
280
src/components/ui/FormDynamique.tsx
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useForm, Controller, FieldValues, Path } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import PrimaryButton from '@/components/PrimaryButton';
|
||||||
|
import PrimaryButton_v2 from '../PrimaryButton_v2';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
type FieldType = 'text' | 'email' | 'password' | 'number' | 'date' | 'tel' | 'url' | 'select' | 'textarea' | 'checkbox' | 'custom';
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
value: string | number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseField<T extends FieldValues = FieldValues> {
|
||||||
|
name: Path<T>;
|
||||||
|
label: string;
|
||||||
|
type?: FieldType;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
help?: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
rows?: number;
|
||||||
|
options?: SelectOption[];
|
||||||
|
value?: any;
|
||||||
|
onChange?: (value: any) => void;
|
||||||
|
renderCustom?: (props: {
|
||||||
|
value: any;
|
||||||
|
onChange: (value: any) => void;
|
||||||
|
error?: string;
|
||||||
|
}) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionField {
|
||||||
|
type: 'section';
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Field<T extends FieldValues = FieldValues> = BaseField<T> | SectionField;
|
||||||
|
|
||||||
|
interface SmartFormProps<T extends FieldValues = FieldValues> {
|
||||||
|
schema: z.ZodSchema<T>;
|
||||||
|
defaultValues?: Partial<T>;
|
||||||
|
onSubmit: (data: T) => void | Promise<void>;
|
||||||
|
fields: Field<T>[];
|
||||||
|
loading?: boolean;
|
||||||
|
submitLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormDynamique<T extends FieldValues = FieldValues>({
|
||||||
|
schema,
|
||||||
|
defaultValues,
|
||||||
|
onSubmit,
|
||||||
|
fields,
|
||||||
|
loading = false,
|
||||||
|
submitLabel = "Enregistrer",
|
||||||
|
cancelLabel = "Annuler",
|
||||||
|
onCancel
|
||||||
|
}: SmartFormProps<T>) {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isDirty, isValid },
|
||||||
|
watch,
|
||||||
|
control
|
||||||
|
} = useForm<T>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: defaultValues as any,
|
||||||
|
mode: "onChange"
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{fields.map((field, index) => {
|
||||||
|
if (field.type === 'section') {
|
||||||
|
return (
|
||||||
|
<div key={field.title || index} className="col-span-1 md:col-span-2 pt-4 pb-2 border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white">{field.title}</h3>
|
||||||
|
{field.description && <p className="text-sm text-gray-500 dark:text-gray-400">{field.description}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseField = field as BaseField<T>;
|
||||||
|
const fieldName = baseField.name;
|
||||||
|
const isError = errors[fieldName];
|
||||||
|
const value = watch(fieldName);
|
||||||
|
const isSuccess = !isError && value && baseField.type !== 'checkbox' && baseField.type !== 'textarea';
|
||||||
|
|
||||||
|
// Si le field a value et onChange personnalisés (custom component)
|
||||||
|
if (baseField.type === 'custom' && baseField.renderCustom) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={String(fieldName)}
|
||||||
|
className={cn(
|
||||||
|
"space-y-1.5 relative",
|
||||||
|
baseField.fullWidth && "col-span-1 md:col-span-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<label className="flex items-center gap-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{baseField.label}
|
||||||
|
{baseField.required && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name={fieldName}
|
||||||
|
control={control}
|
||||||
|
render={({ field: controllerField, fieldState }) => (
|
||||||
|
<>
|
||||||
|
{baseField.renderCustom!({
|
||||||
|
value: baseField.value !== undefined ? baseField.value : controllerField.value,
|
||||||
|
onChange: baseField.onChange || controllerField.onChange,
|
||||||
|
error: fieldState.error?.message
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isError ? (
|
||||||
|
<p className="text-xs text-red-500 animate-in slide-in-from-top-1">
|
||||||
|
{(isError as any).message}
|
||||||
|
</p>
|
||||||
|
) : baseField.help ? (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{baseField.help}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={String(fieldName)}
|
||||||
|
className={cn(
|
||||||
|
"space-y-1.5 relative",
|
||||||
|
baseField.fullWidth && "col-span-1 md:col-span-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<label className="flex items-center gap-1 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{baseField.label}
|
||||||
|
{baseField.required && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{baseField.type === 'select' ? (
|
||||||
|
<Controller
|
||||||
|
name={fieldName}
|
||||||
|
control={control}
|
||||||
|
render={({ field: controllerField }) => (
|
||||||
|
<select
|
||||||
|
{...controllerField}
|
||||||
|
value={baseField.value !== undefined ? baseField.value : controllerField.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
if (baseField.onChange) {
|
||||||
|
baseField.onChange(newValue);
|
||||||
|
}
|
||||||
|
controllerField.onChange(newValue);
|
||||||
|
}}
|
||||||
|
className={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",
|
||||||
|
isError
|
||||||
|
? "border-red-300 focus:border-red-500 focus:ring-red-200"
|
||||||
|
: "border-gray-200 dark:border-gray-800 focus:border-[#941403] focus:ring-red-100/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner...</option>
|
||||||
|
{baseField.options?.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : baseField.type === 'textarea' ? (
|
||||||
|
<Controller
|
||||||
|
name={fieldName}
|
||||||
|
control={control}
|
||||||
|
render={({ field: controllerField }) => (
|
||||||
|
<textarea
|
||||||
|
{...controllerField}
|
||||||
|
value={baseField.value !== undefined ? baseField.value : controllerField.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
if (baseField.onChange) {
|
||||||
|
baseField.onChange(newValue);
|
||||||
|
}
|
||||||
|
controllerField.onChange(newValue);
|
||||||
|
}}
|
||||||
|
rows={baseField.rows || 3}
|
||||||
|
className={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 resize-none",
|
||||||
|
isError
|
||||||
|
? "border-red-300 focus:border-red-500 focus:ring-red-200"
|
||||||
|
: "border-gray-200 dark:border-gray-800 focus:border-[#941403] focus:ring-red-100/50"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Controller
|
||||||
|
name={fieldName}
|
||||||
|
control={control}
|
||||||
|
render={({ field: controllerField }) => (
|
||||||
|
<input
|
||||||
|
type={baseField.type || 'text'}
|
||||||
|
{...controllerField}
|
||||||
|
value={baseField.value !== undefined ? baseField.value : controllerField.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
if (baseField.onChange) {
|
||||||
|
baseField.onChange(newValue);
|
||||||
|
}
|
||||||
|
controllerField.onChange(newValue);
|
||||||
|
}}
|
||||||
|
placeholder={baseField.placeholder}
|
||||||
|
className={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",
|
||||||
|
isError
|
||||||
|
? "border-red-300 focus:border-red-500 focus:ring-red-200 pr-10"
|
||||||
|
: isSuccess
|
||||||
|
? "border-green-300 focus:border-green-500 focus:ring-green-200 pr-10"
|
||||||
|
: "border-gray-200 dark:border-gray-800 focus:border-[#941403] focus:ring-red-100/50"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Icons validation indicators */}
|
||||||
|
{baseField.type !== 'select' && baseField.type !== 'textarea' && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||||
|
{isError && <AlertCircle className="w-4 h-4 text-red-500" />}
|
||||||
|
{isSuccess && <CheckCircle2 className="w-4 h-4 text-green-500" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Helper Text or Error Message */}
|
||||||
|
{isError ? (
|
||||||
|
<p className="text-xs text-red-500 animate-in slide-in-from-top-1">
|
||||||
|
{(isError as any).message}
|
||||||
|
</p>
|
||||||
|
) : baseField.help ? (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{baseField.help}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-6 border-t border-gray-200 dark:border-gray-800">
|
||||||
|
{onCancel && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<PrimaryButton_v2
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={!isDirty || !isValid}
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</PrimaryButton_v2>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormDynamique;
|
||||||
314
src/components/ui/FormModal.tsx
Normal file
314
src/components/ui/FormModal.tsx
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { ArrowLeft, X } from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface FormSectionProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormFieldProps {
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
required?: boolean;
|
||||||
|
error?: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
error?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
onSubmit?: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
submitLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Components
|
||||||
|
export const FormSection: React.FC<FormSectionProps> = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mb-6 pb-6 border-b border-gray-100 dark:border-gray-800 last:border-0 last:mb-0 last:pb-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<h4 className="text-md font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FormField: React.FC<FormFieldProps> = ({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
required,
|
||||||
|
error,
|
||||||
|
fullWidth,
|
||||||
|
className
|
||||||
|
}) => (
|
||||||
|
<div className={cn("space-y-1.5", fullWidth ? "col-span-1 md:col-span-2" : "col-span-1", className)}>
|
||||||
|
<label className="block text-sm font-bold text-gray-900 dark:text-gray-300">
|
||||||
|
{label} {required && <span className="text-green-500">*</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, error, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={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 placeholder-[#6A6A6A]",
|
||||||
|
error
|
||||||
|
? "border-black focus:border-black focus:ring-black"
|
||||||
|
: "border-[#F2F2F2] dark:border-gray-800 focus:border-[#338660] focus:ring-[#338660]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||||
|
({ className, error, children, ...props }, ref) => (
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
className={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",
|
||||||
|
error
|
||||||
|
? "border-red-300 focus:border-red-500 focus:ring-red-200"
|
||||||
|
: "border-gray-200 dark:border-gray-800 focus:border-[#007E45] focus:ring-red-100/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Select.displayName = "Select";
|
||||||
|
|
||||||
|
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, error, ...props }, ref) => (
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
className={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 resize-none",
|
||||||
|
error
|
||||||
|
? "border-red-300 focus:border-red-500 focus:ring-red-200"
|
||||||
|
: "border-gray-200 dark:border-gray-800 focus:border-[#007E45] focus:ring-red-100/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const RepresentantInput = () => {
|
||||||
|
return (
|
||||||
|
<FormField label="Représentant">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full px-2 py-2 opacity-80 cursor-not-allowed bg-gray-50 dark:bg-gray-900 border rounded-xl text-sm shadow-sm flex flex-row items-center gap-2 transition-all focus:outline-none focus:ring-2",
|
||||||
|
"border-gray-200 dark:border-gray-800 focus:border-[#941403] focus:ring-red-100/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-6 h-6 rounded-full bg-[#007E45] text-white flex items-center justify-center text-[10px] font-semibold" style={{background: "#007E45", width:"3vh", height:"3vh"}}>
|
||||||
|
JD
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Jean Dupont</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
interface FormModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onSubmit?: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
submitLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||||
|
disabled?: boolean;
|
||||||
|
showBackButton?: boolean;
|
||||||
|
onBack?: () => void;
|
||||||
|
backLabel?: string;
|
||||||
|
hideFooter?: boolean;
|
||||||
|
submitVariant?: 'primary' | 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormModal: React.FC<FormModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
onSubmit,
|
||||||
|
loading = false,
|
||||||
|
submitLabel = "Enregistrer",
|
||||||
|
cancelLabel = "Annuler",
|
||||||
|
size = "md",
|
||||||
|
disabled = false,
|
||||||
|
showBackButton = false,
|
||||||
|
onBack,
|
||||||
|
backLabel = "Retour",
|
||||||
|
hideFooter = false,
|
||||||
|
submitVariant = 'primary'
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const sizeClasses: Record<string, string> = {
|
||||||
|
sm: "max-w-md",
|
||||||
|
md: "max-w-2xl",
|
||||||
|
lg: "max-w-4xl",
|
||||||
|
xl: "max-w-6xl",
|
||||||
|
full: "max-w-[95vw]"
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitButtonClasses = {
|
||||||
|
primary: "bg-[#007E45] hover:bg-[#006837] focus:ring-[#007E45] shadow-green-900/20",
|
||||||
|
danger: "bg-[#941403] hover:bg-[#7a1003] focus:ring-[#941403] shadow-red-900/20"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 100 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 100 }}
|
||||||
|
className={cn(
|
||||||
|
"relative bg-white dark:bg-gray-950 w-full rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]",
|
||||||
|
sizeClasses[size] || sizeClasses.md
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex-shrink-0 px-6 py-4 border-b border-gray-100 dark:border-gray-800 flex justify-between items-center bg-gray-50/50 dark:bg-gray-900/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{showBackButton && onBack && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-full transition-colors text-gray-500"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{!hideFooter && (
|
||||||
|
<div className="flex-shrink-0 px-6 py-4 border-t border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-900/50 flex justify-between items-center">
|
||||||
|
{/* Left side - Back button or empty */}
|
||||||
|
<div>
|
||||||
|
{showBackButton && onBack && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
{backLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Cancel and Submit */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
{onSubmit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={loading || disabled}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center gap-2 px-6 py-2.5 text-white rounded-xl text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg",
|
||||||
|
submitButtonClasses[submitVariant]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
|
{submitLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FormModal;
|
||||||
394
src/components/ui/InputField.tsx
Normal file
394
src/components/ui/InputField.tsx
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useState, useCallback, forwardRef, InputHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
type InputType =
|
||||||
|
| "text"
|
||||||
|
| "email"
|
||||||
|
| "phone"
|
||||||
|
| "siret"
|
||||||
|
| "siren"
|
||||||
|
| "iban"
|
||||||
|
| "bic"
|
||||||
|
| "password"
|
||||||
|
| "number"
|
||||||
|
| "url"
|
||||||
|
| "postal_code"
|
||||||
|
| "tva_intra";
|
||||||
|
|
||||||
|
interface ValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InputFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange'> {
|
||||||
|
label: string;
|
||||||
|
inputType?: InputType;
|
||||||
|
required?: boolean;
|
||||||
|
error?: string;
|
||||||
|
className?: string;
|
||||||
|
containerClassName?: string;
|
||||||
|
showValidation?: boolean;
|
||||||
|
onValueChange?: (value: string, isValid: boolean) => void;
|
||||||
|
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// VALIDATEURS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const validators: Record<InputType, (value: string) => ValidationResult> = {
|
||||||
|
text: (value) => ({ isValid: value.length > 0, message: "Ce champ est requis" }),
|
||||||
|
|
||||||
|
email: (value) => {
|
||||||
|
const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||||
|
return {
|
||||||
|
isValid: regex.test(value),
|
||||||
|
message: "Format email invalide (ex: exemple@domaine.fr)"
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
phone: (value) => {
|
||||||
|
// Format français: 06 12 34 56 78 ou +33 6 12 34 56 78
|
||||||
|
const cleaned = value.replace(/[\s.-]/g, "");
|
||||||
|
const regexFR = /^(?:(?:\+|00)33|0)\s*[1-9](?:[\s.-]*\d{2}){4}$/;
|
||||||
|
const regexSimple = /^(0[1-9])(\d{8})$|^(\+33|0033)[1-9](\d{8})$/;
|
||||||
|
return {
|
||||||
|
isValid: regexFR.test(value) || regexSimple.test(cleaned),
|
||||||
|
message: "Format: 06 12 34 56 78 ou +33 6 12 34 56 78"
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
siret: (value) => {
|
||||||
|
const cleaned = value.replace(/\s/g, "");
|
||||||
|
if (!/^\d{14}$/.test(cleaned)) {
|
||||||
|
return { isValid: false, message: "Le SIRET doit contenir 14 chiffres" };
|
||||||
|
}
|
||||||
|
// Validation Luhn
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
let digit = parseInt(cleaned[i], 10);
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
digit *= 2;
|
||||||
|
if (digit > 9) digit -= 9;
|
||||||
|
}
|
||||||
|
sum += digit;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isValid: sum % 10 === 0,
|
||||||
|
message: "SIRET invalide (vérification Luhn échouée)"
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
siren: (value) => {
|
||||||
|
const cleaned = value.replace(/\s/g, "");
|
||||||
|
if (!/^\d{9}$/.test(cleaned)) {
|
||||||
|
return { isValid: false, message: "Le SIREN doit contenir 9 chiffres" };
|
||||||
|
}
|
||||||
|
// Validation Luhn
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
let digit = parseInt(cleaned[i], 10);
|
||||||
|
if (i % 2 === 1) {
|
||||||
|
digit *= 2;
|
||||||
|
if (digit > 9) digit -= 9;
|
||||||
|
}
|
||||||
|
sum += digit;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isValid: sum % 10 === 0,
|
||||||
|
message: "SIREN invalide (vérification Luhn échouée)"
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
iban: (value) => {
|
||||||
|
const cleaned = value.replace(/\s/g, "").toUpperCase();
|
||||||
|
// IBAN français: FR + 2 chiffres + 23 caractères alphanumériques
|
||||||
|
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$/.test(cleaned)) {
|
||||||
|
return { isValid: false, message: "Format IBAN invalide" };
|
||||||
|
}
|
||||||
|
// Validation mod 97
|
||||||
|
const rearranged = cleaned.slice(4) + cleaned.slice(0, 4);
|
||||||
|
const numericIBAN = rearranged.replace(/[A-Z]/g, (char) =>
|
||||||
|
(char.charCodeAt(0) - 55).toString()
|
||||||
|
);
|
||||||
|
let remainder = numericIBAN;
|
||||||
|
while (remainder.length > 2) {
|
||||||
|
const block = remainder.slice(0, 9);
|
||||||
|
remainder = (parseInt(block, 10) % 97).toString() + remainder.slice(9);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isValid: parseInt(remainder, 10) % 97 === 1,
|
||||||
|
message: "IBAN invalide (vérification mod-97 échouée)"
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
bic: (value) => {
|
||||||
|
const cleaned = value.replace(/\s/g, "").toUpperCase();
|
||||||
|
// BIC: 8 ou 11 caractères (AAAABBCC ou AAAABBCCDDD)
|
||||||
|
const regex = /^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$/;
|
||||||
|
return {
|
||||||
|
isValid: regex.test(cleaned),
|
||||||
|
message: "Format BIC: 8 ou 11 caractères (ex: BNPAFRPP)"
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
password: (value) => {
|
||||||
|
const hasMinLength = value.length >= 8;
|
||||||
|
const hasUppercase = /[A-Z]/.test(value);
|
||||||
|
const hasLowercase = /[a-z]/.test(value);
|
||||||
|
const hasNumber = /\d/.test(value);
|
||||||
|
const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(value);
|
||||||
|
|
||||||
|
const isValid = hasMinLength && hasUppercase && hasLowercase && hasNumber;
|
||||||
|
let message = "Requis: ";
|
||||||
|
const missing = [];
|
||||||
|
if (!hasMinLength) missing.push("8+ caractères");
|
||||||
|
if (!hasUppercase) missing.push("majuscule");
|
||||||
|
if (!hasLowercase) missing.push("minuscule");
|
||||||
|
if (!hasNumber) missing.push("chiffre");
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid,
|
||||||
|
message: missing.length > 0 ? message + missing.join(", ") : undefined
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
number: (value) => {
|
||||||
|
const isValid = !isNaN(Number(value)) && value.trim() !== "";
|
||||||
|
return { isValid, message: "Veuillez entrer un nombre valide" };
|
||||||
|
},
|
||||||
|
|
||||||
|
url: (value) => {
|
||||||
|
try {
|
||||||
|
new URL(value);
|
||||||
|
return { isValid: true };
|
||||||
|
} catch {
|
||||||
|
return { isValid: false, message: "URL invalide (ex: https://exemple.fr)" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
postal_code: (value) => {
|
||||||
|
const regex = /^(?:0[1-9]|[1-8]\d|9[0-8])\d{3}$/;
|
||||||
|
return {
|
||||||
|
isValid: regex.test(value),
|
||||||
|
message: "Code postal français invalide (5 chiffres)"
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
tva_intra: (value) => {
|
||||||
|
const cleaned = value.replace(/\s/g, "").toUpperCase();
|
||||||
|
// TVA intracommunautaire française: FR + 2 caractères + SIREN (9 chiffres)
|
||||||
|
const regex = /^FR[0-9A-Z]{2}\d{9}$/;
|
||||||
|
if (!regex.test(cleaned)) {
|
||||||
|
return { isValid: false, message: "Format: FRXX123456789" };
|
||||||
|
}
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// FORMATEURS (pour affichage)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const formatters: Partial<Record<InputType, (value: string) => string>> = {
|
||||||
|
phone: (value) => {
|
||||||
|
const cleaned = value.replace(/\D/g, "");
|
||||||
|
if (cleaned.startsWith("33")) {
|
||||||
|
return cleaned.replace(/(\d{2})(\d{1})(\d{2})(\d{2})(\d{2})(\d{2})/, "+$1 $2 $3 $4 $5 $6");
|
||||||
|
}
|
||||||
|
return cleaned.replace(/(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/, "$1 $2 $3 $4 $5");
|
||||||
|
},
|
||||||
|
|
||||||
|
siret: (value) => {
|
||||||
|
const cleaned = value.replace(/\D/g, "").slice(0, 14);
|
||||||
|
return cleaned.replace(/(\d{3})(\d{3})(\d{3})(\d{5})/, "$1 $2 $3 $4");
|
||||||
|
},
|
||||||
|
|
||||||
|
siren: (value) => {
|
||||||
|
const cleaned = value.replace(/\D/g, "").slice(0, 9);
|
||||||
|
return cleaned.replace(/(\d{3})(\d{3})(\d{3})/, "$1 $2 $3");
|
||||||
|
},
|
||||||
|
|
||||||
|
iban: (value) => {
|
||||||
|
const cleaned = value.replace(/\s/g, "").toUpperCase().slice(0, 34);
|
||||||
|
return cleaned.replace(/(.{4})/g, "$1 ").trim();
|
||||||
|
},
|
||||||
|
|
||||||
|
bic: (value) => value.toUpperCase().replace(/\s/g, "").slice(0, 11)
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PLACEHOLDERS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
const placeholders: Partial<Record<InputType, string>> = {
|
||||||
|
email: "exemple@domaine.fr",
|
||||||
|
phone: "06 12 34 56 78",
|
||||||
|
siret: "123 456 789 00012",
|
||||||
|
siren: "123 456 789",
|
||||||
|
iban: "FR76 1234 5678 9012 3456 7890 123",
|
||||||
|
bic: "BNPAFRPP",
|
||||||
|
postal_code: "75001",
|
||||||
|
tva_intra: "FR12345678901",
|
||||||
|
url: "https://exemple.fr"
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// STYLES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const inputClass = (error?: boolean, isValid?: boolean) => cn(
|
||||||
|
"w-full px-3 py-2.5 bg-white dark:bg-gray-900 border rounded-xl text-sm transition-all focus:outline-none focus:ring-2",
|
||||||
|
error
|
||||||
|
? "border-green-300 focus:border-green-500 focus:ring-green-200"
|
||||||
|
: isValid
|
||||||
|
? "border-green-300 focus:border-green-500 focus:ring-green-200"
|
||||||
|
: "border-gray-200 dark:border-gray-700 focus:border-[#007E45] focus:ring-red-100/50"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// COMPOSANT PRINCIPAL
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(({
|
||||||
|
label,
|
||||||
|
inputType = "text",
|
||||||
|
required = false,
|
||||||
|
error: externalError,
|
||||||
|
className = "",
|
||||||
|
containerClassName = "",
|
||||||
|
showValidation = true,
|
||||||
|
onValueChange,
|
||||||
|
onChange,
|
||||||
|
value: controlledValue,
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
const [internalValue, setInternalValue] = useState("");
|
||||||
|
const [touched, setTouched] = useState(false);
|
||||||
|
const [validationResult, setValidationResult] = useState<ValidationResult>({ isValid: true });
|
||||||
|
|
||||||
|
const value = controlledValue !== undefined ? String(controlledValue) : internalValue;
|
||||||
|
|
||||||
|
const validate = useCallback((val: string) => {
|
||||||
|
if (!val && !required) {
|
||||||
|
return { isValid: true };
|
||||||
|
}
|
||||||
|
if (!val && required) {
|
||||||
|
return { isValid: false, message: "Ce champ est requis" };
|
||||||
|
}
|
||||||
|
return validators[inputType](val);
|
||||||
|
}, [inputType, required]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
let newValue = e.target.value;
|
||||||
|
|
||||||
|
// Appliquer le formateur si disponible
|
||||||
|
const formatter = formatters[inputType];
|
||||||
|
if (formatter) {
|
||||||
|
newValue = formatter(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controlledValue === undefined) {
|
||||||
|
setInternalValue(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validate(newValue);
|
||||||
|
setValidationResult(result);
|
||||||
|
|
||||||
|
onValueChange?.(newValue, result.isValid);
|
||||||
|
|
||||||
|
// Créer un nouvel événement avec la valeur formatée
|
||||||
|
const syntheticEvent = {
|
||||||
|
...e,
|
||||||
|
target: { ...e.target, value: newValue }
|
||||||
|
} as React.ChangeEvent<HTMLInputElement>;
|
||||||
|
onChange?.(syntheticEvent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
setTouched(true);
|
||||||
|
const result = validate(value);
|
||||||
|
setValidationResult(result);
|
||||||
|
props.onBlur?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showError = touched && !validationResult.isValid && showValidation;
|
||||||
|
const showSuccess = touched && validationResult.isValid && value && showValidation;
|
||||||
|
const displayError = externalError || (showError ? validationResult.message : undefined);
|
||||||
|
|
||||||
|
// Déterminer le type HTML natif
|
||||||
|
const htmlType = inputType === "email" ? "email"
|
||||||
|
: inputType === "password" ? "password"
|
||||||
|
: inputType === "number" ? "number"
|
||||||
|
: inputType === "url" ? "url"
|
||||||
|
: "text";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-1.5", containerClassName)}>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{label} {required && <span className="text-[#941403]">*</span>}
|
||||||
|
</label>
|
||||||
|
{displayError && (
|
||||||
|
<span className="text-[10px] text-red-500 font-medium flex items-center gap-1">
|
||||||
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{displayError}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showSuccess && !externalError && (
|
||||||
|
<span className="text-[10px] text-green-500 font-medium flex items-center gap-1">
|
||||||
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Valide
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type={htmlType}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder={placeholders[inputType] || props.placeholder}
|
||||||
|
className={cn(
|
||||||
|
inputClass(!!displayError, !!showSuccess),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{/* Icône indicateur */}
|
||||||
|
{touched && showValidation && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
{showSuccess && !externalError ? (
|
||||||
|
<svg className="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
) : showError ? (
|
||||||
|
<svg className="w-4 h-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
InputField.displayName = "InputField";
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// HOOK DE VALIDATION (pour usage externe)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export const useValidation = (inputType: InputType) => {
|
||||||
|
return useCallback((value: string) => validators[inputType](value), [inputType]);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue