create-crm-tmp 1.1.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/package.json +1 -1
  2. package/template/.prettierignore +2 -0
  3. package/template/README.md +53 -67
  4. package/template/components.json +22 -0
  5. package/template/exemple-contacts.csv +54 -0
  6. package/template/next.config.ts +27 -1
  7. package/template/package.json +64 -27
  8. package/template/prisma/schema.prisma +821 -72
  9. package/template/skills-lock.json +25 -0
  10. package/template/src/app/(auth)/invite/[token]/page.tsx +21 -24
  11. package/template/src/app/(auth)/reset-password/complete/page.tsx +12 -21
  12. package/template/src/app/(auth)/reset-password/page.tsx +12 -8
  13. package/template/src/app/(auth)/reset-password/verify/page.tsx +12 -8
  14. package/template/src/app/(auth)/signin/page.tsx +20 -17
  15. package/template/src/app/(dashboard)/agenda/page.tsx +2231 -2188
  16. package/template/src/app/(dashboard)/automatisation/[id]/page.tsx +10 -7
  17. package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +680 -323
  18. package/template/src/app/(dashboard)/automatisation/new/page.tsx +11 -8
  19. package/template/src/app/(dashboard)/automatisation/page.tsx +473 -180
  20. package/template/src/app/(dashboard)/closing/page.tsx +500 -468
  21. package/template/src/app/(dashboard)/contacts/[id]/page.tsx +5035 -4126
  22. package/template/src/app/(dashboard)/contacts/companies/[id]/page.tsx +1703 -0
  23. package/template/src/app/(dashboard)/contacts/loading.tsx +13 -0
  24. package/template/src/app/(dashboard)/contacts/page.tsx +3776 -2064
  25. package/template/src/app/(dashboard)/dashboard/page.tsx +37 -519
  26. package/template/src/app/(dashboard)/error.tsx +37 -0
  27. package/template/src/app/(dashboard)/layout.tsx +1 -1
  28. package/template/src/app/(dashboard)/loading.tsx +5 -0
  29. package/template/src/app/(dashboard)/settings/loading.tsx +19 -0
  30. package/template/src/app/(dashboard)/settings/page.tsx +2685 -2489
  31. package/template/src/app/(dashboard)/templates/page.tsx +500 -300
  32. package/template/src/app/(dashboard)/users/list/page.tsx +356 -350
  33. package/template/src/app/(dashboard)/users/page.tsx +279 -310
  34. package/template/src/app/(dashboard)/users/permissions/page.tsx +104 -99
  35. package/template/src/app/(dashboard)/users/roles/page.tsx +164 -137
  36. package/template/src/app/api/audit-logs/route.ts +1 -1
  37. package/template/src/app/api/auth/google/callback/route.ts +8 -5
  38. package/template/src/app/api/auth/google/disconnect/route.ts +2 -2
  39. package/template/src/app/api/companies/[id]/activities/route.ts +131 -0
  40. package/template/src/app/api/companies/[id]/route.ts +195 -0
  41. package/template/src/app/api/companies/export/route.ts +206 -0
  42. package/template/src/app/api/companies/route.ts +166 -0
  43. package/template/src/app/api/contact-views/[id]/pin/route.ts +69 -0
  44. package/template/src/app/api/contact-views/[id]/route.ts +197 -0
  45. package/template/src/app/api/contact-views/route.ts +146 -0
  46. package/template/src/app/api/contacts/[id]/files/[fileId]/preview/route.ts +77 -0
  47. package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +7 -17
  48. package/template/src/app/api/contacts/[id]/files/route.ts +83 -44
  49. package/template/src/app/api/contacts/[id]/interactions/route.ts +37 -0
  50. package/template/src/app/api/contacts/[id]/kyc/route.ts +71 -0
  51. package/template/src/app/api/contacts/[id]/meet/route.ts +38 -29
  52. package/template/src/app/api/contacts/[id]/route.ts +111 -20
  53. package/template/src/app/api/contacts/[id]/send-email/route.ts +6 -0
  54. package/template/src/app/api/contacts/[id]/workflows/run/route.ts +61 -0
  55. package/template/src/app/api/contacts/export/route.ts +12 -17
  56. package/template/src/app/api/contacts/import/route.ts +22 -19
  57. package/template/src/app/api/contacts/import-preview/route.ts +139 -0
  58. package/template/src/app/api/contacts/route.ts +202 -49
  59. package/template/src/app/api/dashboard/stats/route.ts +9 -292
  60. package/template/src/app/api/integrations/google-sheet/sync/route.ts +203 -185
  61. package/template/src/app/api/invite/complete/route.ts +20 -23
  62. package/template/src/app/api/reminders/route.ts +1 -0
  63. package/template/src/app/api/reset-password/complete/route.ts +11 -13
  64. package/template/src/app/api/send/route.ts +9 -85
  65. package/template/src/app/api/settings/closing-reasons/[id]/route.ts +10 -21
  66. package/template/src/app/api/settings/closing-reasons/route.ts +10 -21
  67. package/template/src/app/api/settings/company/route.ts +19 -26
  68. package/template/src/app/api/settings/google-ads/[id]/route.ts +20 -23
  69. package/template/src/app/api/settings/google-ads/route.ts +20 -23
  70. package/template/src/app/api/settings/google-sheet/[id]/route.ts +20 -23
  71. package/template/src/app/api/settings/google-sheet/auto-map/route.ts +23 -32
  72. package/template/src/app/api/settings/google-sheet/preview/route.ts +104 -0
  73. package/template/src/app/api/settings/google-sheet/route.ts +20 -23
  74. package/template/src/app/api/settings/meta-leads/[id]/route.ts +20 -23
  75. package/template/src/app/api/settings/meta-leads/route.ts +20 -23
  76. package/template/src/app/api/settings/statuses/[id]/route.ts +33 -23
  77. package/template/src/app/api/settings/statuses/route.ts +24 -22
  78. package/template/src/app/api/statuses/route.ts +2 -5
  79. package/template/src/app/api/tasks/[id]/attendees/route.ts +14 -7
  80. package/template/src/app/api/tasks/[id]/route.ts +161 -137
  81. package/template/src/app/api/tasks/meet/route.ts +11 -8
  82. package/template/src/app/api/tasks/route.ts +155 -95
  83. package/template/src/app/api/templates/[id]/route.ts +22 -13
  84. package/template/src/app/api/templates/route.ts +22 -5
  85. package/template/src/app/api/users/[id]/resend-invite/route.ts +95 -0
  86. package/template/src/app/api/users/[id]/route.ts +16 -1
  87. package/template/src/app/api/users/commercials/route.ts +38 -0
  88. package/template/src/app/api/users/for-agenda/route.ts +1 -2
  89. package/template/src/app/api/users/route.ts +94 -55
  90. package/template/src/app/api/webhooks/google-ads/route.ts +20 -1
  91. package/template/src/app/api/webhooks/meta-leads/route.ts +18 -1
  92. package/template/src/app/api/workflows/[id]/route.ts +33 -6
  93. package/template/src/app/api/workflows/process/route.ts +509 -146
  94. package/template/src/app/api/workflows/route.ts +46 -4
  95. package/template/src/app/globals.css +210 -101
  96. package/template/src/app/layout.tsx +19 -8
  97. package/template/src/app/page.tsx +37 -7
  98. package/template/src/components/address-autocomplete.tsx +232 -0
  99. package/template/src/components/contacts/filter-bar.tsx +181 -0
  100. package/template/src/components/contacts/filter-builder.tsx +589 -0
  101. package/template/src/components/contacts/save-view-dialog.tsx +160 -0
  102. package/template/src/components/contacts/views-tab-bar.tsx +440 -0
  103. package/template/src/components/dashboard/activity-chart.tsx +31 -39
  104. package/template/src/components/dashboard/dashboard-content.tsx +79 -0
  105. package/template/src/components/dashboard/stat-card.tsx +40 -42
  106. package/template/src/components/dashboard/tasks-pie-chart.tsx +34 -37
  107. package/template/src/components/dashboard/upcoming-tasks-list.tsx +78 -72
  108. package/template/src/components/date-picker.tsx +396 -0
  109. package/template/src/components/editor.tsx +27 -13
  110. package/template/src/components/email-template.tsx +4 -2
  111. package/template/src/components/global-search.tsx +358 -0
  112. package/template/src/components/header.tsx +57 -62
  113. package/template/src/components/invitation-email-template.tsx +4 -2
  114. package/template/src/components/lazy-editor.tsx +11 -0
  115. package/template/src/components/meet-cancellation-email-template.tsx +11 -3
  116. package/template/src/components/meet-confirmation-email-template.tsx +10 -3
  117. package/template/src/components/meet-update-email-template.tsx +10 -3
  118. package/template/src/components/page-header.tsx +19 -15
  119. package/template/src/components/protected-page.tsx +94 -0
  120. package/template/src/components/reset-password-email-template.tsx +4 -2
  121. package/template/src/components/sidebar.tsx +92 -94
  122. package/template/src/components/skeleton.tsx +128 -42
  123. package/template/src/components/ui/accordion.tsx +64 -0
  124. package/template/src/components/ui/alert-dialog.tsx +139 -0
  125. package/template/src/components/ui/button.tsx +60 -0
  126. package/template/src/components/view-as-banner.tsx +1 -1
  127. package/template/src/components/view-as-modal.tsx +21 -16
  128. package/template/src/config/nav-pages.ts +108 -0
  129. package/template/src/contexts/app-toast-context.tsx +174 -0
  130. package/template/src/contexts/sidebar-context.tsx +16 -47
  131. package/template/src/contexts/task-reminder-context.tsx +6 -6
  132. package/template/src/contexts/view-as-context.tsx +11 -16
  133. package/template/src/hooks/use-alert.tsx +65 -0
  134. package/template/src/hooks/use-confirm.tsx +87 -0
  135. package/template/src/hooks/use-contact-views.ts +140 -0
  136. package/template/src/hooks/use-contacts.ts +69 -0
  137. package/template/src/hooks/use-fetch.ts +17 -0
  138. package/template/src/hooks/use-focus-trap.ts +73 -0
  139. package/template/src/hooks/use-statuses.ts +22 -0
  140. package/template/src/lib/address-api.ts +155 -0
  141. package/template/src/lib/cache.ts +73 -0
  142. package/template/src/lib/check-permission.ts +12 -177
  143. package/template/src/lib/contact-interactions.ts +3 -1
  144. package/template/src/lib/contact-view-filters.ts +341 -0
  145. package/template/src/lib/dashboard-stats.ts +224 -0
  146. package/template/src/lib/date-utils.ts +49 -0
  147. package/template/src/lib/get-auth-user.ts +25 -0
  148. package/template/src/lib/google-calendar.ts +54 -12
  149. package/template/src/lib/google-drive.ts +796 -75
  150. package/template/src/lib/google-fetch.ts +63 -0
  151. package/template/src/lib/local-storage.ts +34 -0
  152. package/template/src/lib/permissions.ts +245 -47
  153. package/template/src/lib/prisma.ts +11 -11
  154. package/template/src/lib/roles.ts +14 -39
  155. package/template/src/lib/template-variables.ts +67 -33
  156. package/template/src/lib/utils.ts +26 -2
  157. package/template/src/lib/workflow-executor.ts +445 -229
  158. package/template/src/proxy.ts +34 -73
  159. package/template/src/types/contact-views.ts +351 -0
  160. package/template/src/types/yousign.ts +52 -0
  161. package/template/vercel.json +12 -0
  162. package/template/WORKFLOWS_CRON.md +0 -185
  163. package/template/prisma/migrations/20251126144728_init/migration.sql +0 -78
  164. package/template/prisma/migrations/20251126155204_add_user_roles/migration.sql +0 -5
  165. package/template/prisma/migrations/20251128095126_add_company_info/migration.sql +0 -19
  166. package/template/prisma/migrations/20251128123321_add_smtp_config/migration.sql +0 -22
  167. package/template/prisma/migrations/20251128132303_add_status/migration.sql +0 -23
  168. package/template/prisma/migrations/20251201102207_add_user_active/migration.sql +0 -75
  169. package/template/prisma/migrations/20251201105507_add_email_signature/migration.sql +0 -2
  170. package/template/prisma/migrations/20251201151122_add_tasks/migration.sql +0 -45
  171. package/template/prisma/migrations/20251202111854_add_task_reminder/migration.sql +0 -2
  172. package/template/prisma/migrations/20251202135859_add_google_meet_integration/migration.sql +0 -27
  173. package/template/prisma/migrations/20251203103317_add_meta_lead_integration/migration.sql +0 -20
  174. package/template/prisma/migrations/20251203104002_add_google_ads_integration/migration.sql +0 -18
  175. package/template/prisma/migrations/20251203112122_add_google_sheet_integration/migration.sql +0 -32
  176. package/template/prisma/migrations/20251203153853_allow_multiple_integration_configs/migration.sql +0 -20
  177. package/template/prisma/migrations/20251205141705_update_user_roles/migration.sql +0 -12
  178. package/template/prisma/migrations/20251205150000_add_commercial_and_telepro_assignment/migration.sql +0 -21
  179. package/template/prisma/migrations/20251205160000_add_interaction_logging/migration.sql +0 -11
  180. package/template/prisma/migrations/20251208090314_add_automatic_interaction_types/migration.sql +0 -12
  181. package/template/prisma/migrations/20251208094843_mg/migration.sql +0 -14
  182. package/template/prisma/migrations/20251208100000_add_company_support/migration.sql +0 -14
  183. package/template/prisma/migrations/20251208110000_add_templates/migration.sql +0 -26
  184. package/template/prisma/migrations/20251208141304_add_video_conference_task_type/migration.sql +0 -2
  185. package/template/prisma/migrations/20251209104759_add_internal_note_to_task/migration.sql +0 -2
  186. package/template/prisma/migrations/20251209134803_add_company_field/migration.sql +0 -2
  187. package/template/prisma/migrations/20251209150000_rename_company_to_company_name/migration.sql +0 -3
  188. package/template/prisma/migrations/20251209150016_add_email_tracking/migration.sql +0 -21
  189. package/template/prisma/migrations/20251209155908_add_notify_contact_to_task/migration.sql +0 -2
  190. package/template/prisma/migrations/20251210110019_add_appointment_types/migration.sql +0 -10
  191. package/template/prisma/migrations/20251210113928_add_contact_files/migration.sql +0 -26
  192. package/template/prisma/migrations/20251212132339_add_custom_roles/migration.sql +0 -24
  193. package/template/prisma/migrations/20251215104448_add_file_interaction_types/migration.sql +0 -11
  194. package/template/prisma/migrations/20251215145616_add_closing_reasons/migration.sql +0 -12
  195. package/template/prisma/migrations/20251216140850_add_log_users/migration.sql +0 -25
  196. package/template/prisma/migrations/20251216151000_rename_perdu_to_ferme/migration.sql +0 -8
  197. package/template/prisma/migrations/20251216162318_add_column_mappings_to_google_sheet/migration.sql +0 -2
  198. package/template/prisma/migrations/20251216185127_add_workflows/migration.sql +0 -80
  199. package/template/prisma/migrations/20251216192237_add_scheduled_workflow_actions/migration.sql +0 -32
  200. package/template/prisma/migrations/20251220000000_add_task_interaction_type/migration.sql +0 -4
  201. package/template/prisma/migrations/20251221000000_add_task_type/migration.sql +0 -3
  202. package/template/prisma/migrations/20251221000001_add_event_color/migration.sql +0 -23
  203. package/template/prisma/migrations/20260210114913_add_dashboard_widget/migration.sql +0 -20
  204. package/template/prisma/migrations/migration_lock.toml +0 -3
  205. package/template/src/app/(dashboard)/users/layout.tsx +0 -30
  206. package/template/src/app/api/dashboard/widgets/[id]/route.ts +0 -47
  207. package/template/src/app/api/dashboard/widgets/route.ts +0 -181
  208. package/template/src/components/dashboard/add-widget-dialog.tsx +0 -161
  209. package/template/src/components/dashboard/color-picker.tsx +0 -65
  210. package/template/src/components/dashboard/contacts-chart.tsx +0 -69
  211. package/template/src/components/dashboard/interactions-by-type-chart.tsx +0 -121
  212. package/template/src/components/dashboard/recent-activity.tsx +0 -157
  213. package/template/src/components/dashboard/sales-analytics-chart.tsx +0 -77
  214. package/template/src/components/dashboard/status-distribution-chart.tsx +0 -82
  215. package/template/src/components/dashboard/top-contacts-list.tsx +0 -119
  216. package/template/src/components/dashboard/widget-wrapper.tsx +0 -39
  217. package/template/src/contexts/dashboard-theme-context.tsx +0 -58
  218. package/template/src/lib/dashboard-themes.ts +0 -140
  219. package/template/src/lib/default-widgets.ts +0 -14
  220. package/template/src/lib/widget-registry.ts +0 -177
@@ -0,0 +1,1703 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
+ import { useParams, useRouter } from 'next/navigation';
5
+ import Link from 'next/link';
6
+ import {
7
+ ArrowLeft,
8
+ Building2,
9
+ Users,
10
+ Trash2,
11
+ RefreshCw,
12
+ Plus,
13
+ Activity,
14
+ X,
15
+ Phone,
16
+ Mail,
17
+ } from 'lucide-react';
18
+ import { cn, normalizePhoneNumber } from '@/lib/utils';
19
+ import { useConfirm } from '@/hooks/use-confirm';
20
+ import { useUserRole } from '@/hooks/use-user-role';
21
+ import { ProtectedPage } from '@/components/protected-page';
22
+ import AddressAutocomplete from '@/components/address-autocomplete';
23
+ import { Spinner } from '@/components/skeleton';
24
+ import { useAppToast } from '@/contexts/app-toast-context';
25
+
26
+ interface CompanyContact {
27
+ id: string;
28
+ firstName: string | null;
29
+ lastName: string | null;
30
+ jobTitle: string | null;
31
+ phone: string;
32
+ email: string | null;
33
+ status: { id: string; name: string; color: string } | null;
34
+ }
35
+
36
+ interface CompanyDetail {
37
+ id: string;
38
+ name: string;
39
+ phone: string | null;
40
+ email: string | null;
41
+ address: string | null;
42
+ city: string | null;
43
+ postalCode: string | null;
44
+ website: string | null;
45
+ siret: string | null;
46
+ industry: string | null;
47
+ notes: string | null;
48
+ assignedCommercialId: string | null;
49
+ assignedCommercial: { id: string; name: string; email: string } | null;
50
+ assignedTeleproId: string | null;
51
+ assignedTelepro: { id: string; name: string; email: string } | null;
52
+ createdById: string | null;
53
+ createdBy: { id: string; name: string; email: string } | null;
54
+ createdAt: string;
55
+ updatedAt: string;
56
+ contacts: CompanyContact[];
57
+ _count: { contacts: number };
58
+ }
59
+
60
+ interface CompanyActivityItem {
61
+ id: string;
62
+ type: string;
63
+ title: string | null;
64
+ content: string;
65
+ metadata: unknown;
66
+ userId: string | null;
67
+ user: { id: string; name: string } | null;
68
+ createdAt: string;
69
+ }
70
+
71
+ interface Status {
72
+ id: string;
73
+ name: string;
74
+ color: string;
75
+ }
76
+
77
+ interface UserOption {
78
+ id: string;
79
+ name: string;
80
+ email: string;
81
+ role?: string;
82
+ }
83
+
84
+ type TabId = 'contacts' | 'activities';
85
+
86
+ export default function CompanyDetailPage() {
87
+ const params = useParams();
88
+ const router = useRouter();
89
+ const { hasPermission, isAdmin } = useUserRole();
90
+ const { confirm, ConfirmDialog } = useConfirm();
91
+ const toast = useAppToast();
92
+ const id = params.id as string;
93
+
94
+ const [company, setCompany] = useState<CompanyDetail | null>(null);
95
+ const [loading, setLoading] = useState(true);
96
+ const [error, setError] = useState('');
97
+ const [activeTab, setActiveTab] = useState<TabId>('contacts');
98
+ const [activities, setActivities] = useState<CompanyActivityItem[]>([]);
99
+ const [companyFormData, setCompanyFormData] = useState({
100
+ name: '',
101
+ phone: '',
102
+ email: '',
103
+ address: '',
104
+ city: '',
105
+ postalCode: '',
106
+ website: '',
107
+ siret: '',
108
+ industry: '',
109
+ notes: '',
110
+ assignedCommercialId: '',
111
+ assignedTeleproId: '',
112
+ });
113
+ const [isSavingCompany, setIsSavingCompany] = useState(false);
114
+ const [lastSavedCompany, setLastSavedCompany] = useState<Date | null>(null);
115
+ const [showAddContactModal, setShowAddContactModal] = useState(false);
116
+ const [contactModalMode, setContactModalMode] = useState<'create' | 'search'>('create');
117
+ const [newContactForm, setNewContactForm] = useState({
118
+ civility: '',
119
+ firstName: '',
120
+ lastName: '',
121
+ jobTitle: '',
122
+ phone: '',
123
+ email: '',
124
+ address: '',
125
+ city: '',
126
+ postalCode: '',
127
+ origin: '',
128
+ statusId: '',
129
+ assignedCommercialId: '',
130
+ assignedTeleproId: '',
131
+ });
132
+ const [savingContact, setSavingContact] = useState(false);
133
+ const [contactFormError, setContactFormError] = useState('');
134
+ const [statuses, setStatuses] = useState<Status[]>([]);
135
+ const [users, setUsers] = useState<UserOption[]>([]);
136
+ const [existingContactSearch, setExistingContactSearch] = useState('');
137
+ const [existingContactResults, setExistingContactResults] = useState<
138
+ {
139
+ id: string;
140
+ firstName: string | null;
141
+ lastName: string | null;
142
+ phone: string;
143
+ email: string | null;
144
+ company: { id: string; name: string } | null;
145
+ }[]
146
+ >([]);
147
+ const [addExistingSelectedId, setAddExistingSelectedId] = useState<string | null>(null);
148
+ const [addExistingJobTitle, setAddExistingJobTitle] = useState('');
149
+ const [addExistingLoading, setAddExistingLoading] = useState(false);
150
+ const [addExistingSearching, setAddExistingSearching] = useState(false);
151
+ const [addExistingError, setAddExistingError] = useState('');
152
+
153
+ const originalFormDataRef = useRef<typeof companyFormData | null>(null);
154
+
155
+ const canEdit = hasPermission('companies.edit') || hasPermission('contacts.edit');
156
+ const canDelete = hasPermission('companies.delete') || hasPermission('contacts.delete');
157
+ const canCreate = hasPermission('companies.create') || hasPermission('contacts.create');
158
+ const canViewActivities = hasPermission('companies.view_activities');
159
+
160
+ const fetchCompany = useCallback(async () => {
161
+ try {
162
+ setLoading(true);
163
+ const res = await fetch(`/api/companies/${id}`);
164
+ if (!res.ok) throw new Error('Entreprise non trouvée');
165
+ const data = await res.json();
166
+ setCompany(data);
167
+ const initialForm = {
168
+ name: data.name ?? '',
169
+ phone: data.phone ?? '',
170
+ email: data.email ?? '',
171
+ address: data.address ?? '',
172
+ city: data.city ?? '',
173
+ postalCode: data.postalCode ?? '',
174
+ website: data.website ?? '',
175
+ siret: data.siret ?? '',
176
+ industry: data.industry ?? '',
177
+ notes: data.notes ?? '',
178
+ assignedCommercialId: data.assignedCommercialId ?? '',
179
+ assignedTeleproId: data.assignedTeleproId ?? '',
180
+ };
181
+ setCompanyFormData(initialForm);
182
+ originalFormDataRef.current = { ...initialForm };
183
+ } catch (err: unknown) {
184
+ setError(err instanceof Error ? err.message : 'Erreur');
185
+ } finally {
186
+ setLoading(false);
187
+ }
188
+ }, [id]);
189
+
190
+ const fetchActivities = useCallback(async () => {
191
+ if (!canViewActivities) return;
192
+ try {
193
+ const res = await fetch(`/api/companies/${id}/activities`);
194
+ if (res.ok) {
195
+ const data = await res.json();
196
+ setActivities(data);
197
+ }
198
+ } catch {
199
+ // ignore
200
+ }
201
+ }, [id, canViewActivities]);
202
+
203
+ useEffect(() => {
204
+ fetchCompany();
205
+ }, [fetchCompany]);
206
+
207
+ useEffect(() => {
208
+ if (company) fetchActivities();
209
+ }, [company, fetchActivities]);
210
+
211
+ useEffect(() => {
212
+ if (!canViewActivities && activeTab === 'activities') setActiveTab('contacts');
213
+ }, [canViewActivities, activeTab]);
214
+
215
+ useEffect(() => {
216
+ if (!error) return;
217
+ toast.error(error);
218
+ setError('');
219
+ }, [error, toast]);
220
+
221
+ useEffect(() => {
222
+ if (!contactFormError) return;
223
+ toast.error(contactFormError);
224
+ setContactFormError('');
225
+ }, [contactFormError, toast]);
226
+
227
+ useEffect(() => {
228
+ if (!addExistingError) return;
229
+ toast.error(addExistingError);
230
+ setAddExistingError('');
231
+ }, [addExistingError, toast]);
232
+
233
+ // Entreprise non trouvée → redirection
234
+ useEffect(() => {
235
+ if (loading) return;
236
+ if (error && !company) {
237
+ router.replace('/contacts?entity=companies');
238
+ }
239
+ }, [loading, error, company, router]);
240
+
241
+ useEffect(() => {
242
+ if (company && canEdit) {
243
+ void (async () => {
244
+ const response = await fetch('/api/users/list');
245
+ if (!response.ok) return;
246
+ const data = await response.json();
247
+ setUsers(data || []);
248
+ })();
249
+ }
250
+ }, [company, canEdit]);
251
+
252
+ const handleSaveCompany = useCallback(
253
+ async (silent = false) => {
254
+ if (!company || !canEdit) return;
255
+ setIsSavingCompany(true);
256
+ if (!silent) setError('');
257
+ try {
258
+ const payload = {
259
+ name: companyFormData.name || undefined,
260
+ phone: companyFormData.phone || null,
261
+ email: companyFormData.email || null,
262
+ address: companyFormData.address || null,
263
+ city: companyFormData.city || null,
264
+ postalCode: companyFormData.postalCode || null,
265
+ website: companyFormData.website || null,
266
+ siret: companyFormData.siret || null,
267
+ industry: companyFormData.industry || null,
268
+ notes: companyFormData.notes || null,
269
+ assignedCommercialId: companyFormData.assignedCommercialId || null,
270
+ assignedTeleproId: companyFormData.assignedTeleproId || null,
271
+ };
272
+ const res = await fetch(`/api/companies/${id}`, {
273
+ method: 'PUT',
274
+ headers: { 'Content-Type': 'application/json' },
275
+ body: JSON.stringify(payload),
276
+ });
277
+ if (!res.ok) throw new Error("Erreur lors de l'enregistrement");
278
+ const updated = await res.json();
279
+ setCompany(updated);
280
+ setLastSavedCompany(new Date());
281
+ originalFormDataRef.current = { ...companyFormData };
282
+ await fetchActivities();
283
+ } catch (err: unknown) {
284
+ if (!silent) setError(err instanceof Error ? err.message : 'Erreur');
285
+ } finally {
286
+ setIsSavingCompany(false);
287
+ }
288
+ },
289
+ [company, canEdit, id, companyFormData, fetchActivities],
290
+ );
291
+
292
+ const hasFormDataChanged = useCallback(() => {
293
+ if (!originalFormDataRef.current) return false;
294
+ const o = originalFormDataRef.current;
295
+ return (
296
+ companyFormData.name !== o.name ||
297
+ companyFormData.phone !== o.phone ||
298
+ companyFormData.email !== o.email ||
299
+ companyFormData.address !== o.address ||
300
+ companyFormData.city !== o.city ||
301
+ companyFormData.postalCode !== o.postalCode ||
302
+ companyFormData.website !== o.website ||
303
+ companyFormData.siret !== o.siret ||
304
+ companyFormData.industry !== o.industry ||
305
+ companyFormData.notes !== o.notes ||
306
+ companyFormData.assignedCommercialId !== o.assignedCommercialId ||
307
+ companyFormData.assignedTeleproId !== o.assignedTeleproId
308
+ );
309
+ }, [companyFormData]);
310
+
311
+ const saveIfDirty = useCallback(async () => {
312
+ if (hasFormDataChanged()) await handleSaveCompany(true);
313
+ }, [hasFormDataChanged, handleSaveCompany]);
314
+
315
+ useEffect(() => {
316
+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
317
+ if (hasFormDataChanged()) e.preventDefault();
318
+ };
319
+ globalThis.addEventListener('beforeunload', onBeforeUnload);
320
+ return () => globalThis.removeEventListener('beforeunload', onBeforeUnload);
321
+ }, [hasFormDataChanged]);
322
+
323
+ useEffect(() => {
324
+ return () => {
325
+ const original = originalFormDataRef.current;
326
+ if (!original) return;
327
+ const changed =
328
+ companyFormData.name !== original.name ||
329
+ companyFormData.phone !== original.phone ||
330
+ companyFormData.email !== original.email ||
331
+ companyFormData.address !== original.address ||
332
+ companyFormData.city !== original.city ||
333
+ companyFormData.postalCode !== original.postalCode ||
334
+ companyFormData.website !== original.website ||
335
+ companyFormData.siret !== original.siret ||
336
+ companyFormData.industry !== original.industry ||
337
+ companyFormData.notes !== original.notes ||
338
+ companyFormData.assignedCommercialId !== original.assignedCommercialId ||
339
+ companyFormData.assignedTeleproId !== original.assignedTeleproId;
340
+ if (!changed) return;
341
+ fetch(`/api/companies/${id}`, {
342
+ method: 'PUT',
343
+ headers: { 'Content-Type': 'application/json' },
344
+ body: JSON.stringify({
345
+ name: companyFormData.name || undefined,
346
+ phone: companyFormData.phone || null,
347
+ email: companyFormData.email || null,
348
+ address: companyFormData.address || null,
349
+ city: companyFormData.city || null,
350
+ postalCode: companyFormData.postalCode || null,
351
+ website: companyFormData.website || null,
352
+ siret: companyFormData.siret || null,
353
+ industry: companyFormData.industry || null,
354
+ notes: companyFormData.notes || null,
355
+ assignedCommercialId: companyFormData.assignedCommercialId || null,
356
+ assignedTeleproId: companyFormData.assignedTeleproId || null,
357
+ }),
358
+ keepalive: true,
359
+ });
360
+ };
361
+ }, [id, companyFormData]);
362
+
363
+ useEffect(() => {
364
+ originalFormDataRef.current = null;
365
+ setLastSavedCompany(null);
366
+ }, [id]);
367
+
368
+ const openAddContactModal = async (mode: 'create' | 'search' = 'create') => {
369
+ await saveIfDirty();
370
+ setContactModalMode(mode);
371
+ if (mode === 'create') {
372
+ setNewContactForm({
373
+ civility: '',
374
+ firstName: '',
375
+ lastName: '',
376
+ jobTitle: '',
377
+ phone: '',
378
+ email: '',
379
+ address: company?.address ?? '',
380
+ city: company?.city ?? '',
381
+ postalCode: company?.postalCode ?? '',
382
+ origin: '',
383
+ statusId: '',
384
+ assignedCommercialId: '',
385
+ assignedTeleproId: '',
386
+ });
387
+ setContactFormError('');
388
+ void (async () => {
389
+ const [statusesResponse, usersResponse] = await Promise.all([
390
+ fetch('/api/statuses'),
391
+ fetch('/api/users/list'),
392
+ ]);
393
+ if (statusesResponse.ok) {
394
+ const statusesData = await statusesResponse.json();
395
+ setStatuses(statusesData || []);
396
+ }
397
+ if (usersResponse.ok) {
398
+ const usersData = await usersResponse.json();
399
+ setUsers(usersData || []);
400
+ }
401
+ })();
402
+ } else {
403
+ setExistingContactSearch('');
404
+ setExistingContactResults([]);
405
+ setAddExistingSelectedId(null);
406
+ setAddExistingJobTitle('');
407
+ setAddExistingError('');
408
+ }
409
+ setShowAddContactModal(true);
410
+ };
411
+
412
+ const searchExistingContacts = useCallback(async () => {
413
+ if (!existingContactSearch.trim()) {
414
+ setExistingContactResults([]);
415
+ return;
416
+ }
417
+ setAddExistingSearching(true);
418
+ setAddExistingError('');
419
+ try {
420
+ const res = await fetch(
421
+ `/api/contacts?search=${encodeURIComponent(existingContactSearch.trim())}&limit=30&page=1`,
422
+ );
423
+ if (!res.ok) throw new Error('Recherche impossible');
424
+ const data = await res.json();
425
+ const alreadyInCompany = new Set((company?.contacts ?? []).map((c) => c.id));
426
+ const filtered = (data.contacts ?? []).filter(
427
+ (c: { id: string }) => !alreadyInCompany.has(c.id),
428
+ );
429
+ setExistingContactResults(filtered);
430
+ } catch {
431
+ setAddExistingError('Erreur lors de la recherche');
432
+ setExistingContactResults([]);
433
+ } finally {
434
+ setAddExistingSearching(false);
435
+ }
436
+ }, [existingContactSearch, company?.contacts]);
437
+
438
+ // Recherche en direct (debounce) quand on tape dans l'onglet « Rechercher un contact existant »
439
+ const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
440
+ useEffect(() => {
441
+ if (!showAddContactModal || contactModalMode !== 'search') return;
442
+ if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
443
+ searchDebounceRef.current = setTimeout(() => {
444
+ searchDebounceRef.current = null;
445
+ searchExistingContacts();
446
+ }, 400);
447
+ return () => {
448
+ if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
449
+ };
450
+ }, [existingContactSearch, showAddContactModal, contactModalMode, searchExistingContacts]);
451
+
452
+ const handleAddExistingContact = async () => {
453
+ if (!addExistingSelectedId || !company) return;
454
+ setAddExistingLoading(true);
455
+ setAddExistingError('');
456
+ try {
457
+ const res = await fetch(`/api/contacts/${addExistingSelectedId}`, {
458
+ method: 'PUT',
459
+ headers: { 'Content-Type': 'application/json' },
460
+ body: JSON.stringify({
461
+ companyId: id,
462
+ jobTitle: addExistingJobTitle.trim() || null,
463
+ }),
464
+ });
465
+ if (!res.ok) {
466
+ const err = await res.json().catch(() => ({}));
467
+ throw new Error(err.error || "Impossible d'ajouter le contact");
468
+ }
469
+ const updated = await res.json();
470
+ const contactName =
471
+ [updated.firstName, updated.lastName].filter(Boolean).join(' ') || 'Contact';
472
+ await fetch(`/api/companies/${id}/activities`, {
473
+ method: 'POST',
474
+ headers: { 'Content-Type': 'application/json' },
475
+ body: JSON.stringify({
476
+ type: 'CONTACT_ADDED',
477
+ title: 'Contact associé',
478
+ content: `${contactName} a été associé à l'entreprise.`,
479
+ metadata: { contactId: updated.id },
480
+ }),
481
+ });
482
+ await fetchCompany();
483
+ await fetchActivities();
484
+ setShowAddContactModal(false);
485
+ } catch (err: unknown) {
486
+ setAddExistingError(err instanceof Error ? err.message : 'Erreur');
487
+ } finally {
488
+ setAddExistingLoading(false);
489
+ }
490
+ };
491
+
492
+ const handleCreateContact = async (e: React.FormEvent) => {
493
+ e.preventDefault();
494
+ setContactFormError('');
495
+ await saveIfDirty();
496
+ if (!newContactForm.phone?.trim()) {
497
+ setContactFormError('Le téléphone est obligatoire');
498
+ return;
499
+ }
500
+ setSavingContact(true);
501
+ try {
502
+ const res = await fetch('/api/contacts', {
503
+ method: 'POST',
504
+ headers: { 'Content-Type': 'application/json' },
505
+ body: JSON.stringify({
506
+ ...newContactForm,
507
+ companyId: id,
508
+ civility: newContactForm.civility || null,
509
+ jobTitle: newContactForm.jobTitle || null,
510
+ assignedCommercialId: newContactForm.assignedCommercialId || null,
511
+ assignedTeleproId: newContactForm.assignedTeleproId || null,
512
+ statusId: newContactForm.statusId || null,
513
+ }),
514
+ });
515
+ const data = await res.json();
516
+ if (!res.ok) throw new Error(data.error || 'Erreur');
517
+ const contactName =
518
+ [newContactForm.firstName, newContactForm.lastName].filter(Boolean).join(' ') ||
519
+ 'Nouveau contact';
520
+ await fetch(`/api/companies/${id}/activities`, {
521
+ method: 'POST',
522
+ headers: { 'Content-Type': 'application/json' },
523
+ body: JSON.stringify({
524
+ type: 'CONTACT_ADDED',
525
+ title: 'Contact ajouté',
526
+ content: `${contactName} a été ajouté à l'entreprise.`,
527
+ metadata: { contactId: data.id },
528
+ }),
529
+ });
530
+ await fetchCompany();
531
+ await fetchActivities();
532
+ setShowAddContactModal(false);
533
+ } catch (err: unknown) {
534
+ setContactFormError(err instanceof Error ? err.message : 'Erreur');
535
+ } finally {
536
+ setSavingContact(false);
537
+ }
538
+ };
539
+
540
+ const handleDelete = async () => {
541
+ const ok = await confirm({
542
+ title: "Supprimer l'entreprise",
543
+ description: `Êtes-vous sûr de vouloir supprimer "${company?.name}" ? Les contacts liés ne seront pas supprimés, mais le lien sera retiré.`,
544
+ confirmText: 'Supprimer',
545
+ variant: 'destructive',
546
+ });
547
+ if (!ok) return;
548
+
549
+ try {
550
+ const res = await fetch(`/api/companies/${id}`, { method: 'DELETE' });
551
+ if (!res.ok) throw new Error('Erreur lors de la suppression');
552
+ router.push('/contacts?entity=companies');
553
+ } catch (err: unknown) {
554
+ setError(err instanceof Error ? err.message : 'Erreur');
555
+ }
556
+ };
557
+
558
+ if (loading && !company) {
559
+ return (
560
+ <div className="flex h-full items-center justify-center">
561
+ <div className="h-12 w-12 animate-spin rounded-full border-4 border-blue-200 border-t-blue-600" />
562
+ </div>
563
+ );
564
+ }
565
+
566
+ if (error && !company) return null;
567
+
568
+ if (!company) return null;
569
+
570
+ return (
571
+ <ProtectedPage
572
+ requiredPermission={[
573
+ 'contacts.view_all',
574
+ 'contacts.view_own',
575
+ 'companies.view_all',
576
+ 'companies.view_own',
577
+ ]}
578
+ >
579
+ <div className="flex h-full flex-col">
580
+ <ConfirmDialog />
581
+
582
+ {/* Header (même style que page contact) */}
583
+ <div className="border-b border-gray-200 bg-white px-4 py-3 sm:px-6 sm:py-3.5 lg:px-8">
584
+ <div className="flex items-center justify-between gap-3">
585
+ <div className="flex items-center gap-2 sm:gap-4">
586
+ <Link
587
+ href="/contacts?entity=companies"
588
+ className="flex cursor-pointer items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
589
+ >
590
+ <ArrowLeft />
591
+ </Link>
592
+ <h1 className="text-base font-semibold text-gray-900 sm:text-lg">Entreprises</h1>
593
+ </div>
594
+ <div className="flex items-center gap-1 sm:gap-2">
595
+ <button
596
+ onClick={() => saveIfDirty().then(() => fetchCompany())}
597
+ className="cursor-pointer rounded-lg p-2 text-gray-600 transition-colors hover:bg-gray-100"
598
+ title="Actualiser"
599
+ >
600
+ <RefreshCw className="h-5 w-5" />
601
+ </button>
602
+ {canDelete && (
603
+ <button
604
+ onClick={handleDelete}
605
+ className="cursor-pointer rounded-lg p-2 text-red-600 transition-colors hover:bg-red-50"
606
+ title="Supprimer l'entreprise"
607
+ >
608
+ <Trash2 className="h-5 w-5" />
609
+ </button>
610
+ )}
611
+ </div>
612
+ </div>
613
+ </div>
614
+
615
+ {/* Section Profil (avatar + nom + boutons d'action, comme contact) */}
616
+ <div className="border-b border-gray-200 bg-white px-4 py-3 sm:px-6 sm:py-4 lg:px-8">
617
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
618
+ <div className="flex items-center gap-3 sm:gap-4">
619
+ <div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-blue-100 text-lg font-semibold text-blue-800 sm:h-16 sm:w-16 sm:text-xl">
620
+ <Building2 className="h-6 w-6 sm:h-8 sm:w-8" />
621
+ </div>
622
+ <div className="min-w-0">
623
+ <h2 className="truncate text-base font-bold text-gray-900 sm:text-lg">
624
+ {company.name}
625
+ </h2>
626
+ <p className="mt-1 truncate text-xs text-gray-500 sm:text-sm">
627
+ {company._count.contacts} contact{company._count.contacts !== 1 ? 's' : ''}{' '}
628
+ associé
629
+ {company._count.contacts !== 1 ? 's' : ''}
630
+ </p>
631
+ </div>
632
+ </div>
633
+ <div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
634
+ {canCreate && (
635
+ <div className="flex flex-wrap items-center gap-1.5">
636
+ {(canCreate || canEdit) && (
637
+ <button
638
+ type="button"
639
+ onClick={() => openAddContactModal('create')}
640
+ className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-blue-600 px-2 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-700 sm:px-4 sm:py-2 sm:text-sm"
641
+ >
642
+ <Plus className="h-3 w-3 sm:h-4 sm:w-4" />
643
+ <span className="hidden sm:inline">Ajouter un contact</span>
644
+ <span className="sm:hidden">Ajouter</span>
645
+ </button>
646
+ )}
647
+ </div>
648
+ )}
649
+ </div>
650
+ </div>
651
+ </div>
652
+
653
+ {/* Contenu principal - Deux colonnes (comme page contact) */}
654
+ <div className="flex-1 overflow-y-auto">
655
+ <div className="flex flex-col gap-6 p-4 lg:flex-row lg:p-6">
656
+ {/* Colonne gauche - Formulaire entreprise éditable */}
657
+ <div className="w-full space-y-4 lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] lg:w-1/3 lg:self-start lg:overflow-y-auto lg:pb-4">
658
+ <div className="rounded-lg bg-white p-3 shadow sm:p-4">
659
+ {canEdit ? (
660
+ <form
661
+ className="space-y-3"
662
+ onSubmit={(e) => {
663
+ e.preventDefault();
664
+ handleSaveCompany();
665
+ }}
666
+ >
667
+ <div>
668
+ <label
669
+ htmlFor="company-name"
670
+ className="mb-1 block text-sm font-medium text-gray-700"
671
+ >
672
+ Nom *
673
+ </label>
674
+ <input
675
+ id="company-name"
676
+ type="text"
677
+ value={companyFormData.name}
678
+ onChange={(e) =>
679
+ setCompanyFormData({ ...companyFormData, name: e.target.value })
680
+ }
681
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
682
+ required
683
+ />
684
+ </div>
685
+ <div className="grid grid-cols-2 gap-2">
686
+ <div>
687
+ <label
688
+ htmlFor="company-phone"
689
+ className="mb-1 block text-sm font-medium text-gray-700"
690
+ >
691
+ Téléphone
692
+ </label>
693
+ <input
694
+ id="company-phone"
695
+ type="tel"
696
+ value={companyFormData.phone}
697
+ onChange={(e) =>
698
+ setCompanyFormData({ ...companyFormData, phone: e.target.value })
699
+ }
700
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
701
+ />
702
+ </div>
703
+ <div>
704
+ <label
705
+ htmlFor="company-email"
706
+ className="mb-1 block text-sm font-medium text-gray-700"
707
+ >
708
+ Email
709
+ </label>
710
+ <input
711
+ id="company-email"
712
+ type="email"
713
+ value={companyFormData.email}
714
+ onChange={(e) =>
715
+ setCompanyFormData({ ...companyFormData, email: e.target.value })
716
+ }
717
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
718
+ />
719
+ </div>
720
+ </div>
721
+ <div>
722
+ <label
723
+ htmlFor="company-address"
724
+ className="mb-1 block text-sm font-medium text-gray-700"
725
+ >
726
+ Adresse
727
+ </label>
728
+ <AddressAutocomplete
729
+ value={companyFormData.address}
730
+ onChange={(address, components) => {
731
+ if (components) {
732
+ setCompanyFormData({
733
+ ...companyFormData,
734
+ address: components.street,
735
+ city: components.city,
736
+ postalCode: components.postalCode,
737
+ });
738
+ } else {
739
+ setCompanyFormData({ ...companyFormData, address });
740
+ }
741
+ }}
742
+ placeholder="Rechercher une adresse..."
743
+ />
744
+ </div>
745
+ <div className="grid grid-cols-2 gap-2">
746
+ <div>
747
+ <label
748
+ htmlFor="company-city"
749
+ className="mb-1 block text-sm font-medium text-gray-700"
750
+ >
751
+ Ville
752
+ </label>
753
+ <input
754
+ id="company-city"
755
+ type="text"
756
+ value={companyFormData.city}
757
+ onChange={(e) =>
758
+ setCompanyFormData({ ...companyFormData, city: e.target.value })
759
+ }
760
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
761
+ />
762
+ </div>
763
+ <div>
764
+ <label
765
+ htmlFor="company-postalCode"
766
+ className="mb-1 block text-sm font-medium text-gray-700"
767
+ >
768
+ Code postal
769
+ </label>
770
+ <input
771
+ id="company-postalCode"
772
+ type="text"
773
+ value={companyFormData.postalCode}
774
+ onChange={(e) =>
775
+ setCompanyFormData({ ...companyFormData, postalCode: e.target.value })
776
+ }
777
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
778
+ />
779
+ </div>
780
+ </div>
781
+ <div>
782
+ <label
783
+ htmlFor="company-website"
784
+ className="mb-1 block text-sm font-medium text-gray-700"
785
+ >
786
+ Site web
787
+ </label>
788
+ <input
789
+ id="company-website"
790
+ type="url"
791
+ value={companyFormData.website}
792
+ onChange={(e) =>
793
+ setCompanyFormData({ ...companyFormData, website: e.target.value })
794
+ }
795
+ placeholder="https://"
796
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
797
+ />
798
+ </div>
799
+ <div className="grid grid-cols-2 gap-2">
800
+ <div>
801
+ <label
802
+ htmlFor="company-siret"
803
+ className="mb-1 block text-sm font-medium text-gray-700"
804
+ >
805
+ SIRET
806
+ </label>
807
+ <input
808
+ id="company-siret"
809
+ type="text"
810
+ value={companyFormData.siret}
811
+ onChange={(e) =>
812
+ setCompanyFormData({ ...companyFormData, siret: e.target.value })
813
+ }
814
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
815
+ />
816
+ </div>
817
+ <div>
818
+ <label
819
+ htmlFor="company-industry"
820
+ className="mb-1 block text-sm font-medium text-gray-700"
821
+ >
822
+ Secteur
823
+ </label>
824
+ <input
825
+ id="company-industry"
826
+ type="text"
827
+ value={companyFormData.industry}
828
+ onChange={(e) =>
829
+ setCompanyFormData({ ...companyFormData, industry: e.target.value })
830
+ }
831
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
832
+ />
833
+ </div>
834
+ </div>
835
+ <div>
836
+ <label
837
+ htmlFor="company-notes"
838
+ className="mb-1 block text-sm font-medium text-gray-700"
839
+ >
840
+ Notes
841
+ </label>
842
+ <textarea
843
+ id="company-notes"
844
+ value={companyFormData.notes}
845
+ onChange={(e) =>
846
+ setCompanyFormData({ ...companyFormData, notes: e.target.value })
847
+ }
848
+ rows={3}
849
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
850
+ />
851
+ </div>
852
+ <div className="grid grid-cols-2 gap-2 border-t border-gray-100 pt-3">
853
+ <div>
854
+ <label
855
+ htmlFor="company-commercial"
856
+ className="mb-1 block text-xs font-medium text-gray-500"
857
+ >
858
+ Commercial
859
+ </label>
860
+ <select
861
+ id="company-commercial"
862
+ value={companyFormData.assignedCommercialId}
863
+ onChange={(e) =>
864
+ setCompanyFormData({
865
+ ...companyFormData,
866
+ assignedCommercialId: e.target.value,
867
+ })
868
+ }
869
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
870
+ >
871
+ <option value="">Non attribué</option>
872
+ {users.map((u) => (
873
+ <option key={u.id} value={u.id}>
874
+ {u.name}
875
+ </option>
876
+ ))}
877
+ </select>
878
+ </div>
879
+ <div>
880
+ <label
881
+ htmlFor="company-telepro"
882
+ className="mb-1 block text-xs font-medium text-gray-500"
883
+ >
884
+ Télépro
885
+ </label>
886
+ <select
887
+ id="company-telepro"
888
+ value={companyFormData.assignedTeleproId}
889
+ onChange={(e) =>
890
+ setCompanyFormData({
891
+ ...companyFormData,
892
+ assignedTeleproId: e.target.value,
893
+ })
894
+ }
895
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
896
+ >
897
+ <option value="">Non attribué</option>
898
+ {users.map((u) => (
899
+ <option key={u.id} value={u.id}>
900
+ {u.name}
901
+ </option>
902
+ ))}
903
+ </select>
904
+ </div>
905
+ </div>
906
+ <div className="flex items-center justify-between border-t border-gray-100 pt-3">
907
+ <div className="text-xs text-gray-500">
908
+ {company.createdBy?.name && `Créé par ${company.createdBy.name}`}
909
+ </div>
910
+ <div className="flex items-center gap-2">
911
+ {lastSavedCompany && (
912
+ <span className="text-xs text-green-600">
913
+ Enregistré à{' '}
914
+ {lastSavedCompany.toLocaleTimeString('fr-FR', {
915
+ hour: '2-digit',
916
+ minute: '2-digit',
917
+ })}
918
+ </span>
919
+ )}
920
+ <button
921
+ type="submit"
922
+ disabled={isSavingCompany}
923
+ className="cursor-pointer rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
924
+ >
925
+ {isSavingCompany ? <Spinner size="sm" /> : 'Enregistrer'}
926
+ </button>
927
+ </div>
928
+ </div>
929
+ </form>
930
+ ) : (
931
+ <div className="space-y-3">
932
+ <div>
933
+ <span className="mb-1 block text-sm font-medium text-gray-700">Nom</span>
934
+ <p className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-1.5 text-sm text-gray-900">
935
+ {company.name}
936
+ </p>
937
+ </div>
938
+ <div className="grid grid-cols-2 gap-2">
939
+ <div>
940
+ <span className="mb-1 block text-sm font-medium text-gray-700">
941
+ Téléphone
942
+ </span>
943
+ <p className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-1.5 text-sm text-gray-900">
944
+ {company.phone || '-'}
945
+ </p>
946
+ </div>
947
+ <div>
948
+ <span className="mb-1 block text-sm font-medium text-gray-700">Email</span>
949
+ <p className="truncate rounded-lg border border-gray-200 bg-gray-50 px-2 py-1.5 text-sm text-gray-900">
950
+ {company.email || '-'}
951
+ </p>
952
+ </div>
953
+ </div>
954
+ {(company.address || company.city) && (
955
+ <div>
956
+ <span className="mb-1 block text-sm font-medium text-gray-700">
957
+ Adresse
958
+ </span>
959
+ <p className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-1.5 text-sm text-gray-900">
960
+ {[company.address, company.city, company.postalCode]
961
+ .filter(Boolean)
962
+ .join(', ')}
963
+ </p>
964
+ </div>
965
+ )}
966
+ {company.website && (
967
+ <div>
968
+ <span className="mb-1 block text-sm font-medium text-gray-700">
969
+ Site web
970
+ </span>
971
+ <a
972
+ href={company.website}
973
+ target="_blank"
974
+ rel="noopener noreferrer"
975
+ className="block truncate text-sm text-blue-600 hover:underline"
976
+ >
977
+ {company.website}
978
+ </a>
979
+ </div>
980
+ )}
981
+ {(company.siret || company.industry) && (
982
+ <div className="grid grid-cols-2 gap-2">
983
+ {company.siret && (
984
+ <div>
985
+ <span className="mb-1 block text-xs font-medium text-gray-500">
986
+ SIRET
987
+ </span>
988
+ <p className="text-sm text-gray-900">{company.siret}</p>
989
+ </div>
990
+ )}
991
+ {company.industry && (
992
+ <div>
993
+ <span className="mb-1 block text-xs font-medium text-gray-500">
994
+ Secteur
995
+ </span>
996
+ <p className="text-sm text-gray-900">{company.industry}</p>
997
+ </div>
998
+ )}
999
+ </div>
1000
+ )}
1001
+ {company.notes && (
1002
+ <div>
1003
+ <span className="mb-1 block text-sm font-medium text-gray-700">Notes</span>
1004
+ <p className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-1.5 text-sm whitespace-pre-wrap text-gray-900">
1005
+ {company.notes}
1006
+ </p>
1007
+ </div>
1008
+ )}
1009
+ <div className="grid grid-cols-2 gap-2 border-t border-gray-100 pt-3">
1010
+ <div>
1011
+ <span className="mb-1 block text-xs font-medium text-gray-500">
1012
+ Commercial
1013
+ </span>
1014
+ <p className="text-sm text-gray-900">
1015
+ {company.assignedCommercial?.name || 'Non attribué'}
1016
+ </p>
1017
+ </div>
1018
+ <div>
1019
+ <span className="mb-1 block text-xs font-medium text-gray-500">
1020
+ Télépro
1021
+ </span>
1022
+ <p className="text-sm text-gray-900">
1023
+ {company.assignedTelepro?.name || 'Non attribué'}
1024
+ </p>
1025
+ </div>
1026
+ </div>
1027
+ <div className="border-t border-gray-100 pt-3">
1028
+ <span className="mb-1 block text-xs font-medium text-gray-500">Créé par</span>
1029
+ <p className="text-sm text-gray-900">{company.createdBy?.name || '-'}</p>
1030
+ </div>
1031
+ </div>
1032
+ )}
1033
+ </div>
1034
+ </div>
1035
+
1036
+ {/* Colonne droite - Onglets (Contacts associés / Activités) */}
1037
+ <div className="flex-1">
1038
+ <div className="rounded-lg bg-white shadow">
1039
+ <div className="border-b border-gray-200">
1040
+ <nav className="flex flex-wrap" aria-label="Tabs">
1041
+ {[
1042
+ { id: 'contacts' as const, label: 'Contacts associés', icon: Users },
1043
+ ...(canViewActivities
1044
+ ? [{ id: 'activities' as const, label: 'Activités', icon: Activity }]
1045
+ : []),
1046
+ ].map((tab) => {
1047
+ const TabIcon = tab.icon;
1048
+ const contactsList = company?.contacts ?? [];
1049
+ return (
1050
+ <button
1051
+ key={tab.id}
1052
+ onClick={() => setActiveTab(tab.id)}
1053
+ className={cn(
1054
+ 'flex cursor-pointer items-center gap-1.5 border-b-2 px-4 py-4 text-sm font-medium transition-colors sm:gap-2 sm:px-6',
1055
+ activeTab === tab.id
1056
+ ? 'border-blue-600 text-blue-600'
1057
+ : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
1058
+ )}
1059
+ >
1060
+ <TabIcon className="h-4 w-4 shrink-0" />
1061
+ <span className="whitespace-nowrap">{tab.label}</span>
1062
+ {tab.id === 'contacts' && contactsList.length > 0 && (
1063
+ <span className="rounded-full bg-gray-200 px-2 py-0.5 text-xs">
1064
+ {contactsList.length}
1065
+ </span>
1066
+ )}
1067
+ </button>
1068
+ );
1069
+ })}
1070
+ </nav>
1071
+ </div>
1072
+
1073
+ <div className="p-4 sm:p-6">
1074
+ {activeTab === 'contacts' && (
1075
+ <div>
1076
+ <div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
1077
+ <h2 className="text-lg font-semibold text-gray-900">Contacts associés</h2>
1078
+ </div>
1079
+
1080
+ {(company.contacts ?? []).length === 0 ? (
1081
+ <div className="rounded-lg border border-gray-200 bg-gray-50 py-12 text-center">
1082
+ <Users className="mx-auto h-12 w-12 text-gray-400" />
1083
+ <p className="mt-2 text-sm text-gray-600">
1084
+ Aucun contact associé à cette entreprise
1085
+ </p>
1086
+ {(canCreate || canEdit) && (
1087
+ <div className="mt-4 flex flex-wrap justify-center gap-2">
1088
+ {canCreate && (
1089
+ <button
1090
+ type="button"
1091
+ onClick={() => openAddContactModal('create')}
1092
+ className="inline-flex cursor-pointer items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
1093
+ >
1094
+ <Plus className="h-4 w-4" />
1095
+ Ajouter un contact
1096
+ </button>
1097
+ )}
1098
+ {canEdit && (
1099
+ <button
1100
+ type="button"
1101
+ onClick={() => openAddContactModal('search')}
1102
+ className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
1103
+ >
1104
+ <Plus className="h-4 w-4" />
1105
+ Rechercher un contact existant
1106
+ </button>
1107
+ )}
1108
+ </div>
1109
+ )}
1110
+ </div>
1111
+ ) : (
1112
+ <div className="overflow-hidden rounded-lg border border-gray-200">
1113
+ <table className="min-w-full divide-y divide-gray-200 text-sm">
1114
+ <thead className="bg-gray-50">
1115
+ <tr>
1116
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase sm:px-6">
1117
+ Contact
1118
+ </th>
1119
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase sm:px-6">
1120
+ Poste
1121
+ </th>
1122
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase sm:px-6">
1123
+ Téléphone
1124
+ </th>
1125
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase sm:px-6">
1126
+ Email
1127
+ </th>
1128
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase sm:px-6">
1129
+ Statut
1130
+ </th>
1131
+ </tr>
1132
+ </thead>
1133
+ <tbody className="divide-y divide-gray-200 bg-white">
1134
+ {(company.contacts ?? []).map((c) => (
1135
+ <tr
1136
+ key={c.id}
1137
+ onClick={() => router.push(`/contacts/${c.id}`)}
1138
+ className="cursor-pointer transition-colors hover:bg-blue-50/50"
1139
+ >
1140
+ <td className="px-4 py-3 font-medium text-gray-900 sm:px-6">
1141
+ {c.firstName} {c.lastName}
1142
+ </td>
1143
+ <td className="px-4 py-3 text-gray-600 sm:px-6">
1144
+ {c.jobTitle || '-'}
1145
+ </td>
1146
+ <td className="px-4 py-3 text-gray-600 sm:px-6">{c.phone}</td>
1147
+ <td className="px-4 py-3 text-gray-600 sm:px-6">
1148
+ {c.email || '-'}
1149
+ </td>
1150
+ <td className="px-4 py-3 sm:px-6">
1151
+ {c.status ? (
1152
+ <span
1153
+ className="inline-flex rounded-full px-2 py-0.5 text-xs font-medium"
1154
+ style={{
1155
+ backgroundColor: `${c.status.color}20`,
1156
+ color: c.status.color,
1157
+ }}
1158
+ >
1159
+ {c.status.name}
1160
+ </span>
1161
+ ) : (
1162
+ '-'
1163
+ )}
1164
+ </td>
1165
+ </tr>
1166
+ ))}
1167
+ </tbody>
1168
+ </table>
1169
+ </div>
1170
+ )}
1171
+ </div>
1172
+ )}
1173
+
1174
+ {activeTab === 'activities' && (
1175
+ <div>
1176
+ <h2 className="mb-4 text-lg font-semibold text-gray-900">Activités</h2>
1177
+ {activities.length === 0 ? (
1178
+ <div className="py-12 text-center">
1179
+ <Activity className="mx-auto h-12 w-12 text-gray-400" />
1180
+ <p className="mt-2 text-sm text-gray-600">
1181
+ Aucune activité pour le moment.
1182
+ </p>
1183
+ <p className="mt-1 text-xs text-gray-500">
1184
+ Cliquez sur un contact dans l’onglet « Contacts associés » pour voir ses
1185
+ activités.
1186
+ </p>
1187
+ </div>
1188
+ ) : (
1189
+ <div className="space-y-2">
1190
+ {activities.map((act) => (
1191
+ <div
1192
+ key={act.id}
1193
+ className="rounded-lg border border-gray-200 bg-gray-50/50 p-3"
1194
+ >
1195
+ <div className="flex items-center justify-between gap-2">
1196
+ <p className="text-sm font-medium text-gray-900">
1197
+ {act.title ||
1198
+ (act.type === 'COMPANY_UPDATE'
1199
+ ? 'Informations modifiées'
1200
+ : act.type === 'CONTACT_ADDED'
1201
+ ? 'Contact ajouté'
1202
+ : act.type)}
1203
+ </p>
1204
+ <span className="text-xs text-gray-500">
1205
+ {new Date(act.createdAt).toLocaleDateString('fr-FR', {
1206
+ day: 'numeric',
1207
+ month: 'short',
1208
+ year: 'numeric',
1209
+ hour: '2-digit',
1210
+ minute: '2-digit',
1211
+ })}
1212
+ </span>
1213
+ </div>
1214
+ {act.content && (
1215
+ <p className="mt-1 text-sm text-gray-700">{act.content}</p>
1216
+ )}
1217
+ {act.user && (
1218
+ <p className="mt-1 text-xs text-gray-500">Par {act.user.name}</p>
1219
+ )}
1220
+ </div>
1221
+ ))}
1222
+ </div>
1223
+ )}
1224
+ </div>
1225
+ )}
1226
+ </div>
1227
+ </div>
1228
+ </div>
1229
+ </div>
1230
+ </div>
1231
+
1232
+ {showAddContactModal && (
1233
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-500/20 p-4 backdrop-blur-sm">
1234
+ <div className="flex max-h-[90vh] w-full max-w-lg flex-col rounded-lg bg-white shadow-xl">
1235
+ <div className="shrink-0 border-b border-gray-200 px-6 py-4">
1236
+ <div className="flex items-center justify-between">
1237
+ <h2 className="text-lg font-semibold text-gray-900">Ajouter un contact</h2>
1238
+ <button
1239
+ type="button"
1240
+ onClick={() => setShowAddContactModal(false)}
1241
+ className="cursor-pointer rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
1242
+ >
1243
+ <X className="h-5 w-5" />
1244
+ </button>
1245
+ </div>
1246
+ <div className="mt-3 flex gap-1 border-b border-gray-200">
1247
+ <button
1248
+ type="button"
1249
+ onClick={() => setContactModalMode('create')}
1250
+ className={cn(
1251
+ 'cursor-pointer border-b-2 px-4 py-2 text-sm font-medium transition-colors',
1252
+ contactModalMode === 'create'
1253
+ ? 'border-blue-600 text-blue-600'
1254
+ : 'border-transparent text-gray-500 hover:text-gray-700',
1255
+ )}
1256
+ >
1257
+ Créer un nouveau contact
1258
+ </button>
1259
+ <button
1260
+ type="button"
1261
+ onClick={() => setContactModalMode('search')}
1262
+ className={cn(
1263
+ 'cursor-pointer border-b-2 px-4 py-2 text-sm font-medium transition-colors',
1264
+ contactModalMode === 'search'
1265
+ ? 'border-blue-600 text-blue-600'
1266
+ : 'border-transparent text-gray-500 hover:text-gray-700',
1267
+ )}
1268
+ >
1269
+ Rechercher un contact existant
1270
+ </button>
1271
+ </div>
1272
+ </div>
1273
+ <div className="flex-1 overflow-y-auto">
1274
+ {contactModalMode === 'create' ? (
1275
+ <form onSubmit={handleCreateContact} className="space-y-4 p-6">
1276
+ <p className="text-sm text-gray-600">
1277
+ Entreprise : <strong>{company.name}</strong>
1278
+ </p>
1279
+ <div className="grid grid-cols-2 gap-2">
1280
+ <div>
1281
+ <label
1282
+ htmlFor="new-civility"
1283
+ className="mb-1 block text-sm font-medium text-gray-700"
1284
+ >
1285
+ Civilité
1286
+ </label>
1287
+ <select
1288
+ id="new-civility"
1289
+ value={newContactForm.civility}
1290
+ onChange={(e) =>
1291
+ setNewContactForm({ ...newContactForm, civility: e.target.value })
1292
+ }
1293
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
1294
+ >
1295
+ <option value="">-</option>
1296
+ <option value="M">M.</option>
1297
+ <option value="MME">Mme</option>
1298
+ <option value="MLLE">Mlle</option>
1299
+ </select>
1300
+ </div>
1301
+ </div>
1302
+ <div className="grid grid-cols-2 gap-2">
1303
+ <div>
1304
+ <label
1305
+ htmlFor="new-firstName"
1306
+ className="mb-1 block text-sm font-medium text-gray-700"
1307
+ >
1308
+ Prénom
1309
+ </label>
1310
+ <input
1311
+ id="new-firstName"
1312
+ type="text"
1313
+ value={newContactForm.firstName}
1314
+ onChange={(e) =>
1315
+ setNewContactForm({ ...newContactForm, firstName: e.target.value })
1316
+ }
1317
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
1318
+ />
1319
+ </div>
1320
+ <div>
1321
+ <label
1322
+ htmlFor="new-lastName"
1323
+ className="mb-1 block text-sm font-medium text-gray-700"
1324
+ >
1325
+ Nom
1326
+ </label>
1327
+ <input
1328
+ id="new-lastName"
1329
+ type="text"
1330
+ value={newContactForm.lastName}
1331
+ onChange={(e) =>
1332
+ setNewContactForm({ ...newContactForm, lastName: e.target.value })
1333
+ }
1334
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
1335
+ />
1336
+ </div>
1337
+ </div>
1338
+ <div>
1339
+ <label
1340
+ htmlFor="new-jobTitle"
1341
+ className="mb-1 block text-sm font-medium text-gray-700"
1342
+ >
1343
+ Intitulé du poste
1344
+ </label>
1345
+ <input
1346
+ id="new-jobTitle"
1347
+ type="text"
1348
+ value={newContactForm.jobTitle}
1349
+ onChange={(e) =>
1350
+ setNewContactForm({ ...newContactForm, jobTitle: e.target.value })
1351
+ }
1352
+ placeholder="Ex. Directeur commercial, Assistant..."
1353
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
1354
+ />
1355
+ </div>
1356
+ <div className="grid grid-cols-2 gap-2">
1357
+ <div>
1358
+ <label
1359
+ htmlFor="new-phone"
1360
+ className="mb-1 block text-sm font-medium text-gray-700"
1361
+ >
1362
+ Téléphone *
1363
+ </label>
1364
+ <input
1365
+ id="new-phone"
1366
+ type="tel"
1367
+ required
1368
+ value={newContactForm.phone}
1369
+ onChange={(e) =>
1370
+ setNewContactForm({ ...newContactForm, phone: e.target.value })
1371
+ }
1372
+ onBlur={(e) => {
1373
+ const n = normalizePhoneNumber(e.target.value);
1374
+ if (n) setNewContactForm((prev) => ({ ...prev, phone: n }));
1375
+ }}
1376
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
1377
+ />
1378
+ </div>
1379
+ <div>
1380
+ <label
1381
+ htmlFor="new-email"
1382
+ className="mb-1 block text-sm font-medium text-gray-700"
1383
+ >
1384
+ Email
1385
+ </label>
1386
+ <input
1387
+ id="new-email"
1388
+ type="email"
1389
+ value={newContactForm.email}
1390
+ onChange={(e) =>
1391
+ setNewContactForm({ ...newContactForm, email: e.target.value })
1392
+ }
1393
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
1394
+ />
1395
+ </div>
1396
+ </div>
1397
+ <div>
1398
+ <label
1399
+ htmlFor="new-address"
1400
+ className="mb-1 block text-sm font-medium text-gray-700"
1401
+ >
1402
+ Adresse
1403
+ </label>
1404
+ <AddressAutocomplete
1405
+ value={newContactForm.address}
1406
+ onChange={(address, components) =>
1407
+ setNewContactForm({
1408
+ ...newContactForm,
1409
+ address,
1410
+ city: components?.city ?? newContactForm.city,
1411
+ postalCode: components?.postalCode ?? newContactForm.postalCode,
1412
+ })
1413
+ }
1414
+ placeholder="Rechercher une adresse..."
1415
+ />
1416
+ </div>
1417
+ <div className="grid grid-cols-2 gap-2">
1418
+ <div>
1419
+ <label
1420
+ htmlFor="new-city"
1421
+ className="mb-1 block text-sm font-medium text-gray-700"
1422
+ >
1423
+ Ville
1424
+ </label>
1425
+ <input
1426
+ id="new-city"
1427
+ type="text"
1428
+ value={newContactForm.city}
1429
+ onChange={(e) =>
1430
+ setNewContactForm({ ...newContactForm, city: e.target.value })
1431
+ }
1432
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
1433
+ />
1434
+ </div>
1435
+ <div>
1436
+ <label
1437
+ htmlFor="new-postalCode"
1438
+ className="mb-1 block text-sm font-medium text-gray-700"
1439
+ >
1440
+ Code postal
1441
+ </label>
1442
+ <input
1443
+ id="new-postalCode"
1444
+ type="text"
1445
+ value={newContactForm.postalCode}
1446
+ onChange={(e) =>
1447
+ setNewContactForm({ ...newContactForm, postalCode: e.target.value })
1448
+ }
1449
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
1450
+ />
1451
+ </div>
1452
+ </div>
1453
+ <div>
1454
+ <label
1455
+ htmlFor="new-status"
1456
+ className="mb-1 block text-sm font-medium text-gray-700"
1457
+ >
1458
+ Statut
1459
+ </label>
1460
+ <select
1461
+ id="new-status"
1462
+ value={newContactForm.statusId}
1463
+ onChange={(e) =>
1464
+ setNewContactForm({ ...newContactForm, statusId: e.target.value })
1465
+ }
1466
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
1467
+ >
1468
+ <option value="">Aucun statut</option>
1469
+ {statuses.map((s) => (
1470
+ <option key={s.id} value={s.id}>
1471
+ {s.name}
1472
+ </option>
1473
+ ))}
1474
+ </select>
1475
+ </div>
1476
+ <div className="grid grid-cols-2 gap-2">
1477
+ <div>
1478
+ <label
1479
+ htmlFor="new-commercial"
1480
+ className="mb-1 block text-sm font-medium text-gray-700"
1481
+ >
1482
+ Commercial
1483
+ </label>
1484
+ <select
1485
+ id="new-commercial"
1486
+ value={newContactForm.assignedCommercialId}
1487
+ onChange={(e) =>
1488
+ setNewContactForm({
1489
+ ...newContactForm,
1490
+ assignedCommercialId: e.target.value,
1491
+ })
1492
+ }
1493
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
1494
+ >
1495
+ <option value="">Non attribué</option>
1496
+ {(isAdmin
1497
+ ? users
1498
+ : users.filter(
1499
+ (u) =>
1500
+ u.role === 'COMMERCIAL' ||
1501
+ u.role === 'ADMIN' ||
1502
+ u.role === 'MANAGER',
1503
+ )
1504
+ ).map((u) => (
1505
+ <option key={u.id} value={u.id}>
1506
+ {u.name}
1507
+ </option>
1508
+ ))}
1509
+ </select>
1510
+ </div>
1511
+ <div>
1512
+ <label
1513
+ htmlFor="new-telepro"
1514
+ className="mb-1 block text-sm font-medium text-gray-700"
1515
+ >
1516
+ Télépro
1517
+ </label>
1518
+ <select
1519
+ id="new-telepro"
1520
+ value={newContactForm.assignedTeleproId}
1521
+ onChange={(e) =>
1522
+ setNewContactForm({
1523
+ ...newContactForm,
1524
+ assignedTeleproId: e.target.value,
1525
+ })
1526
+ }
1527
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
1528
+ >
1529
+ <option value="">Non attribué</option>
1530
+ {(isAdmin
1531
+ ? users
1532
+ : users.filter(
1533
+ (u) =>
1534
+ u.role === 'TELEPRO' ||
1535
+ u.role === 'ADMIN' ||
1536
+ u.role === 'MANAGER',
1537
+ )
1538
+ ).map((u) => (
1539
+ <option key={u.id} value={u.id}>
1540
+ {u.name}
1541
+ </option>
1542
+ ))}
1543
+ </select>
1544
+ </div>
1545
+ </div>
1546
+ <div className="flex justify-end gap-2 border-t border-gray-100 pt-4">
1547
+ <button
1548
+ type="button"
1549
+ onClick={() => setShowAddContactModal(false)}
1550
+ className="cursor-pointer rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
1551
+ >
1552
+ Annuler
1553
+ </button>
1554
+ <button
1555
+ type="submit"
1556
+ disabled={savingContact}
1557
+ className="cursor-pointer rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
1558
+ >
1559
+ {savingContact ? <Spinner size="sm" /> : 'Créer'}
1560
+ </button>
1561
+ </div>
1562
+ </form>
1563
+ ) : (
1564
+ <div className="space-y-4 p-6">
1565
+ <div>
1566
+ <label className="block text-sm font-medium text-gray-700">
1567
+ Rechercher un contact
1568
+ </label>
1569
+ <input
1570
+ type="text"
1571
+ value={existingContactSearch}
1572
+ onChange={(e) => setExistingContactSearch(e.target.value)}
1573
+ placeholder="Rechercher par nom, téléphone ou email..."
1574
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
1575
+ />
1576
+ <p className="mt-1 text-xs text-gray-500">
1577
+ Tapez pour rechercher parmi les contacts existants
1578
+ </p>
1579
+ </div>
1580
+ {existingContactSearch.trim() &&
1581
+ !addExistingSearching &&
1582
+ existingContactResults.length === 0 && (
1583
+ <p className="text-sm text-gray-500">
1584
+ Aucun contact trouvé ou tous sont déjà associés à cette entreprise.
1585
+ </p>
1586
+ )}
1587
+ {existingContactResults.length > 0 && (
1588
+ <div className="space-y-2">
1589
+ <p className="text-xs text-gray-500">
1590
+ Sélectionnez le contact à associer dans la liste, ou tapez ci-dessus pour
1591
+ filtrer.
1592
+ </p>
1593
+ <p className="text-sm font-medium text-gray-700">
1594
+ {existingContactResults.length} contact(s) trouvé(s)
1595
+ </p>
1596
+ <div className="max-h-72 overflow-y-auto rounded-lg border border-gray-200">
1597
+ {existingContactResults.map((c) => {
1598
+ const isSelected = addExistingSelectedId === c.id;
1599
+ return (
1600
+ <div
1601
+ key={c.id}
1602
+ className={cn(
1603
+ 'flex cursor-pointer items-center gap-3 border-b border-gray-100 p-4 transition-colors last:border-b-0',
1604
+ isSelected
1605
+ ? 'bg-blue-50 hover:bg-blue-100'
1606
+ : 'hover:bg-gray-50',
1607
+ )}
1608
+ onClick={() => setAddExistingSelectedId(isSelected ? null : c.id)}
1609
+ >
1610
+ <input
1611
+ type="checkbox"
1612
+ checked={isSelected}
1613
+ onChange={() =>
1614
+ setAddExistingSelectedId(isSelected ? null : c.id)
1615
+ }
1616
+ onClick={(e) => e.stopPropagation()}
1617
+ className="h-4 w-4 cursor-pointer rounded border-gray-300 text-blue-600 focus:ring-gray-400/30"
1618
+ />
1619
+ <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-blue-100 text-sm font-semibold text-blue-800">
1620
+ {(c.firstName?.[0] || c.lastName?.[0] || '?').toUpperCase()}
1621
+ </div>
1622
+ <div className="min-w-0 flex-1">
1623
+ <p className="truncate text-sm font-medium text-gray-900">
1624
+ {[c.firstName, c.lastName].filter(Boolean).join(' ') ||
1625
+ 'Sans nom'}
1626
+ </p>
1627
+ <div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-gray-500">
1628
+ {c.phone && (
1629
+ <span className="flex items-center gap-1">
1630
+ <Phone className="h-3 w-3" />
1631
+ {c.phone}
1632
+ </span>
1633
+ )}
1634
+ {c.email && (
1635
+ <span className="flex items-center gap-1">
1636
+ <Mail className="h-3 w-3" />
1637
+ <span className="max-w-[200px] truncate">{c.email}</span>
1638
+ </span>
1639
+ )}
1640
+ </div>
1641
+ </div>
1642
+ </div>
1643
+ );
1644
+ })}
1645
+ </div>
1646
+ {addExistingSelectedId && (
1647
+ <div className="border-t border-gray-100 pt-4">
1648
+ <label
1649
+ htmlFor="add-existing-jobTitle"
1650
+ className="mb-1 block text-sm font-medium text-gray-700"
1651
+ >
1652
+ Intitulé du poste (optionnel)
1653
+ </label>
1654
+ <input
1655
+ id="add-existing-jobTitle"
1656
+ type="text"
1657
+ value={addExistingJobTitle}
1658
+ onChange={(e) => setAddExistingJobTitle(e.target.value)}
1659
+ placeholder="Ex. Directeur commercial..."
1660
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
1661
+ />
1662
+ </div>
1663
+ )}
1664
+ </div>
1665
+ )}
1666
+ {!existingContactSearch.trim() && existingContactResults.length === 0 && (
1667
+ <div className="rounded-lg border border-gray-200 bg-gray-50 p-8 text-center">
1668
+ <p className="text-sm text-gray-500">
1669
+ Tapez ci-dessus pour rechercher parmi les contacts existants.
1670
+ </p>
1671
+ </div>
1672
+ )}
1673
+ </div>
1674
+ )}
1675
+ </div>
1676
+ {contactModalMode === 'search' && (
1677
+ <div className="shrink-0 border-t border-gray-100 px-6 pt-4 pb-6">
1678
+ <div className="flex justify-end gap-2">
1679
+ <button
1680
+ type="button"
1681
+ onClick={() => setShowAddContactModal(false)}
1682
+ className="cursor-pointer rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
1683
+ >
1684
+ Fermer
1685
+ </button>
1686
+ <button
1687
+ type="button"
1688
+ onClick={handleAddExistingContact}
1689
+ disabled={!addExistingSelectedId || addExistingLoading}
1690
+ className="cursor-pointer rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
1691
+ >
1692
+ {addExistingLoading ? <Spinner size="sm" /> : 'Associer'}
1693
+ </button>
1694
+ </div>
1695
+ </div>
1696
+ )}
1697
+ </div>
1698
+ </div>
1699
+ )}
1700
+ </div>
1701
+ </ProtectedPage>
1702
+ );
1703
+ }