create-crm-tmp 1.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 (187) hide show
  1. package/bin/create-crm-tmp.js +93 -0
  2. package/package.json +25 -0
  3. package/template/.prettierignore +33 -0
  4. package/template/.prettierrc.json +25 -0
  5. package/template/README.md +173 -0
  6. package/template/eslint.config.mjs +18 -0
  7. package/template/exemple-contacts.csv +11 -0
  8. package/template/next.config.ts +8 -0
  9. package/template/package.json +64 -0
  10. package/template/postcss.config.mjs +7 -0
  11. package/template/prisma/migrations/20251126144728_init/migration.sql +78 -0
  12. package/template/prisma/migrations/20251126155204_add_user_roles/migration.sql +5 -0
  13. package/template/prisma/migrations/20251128095126_add_company_info/migration.sql +19 -0
  14. package/template/prisma/migrations/20251128123321_add_smtp_config/migration.sql +22 -0
  15. package/template/prisma/migrations/20251128132303_add_status/migration.sql +23 -0
  16. package/template/prisma/migrations/20251201102207_add_user_active/migration.sql +75 -0
  17. package/template/prisma/migrations/20251201105507_add_email_signature/migration.sql +2 -0
  18. package/template/prisma/migrations/20251201151122_add_tasks/migration.sql +45 -0
  19. package/template/prisma/migrations/20251202111854_add_task_reminder/migration.sql +2 -0
  20. package/template/prisma/migrations/20251202135859_add_google_meet_integration/migration.sql +27 -0
  21. package/template/prisma/migrations/20251203103317_add_meta_lead_integration/migration.sql +20 -0
  22. package/template/prisma/migrations/20251203104002_add_google_ads_integration/migration.sql +18 -0
  23. package/template/prisma/migrations/20251203112122_add_google_sheet_integration/migration.sql +32 -0
  24. package/template/prisma/migrations/20251203153853_allow_multiple_integration_configs/migration.sql +20 -0
  25. package/template/prisma/migrations/20251205141705_update_user_roles/migration.sql +12 -0
  26. package/template/prisma/migrations/20251205150000_add_commercial_and_telepro_assignment/migration.sql +21 -0
  27. package/template/prisma/migrations/20251205160000_add_interaction_logging/migration.sql +11 -0
  28. package/template/prisma/migrations/20251208090314_add_automatic_interaction_types/migration.sql +12 -0
  29. package/template/prisma/migrations/20251208094843_mg/migration.sql +14 -0
  30. package/template/prisma/migrations/20251208100000_add_company_support/migration.sql +14 -0
  31. package/template/prisma/migrations/20251208110000_add_templates/migration.sql +26 -0
  32. package/template/prisma/migrations/20251208141304_add_video_conference_task_type/migration.sql +2 -0
  33. package/template/prisma/migrations/20251209104759_add_internal_note_to_task/migration.sql +2 -0
  34. package/template/prisma/migrations/20251209134803_add_company_field/migration.sql +2 -0
  35. package/template/prisma/migrations/20251209150000_rename_company_to_company_name/migration.sql +3 -0
  36. package/template/prisma/migrations/20251209150016_add_email_tracking/migration.sql +21 -0
  37. package/template/prisma/migrations/20251209155908_add_notify_contact_to_task/migration.sql +2 -0
  38. package/template/prisma/migrations/20251210110019_add_appointment_types/migration.sql +10 -0
  39. package/template/prisma/migrations/20251210113928_add_contact_files/migration.sql +26 -0
  40. package/template/prisma/migrations/20251212132339_add_custom_roles/migration.sql +24 -0
  41. package/template/prisma/migrations/20251215104448_add_file_interaction_types/migration.sql +11 -0
  42. package/template/prisma/migrations/20251215145616_add_closing_reasons/migration.sql +12 -0
  43. package/template/prisma/migrations/20251216140850_add_log_users/migration.sql +25 -0
  44. package/template/prisma/migrations/20251216151000_rename_perdu_to_ferme/migration.sql +8 -0
  45. package/template/prisma/migrations/20251216162318_add_column_mappings_to_google_sheet/migration.sql +2 -0
  46. package/template/prisma/migrations/20251216185127_add_workflows/migration.sql +80 -0
  47. package/template/prisma/migrations/20251216192237_add_scheduled_workflow_actions/migration.sql +32 -0
  48. package/template/prisma/migrations/migration_lock.toml +3 -0
  49. package/template/prisma/schema.prisma +582 -0
  50. package/template/prisma.config.ts +14 -0
  51. package/template/src/app/(auth)/invite/[token]/page.tsx +200 -0
  52. package/template/src/app/(auth)/layout.tsx +3 -0
  53. package/template/src/app/(auth)/reset-password/complete/page.tsx +213 -0
  54. package/template/src/app/(auth)/reset-password/page.tsx +146 -0
  55. package/template/src/app/(auth)/reset-password/verify/page.tsx +183 -0
  56. package/template/src/app/(auth)/signin/page.tsx +166 -0
  57. package/template/src/app/(dashboard)/agenda/page.tsx +3051 -0
  58. package/template/src/app/(dashboard)/automatisation/[id]/page.tsx +24 -0
  59. package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +905 -0
  60. package/template/src/app/(dashboard)/automatisation/new/page.tsx +20 -0
  61. package/template/src/app/(dashboard)/automatisation/page.tsx +337 -0
  62. package/template/src/app/(dashboard)/closing/page.tsx +1052 -0
  63. package/template/src/app/(dashboard)/contacts/[id]/page.tsx +6028 -0
  64. package/template/src/app/(dashboard)/contacts/page.tsx +3713 -0
  65. package/template/src/app/(dashboard)/dashboard/page.tsx +186 -0
  66. package/template/src/app/(dashboard)/layout.tsx +30 -0
  67. package/template/src/app/(dashboard)/settings/page.tsx +4070 -0
  68. package/template/src/app/(dashboard)/templates/page.tsx +567 -0
  69. package/template/src/app/(dashboard)/users/list/page.tsx +507 -0
  70. package/template/src/app/(dashboard)/users/page.tsx +457 -0
  71. package/template/src/app/(dashboard)/users/permissions/page.tsx +181 -0
  72. package/template/src/app/(dashboard)/users/roles/page.tsx +434 -0
  73. package/template/src/app/api/audit-logs/route.ts +57 -0
  74. package/template/src/app/api/auth/[...all]/route.ts +4 -0
  75. package/template/src/app/api/auth/check-active/route.ts +31 -0
  76. package/template/src/app/api/auth/google/callback/route.ts +94 -0
  77. package/template/src/app/api/auth/google/disconnect/route.ts +32 -0
  78. package/template/src/app/api/auth/google/route.ts +34 -0
  79. package/template/src/app/api/auth/google/status/route.ts +32 -0
  80. package/template/src/app/api/closing-reasons/route.ts +27 -0
  81. package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +94 -0
  82. package/template/src/app/api/contacts/[id]/files/route.ts +269 -0
  83. package/template/src/app/api/contacts/[id]/interactions/[interactionId]/route.ts +91 -0
  84. package/template/src/app/api/contacts/[id]/interactions/route.ts +103 -0
  85. package/template/src/app/api/contacts/[id]/meet/route.ts +296 -0
  86. package/template/src/app/api/contacts/[id]/route.ts +322 -0
  87. package/template/src/app/api/contacts/[id]/send-email/route.ts +254 -0
  88. package/template/src/app/api/contacts/export/route.ts +270 -0
  89. package/template/src/app/api/contacts/import/route.ts +381 -0
  90. package/template/src/app/api/contacts/route.ts +283 -0
  91. package/template/src/app/api/dashboard/stats/route.ts +299 -0
  92. package/template/src/app/api/email/track/[id]/route.ts +68 -0
  93. package/template/src/app/api/integrations/google-sheet/sync/route.ts +526 -0
  94. package/template/src/app/api/invite/complete/route.ts +88 -0
  95. package/template/src/app/api/invite/validate/route.ts +55 -0
  96. package/template/src/app/api/reminders/route.ts +95 -0
  97. package/template/src/app/api/reset-password/complete/route.ts +73 -0
  98. package/template/src/app/api/reset-password/request/route.ts +84 -0
  99. package/template/src/app/api/reset-password/validate/route.ts +49 -0
  100. package/template/src/app/api/reset-password/verify/route.ts +74 -0
  101. package/template/src/app/api/roles/[id]/route.ts +183 -0
  102. package/template/src/app/api/roles/route.ts +140 -0
  103. package/template/src/app/api/send/route.ts +282 -0
  104. package/template/src/app/api/settings/change-password/route.ts +95 -0
  105. package/template/src/app/api/settings/closing-reasons/[id]/route.ts +84 -0
  106. package/template/src/app/api/settings/closing-reasons/route.ts +74 -0
  107. package/template/src/app/api/settings/company/route.ts +121 -0
  108. package/template/src/app/api/settings/google-ads/[id]/route.ts +117 -0
  109. package/template/src/app/api/settings/google-ads/route.ts +122 -0
  110. package/template/src/app/api/settings/google-sheet/[id]/route.ts +230 -0
  111. package/template/src/app/api/settings/google-sheet/auto-map/route.ts +196 -0
  112. package/template/src/app/api/settings/google-sheet/route.ts +254 -0
  113. package/template/src/app/api/settings/meta-leads/[id]/route.ts +123 -0
  114. package/template/src/app/api/settings/meta-leads/route.ts +132 -0
  115. package/template/src/app/api/settings/profile/route.ts +42 -0
  116. package/template/src/app/api/settings/smtp/route.ts +130 -0
  117. package/template/src/app/api/settings/smtp/test/route.ts +121 -0
  118. package/template/src/app/api/settings/statuses/[id]/route.ts +101 -0
  119. package/template/src/app/api/settings/statuses/route.ts +83 -0
  120. package/template/src/app/api/statuses/route.ts +25 -0
  121. package/template/src/app/api/tasks/[id]/attendees/route.ts +76 -0
  122. package/template/src/app/api/tasks/[id]/route.ts +728 -0
  123. package/template/src/app/api/tasks/meet/route.ts +240 -0
  124. package/template/src/app/api/tasks/route.ts +417 -0
  125. package/template/src/app/api/templates/[id]/route.ts +140 -0
  126. package/template/src/app/api/templates/route.ts +91 -0
  127. package/template/src/app/api/users/[id]/route.ts +168 -0
  128. package/template/src/app/api/users/list/route.ts +45 -0
  129. package/template/src/app/api/users/me/route.ts +48 -0
  130. package/template/src/app/api/users/route.ts +250 -0
  131. package/template/src/app/api/webhooks/google-ads/route.ts +208 -0
  132. package/template/src/app/api/webhooks/meta-leads/route.ts +258 -0
  133. package/template/src/app/api/workflows/[id]/route.ts +192 -0
  134. package/template/src/app/api/workflows/process/route.ts +293 -0
  135. package/template/src/app/api/workflows/route.ts +124 -0
  136. package/template/src/app/favicon.ico +0 -0
  137. package/template/src/app/globals.css +1416 -0
  138. package/template/src/app/layout.tsx +31 -0
  139. package/template/src/app/page.tsx +32 -0
  140. package/template/src/components/dashboard/activity-chart.tsx +67 -0
  141. package/template/src/components/dashboard/contacts-chart.tsx +63 -0
  142. package/template/src/components/dashboard/recent-activity.tsx +164 -0
  143. package/template/src/components/dashboard/sales-analytics-chart.tsx +81 -0
  144. package/template/src/components/dashboard/stat-card.tsx +61 -0
  145. package/template/src/components/dashboard/status-distribution-chart.tsx +45 -0
  146. package/template/src/components/dashboard/tasks-pie-chart.tsx +88 -0
  147. package/template/src/components/dashboard/top-contacts-list.tsx +129 -0
  148. package/template/src/components/dashboard/upcoming-tasks-list.tsx +126 -0
  149. package/template/src/components/editor.tsx +856 -0
  150. package/template/src/components/email-template.tsx +35 -0
  151. package/template/src/components/header.tsx +320 -0
  152. package/template/src/components/invitation-email-template.tsx +79 -0
  153. package/template/src/components/meet-cancellation-email-template.tsx +120 -0
  154. package/template/src/components/meet-confirmation-email-template.tsx +156 -0
  155. package/template/src/components/meet-update-email-template.tsx +209 -0
  156. package/template/src/components/page-header.tsx +61 -0
  157. package/template/src/components/reset-password-email-template.tsx +79 -0
  158. package/template/src/components/sidebar.tsx +294 -0
  159. package/template/src/components/skeleton.tsx +380 -0
  160. package/template/src/components/ui/commands.tsx +396 -0
  161. package/template/src/components/ui/components.tsx +150 -0
  162. package/template/src/components/ui/theme.tsx +5 -0
  163. package/template/src/components/view-as-banner.tsx +45 -0
  164. package/template/src/components/view-as-modal.tsx +186 -0
  165. package/template/src/contexts/mobile-menu-context.tsx +31 -0
  166. package/template/src/contexts/sidebar-context.tsx +107 -0
  167. package/template/src/contexts/task-reminder-context.tsx +239 -0
  168. package/template/src/contexts/view-as-context.tsx +84 -0
  169. package/template/src/hooks/use-user-role.ts +82 -0
  170. package/template/src/lib/audit-log.ts +45 -0
  171. package/template/src/lib/auth-client.ts +16 -0
  172. package/template/src/lib/auth.ts +35 -0
  173. package/template/src/lib/check-permission.ts +193 -0
  174. package/template/src/lib/contact-duplicate.ts +112 -0
  175. package/template/src/lib/contact-interactions.ts +371 -0
  176. package/template/src/lib/encryption.ts +99 -0
  177. package/template/src/lib/google-calendar.ts +300 -0
  178. package/template/src/lib/google-drive.ts +372 -0
  179. package/template/src/lib/permissions.ts +412 -0
  180. package/template/src/lib/prisma.ts +32 -0
  181. package/template/src/lib/roles.ts +120 -0
  182. package/template/src/lib/template-variables.ts +76 -0
  183. package/template/src/lib/utils.ts +46 -0
  184. package/template/src/lib/workflow-executor.ts +482 -0
  185. package/template/src/proxy.ts +91 -0
  186. package/template/tsconfig.json +34 -0
  187. package/template/vercel.json +8 -0
@@ -0,0 +1,3713 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useMemo } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { useUserRole } from '@/hooks/use-user-role';
6
+ import { useMobileMenuContext } from '@/contexts/mobile-menu-context';
7
+ import {
8
+ Search,
9
+ Plus,
10
+ Trash2,
11
+ Phone,
12
+ Mail,
13
+ MapPin,
14
+ Upload,
15
+ X,
16
+ Filter,
17
+ LayoutGrid,
18
+ List,
19
+ ChevronsLeft,
20
+ ChevronsRight,
21
+ Eye,
22
+ EyeOff,
23
+ GripVertical,
24
+ Users,
25
+ Tag,
26
+ ArrowRight,
27
+ Download,
28
+ RefreshCw,
29
+ Calendar,
30
+ Building2,
31
+ ChevronDown,
32
+ ChevronUp,
33
+ } from 'lucide-react';
34
+ import { ContactTableSkeleton, ContactCardsSkeleton } from '@/components/skeleton';
35
+ import { cn, normalizePhoneNumber } from '@/lib/utils';
36
+
37
+ interface Status {
38
+ id: string;
39
+ name: string;
40
+ color: string;
41
+ }
42
+
43
+ interface User {
44
+ id: string;
45
+ name: string;
46
+ email: string;
47
+ role?: string;
48
+ }
49
+
50
+ interface Contact {
51
+ id: string;
52
+ civility: string | null;
53
+ firstName: string | null;
54
+ lastName: string | null;
55
+ phone: string;
56
+ secondaryPhone: string | null;
57
+ email: string | null;
58
+ address: string | null;
59
+ city: string | null;
60
+ postalCode: string | null;
61
+ origin: string | null;
62
+ companyName: string | null;
63
+ isCompany: boolean;
64
+ companyId: string | null;
65
+ companyRelation: Contact | null;
66
+ statusId: string | null;
67
+ status: Status | null;
68
+ assignedCommercialId: string | null;
69
+ assignedCommercial: User | null;
70
+ assignedTeleproId: string | null;
71
+ assignedTelepro: User | null;
72
+ createdById: string;
73
+ createdBy: User;
74
+ createdAt: string;
75
+ updatedAt: string;
76
+ }
77
+
78
+ interface TableColumn {
79
+ id: string;
80
+ label: string;
81
+ visible: boolean;
82
+ order: number;
83
+ }
84
+
85
+ const DEFAULT_COLUMNS: TableColumn[] = [
86
+ { id: 'contact', label: 'Contact', visible: true, order: 0 },
87
+ { id: 'phone', label: 'Téléphone', visible: true, order: 1 },
88
+ { id: 'email', label: 'Email', visible: true, order: 2 },
89
+ { id: 'status', label: 'Statut', visible: true, order: 3 },
90
+ { id: 'origin', label: 'Origine', visible: true, order: 4 },
91
+ { id: 'commercial', label: 'Commercial', visible: true, order: 5 },
92
+ { id: 'telepro', label: 'Télépro', visible: true, order: 6 },
93
+ { id: 'createdAt', label: 'CRÉÉ LE', visible: true, order: 7 },
94
+ { id: 'updatedAt', label: 'MODIFIÉ LE', visible: true, order: 8 },
95
+ ];
96
+
97
+ export default function ContactsPage() {
98
+ const router = useRouter();
99
+ const { isAdmin } = useUserRole();
100
+ const { toggle: toggleMobileMenu, isOpen: isMobileMenuOpen } = useMobileMenuContext();
101
+
102
+ // Fonction pour formater les dates en français
103
+ const formatDate = (dateString: string) => {
104
+ const date = new Date(dateString);
105
+ return new Intl.DateTimeFormat('fr-FR', {
106
+ day: '2-digit',
107
+ month: '2-digit',
108
+ year: 'numeric',
109
+ hour: '2-digit',
110
+ minute: '2-digit',
111
+ }).format(date);
112
+ };
113
+ const [contacts, setContacts] = useState<Contact[]>([]);
114
+ const [loading, setLoading] = useState(true);
115
+ const [showModal, setShowModal] = useState(false);
116
+ const [editingContact, setEditingContact] = useState<Contact | null>(null);
117
+ const [statuses, setStatuses] = useState<Status[]>([]);
118
+ const [users, setUsers] = useState<User[]>([]);
119
+ const [closingReasons, setClosingReasons] = useState<{ id: string; name: string }[]>([]);
120
+ const [error, setError] = useState('');
121
+ const [success, setSuccess] = useState('');
122
+ const [showImportModal, setShowImportModal] = useState(false);
123
+ const [importFile, setImportFile] = useState<File | null>(null);
124
+ const [importPreview, setImportPreview] = useState<any[]>([]);
125
+ const [importHeaders, setImportHeaders] = useState<string[]>([]);
126
+ const [importFieldMappings, setImportFieldMappings] = useState<
127
+ Record<
128
+ string,
129
+ {
130
+ action: 'map' | 'note' | 'ignore';
131
+ crmField?: string;
132
+ }
133
+ >
134
+ >({});
135
+ const [importSkipFirstRow, setImportSkipFirstRow] = useState(false);
136
+ const [importing, setImporting] = useState(false);
137
+ const [importResult, setImportResult] = useState<any>(null);
138
+ const [showImportResultModal, setShowImportResultModal] = useState(false);
139
+ const [importResultData, setImportResultData] = useState<any>(null);
140
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
141
+
142
+ // Valeurs par défaut pour l'import
143
+ const [defaultStatusId, setDefaultStatusId] = useState('');
144
+ const [defaultCommercialId, setDefaultCommercialId] = useState('');
145
+ const [defaultOrigin, setDefaultOrigin] = useState('');
146
+
147
+ // Filtres
148
+ const [search, setSearch] = useState('');
149
+ const [statusFilter, setStatusFilter] = useState<string>('');
150
+ const [originFilter, setOriginFilter] = useState<string>('');
151
+ const [assignedCommercialFilter, setAssignedCommercialFilter] = useState<string>('');
152
+ const [assignedTeleproFilter, setAssignedTeleproFilter] = useState<string>('');
153
+
154
+ // Filtres de date
155
+ const [dateFilterModal, setDateFilterModal] = useState<'createdAt' | 'updatedAt' | null>(null);
156
+ const [dateFilterPosition, setDateFilterPosition] = useState<{
157
+ top: number;
158
+ left: number;
159
+ } | null>(null);
160
+ const dateFilterModalRef = useRef<HTMLDivElement>(null);
161
+ const listFilterModalRef = useRef<HTMLDivElement>(null);
162
+ const [listFilterModal, setListFilterModal] = useState<
163
+ null | 'status' | 'origin' | 'commercial' | 'telepro'
164
+ >(null);
165
+ const [listFilterPosition, setListFilterPosition] = useState<{
166
+ top: number;
167
+ left: number;
168
+ } | null>(null);
169
+ const [createdAtStart, setCreatedAtStart] = useState('');
170
+ const [createdAtEnd, setCreatedAtEnd] = useState('');
171
+ const [updatedAtStart, setUpdatedAtStart] = useState('');
172
+ const [updatedAtEnd, setUpdatedAtEnd] = useState('');
173
+
174
+ // Tri
175
+ const [sortField, setSortField] = useState<
176
+ '' | 'createdAt' | 'updatedAt' | 'status' | 'commercial' | 'telepro'
177
+ >('');
178
+ const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
179
+ const [showSortMenu, setShowSortMenu] = useState(false);
180
+ const [showDatePicker, setShowDatePicker] = useState(false);
181
+ const [dateRangeStart, setDateRangeStart] = useState('');
182
+ const [dateRangeEnd, setDateRangeEnd] = useState('');
183
+ const sortMenuRef = useRef<HTMLDivElement>(null);
184
+ const datePickerRef = useRef<HTMLDivElement>(null);
185
+
186
+ // Pagination
187
+ const [currentPage, setCurrentPage] = useState(1);
188
+ const [totalPages, setTotalPages] = useState(1);
189
+ const [totalContacts, setTotalContacts] = useState(0);
190
+ const [limit, setLimit] = useState(25);
191
+
192
+ // Vue (table ou cards) - Persistée dans localStorage
193
+ // Initialiser avec 'table' pour éviter l'erreur d'hydratation
194
+ const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
195
+
196
+ // Gestion des colonnes du tableau (avec récupération initiale depuis localStorage)
197
+ const [tableColumns, setTableColumns] = useState<TableColumn[]>(() => {
198
+ if (typeof window === 'undefined') {
199
+ return DEFAULT_COLUMNS;
200
+ }
201
+
202
+ try {
203
+ const saved = window.localStorage.getItem('contactsTableColumns');
204
+ if (!saved) {
205
+ return DEFAULT_COLUMNS;
206
+ }
207
+ const parsed = JSON.parse(saved) as TableColumn[];
208
+ const byId = new Map(parsed.map((col) => [col.id, col]));
209
+
210
+ // On merge toujours avec DEFAULT_COLUMNS pour intégrer les nouvelles colonnes éventuelles
211
+ const merged = DEFAULT_COLUMNS.map((col) => {
212
+ const savedCol = byId.get(col.id);
213
+ if (!savedCol) return col;
214
+ return {
215
+ ...col,
216
+ ...savedCol,
217
+ // S'assurer que l'id reste celui du défaut
218
+ id: col.id,
219
+ };
220
+ });
221
+
222
+ return merged;
223
+ } catch (e) {
224
+ console.error('Erreur lors du chargement des colonnes depuis localStorage:', e);
225
+ return DEFAULT_COLUMNS;
226
+ }
227
+ });
228
+ const [showColumnPanel, setShowColumnPanel] = useState(false);
229
+ const [draggedColumn, setDraggedColumn] = useState<string | null>(null);
230
+
231
+ // Regroupement des informations de contact
232
+ const [groupContactInfo, setGroupContactInfo] = useState<boolean>(() => {
233
+ if (typeof window === 'undefined') {
234
+ return false;
235
+ }
236
+ try {
237
+ const saved = window.localStorage.getItem('contactsGroupContactInfo');
238
+ return saved === 'true';
239
+ } catch (e) {
240
+ return false;
241
+ }
242
+ });
243
+
244
+ // Sélection multiple et actions groupées
245
+ const [selectedContactIds, setSelectedContactIds] = useState<Set<string>>(new Set());
246
+ const [showBulkCommercialModal, setShowBulkCommercialModal] = useState(false);
247
+ const [showBulkStatusModal, setShowBulkStatusModal] = useState(false);
248
+ const [bulkCommercialId, setBulkCommercialId] = useState('');
249
+ const [bulkStatusId, setBulkStatusId] = useState('');
250
+ const [bulkActionLoading, setBulkActionLoading] = useState(false);
251
+ const [showExportModal, setShowExportModal] = useState(false);
252
+ const [exporting, setExporting] = useState(false);
253
+ const [exportAll, setExportAll] = useState(false);
254
+
255
+ // Charger les préférences depuis localStorage après le montage
256
+ useEffect(() => {
257
+ const savedView = localStorage.getItem('contactsViewMode');
258
+ if (savedView === 'cards' || savedView === 'table') {
259
+ setViewMode(savedView);
260
+ }
261
+
262
+ const savedLimit = localStorage.getItem('contactsPageLimit');
263
+ if (savedLimit && ['25', '50', '100'].includes(savedLimit)) {
264
+ setLimit(parseInt(savedLimit, 10));
265
+ }
266
+ }, []);
267
+
268
+ // Formulaire
269
+ const [formData, setFormData] = useState({
270
+ civility: '',
271
+ firstName: '',
272
+ lastName: '',
273
+ phone: '',
274
+ secondaryPhone: '',
275
+ email: '',
276
+ address: '',
277
+ city: '',
278
+ postalCode: '',
279
+ origin: '',
280
+ company: '',
281
+ isCompany: false,
282
+ companyId: '',
283
+ statusId: '',
284
+ closingReason: '',
285
+ assignedCommercialId: '',
286
+ assignedTeleproId: '',
287
+ });
288
+
289
+ // Synchronisation automatique Google Sheets
290
+ useEffect(() => {
291
+ const syncGoogleSheet = async () => {
292
+ try {
293
+ await fetch('/api/integrations/google-sheet/sync', {
294
+ method: 'POST',
295
+ });
296
+ } catch (err) {
297
+ console.error('Erreur lors de la synchronisation Google Sheets:', err);
298
+ }
299
+ };
300
+
301
+ syncGoogleSheet();
302
+ }, []);
303
+
304
+ // Charger les statuts et utilisateurs
305
+ useEffect(() => {
306
+ const fetchData = async () => {
307
+ try {
308
+ const [statusesRes, usersRes, closingReasonsRes] = await Promise.all([
309
+ fetch('/api/statuses'),
310
+ fetch('/api/users/list'),
311
+ fetch('/api/closing-reasons'),
312
+ ]);
313
+
314
+ if (statusesRes.ok) {
315
+ const statusesData = await statusesRes.json();
316
+ setStatuses(statusesData);
317
+ }
318
+
319
+ if (usersRes.ok) {
320
+ const usersData = await usersRes.json();
321
+ setUsers(usersData);
322
+ }
323
+
324
+ if (closingReasonsRes.ok) {
325
+ const reasonsData = await closingReasonsRes.json();
326
+ setClosingReasons(reasonsData || []);
327
+ }
328
+ } catch (error) {
329
+ console.error('Erreur lors du chargement des données:', error);
330
+ }
331
+ };
332
+ fetchData();
333
+ }, []);
334
+
335
+ // Persister les préférences
336
+ useEffect(() => {
337
+ localStorage.setItem('contactsViewMode', viewMode);
338
+ }, [viewMode]);
339
+
340
+ useEffect(() => {
341
+ localStorage.setItem('contactsPageLimit', limit.toString());
342
+ }, [limit]);
343
+
344
+ useEffect(() => {
345
+ localStorage.setItem('contactsTableColumns', JSON.stringify(tableColumns));
346
+ }, [tableColumns]);
347
+
348
+ // Sauvegarder le regroupement dans localStorage quand il change
349
+ useEffect(() => {
350
+ localStorage.setItem('contactsGroupContactInfo', String(groupContactInfo));
351
+ }, [groupContactInfo]);
352
+
353
+ const fetchContacts = async () => {
354
+ try {
355
+ setLoading(true);
356
+ setSelectedContactIds(new Set()); // Réinitialiser la sélection lors du chargement
357
+ const params = new URLSearchParams();
358
+ if (search) params.append('search', search);
359
+ if (statusFilter) params.append('statusId', statusFilter);
360
+ if (originFilter) params.append('origin', originFilter);
361
+ if (assignedCommercialFilter) params.append('assignedCommercialId', assignedCommercialFilter);
362
+ if (assignedTeleproFilter) params.append('assignedTeleproId', assignedTeleproFilter);
363
+ if (createdAtStart) params.append('createdAtStart', createdAtStart);
364
+ if (createdAtEnd) params.append('createdAtEnd', createdAtEnd);
365
+ if (updatedAtStart) params.append('updatedAtStart', updatedAtStart);
366
+ if (updatedAtEnd) params.append('updatedAtEnd', updatedAtEnd);
367
+ params.append('page', currentPage.toString());
368
+ params.append('limit', limit.toString());
369
+
370
+ const response = await fetch(`/api/contacts?${params.toString()}`);
371
+ if (response.ok) {
372
+ const data = await response.json();
373
+ setContacts(data.contacts || []);
374
+ if (data.pagination) {
375
+ setTotalPages(data.pagination.totalPages);
376
+ setTotalContacts(data.pagination.total);
377
+ }
378
+ } else {
379
+ setError('Erreur lors du chargement des contacts');
380
+ }
381
+ } catch (error) {
382
+ console.error('Erreur:', error);
383
+ setError('Erreur lors du chargement des contacts');
384
+ } finally {
385
+ setLoading(false);
386
+ }
387
+ };
388
+
389
+ // Réinitialiser à la page 1 quand les filtres ou le limit changent
390
+ useEffect(() => {
391
+ if (currentPage !== 1) {
392
+ setCurrentPage(1);
393
+ }
394
+ }, [
395
+ search,
396
+ statusFilter,
397
+ assignedCommercialFilter,
398
+ assignedTeleproFilter,
399
+ createdAtStart,
400
+ createdAtEnd,
401
+ updatedAtStart,
402
+ updatedAtEnd,
403
+ limit,
404
+ ]);
405
+
406
+ // Charger les contacts quand la page, les filtres ou le limit changent
407
+ useEffect(() => {
408
+ fetchContacts();
409
+ }, [
410
+ currentPage,
411
+ search,
412
+ statusFilter,
413
+ originFilter,
414
+ assignedCommercialFilter,
415
+ assignedTeleproFilter,
416
+ createdAtStart,
417
+ createdAtEnd,
418
+ updatedAtStart,
419
+ updatedAtEnd,
420
+ limit,
421
+ ]);
422
+
423
+ // Fermer la modal de filtre de date au clic en dehors
424
+ useEffect(() => {
425
+ const handleClickOutside = (event: MouseEvent) => {
426
+ if (
427
+ dateFilterModalRef.current &&
428
+ !dateFilterModalRef.current.contains(event.target as Node)
429
+ ) {
430
+ // Vérifier si le clic n'est pas sur l'icône de filtre elle-même
431
+ const target = event.target as HTMLElement;
432
+ if (!target.closest('button[data-date-filter-icon]')) {
433
+ setDateFilterModal(null);
434
+ setDateFilterPosition(null);
435
+ }
436
+ }
437
+ };
438
+
439
+ if (dateFilterModal) {
440
+ document.addEventListener('mousedown', handleClickOutside);
441
+ }
442
+
443
+ return () => {
444
+ document.removeEventListener('mousedown', handleClickOutside);
445
+ };
446
+ }, [dateFilterModal]);
447
+
448
+ // Fermer la modal de filtre de liste (statut, origine, commercial, télépro) au clic en dehors
449
+ useEffect(() => {
450
+ const handleClickOutside = (event: MouseEvent) => {
451
+ if (
452
+ listFilterModalRef.current &&
453
+ !listFilterModalRef.current.contains(event.target as Node)
454
+ ) {
455
+ const target = event.target as HTMLElement;
456
+ if (!target.closest('button[data-date-filter-icon]')) {
457
+ setListFilterModal(null);
458
+ setListFilterPosition(null);
459
+ }
460
+ }
461
+ };
462
+
463
+ if (listFilterModal) {
464
+ document.addEventListener('mousedown', handleClickOutside);
465
+ }
466
+
467
+ return () => {
468
+ document.removeEventListener('mousedown', handleClickOutside);
469
+ };
470
+ }, [listFilterModal]);
471
+
472
+ // Fermer le menu de tri au clic en dehors
473
+ useEffect(() => {
474
+ const handleClickOutside = (event: MouseEvent) => {
475
+ if (sortMenuRef.current && !sortMenuRef.current.contains(event.target as Node)) {
476
+ setShowSortMenu(false);
477
+ }
478
+ };
479
+
480
+ if (showSortMenu) {
481
+ document.addEventListener('mousedown', handleClickOutside);
482
+ }
483
+
484
+ return () => {
485
+ document.removeEventListener('mousedown', handleClickOutside);
486
+ };
487
+ }, [showSortMenu]);
488
+
489
+ // Fermer le sélecteur de date au clic en dehors
490
+ useEffect(() => {
491
+ const handleClickOutside = (event: MouseEvent) => {
492
+ if (datePickerRef.current && !datePickerRef.current.contains(event.target as Node)) {
493
+ setShowDatePicker(false);
494
+ }
495
+ };
496
+
497
+ if (showDatePicker) {
498
+ document.addEventListener('mousedown', handleClickOutside);
499
+ }
500
+
501
+ return () => {
502
+ document.removeEventListener('mousedown', handleClickOutside);
503
+ };
504
+ }, [showDatePicker]);
505
+
506
+ const handleSubmit = async (e: React.FormEvent) => {
507
+ e.preventDefault();
508
+ setError('');
509
+ setSuccess('');
510
+
511
+ if (!formData.phone) {
512
+ setError('Le téléphone est obligatoire');
513
+ return;
514
+ }
515
+
516
+ try {
517
+ const url = editingContact ? `/api/contacts/${editingContact.id}` : '/api/contacts';
518
+ const method = editingContact ? 'PUT' : 'POST';
519
+
520
+ const selectedStatus = statuses.find((s) => s.id === formData.statusId);
521
+ const isLostStatus = selectedStatus?.name?.toLowerCase() === 'fermé';
522
+
523
+ if (isLostStatus && !formData.closingReason) {
524
+ setError('Veuillez renseigner le motif de fermeture pour un contact fermé.');
525
+ return;
526
+ }
527
+
528
+ const response = await fetch(url, {
529
+ method,
530
+ headers: { 'Content-Type': 'application/json' },
531
+ body: JSON.stringify({
532
+ ...formData,
533
+ civility: formData.civility || null,
534
+ companyName: formData.company || null,
535
+ isCompany: formData.isCompany || false,
536
+ companyId: formData.companyId || null,
537
+ closingReason: isLostStatus ? formData.closingReason || null : null,
538
+ assignedCommercialId: formData.assignedCommercialId || null,
539
+ assignedTeleproId: formData.assignedTeleproId || null,
540
+ }),
541
+ });
542
+
543
+ const data = await response.json();
544
+
545
+ if (!response.ok) {
546
+ throw new Error(data.error || 'Erreur lors de la sauvegarde');
547
+ }
548
+
549
+ setSuccess(editingContact ? 'Contact modifié avec succès !' : 'Contact créé avec succès !');
550
+ setShowModal(false);
551
+ setEditingContact(null);
552
+ setFormData({
553
+ civility: '',
554
+ firstName: '',
555
+ lastName: '',
556
+ phone: '',
557
+ secondaryPhone: '',
558
+ email: '',
559
+ address: '',
560
+ city: '',
561
+ postalCode: '',
562
+ origin: '',
563
+ company: '',
564
+ isCompany: false,
565
+ companyId: '',
566
+ statusId: '',
567
+ closingReason: '',
568
+ assignedCommercialId: '',
569
+ assignedTeleproId: '',
570
+ });
571
+ fetchContacts();
572
+
573
+ setTimeout(() => setSuccess(''), 5000);
574
+ } catch (err: any) {
575
+ setError(err.message);
576
+ }
577
+ };
578
+
579
+ const handleSort = (field: string) => {
580
+ setCurrentPage(1);
581
+
582
+ if (sortField === field) {
583
+ // Même colonne : toggle l'ordre
584
+ setSortOrder((prevOrder) => (prevOrder === 'asc' ? 'desc' : 'asc'));
585
+ } else {
586
+ // Nouvelle colonne : définir la colonne et mettre l'ordre à 'asc'
587
+ setSortField(field as any);
588
+ setSortOrder('asc');
589
+ }
590
+ setShowSortMenu(false);
591
+ };
592
+
593
+ const handleSortByField = (
594
+ field: 'status' | 'commercial' | 'telepro' | 'createdAt' | 'updatedAt' | '',
595
+ ) => {
596
+ handleSort(field);
597
+ };
598
+
599
+ const handleDateFilterClick = (e: React.MouseEvent, contactId: string) => {
600
+ e.stopPropagation();
601
+ if (contactId === 'createdAt' || contactId === 'updatedAt') {
602
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
603
+ const modalWidth = 288; // w-72 = 18rem = 288px
604
+ const padding = 16; // Padding pour éviter que la modal touche les bords
605
+
606
+ // Position initiale (centrée sous l'icône)
607
+ let left = rect.left + rect.width / 2 - modalWidth / 2;
608
+
609
+ // Vérifier si la modal dépasse à droite
610
+ if (left + modalWidth > window.innerWidth - padding) {
611
+ left = window.innerWidth - modalWidth - padding;
612
+ }
613
+
614
+ // Vérifier si la modal dépasse à gauche
615
+ if (left < padding) {
616
+ left = padding;
617
+ }
618
+
619
+ setDateFilterPosition({
620
+ top: rect.bottom + 18,
621
+ left,
622
+ });
623
+ setDateFilterModal(contactId as 'createdAt' | 'updatedAt');
624
+ }
625
+ };
626
+
627
+ const handleListFilterClick = (e: React.MouseEvent, contactId: string) => {
628
+ e.stopPropagation();
629
+ if (
630
+ contactId === 'status' ||
631
+ contactId === 'origin' ||
632
+ contactId === 'commercial' ||
633
+ contactId === 'telepro'
634
+ ) {
635
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
636
+ const modalWidth = 320;
637
+ const padding = 16;
638
+
639
+ let left = rect.left + rect.width / 2 - modalWidth / 2;
640
+ if (left + modalWidth > window.innerWidth - padding) {
641
+ left = window.innerWidth - modalWidth - padding;
642
+ }
643
+ if (left < padding) {
644
+ left = padding;
645
+ }
646
+
647
+ setListFilterPosition({
648
+ top: rect.bottom + 8,
649
+ left,
650
+ });
651
+ setListFilterModal(contactId as 'status' | 'origin' | 'commercial' | 'telepro');
652
+ }
653
+ };
654
+
655
+ const handleApplyDateFilter = () => {
656
+ setDateFilterModal(null);
657
+ setDateFilterPosition(null);
658
+ setCurrentPage(1);
659
+ };
660
+
661
+ const handleClearDateFilter = (type: 'createdAt' | 'updatedAt') => {
662
+ if (type === 'createdAt') {
663
+ setCreatedAtStart('');
664
+ setCreatedAtEnd('');
665
+ } else {
666
+ setUpdatedAtStart('');
667
+ setUpdatedAtEnd('');
668
+ }
669
+ setDateFilterModal(null);
670
+ setDateFilterPosition(null);
671
+ setCurrentPage(1);
672
+ };
673
+
674
+ // Vérifier si des filtres sont actifs
675
+ const hasActiveFilters = () => {
676
+ return (
677
+ !!search ||
678
+ !!statusFilter ||
679
+ !!originFilter ||
680
+ !!assignedCommercialFilter ||
681
+ !!assignedTeleproFilter ||
682
+ !!createdAtStart ||
683
+ !!createdAtEnd ||
684
+ !!updatedAtStart ||
685
+ !!updatedAtEnd ||
686
+ !!sortField
687
+ );
688
+ };
689
+
690
+ // Réinitialiser tous les filtres
691
+ const handleResetAllFilters = () => {
692
+ setSearch('');
693
+ setStatusFilter('');
694
+ setOriginFilter('');
695
+ setAssignedCommercialFilter('');
696
+ setAssignedTeleproFilter('');
697
+ setCreatedAtStart('');
698
+ setCreatedAtEnd('');
699
+ setUpdatedAtStart('');
700
+ setUpdatedAtEnd('');
701
+ setSortField('');
702
+ setSortOrder('asc');
703
+ setCurrentPage(1);
704
+ setListFilterModal(null);
705
+ setDateFilterModal(null);
706
+ setDateFilterPosition(null);
707
+ setListFilterPosition(null);
708
+ };
709
+
710
+ const sortedContacts = useMemo(() => {
711
+ if (!sortField) return contacts;
712
+ const sorted = [...contacts].sort((a, b) => {
713
+ let aValue: any;
714
+ let bValue: any;
715
+
716
+ if (sortField === 'createdAt' || sortField === 'updatedAt') {
717
+ aValue = new Date(a[sortField]);
718
+ bValue = new Date(b[sortField]);
719
+ const diff = aValue.getTime() - bValue.getTime();
720
+ return sortOrder === 'asc' ? diff : -diff;
721
+ } else if (sortField === 'status') {
722
+ aValue = a.status?.name || '';
723
+ bValue = b.status?.name || '';
724
+ } else if (sortField === 'commercial') {
725
+ aValue = a.assignedCommercial?.name || '';
726
+ bValue = b.assignedCommercial?.name || '';
727
+ } else if (sortField === 'telepro') {
728
+ aValue = a.assignedTelepro?.name || '';
729
+ bValue = b.assignedTelepro?.name || '';
730
+ } else {
731
+ return 0;
732
+ }
733
+
734
+ if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1;
735
+ if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1;
736
+ return 0;
737
+ });
738
+ return sorted;
739
+ }, [contacts, sortField, sortOrder]);
740
+
741
+ const handleNewContact = () => {
742
+ setEditingContact(null);
743
+ setFormData({
744
+ civility: '',
745
+ firstName: '',
746
+ lastName: '',
747
+ phone: '',
748
+ secondaryPhone: '',
749
+ email: '',
750
+ address: '',
751
+ city: '',
752
+ postalCode: '',
753
+ origin: '',
754
+ isCompany: false,
755
+ companyId: '',
756
+ company: '',
757
+ statusId: '',
758
+ closingReason: '',
759
+ assignedCommercialId: '',
760
+ assignedTeleproId: '',
761
+ });
762
+ setShowModal(true);
763
+ setError('');
764
+ setSuccess('');
765
+ };
766
+
767
+ // Fonctions d'import
768
+ // Fonction d'auto-mapping intelligent
769
+ const autoMapHeaders = (headers: string[]) => {
770
+ const mappings: Record<
771
+ string,
772
+ {
773
+ action: 'map' | 'note' | 'ignore';
774
+ crmField?: string;
775
+ }
776
+ > = {};
777
+
778
+ // Définir les correspondances possibles pour chaque champ
779
+ const fieldMappings: { [key: string]: string[] } = {
780
+ phone: ['téléphone', 'telephone', 'phone', 'tel', 'tél', 'mobile', 'portable'],
781
+ firstName: ['prénom', 'prenom', 'firstname', 'first name', 'first_name'],
782
+ lastName: ['nom', 'lastname', 'last name', 'last_name', 'nom de famille', 'surname'],
783
+ email: ['email', 'e-mail', 'mail', 'courriel'],
784
+ civility: ['civilité', 'civilite', 'civility', 'titre', 'title'],
785
+ secondaryPhone: [
786
+ 'téléphone 2',
787
+ 'telephone 2',
788
+ 'tel 2',
789
+ 'tél 2',
790
+ 'secondary phone',
791
+ 'phone 2',
792
+ 'téléphone secondaire',
793
+ 'telephone secondaire',
794
+ ],
795
+ address: ['adresse', 'address', 'rue', 'street'],
796
+ city: ['ville', 'city', 'localité', 'locality'],
797
+ postalCode: ['code postal', 'postal code', 'cp', 'zip', 'zipcode', 'code_postal'],
798
+ origin: ['origine', 'origin', 'source', 'campagne', 'campaign', 'origine de la campagne'],
799
+ };
800
+
801
+ headers.forEach((header) => {
802
+ const normalizedHeader = header.toLowerCase().trim();
803
+ let mapped = false;
804
+
805
+ // Chercher une correspondance pour chaque champ
806
+ for (const [field, aliases] of Object.entries(fieldMappings)) {
807
+ if (
808
+ aliases.some(
809
+ (alias) => normalizedHeader.includes(alias) || alias.includes(normalizedHeader),
810
+ )
811
+ ) {
812
+ mappings[header] = { action: 'map', crmField: field };
813
+ mapped = true;
814
+ break;
815
+ }
816
+ }
817
+
818
+ // Si aucune correspondance trouvée, ignorer par défaut
819
+ if (!mapped) {
820
+ mappings[header] = { action: 'ignore' };
821
+ }
822
+ });
823
+
824
+ return mappings;
825
+ };
826
+
827
+ const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
828
+ const file = e.target.files?.[0];
829
+ if (!file) return;
830
+
831
+ setImportFile(file);
832
+ setImportResult(null);
833
+ setError('');
834
+
835
+ try {
836
+ // Parser le fichier pour obtenir les en-têtes et un aperçu
837
+ const fileName = file.name.toLowerCase();
838
+ const fileExtension = fileName.split('.').pop();
839
+
840
+ if (fileExtension === 'csv') {
841
+ const text = await file.text();
842
+ const lines = text.split('\n').filter((line) => line.trim() !== '');
843
+ if (lines.length === 0) {
844
+ setError('Le fichier est vide');
845
+ return;
846
+ }
847
+
848
+ const delimiter = lines[0].includes(';') ? ';' : ',';
849
+ const headers = lines[0].split(delimiter).map((h) => h.trim().replace(/^"|"$/g, ''));
850
+ setImportHeaders(headers);
851
+
852
+ // Auto-mapper les colonnes
853
+ const autoMappings = autoMapHeaders(headers);
854
+ setImportFieldMappings(autoMappings);
855
+
856
+ // Prévisualiser les 5 premières lignes
857
+ const preview: any[] = [];
858
+ for (let i = 1; i < Math.min(6, lines.length); i++) {
859
+ const values = lines[i].split(delimiter).map((v) => v.trim().replace(/^"|"$/g, ''));
860
+ const row: any = {};
861
+ headers.forEach((header, index) => {
862
+ row[header] = values[index] || '';
863
+ });
864
+ preview.push(row);
865
+ }
866
+ setImportPreview(preview);
867
+ } else if (fileExtension === 'xlsx' || fileExtension === 'xls') {
868
+ // Pour Excel, on a besoin de xlsx
869
+ try {
870
+ const XLSX = require('xlsx');
871
+ const buffer = await file.arrayBuffer();
872
+ const workbook = XLSX.read(buffer, { type: 'array' });
873
+ const sheetName = workbook.SheetNames[0];
874
+ const worksheet = workbook.Sheets[sheetName];
875
+ const data = XLSX.utils.sheet_to_json(worksheet, { raw: false });
876
+
877
+ if (data.length === 0) {
878
+ setError('Le fichier est vide');
879
+ return;
880
+ }
881
+
882
+ const headers: any = Object.keys(data[0] as any);
883
+ setImportHeaders(headers);
884
+ setImportPreview(data.slice(0, 5));
885
+
886
+ // Auto-mapper les colonnes pour Excel
887
+ const autoMappings = autoMapHeaders(headers);
888
+ setImportFieldMappings(autoMappings);
889
+ } catch (error) {
890
+ setError(
891
+ 'Erreur lors du parsing Excel. Assurez-vous que xlsx est installé (npm install xlsx)',
892
+ );
893
+ }
894
+ } else {
895
+ setError('Format de fichier non supporté. Utilisez CSV ou Excel (.xlsx, .xls)');
896
+ return;
897
+ }
898
+ } catch (error: any) {
899
+ setError(`Erreur lors de la lecture du fichier: ${error.message}`);
900
+ }
901
+ };
902
+
903
+ const handleImport = async () => {
904
+ if (!importFile) {
905
+ setError('Veuillez sélectionner un fichier');
906
+ return;
907
+ }
908
+
909
+ // Vérifier qu'au moins un champ téléphone est mappé
910
+ const phoneMapped = Object.entries(importFieldMappings).some(
911
+ ([header, mapping]) => mapping.action === 'map' && mapping.crmField === 'phone',
912
+ );
913
+
914
+ if (!phoneMapped) {
915
+ setError('Le mapping du téléphone est obligatoire');
916
+ return;
917
+ }
918
+
919
+ setImporting(true);
920
+ setError('');
921
+ setImportResult(null);
922
+
923
+ try {
924
+ const formData = new FormData();
925
+ formData.append('file', importFile);
926
+ formData.append('fieldMappings', JSON.stringify(importFieldMappings));
927
+ formData.append('skipFirstRow', importSkipFirstRow.toString());
928
+
929
+ // Ajouter les valeurs par défaut
930
+ if (defaultStatusId) {
931
+ formData.append('defaultStatusId', defaultStatusId);
932
+ }
933
+ if (defaultCommercialId) {
934
+ formData.append('defaultCommercialId', defaultCommercialId);
935
+ }
936
+ if (defaultOrigin) {
937
+ formData.append('defaultOrigin', defaultOrigin);
938
+ }
939
+
940
+ const response = await fetch('/api/contacts/import', {
941
+ method: 'POST',
942
+ body: formData,
943
+ });
944
+
945
+ const result = await response.json();
946
+
947
+ if (!response.ok) {
948
+ throw new Error(result.error || "Erreur lors de l'import");
949
+ }
950
+
951
+ setImportResult(result);
952
+ fetchContacts();
953
+
954
+ // Stocker les résultats et fermer la modal d'import
955
+ setImportResultData(result);
956
+ setShowImportModal(false);
957
+ setImportFile(null);
958
+ setImportPreview([]);
959
+ setImportHeaders([]);
960
+ setImportFieldMappings({});
961
+ setImportResult(null);
962
+ setDefaultStatusId('');
963
+ setDefaultCommercialId('');
964
+ setDefaultOrigin('');
965
+ setSuccess('');
966
+
967
+ // Afficher la modal de résultats
968
+ setShowImportResultModal(true);
969
+ } catch (err: any) {
970
+ setError(err.message);
971
+ } finally {
972
+ setImporting(false);
973
+ }
974
+ };
975
+
976
+ // Fonctions de gestion des colonnes
977
+ const toggleColumnVisibility = (contactId: string) => {
978
+ setTableColumns((prev) =>
979
+ prev.map((col) => (col.id === contactId ? { ...col, visible: !col.visible } : col)),
980
+ );
981
+ };
982
+
983
+ const handleDragStart = (contactId: string) => {
984
+ setDraggedColumn(contactId);
985
+ };
986
+
987
+ const handleDragOver = (e: React.DragEvent, contactId: string) => {
988
+ e.preventDefault();
989
+ if (!draggedColumn || draggedColumn === contactId) return;
990
+
991
+ const draggedIndex = tableColumns.findIndex((col) => col.id === draggedColumn);
992
+ const targetIndex = tableColumns.findIndex((col) => col.id === contactId);
993
+
994
+ const newColumns = [...tableColumns];
995
+ const [removed] = newColumns.splice(draggedIndex, 1);
996
+ newColumns.splice(targetIndex, 0, removed);
997
+
998
+ // Réordonner les order
999
+ newColumns.forEach((col, idx) => {
1000
+ col.order = idx;
1001
+ });
1002
+
1003
+ setTableColumns(newColumns);
1004
+ };
1005
+
1006
+ const handleDragEnd = () => {
1007
+ setDraggedColumn(null);
1008
+ };
1009
+
1010
+ const resetColumns = () => {
1011
+ setTableColumns(DEFAULT_COLUMNS);
1012
+ };
1013
+
1014
+ // Gestion de la sélection multiple
1015
+ const handleSelectAll = () => {
1016
+ if (selectedContactIds.size === contacts.length) {
1017
+ setSelectedContactIds(new Set());
1018
+ } else {
1019
+ setSelectedContactIds(new Set(contacts.map((c) => c.id)));
1020
+ }
1021
+ };
1022
+
1023
+ const handleSelectContact = (contactId: string) => {
1024
+ const newSelected = new Set(selectedContactIds);
1025
+ if (newSelected.has(contactId)) {
1026
+ newSelected.delete(contactId);
1027
+ } else {
1028
+ newSelected.add(contactId);
1029
+ }
1030
+ setSelectedContactIds(newSelected);
1031
+ };
1032
+
1033
+ const handleDeselectAll = () => {
1034
+ setSelectedContactIds(new Set());
1035
+ };
1036
+
1037
+ // Actions groupées
1038
+ const handleBulkChangeCommercial = async () => {
1039
+ if (!bulkCommercialId) {
1040
+ setError('Veuillez sélectionner un commercial');
1041
+ return;
1042
+ }
1043
+
1044
+ setBulkActionLoading(true);
1045
+ setError('');
1046
+
1047
+ try {
1048
+ const updatePromises = Array.from(selectedContactIds).map(async (contactId) => {
1049
+ const response = await fetch(`/api/contacts/${contactId}`, {
1050
+ method: 'PUT',
1051
+ headers: { 'Content-Type': 'application/json' },
1052
+ body: JSON.stringify({ assignedCommercialId: bulkCommercialId }),
1053
+ });
1054
+
1055
+ if (!response.ok) {
1056
+ const errorData = await response.json().catch(() => ({}));
1057
+ throw new Error(
1058
+ errorData.error || `Erreur lors de la mise à jour du contact ${contactId}`,
1059
+ );
1060
+ }
1061
+
1062
+ return response.json();
1063
+ });
1064
+
1065
+ await Promise.all(updatePromises);
1066
+
1067
+ setSuccess(`${selectedContactIds.size} contact(s) mis à jour avec succès !`);
1068
+ setShowBulkCommercialModal(false);
1069
+ setBulkCommercialId('');
1070
+ setSelectedContactIds(new Set());
1071
+ fetchContacts();
1072
+ setTimeout(() => setSuccess(''), 5000);
1073
+ } catch (err: any) {
1074
+ setError(err.message || 'Erreur lors de la mise à jour');
1075
+ } finally {
1076
+ setBulkActionLoading(false);
1077
+ }
1078
+ };
1079
+
1080
+ const handleBulkChangeStatus = async () => {
1081
+ if (!bulkStatusId) {
1082
+ setError('Veuillez sélectionner un statut');
1083
+ return;
1084
+ }
1085
+
1086
+ setBulkActionLoading(true);
1087
+ setError('');
1088
+
1089
+ try {
1090
+ const updatePromises = Array.from(selectedContactIds).map(async (contactId) => {
1091
+ const response = await fetch(`/api/contacts/${contactId}`, {
1092
+ method: 'PUT',
1093
+ headers: { 'Content-Type': 'application/json' },
1094
+ body: JSON.stringify({ statusId: bulkStatusId }),
1095
+ });
1096
+
1097
+ if (!response.ok) {
1098
+ const errorData = await response.json().catch(() => ({}));
1099
+ throw new Error(
1100
+ errorData.error || `Erreur lors de la mise à jour du contact ${contactId}`,
1101
+ );
1102
+ }
1103
+
1104
+ return response.json();
1105
+ });
1106
+
1107
+ await Promise.all(updatePromises);
1108
+
1109
+ setSuccess(`${selectedContactIds.size} contact(s) mis à jour avec succès !`);
1110
+ setShowBulkStatusModal(false);
1111
+ setBulkStatusId('');
1112
+ setSelectedContactIds(new Set());
1113
+ fetchContacts();
1114
+ setTimeout(() => setSuccess(''), 5000);
1115
+ } catch (err: any) {
1116
+ setError(err.message || 'Erreur lors de la mise à jour');
1117
+ } finally {
1118
+ setBulkActionLoading(false);
1119
+ }
1120
+ };
1121
+
1122
+ const handleBulkDelete = async () => {
1123
+ if (!isAdmin) {
1124
+ setError('Seuls les administrateurs peuvent supprimer des contacts');
1125
+ return;
1126
+ }
1127
+
1128
+ if (
1129
+ !confirm(
1130
+ `Êtes-vous sûr de vouloir supprimer ${selectedContactIds.size} contact(s) ? Cette action est irréversible.`,
1131
+ )
1132
+ ) {
1133
+ return;
1134
+ }
1135
+
1136
+ setBulkActionLoading(true);
1137
+ setError('');
1138
+
1139
+ try {
1140
+ const deletePromises = Array.from(selectedContactIds).map(async (contactId) => {
1141
+ const response = await fetch(`/api/contacts/${contactId}`, {
1142
+ method: 'DELETE',
1143
+ });
1144
+
1145
+ if (!response.ok) {
1146
+ const errorData = await response.json().catch(() => ({}));
1147
+ throw new Error(
1148
+ errorData.error || `Erreur lors de la suppression du contact ${contactId}`,
1149
+ );
1150
+ }
1151
+
1152
+ return response.json();
1153
+ });
1154
+
1155
+ await Promise.all(deletePromises);
1156
+
1157
+ setSuccess(`${selectedContactIds.size} contact(s) supprimé(s) avec succès !`);
1158
+ setSelectedContactIds(new Set());
1159
+ fetchContacts();
1160
+ setTimeout(() => setSuccess(''), 5000);
1161
+ } catch (err: any) {
1162
+ setError(err.message || 'Erreur lors de la suppression');
1163
+ } finally {
1164
+ setBulkActionLoading(false);
1165
+ }
1166
+ };
1167
+
1168
+ // Fonction d'export
1169
+ const handleExport = async (format: 'csv' | 'excel') => {
1170
+ if (!isAdmin) {
1171
+ setError('Seuls les administrateurs peuvent exporter des contacts');
1172
+ return;
1173
+ }
1174
+
1175
+ setExporting(true);
1176
+ setError('');
1177
+
1178
+ try {
1179
+ const contactIds = exportAll ? null : Array.from(selectedContactIds);
1180
+
1181
+ const response = await fetch('/api/contacts/export', {
1182
+ method: 'POST',
1183
+ headers: { 'Content-Type': 'application/json' },
1184
+ body: JSON.stringify({ contactIds, format }),
1185
+ });
1186
+
1187
+ if (!response.ok) {
1188
+ const errorData = await response.json().catch(() => ({}));
1189
+ throw new Error(errorData.error || "Erreur lors de l'export");
1190
+ }
1191
+
1192
+ // Récupérer le blob
1193
+ const blob = await response.blob();
1194
+
1195
+ // Créer un lien de téléchargement
1196
+ const url = window.URL.createObjectURL(blob);
1197
+ const a = document.createElement('a');
1198
+ a.href = url;
1199
+ a.download = `contacts_${new Date().toISOString().split('T')[0]}.${format === 'csv' ? 'csv' : 'xlsx'}`;
1200
+ document.body.appendChild(a);
1201
+ a.click();
1202
+ document.body.removeChild(a);
1203
+ window.URL.revokeObjectURL(url);
1204
+
1205
+ setSuccess(
1206
+ exportAll
1207
+ ? `Tous les contacts ont été exportés en ${format.toUpperCase()} avec succès !`
1208
+ : `${selectedContactIds.size} contact(s) exporté(s) en ${format.toUpperCase()} avec succès !`,
1209
+ );
1210
+ setShowExportModal(false);
1211
+ if (!exportAll) {
1212
+ setSelectedContactIds(new Set());
1213
+ }
1214
+ setTimeout(() => setSuccess(''), 5000);
1215
+ } catch (err: any) {
1216
+ setError(err.message || "Erreur lors de l'export");
1217
+ } finally {
1218
+ setExporting(false);
1219
+ }
1220
+ };
1221
+
1222
+ // Obtenir les colonnes visibles triées par ordre
1223
+ const visibleColumns = tableColumns
1224
+ .filter((col) => {
1225
+ // Si le regroupement est activé, masquer phone et email
1226
+ if (groupContactInfo && (col.id === 'phone' || col.id === 'email')) {
1227
+ return false;
1228
+ }
1229
+ return col.visible;
1230
+ })
1231
+ .sort((a, b) => a.order - b.order);
1232
+
1233
+ // Rendu d'une cellule de tableau en fonction de la colonne
1234
+ const renderTableCell = (contactId: string, contact: Contact) => {
1235
+ switch (contactId) {
1236
+ case 'contact':
1237
+ return (
1238
+ <td key={contactId} className="px-3 py-4 whitespace-nowrap sm:px-6">
1239
+ <div className="flex items-center">
1240
+ <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-indigo-600">
1241
+ {contact.isCompany ? (
1242
+ <span className="text-xs font-bold">🏢</span>
1243
+ ) : (
1244
+ (contact.firstName?.[0] || contact.lastName?.[0] || '?').toUpperCase()
1245
+ )}
1246
+ </div>
1247
+ <div className="ml-3 min-w-0 sm:ml-4">
1248
+ <div className="flex items-center gap-2">
1249
+ <span className="text-base font-medium text-gray-900">
1250
+ {contact.civility && `${contact.civility}. `}
1251
+ {contact.firstName} {contact.lastName}
1252
+ </span>
1253
+ {contact.isCompany && (
1254
+ <span className="inline-flex rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800">
1255
+ Entreprise
1256
+ </span>
1257
+ )}
1258
+ </div>
1259
+ {groupContactInfo && (
1260
+ <>
1261
+ {contact.email && (
1262
+ <div className="mt-1 flex items-center text-sm text-gray-600">
1263
+ <Mail className="mr-1 h-3.5 w-3.5 text-gray-400" />
1264
+ <span className="max-w-[200px]">{contact.email}</span>
1265
+ </div>
1266
+ )}
1267
+ {contact.phone && (
1268
+ <div className="mt-1 flex items-center text-sm text-gray-600">
1269
+ <Phone className="mr-1 h-3.5 w-3.5 text-gray-400" />
1270
+ {contact.phone}
1271
+ </div>
1272
+ )}
1273
+ </>
1274
+ )}
1275
+ {contact.companyName && (
1276
+ <div className="flex items-center text-sm text-gray-500">
1277
+ <Building2 className="mr-1 h-3.5 w-3.5 text-gray-400" />
1278
+ {contact.companyName}
1279
+ </div>
1280
+ )}
1281
+ {!contact.companyName && contact.companyRelation && (
1282
+ <div className="flex items-center text-sm text-gray-500">
1283
+ <Building2 className="mr-1 h-3.5 w-3.5 text-gray-400" />
1284
+ {contact.companyRelation.firstName ||
1285
+ contact.companyRelation.lastName ||
1286
+ 'Sans nom'}
1287
+ </div>
1288
+ )}
1289
+ {contact.city && (
1290
+ <div className="flex items-center text-sm text-gray-500">
1291
+ <MapPin className="mr-1 h-3.5 w-3.5" />
1292
+ {contact.city}
1293
+ {contact.postalCode && ` ${contact.postalCode}`}
1294
+ </div>
1295
+ )}
1296
+ </div>
1297
+ </div>
1298
+ </td>
1299
+ );
1300
+ case 'phone':
1301
+ return (
1302
+ <td key={contactId} className="px-3 py-4 text-base whitespace-nowrap sm:px-6">
1303
+ <div className="flex items-center text-gray-900">
1304
+ <Phone className="mr-2 h-4 w-4 text-gray-400" />
1305
+ {contact.phone}
1306
+ </div>
1307
+ {contact.secondaryPhone && (
1308
+ <div className="mt-1 text-sm text-gray-500">{contact.secondaryPhone}</div>
1309
+ )}
1310
+ </td>
1311
+ );
1312
+ case 'email':
1313
+ return (
1314
+ <td key={contactId} className="px-3 py-4 text-base whitespace-nowrap sm:px-6">
1315
+ {contact.email ? (
1316
+ <div className="flex items-center text-gray-900">
1317
+ <Mail className="mr-2 h-4 w-4 text-gray-400" />
1318
+ <span className="max-w-[200px]">{contact.email}</span>
1319
+ </div>
1320
+ ) : (
1321
+ <span className="text-gray-400">-</span>
1322
+ )}
1323
+ </td>
1324
+ );
1325
+ case 'status':
1326
+ return (
1327
+ <td key={contactId} className="px-3 py-4 whitespace-nowrap sm:px-6">
1328
+ {contact.status ? (
1329
+ <span
1330
+ className="inline-flex rounded-full px-2 py-1 text-sm font-semibold"
1331
+ style={{
1332
+ backgroundColor: `${contact.status.color}20`,
1333
+ color: contact.status.color,
1334
+ }}
1335
+ >
1336
+ {contact.status.name}
1337
+ </span>
1338
+ ) : (
1339
+ <span className="text-gray-400">-</span>
1340
+ )}
1341
+ </td>
1342
+ );
1343
+ case 'origin':
1344
+ return (
1345
+ <td key={contactId} className="px-3 py-4 text-base whitespace-nowrap sm:px-6">
1346
+ {contact.origin ? (
1347
+ <span className="text-gray-700">{contact.origin}</span>
1348
+ ) : (
1349
+ <span className="text-gray-400">Non renseigné</span>
1350
+ )}
1351
+ </td>
1352
+ );
1353
+ case 'commercial':
1354
+ return (
1355
+ <td key={contactId} className="px-3 py-4 whitespace-nowrap sm:px-6">
1356
+ {contact.assignedCommercial ? (
1357
+ <span className="text-base text-gray-900">{contact.assignedCommercial.name}</span>
1358
+ ) : (
1359
+ <span className="inline-flex rounded-full border border-orange-300 bg-orange-50 px-2 py-1 text-sm font-medium text-orange-800">
1360
+ Non Attribué
1361
+ </span>
1362
+ )}
1363
+ </td>
1364
+ );
1365
+ case 'telepro':
1366
+ return (
1367
+ <td key={contactId} className="px-3 py-4 whitespace-nowrap sm:px-6">
1368
+ {contact.assignedTelepro ? (
1369
+ <span className="text-base text-gray-900">{contact.assignedTelepro.name}</span>
1370
+ ) : (
1371
+ <span className="inline-flex rounded-full border border-orange-300 bg-orange-50 px-2 py-1 text-sm font-medium text-orange-800">
1372
+ Non Attribué
1373
+ </span>
1374
+ )}
1375
+ </td>
1376
+ );
1377
+ case 'createdAt':
1378
+ return (
1379
+ <td
1380
+ key={contactId}
1381
+ className="px-3 py-4 text-base whitespace-nowrap text-gray-500 sm:px-6"
1382
+ >
1383
+ {contact.createdAt ? formatDate(contact.createdAt) : '-'}
1384
+ </td>
1385
+ );
1386
+ case 'updatedAt':
1387
+ return (
1388
+ <td
1389
+ key={contactId}
1390
+ className="px-3 py-4 text-base whitespace-nowrap text-gray-500 sm:px-6"
1391
+ >
1392
+ {contact.updatedAt ? formatDate(contact.updatedAt) : '-'}
1393
+ </td>
1394
+ );
1395
+ default:
1396
+ return null;
1397
+ }
1398
+ };
1399
+
1400
+ return (
1401
+ <div className="bg-crms-bg flex h-full flex-col">
1402
+ {/* Header avec titre, badge et breadcrumbs */}
1403
+ <div className="border-b border-gray-200 bg-white px-4 py-4 sm:px-6 lg:px-8">
1404
+ <div className="mb-3 flex items-start justify-between gap-3">
1405
+ {/* Mobile menu button */}
1406
+ <button
1407
+ onClick={toggleMobileMenu}
1408
+ className="mt-1 shrink-0 cursor-pointer rounded-lg p-2 text-gray-700 transition-colors hover:bg-gray-100 lg:hidden"
1409
+ aria-label="Toggle menu"
1410
+ >
1411
+ <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1412
+ {isMobileMenuOpen ? (
1413
+ <path
1414
+ strokeLinecap="round"
1415
+ strokeLinejoin="round"
1416
+ strokeWidth={2}
1417
+ d="M6 18L18 6M6 6l12 12"
1418
+ />
1419
+ ) : (
1420
+ <path
1421
+ strokeLinecap="round"
1422
+ strokeLinejoin="round"
1423
+ strokeWidth={2}
1424
+ d="M4 6h16M4 12h16M4 18h16"
1425
+ />
1426
+ )}
1427
+ </svg>
1428
+ </button>
1429
+
1430
+ {/* Titre et breadcrumbs */}
1431
+ <div className="flex-1">
1432
+ <div className="mb-1 flex items-center gap-2">
1433
+ <h1 className="text-2xl font-bold text-gray-900">Contacts</h1>
1434
+ <span className="rounded-full bg-indigo-100 px-2.5 py-0.5 text-sm font-semibold text-indigo-600">
1435
+ {totalContacts}
1436
+ </span>
1437
+ </div>
1438
+ <p className="text-base text-gray-500">Home &gt; Contacts</p>
1439
+ </div>
1440
+
1441
+ <button
1442
+ onClick={fetchContacts}
1443
+ className="cursor-pointer rounded-lg p-2 text-gray-600 transition-colors hover:bg-gray-100"
1444
+ title="Actualiser"
1445
+ >
1446
+ <RefreshCw className="h-5 w-5" />
1447
+ </button>
1448
+ </div>
1449
+
1450
+ {/* Barre d'outils avec recherche et filtres */}
1451
+ <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
1452
+ {/* Recherche et filtres à gauche */}
1453
+ <div className="flex flex-1 flex-wrap items-center gap-2">
1454
+ <div className="relative min-w-[200px] flex-1">
1455
+ <Search className="pointer-events-none absolute top-2.5 left-3 h-4 w-4 text-gray-400" />
1456
+ <input
1457
+ type="text"
1458
+ value={search}
1459
+ onChange={(e) => setSearch(e.target.value)}
1460
+ placeholder="Rechercher"
1461
+ className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2 pr-3 pl-9 text-base text-gray-900 placeholder:text-gray-400 focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-500/30 focus:outline-none"
1462
+ />
1463
+ </div>
1464
+ <div className="relative" ref={sortMenuRef}>
1465
+ <button
1466
+ type="button"
1467
+ onClick={() => setShowSortMenu(!showSortMenu)}
1468
+ className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
1469
+ >
1470
+ <ChevronsLeft className="h-3.5 w-3.5" />
1471
+ <span>Trier par</span>
1472
+ </button>
1473
+ {showSortMenu && (
1474
+ <div
1475
+ className="absolute top-full right-0 z-50 mt-2 w-48 rounded-lg border border-gray-200 bg-white shadow-lg"
1476
+ style={{ maxWidth: 'calc(100vw - 2rem)', right: '0' }}
1477
+ >
1478
+ <div className="py-1">
1479
+ <button
1480
+ type="button"
1481
+ onClick={() => handleSortByField('status')}
1482
+ className="w-full cursor-pointer px-4 py-2 text-left text-base text-gray-700 hover:bg-gray-100"
1483
+ >
1484
+ Statut {sortField === 'status' && (sortOrder === 'asc' ? '↑' : '↓')}
1485
+ </button>
1486
+ <button
1487
+ type="button"
1488
+ onClick={() => handleSortByField('commercial')}
1489
+ className="w-full cursor-pointer px-4 py-2 text-left text-base text-gray-700 hover:bg-gray-100"
1490
+ >
1491
+ Commercial {sortField === 'commercial' && (sortOrder === 'asc' ? '↑' : '↓')}
1492
+ </button>
1493
+ <button
1494
+ type="button"
1495
+ onClick={() => handleSortByField('telepro')}
1496
+ className="w-full cursor-pointer px-4 py-2 text-left text-base text-gray-700 hover:bg-gray-100"
1497
+ >
1498
+ Télépro {sortField === 'telepro' && (sortOrder === 'asc' ? '↑' : '↓')}
1499
+ </button>
1500
+ <button
1501
+ type="button"
1502
+ onClick={() => handleSortByField('createdAt')}
1503
+ className="w-full cursor-pointer px-4 py-2 text-left text-base text-gray-700 hover:bg-gray-100"
1504
+ >
1505
+ Date de création{' '}
1506
+ {sortField === 'createdAt' && (sortOrder === 'asc' ? '↑' : '↓')}
1507
+ </button>
1508
+ <button
1509
+ type="button"
1510
+ onClick={() => handleSortByField('updatedAt')}
1511
+ className="w-full cursor-pointer px-4 py-2 text-left text-base text-gray-700 hover:bg-gray-100"
1512
+ >
1513
+ Date de modification{' '}
1514
+ {sortField === 'updatedAt' && (sortOrder === 'asc' ? '↑' : '↓')}
1515
+ </button>
1516
+ {sortField && (
1517
+ <button
1518
+ type="button"
1519
+ onClick={() => {
1520
+ setSortField('');
1521
+ setSortOrder('asc');
1522
+ setShowSortMenu(false);
1523
+ }}
1524
+ className="w-full cursor-pointer px-4 py-2 text-left text-base text-gray-500 hover:bg-gray-100"
1525
+ >
1526
+ Réinitialiser
1527
+ </button>
1528
+ )}
1529
+ </div>
1530
+ </div>
1531
+ )}
1532
+ </div>
1533
+ <div className="flex items-center gap-2">
1534
+ <div className="relative" ref={datePickerRef}>
1535
+ <button
1536
+ type="button"
1537
+ onClick={() => setShowDatePicker(!showDatePicker)}
1538
+ className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
1539
+ >
1540
+ <Calendar className="h-3.5 w-3.5" />
1541
+ <span className="hidden sm:inline">
1542
+ {dateRangeStart && dateRangeEnd
1543
+ ? `${new Date(dateRangeStart + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })} - ${new Date(dateRangeEnd + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })}`
1544
+ : createdAtStart && createdAtEnd
1545
+ ? `${new Date(createdAtStart + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })} - ${new Date(createdAtEnd + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })}`
1546
+ : 'Sélectionner une date'}
1547
+ </span>
1548
+ <span className="sm:hidden">Date</span>
1549
+ </button>
1550
+ {showDatePicker && (
1551
+ <div
1552
+ className="absolute top-full right-0 z-50 mt-2 w-72 rounded-lg border border-gray-200 bg-white p-4 shadow-lg"
1553
+ style={{ maxWidth: 'calc(100vw - 2rem)', right: '0' }}
1554
+ >
1555
+ <div className="space-y-3">
1556
+ <div>
1557
+ <label className="mb-1 block text-xs font-medium text-gray-700">Du</label>
1558
+ <input
1559
+ type="date"
1560
+ value={dateRangeStart}
1561
+ onChange={(e) => setDateRangeStart(e.target.value)}
1562
+ className="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
1563
+ />
1564
+ </div>
1565
+ <div>
1566
+ <label className="mb-1 block text-xs font-medium text-gray-700">Au</label>
1567
+ <input
1568
+ type="date"
1569
+ value={dateRangeEnd}
1570
+ onChange={(e) => setDateRangeEnd(e.target.value)}
1571
+ className="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
1572
+ />
1573
+ </div>
1574
+ <div className="flex justify-between gap-2">
1575
+ <button
1576
+ type="button"
1577
+ onClick={() => {
1578
+ // Réinitialiser tous les filtres de date
1579
+ setDateRangeStart('');
1580
+ setDateRangeEnd('');
1581
+ setCreatedAtStart('');
1582
+ setCreatedAtEnd('');
1583
+ setUpdatedAtStart('');
1584
+ setUpdatedAtEnd('');
1585
+ setCurrentPage(1);
1586
+ setShowDatePicker(false);
1587
+ // Recharger les contacts sans filtres de date
1588
+ setTimeout(() => {
1589
+ fetchContacts();
1590
+ }, 0);
1591
+ }}
1592
+ className="cursor-pointer rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
1593
+ >
1594
+ Réinitialiser
1595
+ </button>
1596
+ <div className="flex gap-2">
1597
+ <button
1598
+ type="button"
1599
+ onClick={() => {
1600
+ setDateRangeStart('');
1601
+ setDateRangeEnd('');
1602
+ setShowDatePicker(false);
1603
+ }}
1604
+ className="cursor-pointer rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
1605
+ >
1606
+ Effacer
1607
+ </button>
1608
+ <button
1609
+ type="button"
1610
+ onClick={() => {
1611
+ if (dateRangeStart && dateRangeEnd) {
1612
+ // Valider que les dates sont valides
1613
+ const start = new Date(dateRangeStart + 'T00:00:00');
1614
+ const end = new Date(dateRangeEnd + 'T00:00:00');
1615
+ if (
1616
+ !isNaN(start.getTime()) &&
1617
+ !isNaN(end.getTime()) &&
1618
+ start <= end
1619
+ ) {
1620
+ setCreatedAtStart(dateRangeStart);
1621
+ setCreatedAtEnd(dateRangeEnd);
1622
+ setCurrentPage(1);
1623
+ setShowDatePicker(false);
1624
+ } else {
1625
+ setError('Les dates sélectionnées sont invalides');
1626
+ }
1627
+ } else {
1628
+ setShowDatePicker(false);
1629
+ }
1630
+ }}
1631
+ className="cursor-pointer rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-indigo-700"
1632
+ >
1633
+ Appliquer
1634
+ </button>
1635
+ </div>
1636
+ </div>
1637
+ </div>
1638
+ </div>
1639
+ )}
1640
+ </div>
1641
+ {(createdAtStart || createdAtEnd || updatedAtStart || updatedAtEnd) && (
1642
+ <button
1643
+ type="button"
1644
+ onClick={() => {
1645
+ // Réinitialiser tous les filtres de date
1646
+ setDateRangeStart('');
1647
+ setDateRangeEnd('');
1648
+ setCreatedAtStart('');
1649
+ setCreatedAtEnd('');
1650
+ setUpdatedAtStart('');
1651
+ setUpdatedAtEnd('');
1652
+ setCurrentPage(1);
1653
+ // Recharger les contacts sans filtres de date
1654
+ setTimeout(() => {
1655
+ fetchContacts();
1656
+ }, 0);
1657
+ }}
1658
+ className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
1659
+ title="Réinitialiser les filtres de date"
1660
+ >
1661
+ <X className="h-3.5 w-3.5" />
1662
+ <span className="hidden sm:inline">Réinitialiser</span>
1663
+ </button>
1664
+ )}
1665
+ </div>
1666
+ </div>
1667
+
1668
+ {/* Actions à droite */}
1669
+ <div className="flex items-center gap-2">
1670
+ {/* Gérer les colonnes */}
1671
+ {viewMode === 'table' && (
1672
+ <button
1673
+ onClick={() => setShowColumnPanel(true)}
1674
+ className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
1675
+ title="Gérer les colonnes"
1676
+ >
1677
+ Gérer les colonnes
1678
+ </button>
1679
+ )}
1680
+ {/* Bouton Réinitialiser les filtres */}
1681
+ {hasActiveFilters() && (
1682
+ <button
1683
+ onClick={handleResetAllFilters}
1684
+ className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
1685
+ title="Réinitialiser tous les filtres"
1686
+ >
1687
+ <X className="h-3.5 w-3.5" />
1688
+ Réinitialiser
1689
+ </button>
1690
+ )}
1691
+ {/* Groupe vue (liste / grille) */}
1692
+ <div className="flex items-center rounded-lg border border-gray-200 bg-white p-1">
1693
+ <button
1694
+ onClick={() => setViewMode('table')}
1695
+ className={cn(
1696
+ 'cursor-pointer rounded-md px-2 py-1.5 text-gray-600 transition-colors',
1697
+ viewMode === 'table' ? 'bg-gray-100 text-gray-900' : 'hover:bg-gray-50',
1698
+ )}
1699
+ title="Vue liste"
1700
+ >
1701
+ <List className="h-4 w-4" />
1702
+ </button>
1703
+ <button
1704
+ onClick={() => setViewMode('cards')}
1705
+ className={cn(
1706
+ 'cursor-pointer rounded-md px-2 py-1.5 text-gray-600 transition-colors',
1707
+ viewMode === 'cards' ? 'bg-gray-100 text-gray-900' : 'hover:bg-gray-50',
1708
+ )}
1709
+ title="Vue grille"
1710
+ >
1711
+ <LayoutGrid className="h-4 w-4" />
1712
+ </button>
1713
+ </div>
1714
+
1715
+ {/* Exporter tous les contacts (admin uniquement) */}
1716
+ {isAdmin && (
1717
+ <button
1718
+ onClick={() => {
1719
+ setExportAll(true);
1720
+ setShowExportModal(true);
1721
+ }}
1722
+ disabled={exporting}
1723
+ className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-indigo-600 bg-white px-4 py-2 text-xs font-semibold text-indigo-600 shadow-sm transition-colors hover:bg-indigo-50 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 sm:text-sm"
1724
+ >
1725
+ <Download className="h-4 w-4" />
1726
+ Exporter tous
1727
+ </button>
1728
+ )}
1729
+
1730
+ {/* Ajouter un contact */}
1731
+ <button
1732
+ onClick={handleNewContact}
1733
+ className="inline-flex cursor-pointer items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-xs font-semibold text-white shadow-sm transition-colors hover:bg-indigo-700 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:outline-none sm:text-sm"
1734
+ >
1735
+ <Plus className="h-4 w-4" />
1736
+ Ajouter un contact
1737
+ </button>
1738
+ </div>
1739
+ </div>
1740
+ </div>
1741
+
1742
+ <div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
1743
+ {success && (
1744
+ <div className="mb-4 rounded-lg bg-green-50 p-4 text-sm text-green-600">{success}</div>
1745
+ )}
1746
+
1747
+ {error && <div className="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-600">{error}</div>}
1748
+
1749
+ {/* Barre d'actions groupées */}
1750
+ {selectedContactIds.size > 0 && (
1751
+ <div className="mb-4 flex items-center justify-between rounded-lg border border-indigo-200 bg-indigo-50 p-4 shadow-sm">
1752
+ <div className="flex items-center gap-4">
1753
+ <span className="text-sm font-medium text-indigo-900">
1754
+ {selectedContactIds.size} contact(s) sélectionné(s)
1755
+ </span>
1756
+ <button
1757
+ onClick={handleDeselectAll}
1758
+ className="cursor-pointer text-sm text-indigo-600 hover:text-indigo-700 hover:underline"
1759
+ >
1760
+ Désélectionner tout
1761
+ </button>
1762
+ </div>
1763
+ <div className="flex items-center gap-2">
1764
+ <button
1765
+ onClick={() => setShowBulkCommercialModal(true)}
1766
+ className="flex cursor-pointer items-center gap-2 rounded-lg bg-orange-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-orange-700"
1767
+ >
1768
+ <Users className="h-4 w-4" />
1769
+ Changer commercial
1770
+ </button>
1771
+ <button
1772
+ onClick={() => setShowBulkStatusModal(true)}
1773
+ className="flex cursor-pointer items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700"
1774
+ >
1775
+ <Tag className="h-4 w-4" />
1776
+ Changer statut
1777
+ </button>
1778
+ {isAdmin && (
1779
+ <>
1780
+ <button
1781
+ onClick={() => {
1782
+ setExportAll(false);
1783
+ setShowExportModal(true);
1784
+ }}
1785
+ disabled={bulkActionLoading || exporting}
1786
+ className="flex cursor-pointer items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
1787
+ >
1788
+ <Download className="h-4 w-4" />
1789
+ Exporter
1790
+ </button>
1791
+ <button
1792
+ onClick={handleBulkDelete}
1793
+ disabled={bulkActionLoading}
1794
+ className="flex cursor-pointer items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
1795
+ >
1796
+ <Trash2 className="h-4 w-4" />
1797
+ Supprimer
1798
+ </button>
1799
+ </>
1800
+ )}
1801
+ </div>
1802
+ </div>
1803
+ )}
1804
+
1805
+ {/* Liste des contacts */}
1806
+ {loading ? (
1807
+ viewMode === 'table' ? (
1808
+ <ContactTableSkeleton />
1809
+ ) : (
1810
+ <ContactCardsSkeleton />
1811
+ )
1812
+ ) : contacts.length === 0 ? (
1813
+ <div className="rounded-lg bg-white p-12 text-center shadow">
1814
+ <div className="text-4xl sm:text-6xl">👥</div>
1815
+ <h2 className="mt-4 text-lg font-semibold text-gray-900 sm:text-xl">
1816
+ Aucun contact trouvé
1817
+ </h2>
1818
+ <p className="mt-2 text-sm text-gray-600 sm:text-base">
1819
+ {search || statusFilter || assignedCommercialFilter || assignedTeleproFilter
1820
+ ? 'Aucun contact ne correspond à vos critères'
1821
+ : 'Commencez par ajouter votre premier contact'}
1822
+ </p>
1823
+ {!search && !statusFilter && !assignedCommercialFilter && !assignedTeleproFilter && (
1824
+ <button
1825
+ onClick={handleNewContact}
1826
+ className="mt-6 cursor-pointer rounded-lg bg-indigo-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 sm:text-base"
1827
+ >
1828
+ Ajouter un contact
1829
+ </button>
1830
+ )}
1831
+ </div>
1832
+ ) : (
1833
+ <>
1834
+ {/* Vue Tableau */}
1835
+ {viewMode === 'table' && (
1836
+ <div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
1837
+ <div className="overflow-x-auto">
1838
+ <table className="min-w-full divide-y divide-gray-100 text-sm">
1839
+ <thead className="bg-gray-50/60">
1840
+ <tr>
1841
+ {/* Checkbox pour tout sélectionner */}
1842
+ <th className="px-3 py-3 text-left sm:px-6">
1843
+ <input
1844
+ type="checkbox"
1845
+ checked={
1846
+ contacts.length > 0 && selectedContactIds.size === contacts.length
1847
+ }
1848
+ onChange={handleSelectAll}
1849
+ className="h-4 w-4 cursor-pointer rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
1850
+ />
1851
+ </th>
1852
+ {/* En-têtes dynamiques des colonnes */}
1853
+ {visibleColumns.map((column) => {
1854
+ const isSortable = column.id === 'createdAt' || column.id === 'updatedAt';
1855
+ const showFilterIcon =
1856
+ column.id === 'status' ||
1857
+ column.id === 'origin' ||
1858
+ column.id === 'commercial' ||
1859
+ column.id === 'telepro' ||
1860
+ column.id === 'createdAt' ||
1861
+ column.id === 'updatedAt';
1862
+
1863
+ return (
1864
+ <th
1865
+ key={column.id}
1866
+ className="px-3 py-3 text-left text-sm font-medium tracking-wider text-gray-500 uppercase sm:px-6"
1867
+ >
1868
+ {showFilterIcon ? (
1869
+ <div className="inline-flex items-center gap-1">
1870
+ {isSortable ? (
1871
+ <button
1872
+ type="button"
1873
+ onClick={() => handleSort(column.id)}
1874
+ className="inline-flex cursor-pointer items-center gap-1"
1875
+ >
1876
+ <span>{column.label}</span>
1877
+ {sortField === column.id && (
1878
+ <span className="text-indigo-600">
1879
+ {sortOrder === 'asc' ? <ChevronDown /> : <ChevronUp />}
1880
+ </span>
1881
+ )}
1882
+ </button>
1883
+ ) : (
1884
+ <span>{column.label}</span>
1885
+ )}
1886
+ <button
1887
+ type="button"
1888
+ data-date-filter-icon={
1889
+ column.id === 'createdAt' || column.id === 'updatedAt'
1890
+ ? true
1891
+ : undefined
1892
+ }
1893
+ onClick={(e) =>
1894
+ column.id === 'createdAt' || column.id === 'updatedAt'
1895
+ ? handleDateFilterClick(e, column.id)
1896
+ : handleListFilterClick(e, column.id)
1897
+ }
1898
+ className="cursor-pointer"
1899
+ >
1900
+ <Filter
1901
+ className={cn(
1902
+ 'h-3 w-3',
1903
+ (isSortable && sortField === column.id) ||
1904
+ (column.id === 'createdAt' &&
1905
+ (createdAtStart || createdAtEnd)) ||
1906
+ (column.id === 'updatedAt' &&
1907
+ (updatedAtStart || updatedAtEnd))
1908
+ ? 'text-indigo-600'
1909
+ : 'text-gray-400',
1910
+ )}
1911
+ />
1912
+ </button>
1913
+ </div>
1914
+ ) : column.id === 'contact' && groupContactInfo ? (
1915
+ 'Contact'
1916
+ ) : (
1917
+ column.label
1918
+ )}
1919
+ </th>
1920
+ );
1921
+ })}
1922
+ </tr>
1923
+ </thead>
1924
+ <tbody className="divide-y divide-gray-200">
1925
+ {sortedContacts.map((contact) => (
1926
+ <tr
1927
+ key={contact.id}
1928
+ onClick={() => router.push(`/contacts/${contact.id}`)}
1929
+ className={cn(
1930
+ 'cursor-pointer transition-colors hover:bg-gray-50',
1931
+ selectedContactIds.has(contact.id) && 'bg-indigo-50',
1932
+ )}
1933
+ >
1934
+ {/* Checkbox pour sélectionner ce contact */}
1935
+ <td
1936
+ className="px-3 py-4 whitespace-nowrap sm:px-6"
1937
+ onClick={(e) => {
1938
+ e.stopPropagation();
1939
+ }}
1940
+ >
1941
+ <input
1942
+ type="checkbox"
1943
+ checked={selectedContactIds.has(contact.id)}
1944
+ onChange={() => handleSelectContact(contact.id)}
1945
+ className="h-4 w-4 cursor-pointer rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
1946
+ />
1947
+ </td>
1948
+ {/* Cellules dynamiques */}
1949
+ {visibleColumns.map((column) => renderTableCell(column.id, contact))}
1950
+ </tr>
1951
+ ))}
1952
+ </tbody>
1953
+ </table>
1954
+ </div>
1955
+ </div>
1956
+ )}
1957
+
1958
+ {/* Vue Cartes */}
1959
+ {viewMode === 'cards' ? (
1960
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
1961
+ {sortedContacts.map((contact) => {
1962
+ return (
1963
+ <div
1964
+ key={contact.id}
1965
+ onClick={() => router.push(`/contacts/${contact.id}`)}
1966
+ className="relative cursor-pointer rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
1967
+ >
1968
+ {/* En-tête avec trois points */}
1969
+ <div className="mb-4 flex items-start justify-between">
1970
+ <div className="flex items-center gap-3">
1971
+ <div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-lg font-semibold text-indigo-600">
1972
+ {contact.isCompany ? (
1973
+ <span>🏢</span>
1974
+ ) : (
1975
+ (contact.firstName?.[0] || contact.lastName?.[0] || '?').toUpperCase()
1976
+ )}
1977
+ </div>
1978
+ <div className="min-w-0">
1979
+ <h3 className="text-lg font-semibold text-gray-900">
1980
+ {contact.civility && `${contact.civility}. `}
1981
+ {contact.firstName} {contact.lastName}
1982
+ </h3>
1983
+ <p className="text-sm text-gray-500">
1984
+ CRÉÉ LE :{' '}
1985
+ {new Date(contact.createdAt).toLocaleDateString('fr-FR', {
1986
+ day: 'numeric',
1987
+ month: 'short',
1988
+ year: 'numeric',
1989
+ })}
1990
+ </p>
1991
+ </div>
1992
+ </div>
1993
+ </div>
1994
+
1995
+ {/* Informations de contact */}
1996
+ <div className="mb-4 space-y-2">
1997
+ <div className="flex items-center text-base text-gray-900">
1998
+ <Phone className="mr-2 h-4 w-4 text-gray-400" />
1999
+ <span className="font-medium">Tél. :</span>
2000
+ <span className="ml-1">{contact.phone}</span>
2001
+ </div>
2002
+ {contact.secondaryPhone && (
2003
+ <div className="text-sm text-gray-500">
2004
+ Tél. secondaire : {contact.secondaryPhone}
2005
+ </div>
2006
+ )}
2007
+ <div className="flex items-center text-base text-gray-900">
2008
+ <Mail className="mr-2 h-4 w-4 text-gray-400" />
2009
+ <span className="font-medium">Email :</span>
2010
+ <span className="ml-1">{contact.email || '-'}</span>
2011
+ </div>
2012
+ {(contact.companyName || contact.companyRelation) && (
2013
+ <div className="flex items-center text-base text-gray-900">
2014
+ <Building2 className="mr-2 h-4 w-4 text-gray-400" />
2015
+ <span>
2016
+ {contact.companyName ||
2017
+ (contact.companyRelation &&
2018
+ (contact.companyRelation.firstName ||
2019
+ contact.companyRelation.lastName ||
2020
+ 'Sans nom'))}
2021
+ </span>
2022
+ </div>
2023
+ )}
2024
+ {contact.city && (
2025
+ <div className="flex items-center text-base text-gray-500">
2026
+ <MapPin className="mr-2 h-4 w-4 text-gray-400" />
2027
+ <span className="font-medium">Localisation :</span>
2028
+ <span className="ml-1">
2029
+ {contact.city}
2030
+ {contact.postalCode && `, ${contact.postalCode}`}
2031
+ </span>
2032
+ </div>
2033
+ )}
2034
+ {contact.origin && (
2035
+ <div className="flex items-center text-base text-gray-500">
2036
+ <span className="mr-2 text-sm">🎯</span>
2037
+ <span className="font-medium">Origine :</span>
2038
+ <span className="ml-1">{contact.origin}</span>
2039
+ </div>
2040
+ )}
2041
+ </div>
2042
+
2043
+ {/* Badges et statut */}
2044
+ <div className="mb-4 flex flex-wrap items-center gap-2">
2045
+ {contact.status && (
2046
+ <span
2047
+ className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold"
2048
+ style={{
2049
+ backgroundColor: `${contact.status.color}20`,
2050
+ color: contact.status.color,
2051
+ }}
2052
+ >
2053
+ {contact.status.name}
2054
+ </span>
2055
+ )}
2056
+ {contact.isCompany && (
2057
+ <span className="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-semibold text-blue-800">
2058
+ Entreprise
2059
+ </span>
2060
+ )}
2061
+ {!contact.assignedCommercial && (
2062
+ <span className="inline-flex items-center rounded-full border border-orange-300 bg-orange-50 px-2.5 py-0.5 text-xs font-semibold text-orange-800">
2063
+ Non Attribué
2064
+ </span>
2065
+ )}
2066
+ </div>
2067
+
2068
+ {/* Pied de carte avec utilisateurs assignés */}
2069
+ <div className="flex items-start justify-between border-t border-gray-100 pt-4">
2070
+ <div className="space-y-2">
2071
+ {/* Commercial */}
2072
+ {contact.assignedCommercial && (
2073
+ <div className="flex items-center gap-2">
2074
+ <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-xs font-semibold text-indigo-600">
2075
+ {contact.assignedCommercial.name
2076
+ .split(' ')
2077
+ .map((n) => n[0])
2078
+ .join('')
2079
+ .slice(0, 2)
2080
+ .toUpperCase()}
2081
+ </div>
2082
+ <div className="min-w-0 flex-1">
2083
+ <div className="flex items-center gap-1.5">
2084
+ <span className="text-xs font-medium text-gray-900">
2085
+ {contact.assignedCommercial.name}
2086
+ </span>
2087
+ <span className="inline-flex items-center rounded-full bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-700">
2088
+ Commercial
2089
+ </span>
2090
+ </div>
2091
+ </div>
2092
+ </div>
2093
+ )}
2094
+
2095
+ {/* Télépro */}
2096
+ {contact.assignedTelepro && (
2097
+ <div className="flex items-center gap-2">
2098
+ <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-purple-100 text-xs font-semibold text-purple-600">
2099
+ {contact.assignedTelepro.name
2100
+ .split(' ')
2101
+ .map((n) => n[0])
2102
+ .join('')
2103
+ .slice(0, 2)
2104
+ .toUpperCase()}
2105
+ </div>
2106
+ <div className="min-w-0 flex-1">
2107
+ <div className="flex items-center gap-1.5">
2108
+ <span className="text-xs font-medium text-gray-900">
2109
+ {contact.assignedTelepro.name}
2110
+ </span>
2111
+ <span className="inline-flex items-center rounded-full bg-purple-100 px-1.5 py-0.5 text-[10px] font-medium text-purple-700">
2112
+ Télépro
2113
+ </span>
2114
+ </div>
2115
+ </div>
2116
+ </div>
2117
+ )}
2118
+
2119
+ {/* Aucun utilisateur assigné */}
2120
+ {!contact.assignedCommercial && !contact.assignedTelepro && (
2121
+ <div className="text-xs text-gray-400">Aucun utilisateur assigné</div>
2122
+ )}
2123
+ </div>
2124
+ </div>
2125
+ </div>
2126
+ );
2127
+ })}
2128
+ </div>
2129
+ ) : null}
2130
+
2131
+ {/* Pagination */}
2132
+ {totalPages > 1 && (
2133
+ <div className="mt-6 flex items-center justify-center gap-2">
2134
+ {/* Première page */}
2135
+ <button
2136
+ onClick={() => setCurrentPage(1)}
2137
+ disabled={currentPage === 1}
2138
+ className={cn(
2139
+ 'cursor-pointer rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50',
2140
+ )}
2141
+ title="Première page"
2142
+ >
2143
+ <ChevronsLeft className="h-4 w-4" />
2144
+ </button>
2145
+
2146
+ {/* Page précédente */}
2147
+ <button
2148
+ onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
2149
+ disabled={currentPage === 1}
2150
+ className={cn(
2151
+ 'cursor-pointer rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50',
2152
+ )}
2153
+ >
2154
+ Précédent
2155
+ </button>
2156
+
2157
+ {/* Numéros de pages */}
2158
+ <div className="flex items-center gap-2">
2159
+ {Array.from({ length: totalPages }, (_, i) => i + 1)
2160
+ .filter((page) => {
2161
+ // Afficher les 5 pages autour de la page actuelle
2162
+ if (totalPages <= 7) return true;
2163
+ if (page === 1 || page === totalPages) return true;
2164
+ return Math.abs(page - currentPage) <= 2;
2165
+ })
2166
+ .map((page, idx, array) => {
2167
+ // Ajouter des ellipses si nécessaire
2168
+ const prevPage = array[idx - 1];
2169
+ const showEllipsis = prevPage && page - prevPage > 1;
2170
+
2171
+ return (
2172
+ <div key={page} className="flex items-center gap-2">
2173
+ {showEllipsis && <span className="text-gray-400">...</span>}
2174
+ <button
2175
+ onClick={() => setCurrentPage(page)}
2176
+ className={cn(
2177
+ 'cursor-pointer rounded-lg border px-3 py-2 text-sm font-medium transition-colors',
2178
+ currentPage === page
2179
+ ? 'border-indigo-600 bg-indigo-600 text-white'
2180
+ : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50',
2181
+ )}
2182
+ >
2183
+ {page}
2184
+ </button>
2185
+ </div>
2186
+ );
2187
+ })}
2188
+ </div>
2189
+
2190
+ {/* Page suivante */}
2191
+ <button
2192
+ onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
2193
+ disabled={currentPage === totalPages}
2194
+ className={cn(
2195
+ 'cursor-pointer rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50',
2196
+ )}
2197
+ >
2198
+ Suivant
2199
+ </button>
2200
+
2201
+ {/* Dernière page */}
2202
+ <button
2203
+ onClick={() => setCurrentPage(totalPages)}
2204
+ disabled={currentPage === totalPages}
2205
+ className={cn(
2206
+ 'cursor-pointer rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50',
2207
+ )}
2208
+ title="Dernière page"
2209
+ >
2210
+ <ChevronsRight className="h-4 w-4" />
2211
+ </button>
2212
+ </div>
2213
+ )}
2214
+ </>
2215
+ )}
2216
+ </div>
2217
+
2218
+ {/* Panneau de gestion des colonnes */}
2219
+ {showColumnPanel && (
2220
+ <>
2221
+ {/* Overlay */}
2222
+ <div
2223
+ className="fixed inset-0 z-40 bg-black/30"
2224
+ onClick={() => setShowColumnPanel(false)}
2225
+ />
2226
+ {/* Panneau */}
2227
+ <div className="fixed top-0 right-0 z-50 h-full w-full max-w-md bg-white shadow-xl">
2228
+ <div className="flex h-full flex-col">
2229
+ {/* En-tête */}
2230
+ <div className="flex items-center justify-between border-b border-gray-200 p-6">
2231
+ <h2 className="text-lg font-semibold text-gray-900">Gérer les colonnes</h2>
2232
+ <button
2233
+ onClick={() => setShowColumnPanel(false)}
2234
+ className="cursor-pointer rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-100"
2235
+ >
2236
+ <X className="h-5 w-5" />
2237
+ </button>
2238
+ </div>
2239
+
2240
+ {/* Liste des colonnes */}
2241
+ <div className="flex-1 overflow-y-auto p-6">
2242
+ {/* Option de regroupement */}
2243
+ <div className="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
2244
+ <h3 className="mb-3 text-sm font-semibold text-gray-900">
2245
+ Regrouper les informations de contact
2246
+ </h3>
2247
+ <label className="flex cursor-pointer items-center gap-3">
2248
+ <input
2249
+ type="checkbox"
2250
+ checked={groupContactInfo}
2251
+ onChange={(e) => setGroupContactInfo(e.target.checked)}
2252
+ className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
2253
+ />
2254
+ <span className="text-sm text-gray-700">
2255
+ Afficher nom, prénom, email et téléphone dans une seule colonne
2256
+ </span>
2257
+ </label>
2258
+ </div>
2259
+
2260
+ <p className="mb-4 text-sm text-gray-600">
2261
+ Cliquez sur l'œil pour masquer/afficher une colonne. Glissez-déposez pour
2262
+ réorganiser.
2263
+ </p>
2264
+ <div className="space-y-2">
2265
+ {tableColumns
2266
+ .sort((a, b) => a.order - b.order)
2267
+ .map((contact) => (
2268
+ <div
2269
+ key={contact.id}
2270
+ draggable
2271
+ onDragStart={() => handleDragStart(contact.id)}
2272
+ onDragOver={(e) => handleDragOver(e, contact.id)}
2273
+ onDragEnd={handleDragEnd}
2274
+ className={cn(
2275
+ 'flex items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 transition-colors',
2276
+ draggedColumn === contact.id
2277
+ ? 'opacity-50'
2278
+ : 'cursor-move hover:bg-gray-50',
2279
+ )}
2280
+ >
2281
+ {/* Poignée de drag */}
2282
+ <GripVertical className="h-5 w-5 text-gray-400" />
2283
+
2284
+ {/* Label */}
2285
+ <span className="flex-1 text-sm font-medium text-gray-900">
2286
+ {contact.label}
2287
+ </span>
2288
+
2289
+ {/* Toggle visibilité */}
2290
+ <button
2291
+ onClick={() => toggleColumnVisibility(contact.id)}
2292
+ className="cursor-pointer rounded-lg p-1 text-gray-400 transition-colors hover:bg-gray-200"
2293
+ title={contact.visible ? 'Masquer' : 'Afficher'}
2294
+ >
2295
+ {contact.visible ? (
2296
+ <Eye className="h-5 w-5 text-indigo-600" />
2297
+ ) : (
2298
+ <EyeOff className="h-5 w-5" />
2299
+ )}
2300
+ </button>
2301
+ </div>
2302
+ ))}
2303
+ </div>
2304
+ </div>
2305
+
2306
+ {/* Pied */}
2307
+ <div className="border-t border-gray-200 p-6">
2308
+ <div className="flex flex-col gap-3 sm:flex-row sm:justify-between">
2309
+ <button
2310
+ onClick={resetColumns}
2311
+ className="cursor-pointer rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
2312
+ >
2313
+ Réinitialiser
2314
+ </button>
2315
+ <button
2316
+ onClick={() => setShowColumnPanel(false)}
2317
+ className="cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
2318
+ >
2319
+ Appliquer
2320
+ </button>
2321
+ </div>
2322
+ </div>
2323
+ </div>
2324
+ </div>
2325
+ </>
2326
+ )}
2327
+
2328
+ {/* Modal de création/édition */}
2329
+ {showModal && (
2330
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-500/20 p-4 backdrop-blur-sm sm:p-6">
2331
+ <div className="flex max-h-[90vh] w-full max-w-5xl flex-col rounded-lg bg-white p-6 shadow-xl sm:p-8">
2332
+ {/* En-tête fixe */}
2333
+ <div className="shrink-0 border-b border-gray-100 pb-4">
2334
+ <div className="flex items-center justify-between">
2335
+ <h2 className="text-xl font-bold text-gray-900 sm:text-2xl">
2336
+ {editingContact ? 'Modifier le contact' : 'Nouveau contact'}
2337
+ </h2>
2338
+ <button
2339
+ type="button"
2340
+ onClick={() => {
2341
+ setShowModal(false);
2342
+ setEditingContact(null);
2343
+ setError('');
2344
+ }}
2345
+ className="cursor-pointer rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-100"
2346
+ >
2347
+ <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2348
+ <path
2349
+ strokeLinecap="round"
2350
+ strokeLinejoin="round"
2351
+ strokeWidth={2}
2352
+ d="M6 18L18 6M6 6l12 12"
2353
+ />
2354
+ </svg>
2355
+ </button>
2356
+ </div>
2357
+ </div>
2358
+
2359
+ {/* Contenu scrollable */}
2360
+ <form
2361
+ id="contact-form"
2362
+ onSubmit={handleSubmit}
2363
+ className="flex-1 space-y-6 overflow-y-auto pt-4 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
2364
+ >
2365
+ {/* Informations personnelles */}
2366
+ <div>
2367
+ <h3 className="mb-4 text-lg font-semibold text-gray-900">
2368
+ Informations personnelles
2369
+ </h3>
2370
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
2371
+ <div>
2372
+ <label className="block text-sm font-medium text-gray-700">Civilité</label>
2373
+ <select
2374
+ value={formData.civility}
2375
+ onChange={(e) => setFormData({ ...formData, civility: e.target.value })}
2376
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2377
+ >
2378
+ <option value="">-</option>
2379
+ <option value="M">M.</option>
2380
+ <option value="MME">Mme</option>
2381
+ <option value="MLLE">Mlle</option>
2382
+ </select>
2383
+ </div>
2384
+
2385
+ <div>
2386
+ <label className="block text-sm font-medium text-gray-700">Prénom</label>
2387
+ <input
2388
+ type="text"
2389
+ value={formData.firstName}
2390
+ onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
2391
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2392
+ placeholder="Prénom"
2393
+ />
2394
+ </div>
2395
+
2396
+ <div>
2397
+ <label className="block text-sm font-medium text-gray-700">Nom</label>
2398
+ <input
2399
+ type="text"
2400
+ value={formData.lastName}
2401
+ onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
2402
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2403
+ placeholder="Nom"
2404
+ />
2405
+ </div>
2406
+ </div>
2407
+ </div>
2408
+
2409
+ {/* Coordonnées */}
2410
+ <div>
2411
+ <h3 className="mb-4 text-lg font-semibold text-gray-900">Coordonnées</h3>
2412
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
2413
+ <div>
2414
+ <label className="block text-sm font-medium text-gray-700">Téléphone *</label>
2415
+ <input
2416
+ type="tel"
2417
+ required
2418
+ value={formData.phone}
2419
+ onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
2420
+ onBlur={(e) => {
2421
+ const normalized = normalizePhoneNumber(e.target.value);
2422
+ if (normalized) {
2423
+ setFormData({ ...formData, phone: normalized });
2424
+ }
2425
+ }}
2426
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2427
+ placeholder="06 12 34 56 78"
2428
+ />
2429
+ </div>
2430
+
2431
+ <div>
2432
+ <label className="block text-sm font-medium text-gray-700">
2433
+ Téléphone secondaire
2434
+ </label>
2435
+ <input
2436
+ type="tel"
2437
+ value={formData.secondaryPhone}
2438
+ onChange={(e) => setFormData({ ...formData, secondaryPhone: e.target.value })}
2439
+ onBlur={(e) => {
2440
+ const normalized = normalizePhoneNumber(e.target.value);
2441
+ if (normalized) {
2442
+ setFormData({ ...formData, secondaryPhone: normalized });
2443
+ }
2444
+ }}
2445
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2446
+ placeholder="06 12 34 56 78"
2447
+ />
2448
+ </div>
2449
+
2450
+ <div className="md:col-span-2">
2451
+ <label className="block text-sm font-medium text-gray-700">Email</label>
2452
+ <input
2453
+ type="email"
2454
+ value={formData.email}
2455
+ onChange={(e) => setFormData({ ...formData, email: e.target.value })}
2456
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2457
+ placeholder="email@exemple.com"
2458
+ />
2459
+ </div>
2460
+ </div>
2461
+ </div>
2462
+
2463
+ {/* Adresse */}
2464
+ <div>
2465
+ <h3 className="mb-4 text-lg font-semibold text-gray-900">Adresse</h3>
2466
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
2467
+ <div className="md:col-span-3">
2468
+ <label className="block text-sm font-medium text-gray-700">
2469
+ Adresse complète
2470
+ </label>
2471
+ <input
2472
+ type="text"
2473
+ value={formData.address}
2474
+ onChange={(e) => setFormData({ ...formData, address: e.target.value })}
2475
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2476
+ placeholder="123 Rue de la République"
2477
+ />
2478
+ </div>
2479
+
2480
+ <div>
2481
+ <label className="block text-sm font-medium text-gray-700">Ville</label>
2482
+ <input
2483
+ type="text"
2484
+ value={formData.city}
2485
+ onChange={(e) => setFormData({ ...formData, city: e.target.value })}
2486
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2487
+ placeholder="Paris"
2488
+ />
2489
+ </div>
2490
+
2491
+ <div>
2492
+ <label className="block text-sm font-medium text-gray-700">Code postal</label>
2493
+ <input
2494
+ type="text"
2495
+ value={formData.postalCode}
2496
+ onChange={(e) => setFormData({ ...formData, postalCode: e.target.value })}
2497
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2498
+ placeholder="75001"
2499
+ />
2500
+ </div>
2501
+ </div>
2502
+ </div>
2503
+
2504
+ {/* Entreprise */}
2505
+ <div>
2506
+ <h3 className="mb-4 text-lg font-semibold text-gray-900">Entreprise</h3>
2507
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
2508
+ <div className="flex items-center">
2509
+ <input
2510
+ type="checkbox"
2511
+ id="isCompany"
2512
+ checked={formData.isCompany}
2513
+ onChange={(e) =>
2514
+ setFormData({
2515
+ ...formData,
2516
+ isCompany: e.target.checked,
2517
+ companyId: e.target.checked ? formData.companyId : '',
2518
+ })
2519
+ }
2520
+ className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
2521
+ />
2522
+ <label htmlFor="isCompany" className="ml-2 text-sm font-medium text-gray-700">
2523
+ Ce contact est une entreprise
2524
+ </label>
2525
+ </div>
2526
+
2527
+ {!formData.isCompany && (
2528
+ <div>
2529
+ <label className="block text-sm font-medium text-gray-700">
2530
+ Entreprise associée
2531
+ </label>
2532
+ <select
2533
+ value={formData.companyId}
2534
+ onChange={(e) => setFormData({ ...formData, companyId: e.target.value })}
2535
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2536
+ >
2537
+ <option value="">Aucune entreprise</option>
2538
+ {contacts
2539
+ .filter((c) => c.isCompany)
2540
+ .map((company) => (
2541
+ <option key={company.id} value={company.id}>
2542
+ {company.firstName || company.lastName || 'Entreprise sans nom'}
2543
+ </option>
2544
+ ))}
2545
+ </select>
2546
+ </div>
2547
+ )}
2548
+ </div>
2549
+ </div>
2550
+
2551
+ {/* Autres informations */}
2552
+ <div>
2553
+ <h3 className="mb-4 text-lg font-semibold text-gray-900">Autres informations</h3>
2554
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
2555
+ <div>
2556
+ <label className="block text-sm font-medium text-gray-700">Entreprise</label>
2557
+ <input
2558
+ type="text"
2559
+ value={formData.company}
2560
+ onChange={(e) => setFormData({ ...formData, company: e.target.value })}
2561
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2562
+ placeholder="Nom de l'entreprise"
2563
+ />
2564
+ </div>
2565
+ <div>
2566
+ <label className="block text-sm font-medium text-gray-700">
2567
+ Origine du contact
2568
+ </label>
2569
+ <input
2570
+ type="text"
2571
+ value={formData.origin}
2572
+ onChange={(e) => setFormData({ ...formData, origin: e.target.value })}
2573
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2574
+ placeholder="Site web, recommandation, etc."
2575
+ />
2576
+ </div>
2577
+
2578
+ <div>
2579
+ <label className="block text-sm font-medium text-gray-700">Statut</label>
2580
+ <select
2581
+ value={formData.statusId}
2582
+ onChange={(e) => setFormData({ ...formData, statusId: e.target.value })}
2583
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2584
+ >
2585
+ <option value="">Aucun statut</option>
2586
+ {statuses.map((status) => (
2587
+ <option key={status.id} value={status.id}>
2588
+ {status.name}
2589
+ </option>
2590
+ ))}
2591
+ </select>
2592
+ </div>
2593
+
2594
+ {/* Motif de fermeture (visible uniquement si statut = Fermé) */}
2595
+ {(() => {
2596
+ const selectedStatus = statuses.find((s) => s.id === formData.statusId);
2597
+ const isLostStatus = selectedStatus?.name?.toLowerCase() === 'fermé';
2598
+ if (!isLostStatus) return null;
2599
+ return (
2600
+ <div>
2601
+ <label className="block text-sm font-medium text-gray-700">
2602
+ Motif de fermeture *
2603
+ </label>
2604
+ <select
2605
+ value={formData.closingReason}
2606
+ onChange={(e) =>
2607
+ setFormData({ ...formData, closingReason: e.target.value })
2608
+ }
2609
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2610
+ >
2611
+ <option value="">Sélectionnez un motif</option>
2612
+ {closingReasons.map((reason) => (
2613
+ <option key={reason.id} value={reason.name}>
2614
+ {reason.name}
2615
+ </option>
2616
+ ))}
2617
+ </select>
2618
+ </div>
2619
+ );
2620
+ })()}
2621
+
2622
+ <div>
2623
+ <label className="block text-sm font-medium text-gray-700">Commercial</label>
2624
+ <select
2625
+ value={formData.assignedCommercialId}
2626
+ onChange={(e) =>
2627
+ setFormData({ ...formData, assignedCommercialId: e.target.value })
2628
+ }
2629
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2630
+ >
2631
+ <option value="">Non assigné</option>
2632
+ {(isAdmin
2633
+ ? users.filter((u) => u.role !== 'USER')
2634
+ : users.filter(
2635
+ (u) =>
2636
+ u.role === 'COMMERCIAL' || u.role === 'ADMIN' || u.role === 'MANAGER',
2637
+ )
2638
+ ).map((user) => (
2639
+ <option key={user.id} value={user.id}>
2640
+ {user.name}
2641
+ </option>
2642
+ ))}
2643
+ </select>
2644
+ </div>
2645
+
2646
+ <div>
2647
+ <label className="block text-sm font-medium text-gray-700">Télépro</label>
2648
+ <select
2649
+ value={formData.assignedTeleproId}
2650
+ onChange={(e) =>
2651
+ setFormData({ ...formData, assignedTeleproId: e.target.value })
2652
+ }
2653
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2654
+ >
2655
+ <option value="">Non assigné</option>
2656
+ {(isAdmin
2657
+ ? users.filter((u) => u.role !== 'USER')
2658
+ : users.filter(
2659
+ (u) =>
2660
+ u.role === 'TELEPRO' || u.role === 'ADMIN' || u.role === 'MANAGER',
2661
+ )
2662
+ ).map((user) => (
2663
+ <option key={user.id} value={user.id}>
2664
+ {user.name}
2665
+ </option>
2666
+ ))}
2667
+ </select>
2668
+ </div>
2669
+ </div>
2670
+ </div>
2671
+
2672
+ {error && (
2673
+ <div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">{error}</div>
2674
+ )}
2675
+ </form>
2676
+
2677
+ {/* Pied de modal fixe */}
2678
+ <div className="shrink-0 border-t border-gray-100 pt-4">
2679
+ <div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
2680
+ <button
2681
+ type="button"
2682
+ onClick={() => {
2683
+ setShowModal(false);
2684
+ setEditingContact(null);
2685
+ setError('');
2686
+ }}
2687
+ className="w-full cursor-pointer rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 sm:w-auto"
2688
+ >
2689
+ Annuler
2690
+ </button>
2691
+ <button
2692
+ type="submit"
2693
+ form="contact-form"
2694
+ className="w-full cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 sm:w-auto"
2695
+ >
2696
+ {editingContact ? 'Modifier' : 'Créer'}
2697
+ </button>
2698
+ </div>
2699
+ </div>
2700
+ </div>
2701
+ </div>
2702
+ )}
2703
+
2704
+ {/* Modal d'import */}
2705
+ {showImportModal && (
2706
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-500/20 p-4 backdrop-blur-sm sm:p-6">
2707
+ <div className="flex max-h-[90vh] w-full max-w-5xl flex-col rounded-lg bg-white shadow-xl">
2708
+ {/* En-tête */}
2709
+ <div className="shrink-0 border-b border-gray-100 px-6 py-4">
2710
+ <div className="flex items-center justify-between">
2711
+ <h2 className="text-lg font-semibold text-gray-900">Importer des contacts</h2>
2712
+ <button
2713
+ onClick={() => {
2714
+ setShowImportModal(false);
2715
+ setImportFile(null);
2716
+ setImportPreview([]);
2717
+ setImportHeaders([]);
2718
+ setImportFieldMappings({});
2719
+ setImportResult(null);
2720
+ setDefaultStatusId('');
2721
+ setDefaultCommercialId('');
2722
+ setDefaultOrigin('');
2723
+ setError('');
2724
+ }}
2725
+ className="cursor-pointer rounded-lg p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
2726
+ >
2727
+ <X className="h-5 w-5" />
2728
+ </button>
2729
+ </div>
2730
+ </div>
2731
+
2732
+ {/* Contenu */}
2733
+ <div className="flex-1 overflow-y-auto px-6 py-4">
2734
+ {!importFile ? (
2735
+ <div>
2736
+ <label className="mb-2 block text-sm font-medium text-gray-700">
2737
+ Sélectionner un fichier (CSV ou Excel)
2738
+ </label>
2739
+ <div
2740
+ className="mt-1 flex cursor-pointer justify-center rounded-lg border-2 border-dashed border-gray-300 px-6 py-10 transition-colors hover:border-indigo-400 hover:bg-indigo-50/40"
2741
+ role="button"
2742
+ tabIndex={0}
2743
+ onClick={() => fileInputRef.current?.click()}
2744
+ onKeyDown={(e) => {
2745
+ if (e.key === 'Enter' || e.key === ' ') {
2746
+ e.preventDefault();
2747
+ fileInputRef.current?.click();
2748
+ }
2749
+ }}
2750
+ >
2751
+ <div className="text-center">
2752
+ <Upload className="mx-auto h-12 w-12 text-gray-400" />
2753
+ <div className="mt-4 flex text-sm leading-6 text-gray-600">
2754
+ <span className="relative rounded-md bg-white font-semibold text-indigo-600">
2755
+ Téléverser un fichier
2756
+ </span>
2757
+ <p className="pl-1">ou glissez-déposez</p>
2758
+ </div>
2759
+ <p className="text-xs leading-5 text-gray-600">
2760
+ CSV, XLSX ou XLS jusqu'à 10MB
2761
+ </p>
2762
+ <input
2763
+ ref={fileInputRef}
2764
+ type="file"
2765
+ accept=".csv,.xlsx,.xls"
2766
+ onChange={handleFileSelect}
2767
+ className="sr-only"
2768
+ />
2769
+ </div>
2770
+ </div>
2771
+ </div>
2772
+ ) : (
2773
+ <div className="space-y-6">
2774
+ {/* Fichier sélectionné */}
2775
+ <div className="rounded-lg border border-gray-200 p-4">
2776
+ <div className="flex items-center justify-between">
2777
+ <div>
2778
+ <p className="text-sm font-medium text-gray-900">{importFile.name}</p>
2779
+ <p className="text-xs text-gray-500">
2780
+ {(importFile.size / 1024).toFixed(2)} KB
2781
+ </p>
2782
+ </div>
2783
+ <button
2784
+ onClick={() => {
2785
+ setImportFile(null);
2786
+ setImportPreview([]);
2787
+ setImportHeaders([]);
2788
+ setImportFieldMappings({});
2789
+ setImportResult(null);
2790
+ setDefaultStatusId('');
2791
+ setDefaultCommercialId('');
2792
+ setDefaultOrigin('');
2793
+ }}
2794
+ className="cursor-pointer rounded-lg p-1 text-gray-400 transition-colors hover:text-gray-600"
2795
+ >
2796
+ <X className="h-5 w-5" />
2797
+ </button>
2798
+ </div>
2799
+ </div>
2800
+
2801
+ {/* Options */}
2802
+ <div>
2803
+ <label className="flex items-center gap-2">
2804
+ <input
2805
+ type="checkbox"
2806
+ checked={importSkipFirstRow}
2807
+ onChange={(e) => setImportSkipFirstRow(e.target.checked)}
2808
+ className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
2809
+ />
2810
+ <span className="text-sm text-gray-700">
2811
+ La seconde ligne contient les en-têtes
2812
+ </span>
2813
+ </label>
2814
+ </div>
2815
+
2816
+ {/* Valeurs par défaut */}
2817
+ <div>
2818
+ <h3 className="mb-3 text-sm font-semibold text-gray-900">
2819
+ Valeurs par défaut (optionnel)
2820
+ </h3>
2821
+ <p className="mb-3 text-xs text-gray-600">
2822
+ Ces valeurs seront appliquées aux contacts qui n'ont pas ces informations dans
2823
+ le fichier
2824
+ </p>
2825
+ <div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-4">
2826
+ <div>
2827
+ <label className="mb-1 block text-sm font-medium text-gray-700">
2828
+ Statut par défaut
2829
+ </label>
2830
+ <select
2831
+ value={defaultStatusId}
2832
+ onChange={(e) => setDefaultStatusId(e.target.value)}
2833
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2834
+ >
2835
+ <option value="">Aucun statut par défaut</option>
2836
+ {statuses.map((status) => (
2837
+ <option key={status.id} value={status.id}>
2838
+ {status.name}
2839
+ </option>
2840
+ ))}
2841
+ </select>
2842
+ </div>
2843
+
2844
+ <div>
2845
+ <label className="mb-1 block text-sm font-medium text-gray-700">
2846
+ Commercial par défaut
2847
+ </label>
2848
+ <select
2849
+ value={defaultCommercialId}
2850
+ onChange={(e) => setDefaultCommercialId(e.target.value)}
2851
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2852
+ >
2853
+ <option value="">Aucun commercial par défaut</option>
2854
+ {users
2855
+ .filter((u) => u.role !== 'USER')
2856
+ .map((user) => (
2857
+ <option key={user.id} value={user.id}>
2858
+ {user.name}
2859
+ </option>
2860
+ ))}
2861
+ </select>
2862
+ </div>
2863
+
2864
+ <div>
2865
+ <label className="mb-1 block text-sm font-medium text-gray-700">
2866
+ Origine de la campagne par défaut
2867
+ </label>
2868
+ <input
2869
+ type="text"
2870
+ value={defaultOrigin}
2871
+ onChange={(e) => setDefaultOrigin(e.target.value)}
2872
+ placeholder="Ex: Facebook Ads, Google Ads, etc."
2873
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2874
+ />
2875
+ </div>
2876
+ </div>
2877
+ </div>
2878
+
2879
+ {/* Correspondance des champs */}
2880
+ {importHeaders.length > 0 && (
2881
+ <div>
2882
+ <h3 className="mb-2 text-base font-semibold text-gray-900">
2883
+ Correspondance des champs
2884
+ </h3>
2885
+ <p className="mb-4 text-sm text-gray-600">
2886
+ Vérifiez et ajustez les correspondances automatiques entre vos colonnes et
2887
+ les champs du CRM.
2888
+ </p>
2889
+ <div className="space-y-3">
2890
+ {importHeaders.map((header) => {
2891
+ const mapping = importFieldMappings[header] || { action: 'ignore' };
2892
+ const exampleValue =
2893
+ importPreview.length > 0 && importPreview[0][header]
2894
+ ? importPreview[0][header]
2895
+ : '';
2896
+
2897
+ return (
2898
+ <div
2899
+ key={header}
2900
+ className="rounded-lg border border-gray-200 bg-gray-50 p-4"
2901
+ >
2902
+ <div className="flex items-start gap-3">
2903
+ {/* Colonne du fichier */}
2904
+ <div className="flex-1">
2905
+ <div className="mb-2">
2906
+ <span className="text-xs font-medium text-gray-500">
2907
+ Colonne du fichier
2908
+ </span>
2909
+ <p className="mt-1 text-sm font-semibold text-gray-900">
2910
+ {header}
2911
+ </p>
2912
+ {exampleValue && (
2913
+ <p className="mt-1 text-xs text-gray-500">
2914
+ Exemple: {String(exampleValue).substring(0, 50)}
2915
+ {String(exampleValue).length > 50 ? '...' : ''}
2916
+ </p>
2917
+ )}
2918
+ </div>
2919
+ </div>
2920
+
2921
+ {/* Flèche */}
2922
+ <div className="mt-8 flex items-center">
2923
+ <ArrowRight className="h-5 w-5 text-gray-400" />
2924
+ </div>
2925
+
2926
+ {/* Action */}
2927
+ <div className="flex-1">
2928
+ <label className="mb-2 block text-xs font-medium text-gray-500">
2929
+ Action
2930
+ </label>
2931
+ <select
2932
+ value={mapping.action}
2933
+ onChange={(e) => {
2934
+ const newAction = e.target.value as 'map' | 'note' | 'ignore';
2935
+ setImportFieldMappings({
2936
+ ...importFieldMappings,
2937
+ [header]: {
2938
+ action: newAction,
2939
+ crmField:
2940
+ newAction === 'map' && mapping.crmField
2941
+ ? mapping.crmField
2942
+ : newAction === 'map'
2943
+ ? 'firstName'
2944
+ : undefined,
2945
+ },
2946
+ });
2947
+ }}
2948
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2949
+ >
2950
+ <option value="map">Mapper vers un champ</option>
2951
+ <option value="note">Ajouter comme note</option>
2952
+ <option value="ignore">Ignorer</option>
2953
+ </select>
2954
+ </div>
2955
+
2956
+ {/* Flèche (si action = map) */}
2957
+ {mapping.action === 'map' && (
2958
+ <div className="mt-8 flex items-center">
2959
+ <ArrowRight className="h-5 w-5 text-gray-400" />
2960
+ </div>
2961
+ )}
2962
+
2963
+ {/* Champ du CRM (si action = map) */}
2964
+ {mapping.action === 'map' && (
2965
+ <div className="flex-1">
2966
+ <label className="mb-2 block text-xs font-medium text-gray-500">
2967
+ Champ du CRM
2968
+ </label>
2969
+ <select
2970
+ value={mapping.crmField || ''}
2971
+ onChange={(e) => {
2972
+ setImportFieldMappings({
2973
+ ...importFieldMappings,
2974
+ [header]: {
2975
+ action: 'map',
2976
+ crmField: e.target.value,
2977
+ },
2978
+ });
2979
+ }}
2980
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
2981
+ >
2982
+ <option value="">Sélectionnez un champ</option>
2983
+ <option value="phone">Téléphone *</option>
2984
+ <option value="firstName">Prénom</option>
2985
+ <option value="lastName">Nom</option>
2986
+ <option value="email">Email</option>
2987
+ <option value="civility">Civilité</option>
2988
+ <option value="secondaryPhone">Téléphone secondaire</option>
2989
+ <option value="address">Adresse</option>
2990
+ <option value="city">Ville</option>
2991
+ <option value="postalCode">Code postal</option>
2992
+ <option value="origin">Origine</option>
2993
+ </select>
2994
+ </div>
2995
+ )}
2996
+ </div>
2997
+ </div>
2998
+ );
2999
+ })}
3000
+ </div>
3001
+ </div>
3002
+ )}
3003
+
3004
+ {/* Prévisualisation */}
3005
+ {importPreview.length > 0 && (
3006
+ <div>
3007
+ <h3 className="mb-3 text-sm font-semibold text-gray-900">
3008
+ Aperçu (5 premières lignes)
3009
+ </h3>
3010
+ <div className="overflow-x-auto rounded-lg border border-gray-200">
3011
+ <table className="min-w-full divide-y divide-gray-200">
3012
+ <thead className="bg-gray-50">
3013
+ <tr>
3014
+ {importHeaders.map((header) => (
3015
+ <th
3016
+ key={header}
3017
+ className="px-4 py-2 text-left text-xs font-medium text-gray-700"
3018
+ >
3019
+ {header}
3020
+ </th>
3021
+ ))}
3022
+ </tr>
3023
+ </thead>
3024
+ <tbody className="divide-y divide-gray-200 bg-white">
3025
+ {importPreview.map((row, idx) => (
3026
+ <tr key={idx}>
3027
+ {importHeaders.map((header) => (
3028
+ <td
3029
+ key={header}
3030
+ className="px-4 py-2 text-xs whitespace-nowrap text-gray-900"
3031
+ >
3032
+ {row[header] || '-'}
3033
+ </td>
3034
+ ))}
3035
+ </tr>
3036
+ ))}
3037
+ </tbody>
3038
+ </table>
3039
+ </div>
3040
+ </div>
3041
+ )}
3042
+
3043
+ {/* Résultat de l'import */}
3044
+ {importResult && (
3045
+ <div className="rounded-lg border border-green-200 bg-green-50 p-4">
3046
+ <h3 className="mb-2 text-sm font-semibold text-green-900">
3047
+ Résultat de l'import
3048
+ </h3>
3049
+ <ul className="space-y-1 text-sm text-green-800">
3050
+ <li>✓ {importResult.imported} contact(s) importé(s)</li>
3051
+ {importResult.skipped > 0 && (
3052
+ <li className="text-yellow-700">
3053
+ ⚠ {importResult.skipped} ligne(s) ignorée(s)
3054
+ </li>
3055
+ )}
3056
+ {importResult.duplicates > 0 && (
3057
+ <li className="text-orange-700">
3058
+ ⚠ {importResult.duplicates} doublon(s) détecté(s)
3059
+ </li>
3060
+ )}
3061
+ {importResult.errors > 0 && (
3062
+ <li className="text-red-700">✗ {importResult.errors} erreur(s)</li>
3063
+ )}
3064
+ </ul>
3065
+ </div>
3066
+ )}
3067
+
3068
+ {error && (
3069
+ <div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">{error}</div>
3070
+ )}
3071
+ </div>
3072
+ )}
3073
+ </div>
3074
+
3075
+ {/* Pied de modal */}
3076
+ {importFile && (
3077
+ <div className="shrink-0 border-t border-gray-100 px-6 py-4">
3078
+ <div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
3079
+ <button
3080
+ type="button"
3081
+ onClick={() => {
3082
+ setShowImportModal(false);
3083
+ setImportFile(null);
3084
+ setImportPreview([]);
3085
+ setImportHeaders([]);
3086
+ setImportFieldMappings({});
3087
+ setImportResult(null);
3088
+ setDefaultStatusId('');
3089
+ setDefaultCommercialId('');
3090
+ setDefaultOrigin('');
3091
+ setError('');
3092
+ }}
3093
+ className="w-full cursor-pointer rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 sm:w-auto"
3094
+ disabled={importing}
3095
+ >
3096
+ Annuler
3097
+ </button>
3098
+ <button
3099
+ type="button"
3100
+ onClick={handleImport}
3101
+ disabled={
3102
+ importing ||
3103
+ !Object.entries(importFieldMappings).some(
3104
+ ([, mapping]) => mapping.action === 'map' && mapping.crmField === 'phone',
3105
+ )
3106
+ }
3107
+ className="w-full cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto"
3108
+ >
3109
+ {importing ? 'Import en cours...' : 'Importer'}
3110
+ </button>
3111
+ </div>
3112
+ </div>
3113
+ )}
3114
+ </div>
3115
+ </div>
3116
+ )}
3117
+
3118
+ {/* Modal de résultats d'import */}
3119
+ {showImportResultModal && importResultData && (
3120
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-500/20 p-4 backdrop-blur-sm">
3121
+ <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
3122
+ <div className="mb-4 flex items-center justify-between">
3123
+ <h3 className="text-lg font-semibold text-gray-900">Résultat de l'import</h3>
3124
+ <button
3125
+ onClick={() => {
3126
+ setShowImportResultModal(false);
3127
+ setImportResultData(null);
3128
+ }}
3129
+ className="cursor-pointer rounded-lg p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
3130
+ >
3131
+ <X className="h-5 w-5" />
3132
+ </button>
3133
+ </div>
3134
+
3135
+ <div className="space-y-4">
3136
+ {/* Contacts importés */}
3137
+ <div className="flex items-start gap-3 rounded-lg border border-green-200 bg-green-50 p-4">
3138
+ <div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-green-100">
3139
+ <svg
3140
+ className="h-5 w-5 text-green-600"
3141
+ fill="none"
3142
+ stroke="currentColor"
3143
+ viewBox="0 0 24 24"
3144
+ >
3145
+ <path
3146
+ strokeLinecap="round"
3147
+ strokeLinejoin="round"
3148
+ strokeWidth={2}
3149
+ d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
3150
+ />
3151
+ </svg>
3152
+ </div>
3153
+ <div className="flex-1">
3154
+ <p className="text-sm font-semibold text-green-900">
3155
+ {importResultData.imported} contact(s) importé(s)
3156
+ </p>
3157
+ <p className="mt-1 text-xs text-green-700">
3158
+ Les contacts ont été ajoutés avec succès à votre base de données.
3159
+ </p>
3160
+ </div>
3161
+ </div>
3162
+
3163
+ {/* Doublons fusionnés */}
3164
+ {importResultData.duplicates > 0 && (
3165
+ <div className="flex items-start gap-3 rounded-lg border border-orange-200 bg-orange-50 p-4">
3166
+ <div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-orange-100">
3167
+ <svg
3168
+ className="h-5 w-5 text-orange-600"
3169
+ fill="none"
3170
+ stroke="currentColor"
3171
+ viewBox="0 0 24 24"
3172
+ >
3173
+ <path
3174
+ strokeLinecap="round"
3175
+ strokeLinejoin="round"
3176
+ strokeWidth={2}
3177
+ d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
3178
+ />
3179
+ </svg>
3180
+ </div>
3181
+ <div className="flex-1">
3182
+ <p className="text-sm font-semibold text-orange-900">
3183
+ {importResultData.duplicates} doublon(s) fusionné(s)
3184
+ </p>
3185
+ <p className="mt-1 text-xs text-orange-700">
3186
+ Ces contacts existaient déjà et ont été fusionnés avec les données existantes.
3187
+ </p>
3188
+ </div>
3189
+ </div>
3190
+ )}
3191
+
3192
+ {/* Lignes ignorées */}
3193
+ {importResultData.skipped > 0 && (
3194
+ <div className="flex items-start gap-3 rounded-lg border border-yellow-200 bg-yellow-50 p-4">
3195
+ <div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-yellow-100">
3196
+ <svg
3197
+ className="h-5 w-5 text-yellow-600"
3198
+ fill="none"
3199
+ stroke="currentColor"
3200
+ viewBox="0 0 24 24"
3201
+ >
3202
+ <path
3203
+ strokeLinecap="round"
3204
+ strokeLinejoin="round"
3205
+ strokeWidth={2}
3206
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
3207
+ />
3208
+ </svg>
3209
+ </div>
3210
+ <div className="flex-1">
3211
+ <p className="text-sm font-semibold text-yellow-900">
3212
+ {importResultData.skipped} ligne(s) ignorée(s)
3213
+ </p>
3214
+ <p className="mt-1 text-xs text-yellow-700">
3215
+ Ces lignes n'ont pas pu être importées (téléphone manquant ou invalide).
3216
+ </p>
3217
+ </div>
3218
+ </div>
3219
+ )}
3220
+
3221
+ {/* Erreurs */}
3222
+ {importResultData.errors > 0 && (
3223
+ <div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4">
3224
+ <div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-red-100">
3225
+ <svg
3226
+ className="h-5 w-5 text-red-600"
3227
+ fill="none"
3228
+ stroke="currentColor"
3229
+ viewBox="0 0 24 24"
3230
+ >
3231
+ <path
3232
+ strokeLinecap="round"
3233
+ strokeLinejoin="round"
3234
+ strokeWidth={2}
3235
+ d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
3236
+ />
3237
+ </svg>
3238
+ </div>
3239
+ <div className="flex-1">
3240
+ <p className="text-sm font-semibold text-red-900">
3241
+ {importResultData.errors} erreur(s)
3242
+ </p>
3243
+ <p className="mt-1 text-xs text-red-700">
3244
+ Certaines lignes ont rencontré des erreurs lors de l'import.
3245
+ </p>
3246
+ </div>
3247
+ </div>
3248
+ )}
3249
+ </div>
3250
+
3251
+ <div className="mt-6 flex justify-end">
3252
+ <button
3253
+ onClick={() => {
3254
+ setShowImportResultModal(false);
3255
+ setImportResultData(null);
3256
+ }}
3257
+ className="cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
3258
+ >
3259
+ Fermer
3260
+ </button>
3261
+ </div>
3262
+ </div>
3263
+ </div>
3264
+ )}
3265
+
3266
+ {/* Modal - Changer commercial */}
3267
+ {showBulkCommercialModal && (
3268
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-500/20 p-4 backdrop-blur-sm">
3269
+ <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
3270
+ <div className="mb-4 flex items-center justify-between">
3271
+ <h3 className="text-lg font-semibold text-gray-900">Changer le commercial</h3>
3272
+ <button
3273
+ onClick={() => {
3274
+ setShowBulkCommercialModal(false);
3275
+ setBulkCommercialId('');
3276
+ }}
3277
+ className="cursor-pointer rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-100"
3278
+ >
3279
+ <X className="h-5 w-5" />
3280
+ </button>
3281
+ </div>
3282
+
3283
+ <p className="mb-4 text-sm text-gray-600">
3284
+ {selectedContactIds.size} contact(s) sélectionné(s)
3285
+ </p>
3286
+
3287
+ <div className="mb-4">
3288
+ <label className="mb-2 block text-sm font-medium text-gray-700">
3289
+ Nouveau commercial
3290
+ </label>
3291
+ <select
3292
+ value={bulkCommercialId}
3293
+ onChange={(e) => setBulkCommercialId(e.target.value)}
3294
+ className="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
3295
+ >
3296
+ <option value="">Sélectionner un commercial</option>
3297
+ {users
3298
+ .filter((u) => u.role !== 'USER')
3299
+ .map((user) => (
3300
+ <option key={user.id} value={user.id}>
3301
+ {user.name}
3302
+ </option>
3303
+ ))}
3304
+ </select>
3305
+ </div>
3306
+
3307
+ <div className="flex justify-end gap-3">
3308
+ <button
3309
+ onClick={() => {
3310
+ setShowBulkCommercialModal(false);
3311
+ setBulkCommercialId('');
3312
+ }}
3313
+ className="cursor-pointer rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
3314
+ >
3315
+ Annuler
3316
+ </button>
3317
+ <button
3318
+ onClick={handleBulkChangeCommercial}
3319
+ disabled={bulkActionLoading || !bulkCommercialId}
3320
+ className="cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
3321
+ >
3322
+ {bulkActionLoading ? 'En cours...' : 'Appliquer'}
3323
+ </button>
3324
+ </div>
3325
+ </div>
3326
+ </div>
3327
+ )}
3328
+
3329
+ {/* Modal - Changer statut */}
3330
+ {showBulkStatusModal && (
3331
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-500/20 p-4 backdrop-blur-sm">
3332
+ <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
3333
+ <div className="mb-4 flex items-center justify-between">
3334
+ <h3 className="text-lg font-semibold text-gray-900">Changer le statut</h3>
3335
+ <button
3336
+ onClick={() => {
3337
+ setShowBulkStatusModal(false);
3338
+ setBulkStatusId('');
3339
+ }}
3340
+ className="cursor-pointer rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-100"
3341
+ >
3342
+ <X className="h-5 w-5" />
3343
+ </button>
3344
+ </div>
3345
+
3346
+ <p className="mb-4 text-sm text-gray-600">
3347
+ {selectedContactIds.size} contact(s) sélectionné(s)
3348
+ </p>
3349
+
3350
+ <div className="mb-4">
3351
+ <label className="mb-2 block text-sm font-medium text-gray-700">Nouveau statut</label>
3352
+ <select
3353
+ value={bulkStatusId}
3354
+ onChange={(e) => setBulkStatusId(e.target.value)}
3355
+ className="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
3356
+ >
3357
+ <option value="">Sélectionner un statut</option>
3358
+ {statuses.map((status) => (
3359
+ <option key={status.id} value={status.id}>
3360
+ {status.name}
3361
+ </option>
3362
+ ))}
3363
+ </select>
3364
+ </div>
3365
+
3366
+ <div className="flex justify-end gap-3">
3367
+ <button
3368
+ onClick={() => {
3369
+ setShowBulkStatusModal(false);
3370
+ setBulkStatusId('');
3371
+ }}
3372
+ className="cursor-pointer rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
3373
+ >
3374
+ Annuler
3375
+ </button>
3376
+ <button
3377
+ onClick={handleBulkChangeStatus}
3378
+ disabled={bulkActionLoading || !bulkStatusId}
3379
+ className="cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
3380
+ >
3381
+ {bulkActionLoading ? 'En cours...' : 'Appliquer'}
3382
+ </button>
3383
+ </div>
3384
+ </div>
3385
+ </div>
3386
+ )}
3387
+
3388
+ {/* Popover de filtre de date (sous l'icône) */}
3389
+ {dateFilterModal && dateFilterPosition && (
3390
+ <div
3391
+ ref={dateFilterModalRef}
3392
+ className="fixed z-50 w-72 rounded-lg border border-gray-200 bg-white p-4 shadow-xl"
3393
+ style={{ top: dateFilterPosition.top, left: dateFilterPosition.left }}
3394
+ >
3395
+ <div className="relative">
3396
+ <button
3397
+ onClick={() => {
3398
+ setDateFilterModal(null);
3399
+ setDateFilterPosition(null);
3400
+ }}
3401
+ className="absolute top-0 right-0 cursor-pointer rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100"
3402
+ >
3403
+ <X className="h-4 w-4" />
3404
+ </button>
3405
+
3406
+ <h3 className="mb-3 pr-6 text-sm font-semibold text-gray-900">
3407
+ {dateFilterModal === 'createdAt' ? 'Date de création' : 'Date de modification'}
3408
+ </h3>
3409
+
3410
+ <div className="space-y-3">
3411
+ <div>
3412
+ <label className="mb-1 block text-xs font-medium text-gray-700">Du</label>
3413
+ <input
3414
+ type="date"
3415
+ value={dateFilterModal === 'createdAt' ? createdAtStart : updatedAtStart}
3416
+ onChange={(e) => {
3417
+ if (dateFilterModal === 'createdAt') {
3418
+ setCreatedAtStart(e.target.value);
3419
+ } else {
3420
+ setUpdatedAtStart(e.target.value);
3421
+ }
3422
+ }}
3423
+ className="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
3424
+ />
3425
+ </div>
3426
+
3427
+ <div>
3428
+ <label className="mb-1 block text-xs font-medium text-gray-700">Au</label>
3429
+ <input
3430
+ type="date"
3431
+ value={dateFilterModal === 'createdAt' ? createdAtEnd : updatedAtEnd}
3432
+ onChange={(e) => {
3433
+ if (dateFilterModal === 'createdAt') {
3434
+ setCreatedAtEnd(e.target.value);
3435
+ } else {
3436
+ setUpdatedAtEnd(e.target.value);
3437
+ }
3438
+ }}
3439
+ className="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
3440
+ />
3441
+ </div>
3442
+ </div>
3443
+
3444
+ <div className="mt-4 flex justify-end gap-2">
3445
+ <button
3446
+ onClick={() => handleClearDateFilter(dateFilterModal)}
3447
+ className="cursor-pointer rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
3448
+ >
3449
+ Effacer
3450
+ </button>
3451
+ <button
3452
+ onClick={handleApplyDateFilter}
3453
+ className="cursor-pointer rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-indigo-700"
3454
+ >
3455
+ Filtrer
3456
+ </button>
3457
+ </div>
3458
+ </div>
3459
+ </div>
3460
+ )}
3461
+
3462
+ {/* Popover de filtre de liste (Statut, Origine, Commercial, Télépro) */}
3463
+ {listFilterModal && listFilterPosition && (
3464
+ <div
3465
+ ref={listFilterModalRef}
3466
+ className="fixed z-50 w-80 rounded-lg border border-gray-200 bg-white p-4 shadow-xl"
3467
+ style={{ top: listFilterPosition.top, left: listFilterPosition.left }}
3468
+ >
3469
+ <div className="relative">
3470
+ <button
3471
+ onClick={() => {
3472
+ setListFilterModal(null);
3473
+ setListFilterPosition(null);
3474
+ }}
3475
+ className="absolute top-0 right-0 cursor-pointer rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100"
3476
+ >
3477
+ <X className="h-4 w-4" />
3478
+ </button>
3479
+
3480
+ {/* Titre */}
3481
+ <h3 className="mb-3 pr-6 text-sm font-semibold text-gray-900">
3482
+ {listFilterModal === 'status'
3483
+ ? 'Status'
3484
+ : listFilterModal === 'origin'
3485
+ ? 'Origine'
3486
+ : listFilterModal === 'commercial'
3487
+ ? 'Commercial'
3488
+ : 'Télépro'}
3489
+ </h3>
3490
+
3491
+ {/* Boutons tout cocher / tout décocher */}
3492
+ <div className="mb-3 flex gap-2">
3493
+ <button
3494
+ type="button"
3495
+ onClick={() => {
3496
+ if (listFilterModal === 'status') {
3497
+ setStatusFilter('');
3498
+ } else if (listFilterModal === 'origin') {
3499
+ setOriginFilter('');
3500
+ } else if (listFilterModal === 'commercial') {
3501
+ setAssignedCommercialFilter('');
3502
+ } else if (listFilterModal === 'telepro') {
3503
+ setAssignedTeleproFilter('');
3504
+ }
3505
+ setCurrentPage(1);
3506
+ }}
3507
+ className="flex-1 cursor-pointer rounded-md bg-indigo-50 px-3 py-1.5 text-center text-xs font-medium text-indigo-700"
3508
+ >
3509
+ Tout cocher
3510
+ </button>
3511
+ <button
3512
+ type="button"
3513
+ onClick={() => {
3514
+ if (listFilterModal === 'status') {
3515
+ setStatusFilter('');
3516
+ } else if (listFilterModal === 'origin') {
3517
+ setOriginFilter('');
3518
+ } else if (listFilterModal === 'commercial') {
3519
+ setAssignedCommercialFilter('');
3520
+ } else if (listFilterModal === 'telepro') {
3521
+ setAssignedTeleproFilter('');
3522
+ }
3523
+ setCurrentPage(1);
3524
+ }}
3525
+ className="flex-1 cursor-pointer rounded-md bg-gray-50 px-3 py-1.5 text-center text-xs font-medium text-gray-700"
3526
+ >
3527
+ Tout décocher
3528
+ </button>
3529
+ </div>
3530
+
3531
+ {/* Liste des options */}
3532
+ <div className="max-h-72 space-y-2 overflow-y-auto pt-1">
3533
+ {listFilterModal === 'status' &&
3534
+ statuses.map((status) => {
3535
+ const checked = statusFilter === status.id;
3536
+ return (
3537
+ <button
3538
+ key={status.id}
3539
+ type="button"
3540
+ onClick={() => {
3541
+ setStatusFilter(checked ? '' : status.id);
3542
+ setCurrentPage(1);
3543
+ }}
3544
+ className="flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-1.5 text-left text-sm hover:bg-gray-50"
3545
+ >
3546
+ <div className="flex items-center gap-2">
3547
+ <span
3548
+ className="inline-block h-3 w-3 rounded-full"
3549
+ style={{ backgroundColor: status.color || '#E5E7EB' }}
3550
+ />
3551
+ <span className="text-sm text-gray-800">{status.name}</span>
3552
+ </div>
3553
+ {checked && <span className="text-indigo-600">✓</span>}
3554
+ </button>
3555
+ );
3556
+ })}
3557
+
3558
+ {listFilterModal === 'origin' &&
3559
+ Array.from(
3560
+ new Set(
3561
+ contacts
3562
+ .map((c) => c.origin)
3563
+ .filter((o): o is string => Boolean(o && o.trim())),
3564
+ ),
3565
+ ).map((origin) => {
3566
+ const checked = originFilter === origin;
3567
+ return (
3568
+ <button
3569
+ key={origin}
3570
+ type="button"
3571
+ onClick={() => {
3572
+ setOriginFilter(checked ? '' : origin);
3573
+ setCurrentPage(1);
3574
+ }}
3575
+ className="flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-1.5 text-left text-sm hover:bg-gray-50"
3576
+ >
3577
+ <span className="text-sm text-gray-800">{origin}</span>
3578
+ {checked && <span className="text-indigo-600">✓</span>}
3579
+ </button>
3580
+ );
3581
+ })}
3582
+
3583
+ {listFilterModal === 'commercial' &&
3584
+ [
3585
+ { id: 'unassigned', name: 'NON ATTRIBUÉ' },
3586
+ ...users
3587
+ .filter((u) => u.role !== 'USER')
3588
+ .map((u) => ({ id: u.id, name: u.name || u.email || 'Utilisateur' })),
3589
+ ].map((item) => {
3590
+ const value = item.id === 'unassigned' ? 'UNASSIGNED' : item.id;
3591
+ const checked = assignedCommercialFilter === value;
3592
+ return (
3593
+ <button
3594
+ key={item.id}
3595
+ type="button"
3596
+ onClick={() => {
3597
+ setAssignedCommercialFilter(checked ? '' : value);
3598
+ setCurrentPage(1);
3599
+ }}
3600
+ className="flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-1.5 text-left text-sm hover:bg-gray-50"
3601
+ >
3602
+ <span className="text-sm text-gray-800">{item.name}</span>
3603
+ {checked && <span className="text-indigo-600">✓</span>}
3604
+ </button>
3605
+ );
3606
+ })}
3607
+
3608
+ {listFilterModal === 'telepro' &&
3609
+ [
3610
+ { id: 'unassigned', name: 'NON ATTRIBUÉ' },
3611
+ ...users
3612
+ .filter((u) => u.role !== 'USER')
3613
+ .map((u) => ({ id: u.id, name: u.name || u.email || 'Utilisateur' })),
3614
+ ].map((item) => {
3615
+ const value = item.id === 'unassigned' ? 'UNASSIGNED' : item.id;
3616
+ const checked = assignedTeleproFilter === value;
3617
+ return (
3618
+ <button
3619
+ key={item.id}
3620
+ type="button"
3621
+ onClick={() => {
3622
+ setAssignedTeleproFilter(checked ? '' : value);
3623
+ setCurrentPage(1);
3624
+ }}
3625
+ className="flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-1.5 text-left text-sm hover:bg-gray-50"
3626
+ >
3627
+ <span className="text-sm text-gray-800">{item.name}</span>
3628
+ {checked && <span className="text-indigo-600">✓</span>}
3629
+ </button>
3630
+ );
3631
+ })}
3632
+ </div>
3633
+ </div>
3634
+ </div>
3635
+ )}
3636
+
3637
+ {/* Modal d'export */}
3638
+ {showExportModal && (
3639
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-500/20 p-4 backdrop-blur-sm">
3640
+ <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
3641
+ <div className="mb-4 flex items-center justify-between">
3642
+ <h3 className="text-lg font-semibold text-gray-900">
3643
+ {exportAll
3644
+ ? 'Exporter tous les contacts'
3645
+ : `Exporter ${selectedContactIds.size} contact(s)`}
3646
+ </h3>
3647
+ <button
3648
+ onClick={() => {
3649
+ setShowExportModal(false);
3650
+ setExportAll(false);
3651
+ }}
3652
+ className="cursor-pointer rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-100"
3653
+ >
3654
+ <X className="h-5 w-5" />
3655
+ </button>
3656
+ </div>
3657
+
3658
+ <p className="mb-4 text-sm text-gray-600">Choisissez le format d'export :</p>
3659
+
3660
+ <div className="mb-4 space-y-3">
3661
+ <button
3662
+ onClick={() => handleExport('csv')}
3663
+ disabled={exporting}
3664
+ className="flex w-full cursor-pointer items-center justify-between rounded-lg border-2 border-gray-200 bg-white p-4 transition-colors hover:border-indigo-500 hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-50"
3665
+ >
3666
+ <div className="flex items-center gap-3">
3667
+ <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
3668
+ <Download className="h-5 w-5 text-green-600" />
3669
+ </div>
3670
+ <div className="text-left">
3671
+ <div className="font-medium text-gray-900">CSV</div>
3672
+ <div className="text-xs text-gray-500">Format texte séparé par virgules</div>
3673
+ </div>
3674
+ </div>
3675
+ {exporting && <div className="text-sm text-gray-500">Export en cours...</div>}
3676
+ </button>
3677
+
3678
+ <button
3679
+ onClick={() => handleExport('excel')}
3680
+ disabled={exporting}
3681
+ className="flex w-full cursor-pointer items-center justify-between rounded-lg border-2 border-gray-200 bg-white p-4 transition-colors hover:border-indigo-500 hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-50"
3682
+ >
3683
+ <div className="flex items-center gap-3">
3684
+ <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
3685
+ <Download className="h-5 w-5 text-blue-600" />
3686
+ </div>
3687
+ <div className="text-left">
3688
+ <div className="font-medium text-gray-900">Excel</div>
3689
+ <div className="text-xs text-gray-500">Format Microsoft Excel (.xlsx)</div>
3690
+ </div>
3691
+ </div>
3692
+ {exporting && <div className="text-sm text-gray-500">Export en cours...</div>}
3693
+ </button>
3694
+ </div>
3695
+
3696
+ <div className="flex justify-end gap-3">
3697
+ <button
3698
+ onClick={() => {
3699
+ setShowExportModal(false);
3700
+ setExportAll(false);
3701
+ }}
3702
+ disabled={exporting}
3703
+ className="cursor-pointer rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
3704
+ >
3705
+ Annuler
3706
+ </button>
3707
+ </div>
3708
+ </div>
3709
+ </div>
3710
+ )}
3711
+ </div>
3712
+ );
3713
+ }