1001 lines
No EOL
51 KiB
TypeScript
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; |