diff --git a/apps/frontend/app/[locale]/dashboard/admin/organizations/page.tsx b/apps/frontend/app/[locale]/dashboard/admin/organizations/page.tsx index 0773b97..84ecdd5 100644 --- a/apps/frontend/app/[locale]/dashboard/admin/organizations/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/admin/organizations/page.tsx @@ -30,6 +30,49 @@ interface Organization { createdAt: string; } +interface FormData { + name: string; + type: string; + scac: string; + siren: string; + siret: string; + eori: string; + contact_phone: string; + contact_email: string; + address: { + street: string; + city: string; + state: string; + postalCode: string; + country: string; + }; + logoUrl: string; + isActive: boolean; +} + +const EMPTY_FORM: FormData = { + name: '', + type: 'FREIGHT_FORWARDER', + scac: '', + siren: '', + siret: '', + eori: '', + contact_phone: '', + contact_email: '', + address: { street: '', city: '', state: '', postalCode: '', country: 'FR' }, + logoUrl: '', + isActive: true, +}; + +function FieldHint({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +function FieldError({ message }: { message?: string }) { + if (!message) return null; + return

{message}

; +} + export default function AdminOrganizationsPage() { const t = useTranslations('dashboard.admin.organizations'); @@ -40,43 +83,10 @@ export default function AdminOrganizationsPage() { const [showCreateModal, setShowCreateModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [verifyingId, setVerifyingId] = useState(null); - - // Form state - const [formData, setFormData] = useState<{ - name: string; - type: string; - scac: string; - siren: string; - siret: string; - eori: string; - contact_phone: string; - contact_email: string; - address: { - street: string; - city: string; - state?: string; - postalCode: string; - country: string; - }; - logoUrl: string; - }>({ - name: '', - type: 'FREIGHT_FORWARDER', - scac: '', - siren: '', - siret: '', - eori: '', - contact_phone: '', - contact_email: '', - address: { - street: '', - city: '', - state: '', - postalCode: '', - country: 'FR', - }, - logoUrl: '', - }); + const [formData, setFormData] = useState(EMPTY_FORM); + const [formError, setFormError] = useState(null); + const [fieldErrors, setFieldErrors] = useState>>({}); + const [submitting, setSubmitting] = useState(false); useEffect(() => { fetchOrganizations(); @@ -95,70 +105,120 @@ export default function AdminOrganizationsPage() { } }; + const validateForm = (isCreate: boolean): boolean => { + const errors: Partial> = {}; + + if (!formData.name.trim() || formData.name.trim().length < 2) { + errors.name = t('validation.nameTooShort'); + } + + if (isCreate && formData.type === 'CARRIER') { + if (!formData.scac) { + errors.scac = t('validation.scacRequired'); + } else if (!/^[A-Z]{4}$/.test(formData.scac)) { + errors.scac = t('validation.scacFormat'); + } + } + + if (formData.siren && !/^[0-9]{9}$/.test(formData.siren)) { + errors.siren = t('validation.sirenFormat'); + } + + if (formData.siret && !/^[0-9]{14}$/.test(formData.siret)) { + errors.siret = t('validation.siretFormat'); + } + + if (!formData.address.street.trim()) errors.street = t('validation.required'); + if (!formData.address.city.trim()) errors.city = t('validation.required'); + if (!formData.address.postalCode.trim()) errors.postalCode = t('validation.required'); + if (!/^[A-Z]{2}$/.test(formData.address.country)) { + errors.country = t('validation.countryFormat'); + } + + setFieldErrors(errors); + return Object.keys(errors).length === 0; + }; + + const buildApiAddress = () => ({ + street: formData.address.street, + city: formData.address.city, + postalCode: formData.address.postalCode, + country: formData.address.country, + ...(formData.address.state ? { state: formData.address.state } : {}), + }); + const handleCreate = async (e: React.FormEvent) => { e.preventDefault(); + setFormError(null); + if (!validateForm(true)) return; + + setSubmitting(true); try { - const apiData = { - name: formData.name, + await createOrganization({ + name: formData.name.trim(), type: formData.type as any, - address: { - street: formData.address.street, - city: formData.address.city, - postalCode: formData.address.postalCode, - country: formData.address.country, - ...(formData.address.state ? { state: formData.address.state } : {}), - }, + address: buildApiAddress(), contact_email: formData.contact_email || undefined, contact_phone: formData.contact_phone || undefined, - logo_url: formData.logoUrl || undefined, + logoUrl: formData.logoUrl || undefined, ...(formData.siren ? { siren: formData.siren } : {}), ...(formData.siret ? { siret: formData.siret } : {}), ...(formData.eori ? { eori: formData.eori } : {}), ...(formData.scac ? { scac: formData.scac } : {}), - }; - await createOrganization(apiData); + }); await fetchOrganizations(); setShowCreateModal(false); resetForm(); } catch (err: any) { - alert(err.message || t('createError')); + setFormError(err.message || t('createError')); + } finally { + setSubmitting(false); } }; const handleUpdate = async (e: React.FormEvent) => { e.preventDefault(); if (!selectedOrg) return; + setFormError(null); + if (!validateForm(false)) return; + setSubmitting(true); try { - await updateOrganization(selectedOrg.id, formData); + await updateOrganization(selectedOrg.id, { + name: formData.name.trim(), + address: buildApiAddress(), + contact_email: formData.contact_email || undefined, + contact_phone: formData.contact_phone || undefined, + logoUrl: formData.logoUrl || undefined, + isActive: formData.isActive, + ...(formData.siren ? { siren: formData.siren } : {}), + ...(formData.siret ? { siret: formData.siret } : {}), + ...(formData.eori ? { eori: formData.eori } : {}), + }); await fetchOrganizations(); setShowEditModal(false); setSelectedOrg(null); resetForm(); } catch (err: any) { - alert(err.message || t('updateError')); + setFormError(err.message || t('updateError')); + } finally { + setSubmitting(false); } }; const resetForm = () => { - setFormData({ - name: '', - type: 'FREIGHT_FORWARDER', - scac: '', - siren: '', - siret: '', - eori: '', - contact_phone: '', - contact_email: '', - address: { - street: '', - city: '', - state: '', - postalCode: '', - country: 'FR', - }, - logoUrl: '', - }); + setFormData(EMPTY_FORM); + setFormError(null); + setFieldErrors({}); + }; + + const handleTypeChange = (newType: string) => { + setFormData(prev => ({ + ...prev, + type: newType, + scac: newType !== 'CARRIER' ? '' : prev.scac, + })); + setFieldErrors(prev => ({ ...prev, scac: undefined })); }; const handleVerifySiret = async (orgId: string) => { @@ -222,20 +282,41 @@ export default function AdminOrganizationsPage() { eori: org.eori || '', contact_phone: org.contact_phone || '', contact_email: org.contact_email || '', - address: org.address, + address: { + street: org.address.street, + city: org.address.city, + state: org.address.state || '', + postalCode: org.address.postalCode, + country: org.address.country, + }, logoUrl: org.logoUrl || '', + isActive: org.isActive, }); + setFormError(null); + setFieldErrors({}); setShowEditModal(true); }; + const closeModal = () => { + setShowCreateModal(false); + setShowEditModal(false); + setSelectedOrg(null); + resetForm(); + }; + const getTypeLabel = (type: string) => { const allowed = ['FREIGHT_FORWARDER', 'CARRIER', 'SHIPPER']; - if (allowed.includes(type)) { - return t(`types.${type}` as any); - } + if (allowed.includes(type)) return t(`types.${type}` as any); return type.replace('_', ' '); }; + const inputClass = (hasError?: string) => + `mt-1 block w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none text-sm ${ + hasError + ? 'border-red-400 focus:border-red-500 focus:ring-red-500' + : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500' + }`; + if (loading) { return (
@@ -262,7 +343,6 @@ export default function AdminOrganizationsPage() { } /> - {/* Error Message */} {error && (
{error} @@ -382,247 +462,365 @@ export default function AdminOrganizationsPage() { ))}
- {/* Create/Edit Modal */} + {/* Create / Edit Modal */} {(showCreateModal || showEditModal) && ( -
-
-

+
+
+

{showCreateModal ? t('modal.createTitle') : t('modal.editTitle')}

-
-
-
- - setFormData({ ...formData, name: e.target.value })} - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" - /> -
-
- - -
- - {formData.type === 'CARRIER' && ( -
+ + {/* ── Informations générales ── */} +
+

+ {t('modal.sectionGeneral')} +

+
+
- setFormData({ ...formData, scac: e.target.value.toUpperCase() }) - } - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" + value={formData.name} + onChange={e => { + setFormData({ ...formData, name: e.target.value }); + setFieldErrors(prev => ({ ...prev, name: undefined })); + }} + minLength={2} + maxLength={200} + className={inputClass(fieldErrors.name)} + placeholder="Acme Freight Forwarding" + /> + +
+ +
+ + {showCreateModal ? ( + + ) : ( +
+ + {getTypeLabel(formData.type)} + + {t('modal.typeReadOnly')} +
+ )} +
+ + {/* SCAC — création uniquement, visible en lecture seule en édition si CARRIER */} + {showCreateModal && formData.type === 'CARRIER' && ( +
+ + { + const v = e.target.value.toUpperCase().replace(/[^A-Z]/g, ''); + setFormData({ ...formData, scac: v }); + setFieldErrors(prev => ({ ...prev, scac: undefined })); + }} + maxLength={4} + minLength={4} + className={inputClass(fieldErrors.scac)} + placeholder="MAEU" + /> + {t('modal.scacHint')} + +
+ )} + {showEditModal && formData.type === 'CARRIER' && formData.scac && ( +
+ +
+ {formData.scac} +
+ {t('modal.typeReadOnly')} +
+ )} +
+
+ + {/* ── Identifiants légaux ── */} +
+

+ {t('modal.sectionLegal')} +

+
+
+ + { + const v = e.target.value.replace(/\D/g, '').slice(0, 9); + setFormData({ ...formData, siren: v }); + setFieldErrors(prev => ({ ...prev, siren: undefined })); + }} + maxLength={9} + className={inputClass(fieldErrors.siren)} + placeholder="123456789" + /> + {t('modal.sirenHint')} + +
+ +
+ + { + const v = e.target.value.replace(/\D/g, '').slice(0, 14); + setFormData({ ...formData, siret: v }); + setFieldErrors(prev => ({ ...prev, siret: undefined })); + }} + maxLength={14} + className={inputClass(fieldErrors.siret)} + placeholder="12345678901234" + /> + {t('modal.siretHint')} + +
+ +
+ + setFormData({ ...formData, eori: e.target.value })} + className={inputClass()} + placeholder="FR123456789" + /> + {t('modal.eoriHint')} +
+
+
+ + {/* ── Contact ── */} +
+

+ {t('modal.sectionContact')} +

+
+
+ + setFormData({ ...formData, contact_email: e.target.value })} + className={inputClass()} + placeholder="contact@exemple.com" />
- )} -
- - setFormData({ ...formData, siren: e.target.value })} - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" - /> +
+ + setFormData({ ...formData, contact_phone: e.target.value })} + className={inputClass()} + placeholder="+33 6 00 00 00 00" + /> +
+
-
- - - setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') }) - } - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" - placeholder={t('modal.siretPlaceholder')} - /> + {/* ── Adresse ── */} +
+

+ {t('modal.sectionAddress')} +

+
+
+ + { + setFormData({ ...formData, address: { ...formData.address, street: e.target.value } }); + setFieldErrors(prev => ({ ...prev, street: undefined })); + }} + className={inputClass(fieldErrors.street)} + placeholder="123 Rue de la République" + /> + +
+ +
+ + { + setFormData({ ...formData, address: { ...formData.address, city: e.target.value } }); + setFieldErrors(prev => ({ ...prev, city: undefined })); + }} + className={inputClass(fieldErrors.city)} + placeholder="Paris" + /> + +
+ +
+ + { + setFormData({ ...formData, address: { ...formData.address, postalCode: e.target.value } }); + setFieldErrors(prev => ({ ...prev, postalCode: undefined })); + }} + className={inputClass(fieldErrors.postalCode)} + placeholder="75001" + /> + +
+ +
+ + + setFormData({ ...formData, address: { ...formData.address, state: e.target.value } }) + } + className={inputClass()} + placeholder="Île-de-France" + /> +
+ +
+ + { + const v = e.target.value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 2); + setFormData({ ...formData, address: { ...formData.address, country: v } }); + setFieldErrors(prev => ({ ...prev, country: undefined })); + }} + maxLength={2} + minLength={2} + className={inputClass(fieldErrors.country)} + placeholder="FR" + /> + {t('modal.countryHint')} + +
+
-
- - setFormData({ ...formData, eori: e.target.value })} - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" - /> + {/* ── Autres ── */} +
+

+ {t('modal.sectionOther')} +

+
+
+ + setFormData({ ...formData, logoUrl: e.target.value })} + className={inputClass()} + placeholder="https://exemple.com/logo.png" + /> +
+ + {showEditModal && ( +
+ setFormData({ ...formData, isActive: e.target.checked })} + className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> + +
+ )}
+
-
- - setFormData({ ...formData, contact_phone: e.target.value })} - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" - /> + {/* Erreur globale */} + {formError && ( +
+ {formError}
+ )} -
- - setFormData({ ...formData, contact_email: e.target.value })} - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" - /> -
- -
- - - setFormData({ - ...formData, - address: { ...formData.address, street: e.target.value }, - }) - } - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" - /> -
- -
- - - setFormData({ - ...formData, - address: { ...formData.address, city: e.target.value }, - }) - } - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" - /> -
- -
- - - setFormData({ - ...formData, - address: { ...formData.address, postalCode: e.target.value }, - }) - } - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" - /> -
- -
- - - setFormData({ - ...formData, - address: { ...formData.address, state: e.target.value }, - }) - } - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" - /> -
- -
- - - setFormData({ - ...formData, - address: { ...formData.address, country: e.target.value.toUpperCase() }, - }) - } - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" - /> -
- -
- - setFormData({ ...formData, logoUrl: e.target.value })} - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" - /> -
-
- -
+
diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index d45183e..d88e432 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -1200,24 +1200,46 @@ "modal": { "createTitle": "Create New Organization", "editTitle": "Edit Organization", - "name": "Organization Name *", - "type": "Type *", - "scacLabel": "SCAC Code *", + "sectionGeneral": "General information", + "sectionLegal": "Legal identifiers", + "sectionContact": "Contact", + "sectionAddress": "Address", + "sectionOther": "Other", + "name": "Organization name", + "type": "Type", + "typeReadOnly": "Cannot be changed after creation", + "scacLabel": "SCAC code", + "scacHint": "4 uppercase letters (e.g. MAEU). Required for carriers.", "sirenLabel": "SIREN", - "siretLabel": "SIRET (14 digits)", + "sirenHint": "9 digits — optional", + "siretLabel": "SIRET", + "siretHint": "14 digits — optional", "siretPlaceholder": "12345678901234", "eoriLabel": "EORI", - "contactPhone": "Contact Phone", - "contactEmail": "Contact Email", - "street": "Street Address *", - "city": "City *", - "postalCode": "Postal Code *", + "eoriHint": "EU EORI number (e.g. FR123456789) — optional", + "contactEmail": "Contact email", + "contactPhone": "Contact phone", + "street": "Street address", + "city": "City", + "postalCode": "Postal code", "state": "State / Region", - "country": "Country *", + "country": "Country", + "countryHint": "2-letter ISO code (e.g. FR, DE, US)", "logoUrl": "Logo URL", + "isActive": "Active organization", "cancel": "Cancel", "create": "Create", - "update": "Update" + "update": "Update", + "saving": "Saving..." + }, + "validation": { + "required": "This field is required", + "nameTooShort": "Name must be at least 2 characters", + "scacRequired": "SCAC code is required for carriers", + "scacFormat": "SCAC must be exactly 4 uppercase letters (e.g. MAEU)", + "sirenFormat": "SIREN must be exactly 9 digits", + "siretFormat": "SIRET must be exactly 14 digits", + "countryFormat": "Country must be a 2-letter ISO code (e.g. FR)" } }, "users": { diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json index 0220c2e..81a92d8 100644 --- a/apps/frontend/messages/fr.json +++ b/apps/frontend/messages/fr.json @@ -1200,24 +1200,46 @@ "modal": { "createTitle": "Créer une nouvelle organisation", "editTitle": "Modifier l'organisation", - "name": "Nom de l'organisation *", - "type": "Type *", - "scacLabel": "Code SCAC *", + "sectionGeneral": "Informations générales", + "sectionLegal": "Identifiants légaux", + "sectionContact": "Contact", + "sectionAddress": "Adresse", + "sectionOther": "Autres", + "name": "Nom de l'organisation", + "type": "Type", + "typeReadOnly": "Non modifiable après création", + "scacLabel": "Code SCAC", + "scacHint": "4 lettres majuscules (ex. MAEU). Obligatoire pour les transporteurs.", "sirenLabel": "SIREN", - "siretLabel": "SIRET (14 chiffres)", + "sirenHint": "9 chiffres — facultatif", + "siretLabel": "SIRET", + "siretHint": "14 chiffres — facultatif", "siretPlaceholder": "12345678901234", "eoriLabel": "EORI", - "contactPhone": "Téléphone de contact", + "eoriHint": "Numéro EORI européen (ex. FR123456789) — facultatif", "contactEmail": "Email de contact", - "street": "Rue *", - "city": "Ville *", - "postalCode": "Code postal *", + "contactPhone": "Téléphone de contact", + "street": "Rue", + "city": "Ville", + "postalCode": "Code postal", "state": "État / Région", - "country": "Pays *", + "country": "Pays", + "countryHint": "Code ISO 2 lettres majuscules (ex. FR, DE, US)", "logoUrl": "URL du logo", + "isActive": "Organisation active", "cancel": "Annuler", "create": "Créer", - "update": "Mettre à jour" + "update": "Mettre à jour", + "saving": "Enregistrement..." + }, + "validation": { + "required": "Ce champ est obligatoire", + "nameTooShort": "Le nom doit comporter au moins 2 caractères", + "scacRequired": "Le code SCAC est obligatoire pour les transporteurs", + "scacFormat": "Le code SCAC doit comporter exactement 4 lettres majuscules (ex. MAEU)", + "sirenFormat": "Le SIREN doit comporter exactement 9 chiffres", + "siretFormat": "Le SIRET doit comporter exactement 14 chiffres", + "countryFormat": "Le pays doit être un code ISO à 2 lettres majuscules (ex. FR)" } }, "users": { diff --git a/apps/frontend/src/types/api.ts b/apps/frontend/src/types/api.ts index 7726daa..d658af9 100644 --- a/apps/frontend/src/types/api.ts +++ b/apps/frontend/src/types/api.ts @@ -124,7 +124,7 @@ export interface CreateOrganizationRequest { scac?: string; contact_email?: string; contact_phone?: string; - logo_url?: string; + logoUrl?: string; } export interface UpdateOrganizationRequest {