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

1001 lines
No EOL
51 KiB
TypeScript

import React, { useEffect, useMemo, useState, useCallback, useRef } from 'react';
import { Helmet } from 'react-helmet';
import { useForm, Controller, type SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import {
ArrowLeft, Save, Building2, User, Phone, Mail, Globe, CreditCard, Loader2,
Search, X, CheckCircle, AlertCircle
} from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { toast } from '@/components/ui/use-toast';
import { cn } from '@/lib/utils';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { Client, ClientCreateResponse, ClientRequest } from '@/types/clientType';
import { addClient, getClient, updateClient } from '@/store/features/client/thunk';
import { clientStatus, getAllClients, getClientSelected } from '@/store/features/client/selectors';
import { selectClient } from '@/store/features/client/slice';
import { Progress } from "@/components/ui/Progress";
import { CompanyInfo } from '@/data/mockData';
import { Alert } from '@mui/material';
import { InputField } from '@/components/ui/InputValidator';
import { searchEntreprise } from '@/store/features/entreprise/thunk';
import { Entreprise, EntrepriseResponse } from '@/types/entreprise';
import { selectentreprise } from '@/store/features/entreprise/slice';
import { getentrepriseSelected } from '@/store/features/entreprise/selectors';
import { motion, AnimatePresence } from 'framer-motion';
import { InternationalPhoneInput } from '@/components/ui/InternationalPhoneInput';
// ============================================
// SCHÉMA DE VALIDATION ZOD
// ============================================
const clientSchema = z.object({
intitule: z.string()
.min(1, "L'intitulé est requis")
.max(69, "L'intitulé ne peut pas dépasser 69 caractères"),
numero: z.string()
.min(1, "Le code client est requis")
.max(17, "Le code client ne peut pas dépasser 17 caractères")
.regex(/^[A-Za-z0-9]+$/, "Le code client ne peut contenir que des lettres et des chiffres"),
type_tiers: z.number().min(0).max(3).optional().nullable(),
qualite: z.string().optional().or(z.literal("")),
classement: z.string().optional().or(z.literal("")),
raccourci: z.string().optional().or(z.literal("")),
siret: z.string()
.regex(/^\d*$/, "Le SIRET doit contenir uniquement des chiffres")
.optional()
.or(z.literal("")),
tva_intra: z.string().optional().or(z.literal("")),
code_naf: z.string().optional().or(z.literal("")),
contact: z.string().optional().or(z.literal("")),
adresse: z.string()
.max(35, "L'adresse ne peut pas dépasser 35 caractères")
.optional()
.or(z.literal("")),
complement: z.string().optional().or(z.literal("")),
code_postal: z.string().optional().or(z.literal("")),
ville: z.string().optional().or(z.literal("")),
region: z.string().optional().or(z.literal("")),
pays: z.string().default("France"),
telephone: 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("")),
site_web: z.string().optional().or(z.literal("")),
facebook: z.string().optional().or(z.literal("")),
linkedin: z.string().optional().or(z.literal("")),
compte_general: z.string().optional().or(z.literal("")),
categorie_tarifaire: z.string().optional().or(z.literal("")),
categorie_comptable: z.string().optional().or(z.literal("")),
taux01: z.number().optional(),
taux02: z.number().optional(),
taux03: z.number().optional(),
taux04: z.number().optional(),
encours_autorise: z.number().optional(),
assurance_credit: z.number().optional(),
langue: z.number().optional(),
commercial_code: z.number().optional(),
lettrage_auto: z.boolean().optional(),
est_actif: z.boolean().default(true),
type_facture: z.number().optional(),
est_prospect: z.boolean().default(false),
forme_juridique: z.string().optional().or(z.literal("")),
effectif: z.number().optional(),
ca_annuel: z.number().optional(),
commentaire: z.string().optional().or(z.literal("")),
});
type ClientFormData = z.infer<typeof clientSchema>;
// ============================================
// COMPOSANT RECHERCHE ENTREPRISE
// ============================================
interface EntrepriseSearchProps {
onSelect: (entreprise: Entreprise) => void;
}
const EntrepriseSearch: React.FC<EntrepriseSearchProps> = ({ onSelect }) => {
const dispatch = useAppDispatch();
const [query, setQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const [results, setResults] = useState<Entreprise[]>([]);
const [showResults, setShowResults] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedEntreprise, setSelectedEntreprise] = useState<Entreprise | null>(null);
const searchRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<number | null>(null);
// Fermer les résultats au clic extérieur
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
setShowResults(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Recherche avec debounce
const handleSearch = useCallback(async (searchQuery: string) => {
if (!searchQuery || searchQuery.length < 2) {
setResults([]);
setShowResults(false);
return;
}
setIsSearching(true);
setError(null);
try {
const response = await dispatch(searchEntreprise(searchQuery)).unwrap() as EntrepriseResponse;
setResults(response.results || []);
setShowResults(true);
} catch (e: any) {
setError("Erreur lors de la recherche");
setResults([]);
} finally {
setIsSearching(false);
}
}, [dispatch]);
// Debounce de la recherche
const handleInputChange = (value: string) => {
setQuery(value);
setSelectedEntreprise(null);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
handleSearch(value);
}, 400);
};
// Sélectionner une entreprise
const handleSelectEntreprise = (entreprise: Entreprise) => {
setSelectedEntreprise(entreprise);
setQuery(entreprise.company_name);
setShowResults(false);
dispatch(selectentreprise(entreprise));
onSelect(entreprise);
toast({
title: "Entreprise sélectionnée",
description: `Les informations de "${entreprise.company_name}" ont été pré-remplies.`,
className: "bg-green-500 text-white border-green-600"
});
};
// Réinitialiser la recherche
const handleClear = () => {
setQuery('');
setResults([]);
setShowResults(false);
setSelectedEntreprise(null);
setError(null);
};
return (
<div ref={searchRef} className="relative">
<div className="flex flex-col gap-3">
<Alert severity="info" className="text-xs">
Recherchez une entreprise par son nom, SIREN ou SIRET pour pré-remplir automatiquement les informations
</Alert>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
{isSearching ? (
<Loader2 className="w-4 h-4 text-gray-400 animate-spin" />
) : (
<Search className="w-4 h-4 text-gray-400" />
)}
</div>
<input
type="text"
value={query}
onChange={(e) => handleInputChange(e.target.value)}
onFocus={() => results.length > 0 && setShowResults(true)}
placeholder="Nom de l'entreprise, n° SIREN ou n° SIRET..."
className={cn(
"w-full pl-10 pr-10 py-3 bg-white dark:bg-gray-900 border rounded-xl text-sm transition-all",
"focus:outline-none focus:ring-2 focus:ring-[#007E45]/20 focus:border-[#007E45]",
selectedEntreprise
? "border-green-500 bg-green-50 dark:bg-green-900/20"
: "border-gray-200 dark:border-gray-700"
)}
/>
{/* Indicateur de sélection ou bouton clear */}
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
{selectedEntreprise ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : query && (
<button
type="button"
onClick={handleClear}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors"
>
<X className="w-4 h-4 text-gray-400" />
</button>
)}
</div>
</div>
{/* Message entreprise sélectionnée */}
{selectedEntreprise && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl"
>
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-green-800 dark:text-green-200 truncate">
{selectedEntreprise.company_name}
</p>
<p className="text-xs text-green-600 dark:text-green-400">
SIRET: {selectedEntreprise.siret_siege} {selectedEntreprise.ville}
</p>
</div>
<button
type="button"
onClick={handleClear}
className="p-1 hover:bg-green-100 dark:hover:bg-green-800 rounded-full transition-colors"
>
<X className="w-4 h-4 text-green-600" />
</button>
</motion.div>
)}
</div>
{/* Liste des résultats */}
<AnimatePresence>
{showResults && results.length > 0 && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute z-50 w-full mt-2 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl shadow-xl max-h-80 overflow-y-auto"
>
<div className="p-2 border-b border-gray-100 dark:border-gray-800">
<p className="text-xs text-gray-500 px-2">
{results.length} résultat{results.length > 1 ? 's' : ''} trouvé{results.length > 1 ? 's' : ''}
</p>
</div>
<div className="py-1">
{results.map((entreprise, index) => (
<button
key={`${entreprise.siren}-${index}`}
type="button"
onClick={() => handleSelectEntreprise(entreprise)}
className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors flex items-start gap-3"
>
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg flex-shrink-0">
<Building2 className="w-4 h-4 text-gray-600 dark:text-gray-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{entreprise.company_name}
</p>
<p className="text-xs text-gray-500 mt-0.5">
SIRET: {entreprise.siret_siege}
</p>
<p className="text-xs text-gray-400 mt-0.5">
{entreprise.address && `${entreprise.address}, `}
{entreprise.code_postal} {entreprise.ville}
</p>
</div>
{entreprise.is_active ? (
<span className="px-2 py-0.5 text-xs font-medium text-green-700 bg-green-100 rounded-full flex-shrink-0">
Active
</span>
) : (
<span className="px-2 py-0.5 text-xs font-medium text-red-700 bg-red-100 rounded-full flex-shrink-0">
Inactive
</span>
)}
</button>
))}
</div>
</motion.div>
)}
{/* Pas de résultats */}
{showResults && query.length >= 2 && results.length === 0 && !isSearching && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute z-50 w-full mt-2 bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-xl shadow-xl p-6 text-center"
>
<AlertCircle className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-sm text-gray-500">Aucune entreprise trouvée</p>
<p className="text-xs text-gray-400 mt-1">Essayez avec un autre nom ou numéro</p>
</motion.div>
)}
{/* Erreur */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute z-50 w-full mt-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4"
>
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
// ============================================
// COMPOSANTS UI
// ============================================
const SectionHeader = ({ title, icon: Icon }: { title: string; icon: React.ElementType }) => (
<div className="flex items-center gap-2 mb-6 pb-3 border-b border-gray-200 dark:border-gray-800">
<div className="p-1.5 bg-green-50 dark:bg-green-900/20 rounded-lg">
<Icon className="w-4 h-4 text-[#007E45]" />
</div>
<h3 className="text-base font-bold text-gray-900 dark:text-white">{title}</h3>
</div>
);
// ============================================
// COMPOSANT PRINCIPAL
// ============================================
const CreateClientPage = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { id } = useParams<{ id?: string }>();
const status = useAppSelector(clientStatus);
const clients = useAppSelector(getAllClients) as Client[];
const clientSelected = useAppSelector(getClientSelected) as Client | null;
const entrepriseSelected = useAppSelector(getentrepriseSelected) as Entreprise | null;
const [completion, setCompletion] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEditing = !!id;
const editing = isEditing ? clientSelected : null;
const {
control,
handleSubmit,
watch,
reset,
setValue,
formState: { errors }
} = useForm<ClientFormData>({
resolver: zodResolver(clientSchema),
mode: "onChange",
defaultValues: {
intitule: "",
numero: "",
type_tiers: undefined,
qualite: "",
classement: "",
raccourci: "",
siret: "",
tva_intra: "",
code_naf: "",
contact: "",
adresse: "",
complement: "",
code_postal: "",
ville: "",
region: "",
pays: "France",
telephone: "",
telecopie: "",
email: "",
site_web: "",
facebook: "",
linkedin: "",
compte_general: "",
categorie_tarifaire: "",
categorie_comptable: "",
est_actif: true,
est_prospect: false,
}
});
const formValues = watch();
const intitule = watch("intitule");
const numero = watch("numero");
const adresse = watch("adresse");
// ✅ Pré-remplir les champs quand une entreprise est sélectionnée
const handleEntrepriseSelect = useCallback((entreprise: Entreprise) => {
let cleanedAddress = entreprise.address || '';
if (cleanedAddress && entreprise.code_postal) {
const pattern = new RegExp(`\\s*${entreprise.code_postal}.*$`, 'i');
cleanedAddress = cleanedAddress.replace(pattern, '').trim();
}
if (cleanedAddress && entreprise.ville) {
const villePattern = new RegExp(`\\s*${entreprise.ville}.*$`, 'i');
cleanedAddress = cleanedAddress.replace(villePattern, '').trim();
}
if (cleanedAddress.length > 35) {
cleanedAddress = cleanedAddress.substring(0, 35).trim();
}
setValue('intitule', entreprise.company_name || '', { shouldValidate: true });
setValue('siret', entreprise.siret_siege || '', { shouldValidate: true });
setValue('tva_intra', entreprise.vat_number || '', { shouldValidate: true });
setValue('code_naf', entreprise.naf_code || '', { shouldValidate: true });
setValue('adresse', cleanedAddress, { shouldValidate: true });
setValue('code_postal', entreprise.code_postal || '', { shouldValidate: true });
setValue('ville', entreprise.ville || '', { shouldValidate: true });
setValue('est_actif', entreprise.is_active ?? true, { shouldValidate: true });
}, [setValue]);
useEffect(() => {
if (isEditing && editing) {
reset({
intitule: editing.intitule || "",
numero: editing.numero || "",
type_tiers: editing.type_tiers ?? undefined,
qualite: editing.qualite || "",
classement: editing.classement || "",
raccourci: editing.raccourci || "",
siret: editing.siret || "",
tva_intra: editing.tva_intra || "",
code_naf: editing.code_naf || "",
contact: editing.contact || "",
adresse: editing.adresse || "",
complement: editing.complement || "",
code_postal: editing.code_postal || "",
ville: editing.ville || "",
region: editing.region || "",
pays: editing.pays || "France",
telephone: editing.telephone || "",
telecopie: editing.telecopie || "",
email: editing.email || "",
site_web: editing.site_web || "",
facebook: editing.facebook || "",
linkedin: editing.linkedin || "",
compte_general: editing.compte_general || "",
categorie_tarifaire: editing.categorie_tarif?.toString() || "",
categorie_comptable: editing.categorie_compta?.toString() || "",
taux01: editing.taux01 || undefined,
taux02: editing.taux02 || undefined,
taux03: editing.taux03 || undefined,
taux04: editing.taux04 || undefined,
encours_autorise: editing.encours_autorise || undefined,
assurance_credit: editing.assurance_credit || undefined,
langue: editing.langue || undefined,
commercial_code: editing.commercial_code || undefined,
lettrage_auto: editing.lettrage_auto || undefined,
est_actif: editing.est_actif ?? true,
type_facture: editing.type_facture || undefined,
est_prospect: editing.est_prospect ?? false,
forme_juridique: editing.forme_juridique || "",
effectif: editing.effectif || undefined,
ca_annuel: undefined,
commentaire: editing.commentaire || "",
});
}
}, [editing, isEditing, reset]);
// Calcul completion
useEffect(() => {
const requiredFilled = (intitule ? 1 : 0) + (numero ? 1 : 0);
const optionalFields = ['email', 'telephone', 'adresse', 'ville', 'siret'];
const optionalFilled = optionalFields.filter(f => !!formValues[f as keyof ClientFormData]).length;
const requiredPercent = (requiredFilled / 2) * 40;
const optionalPercent = (optionalFilled / optionalFields.length) * 60;
setCompletion(Math.round(requiredPercent + optionalPercent));
}, [formValues, intitule, numero]);
const canSave = useMemo(() => {
const hasIntitule = !!intitule && intitule.length <= 69;
const hasNumero = !!numero && numero.length > 0 && numero.length <= 17;
const validAdresse = !adresse || adresse.length <= 35;
const notLoading = !isLoading && status !== "loading";
const noErrors = Object.keys(errors).length === 0;
return hasIntitule && hasNumero && validAdresse && notLoading && noErrors;
}, [intitule, numero, adresse, isLoading, status, errors]);
// Submit handler
const onSave: SubmitHandler<ClientFormData> = async (data) => {
setIsLoading(true);
setError(null);
try {
if (!isEditing && data.numero) {
const existingClient = clients.find(client => client.numero === data.numero);
if (existingClient) {
setError(`Le code client "${data.numero}" est déjà utilisé.`);
setIsLoading(false);
return;
}
}
const payload: ClientRequest = {
intitule: data.intitule,
numero: data.numero || undefined,
type_tiers: data.type_tiers ?? undefined,
qualite: data.qualite || null,
classement: data.classement || null,
raccourci: data.raccourci || null,
siret: data.siret?.replace(/\D/g, "") || null,
tva_intra: data.tva_intra || null,
code_naf: data.code_naf || null,
contact: data.contact || null,
adresse: data.adresse || null,
complement: data.complement || null,
code_postal: data.code_postal || null,
ville: data.ville || null,
region: data.region || null,
pays: data.pays || "France",
telephone: data.telephone || null,
telecopie: data.telecopie || null,
email: data.email || null,
site_web: data.site_web || null,
facebook: data.facebook || null,
linkedin: data.linkedin || null,
compte_general: data.compte_general || null,
categorie_tarifaire: data.categorie_tarifaire || null,
categorie_comptable: data.categorie_comptable || null,
taux01: data.taux01 || null,
taux02: data.taux02 || null,
taux03: data.taux03 || null,
taux04: data.taux04 || null,
encours_autorise: data.encours_autorise || null,
assurance_credit: data.assurance_credit || null,
langue: data.langue || null,
commercial_code: data.commercial_code || null,
lettrage_auto: data.lettrage_auto || null,
est_actif: data.est_actif,
type_facture: data.type_facture || null,
est_prospect: data.est_prospect,
forme_juridique: data.forme_juridique || null,
effectif: data.effectif || null,
ca_annuel: data.ca_annuel || null,
commentaire: data.commentaire || null,
};
if (isEditing && editing) {
const rep = await dispatch(updateClient({
numero: editing.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"
});
const clientCreated = await dispatch(getClient(dataResponse.numero)).unwrap() as Client;
dispatch(selectClient(clientCreated));
navigate(`/home/clients/${dataResponse.numero}`);
} else {
const rep = await dispatch(addClient(payload)).unwrap() as ClientCreateResponse;
if (!rep.success) {
setError("La création du client a échoué.");
} else {
toast({
title: "Client créé !",
description: `Le client ${rep.data.numero} a été créé avec succès.`,
className: "bg-green-500 text-white border-green-600"
});
const clientCreated = await dispatch(getClient(rep.data.numero)).unwrap() as Client;
dispatch(selectClient(clientCreated));
navigate(`/home/clients/${clientCreated.numero}`);
}
}
} catch (e: any) {
setError("Une erreur est survenue lors de la sauvegarde du client.");
console.error("Erreur:", e);
toast({
title: "Erreur",
description: e.message || "Une erreur est survenue.",
variant: "destructive"
});
} finally {
setIsLoading(false);
}
};
const inputClass = (error?: any) => cn(
"w-full px-3 py-2.5 bg-white dark:bg-gray-900 border rounded-xl text-xs transition-all focus:outline-none focus:ring-2 focus:ring-[#007E45]/20 focus:border-[#007E45]",
error ? "border-red-300 focus:border-red-500 focus:ring-red-200" : "border-gray-200 dark:border-gray-700"
);
return (
<>
<Helmet>
<title>{isEditing ? `Modifier ${editing?.intitule || 'Client'}` : 'Nouveau Client'} - {CompanyInfo.name}</title>
</Helmet>
<div className="min-h-screen pb-20">
<div className="max-w-6xl mx-auto pt-6 px-4 sm:px-6">
{/* Header */}
<div className="mb-6">
<button
onClick={() => navigate('/home/clients')}
className="flex items-center text-xs font-medium text-gray-500 hover:text-gray-900 dark:hover:text-gray-300 mb-3 transition-colors"
>
<ArrowLeft className="w-4 h-4 mr-1" />
Retour aux clients
</button>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white tracking-tight">
{isEditing ? `Modifier: ${editing?.intitule}` : 'Nouveau Client'}
</h1>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{isEditing ? 'Modifiez les informations du client' : 'Créez une nouvelle fiche client'}
</p>
</div>
<div className="text-right">
<p className="text-xs font-medium text-gray-900 dark:text-white">{completion}%</p>
<p className="text-xs text-gray-500">complété</p>
</div>
</div>
<Progress value={completion} className="h-1.5 mt-4 rounded-full" />
</div>
<div className="bg-white dark:bg-gray-950 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-800 p-6 md:p-8 flex flex-col gap-8">
{/* Section Recherche Entreprise */}
<section>
<SectionHeader title="Recherche d'entreprise" icon={Search} />
<EntrepriseSearch onSelect={handleEntrepriseSelect} />
</section>
{/* Formulaire principal */}
<form onSubmit={handleSubmit(onSave)} className="space-y-8">
<section>
<SectionHeader title="Informations Générales" icon={Building2} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Controller
name="intitule"
control={control}
render={({ field }) => (
<div className="space-y-1.5 md:col-span-2">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">
Intitulé / Raison sociale <span className="text-red-500">*</span>
<span className="text-gray-400 ml-1">(max 69 caractères)</span>
</label>
<input
{...field}
placeholder="Nom de l'entreprise ou du client"
className={inputClass(errors.intitule)}
maxLength={69}
/>
<div className="flex justify-between text-xs">
{errors.intitule && <p className="text-red-500">{errors.intitule.message}</p>}
<p className="text-gray-400 ml-auto">{field.value?.length || 0}/69</p>
</div>
</div>
)}
/>
<Controller
name="numero"
control={control}
render={({ field }) => (
<div className="space-y-1.5">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">
Code client <span className="text-red-500">*</span>
<span className="text-gray-400 ml-1">(max 17 caractères)</span>
</label>
<input
{...field}
disabled={isEditing}
placeholder="Ex: CLI001"
className={cn(inputClass(errors.numero), isEditing && "bg-gray-100 dark:bg-gray-800 cursor-not-allowed")}
maxLength={17}
/>
<div className="flex justify-between text-xs">
{errors.numero && <p className="text-red-500">{errors.numero.message}</p>}
<p className="text-gray-400 ml-auto">{field.value?.length || 0}/17</p>
</div>
</div>
)}
/>
<Controller
name="type_tiers"
control={control}
render={({ field }) => (
<div className="space-y-1.5">
<label className="text-xs font-medium text-gray-800 dark:text-gray-300">
Type de tiers
</label>
<select
{...field}
value={field.value ?? ""}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? undefined : parseInt(val));
}}
className={inputClass()}
>
<option value="">-- Non spécifié --</option>
<option value={0}>Client</option>
<option value={1}>Fournisseur</option>
<option value={2}>Client & Fournisseur</option>
<option value={3}>Autre</option>
</select>
</div>
)}
/>
<div className="flex gap-6 md:col-span-2">
<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-[#007E45] rounded border-gray-300 focus:ring-[#007E45]"
/>
<span className="text-xs 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-[#007E45] rounded border-gray-300 focus:ring-[#007E45]"
/>
<span className="text-xs text-gray-700 dark:text-gray-300">Actif</span>
</label>
)}
/>
</div>
<Controller
name="email"
control={control}
render={({ field }) => (
<InputField
{...field}
label="Email"
inputType="email"
placeholder="contact@exemple.com"
error={errors.email?.message}
leftIcon={<Mail className="w-4 h-4" />}
className='text-xs'
/>
)}
/>
<Controller
name="telephone"
control={control}
render={({ field, fieldState }) => (
<InternationalPhoneInput
{...field}
label="Téléphone"
error={fieldState.error?.message}
required={false}
defaultCountry="FR"
/>
)}
/>
<Controller
name="adresse"
control={control}
render={({ field }) => (
<div className="space-y-1.5">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">
Adresse
<span className="text-gray-400 ml-1">(max 35 caractères)</span>
</label>
<input
{...field}
placeholder="Numéro et nom de rue"
className={inputClass(errors.adresse)}
maxLength={35}
/>
<div className="flex justify-between text-xs">
{errors.adresse && <p className="text-red-500">{errors.adresse.message}</p>}
<p className="text-gray-400 ml-auto">{field.value?.length || 0}/35</p>
</div>
</div>
)}
/>
<Controller
name="code_postal"
control={control}
render={({ field }) => (
<InputField
{...field}
label="Code postal"
inputType="postal_code"
error={errors.code_postal?.message}
className='text-xs'
/>
)}
/>
<Controller
name="ville"
control={control}
render={({ field }) => (
<InputField
{...field}
label="Ville"
inputType="text"
containerClassName="col-span-2"
className='text-xs'
/>
)}
/>
<Controller
name="pays"
control={control}
render={({ field }) => (
<InputField
{...field}
label="Pays"
inputType="text"
className='text-xs'
/>
)}
/>
</div>
</section>
<section>
<SectionHeader title="Informations légales" icon={Building2} />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Controller
name="siret"
control={control}
render={({ field }) => (
<InputField
{...field}
label="SIRET"
inputType="siret"
maxLength={14}
placeholder="14 chiffres"
error={errors.siret?.message}
className='text-xs'
/>
)}
/>
<Controller
name="tva_intra"
control={control}
render={({ field }) => (
<InputField
{...field}
label="TVA Intracommunautaire"
inputType="tva_intra"
placeholder="FR12345678901"
error={errors.tva_intra?.message}
maxLength={13}
className='text-xs'
/>
)}
/>
<Controller
name="code_naf"
control={control}
render={({ field }) => (
<InputField
{...field}
label="Code NAF"
inputType="text"
placeholder="Ex: 4651Z"
className='text-xs'
maxLength={5}
/>
)}
/>
</div>
</section>
<section>
<SectionHeader title="Données financières" icon={CreditCard} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Controller name="encours_autorise" control={control} render={({ field }) => (
<div className="space-y-1.5">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">Encours autorisé ()</label>
<input
{...field}
type="number"
value={field.value || ''}
onChange={(e) => field.onChange(parseFloat(e.target.value) || undefined)}
placeholder="0.00"
className={inputClass()}
/>
</div>
)} />
<Controller name="assurance_credit" control={control} render={({ field }) => (
<div className="space-y-1.5">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300">Assurance crédit ()</label>
<input
{...field}
type="number"
value={field.value || ''}
onChange={(e) => field.onChange(parseFloat(e.target.value) || undefined)}
placeholder="0.00"
className={inputClass()}
/>
</div>
)} />
</div>
</section>
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
<div className='flex flex-row justify-end items-end gap-3'>
<button
type="button"
onClick={() => navigate('/home/clients')}
className="px-6 py-2.5 text-xs font-medium text-gray-700 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Annuler
</button>
<button
type="submit"
disabled={!canSave}
className="inline-flex items-center justify-center gap-2 px-6 py-2.5 bg-[#007E45] text-white text-sm font-medium rounded-xl hover:bg-[#006838] disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-[#007E45]/20"
>
{isLoading || 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>
</form>
</div>
</div>
</div>
</>
);
};
export default CreateClientPage;