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

652 lines
30 KiB
JavaScript

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