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,1052 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useState } from 'react';
4
+ import Link from 'next/link';
5
+ import {
6
+ Filter,
7
+ Settings as SettingsIcon,
8
+ Phone,
9
+ Mail,
10
+ MapPin,
11
+ Users,
12
+ X,
13
+ Plus,
14
+ Calendar,
15
+ Eye,
16
+ } from 'lucide-react';
17
+ import { cn } from '@/lib/utils';
18
+ import { useMobileMenuContext } from '@/contexts/mobile-menu-context';
19
+
20
+ interface Status {
21
+ id: string;
22
+ name: string;
23
+ color: string;
24
+ }
25
+
26
+ interface User {
27
+ id: string;
28
+ name: string;
29
+ email: string;
30
+ }
31
+
32
+ interface Contact {
33
+ id: string;
34
+ civility: string | null;
35
+ firstName: string | null;
36
+ lastName: string | null;
37
+ phone: string;
38
+ secondaryPhone: string | null;
39
+ email: string | null;
40
+ address: string | null;
41
+ city: string | null;
42
+ postalCode: string | null;
43
+ origin: string | null;
44
+ companyName: string | null;
45
+ isCompany: boolean;
46
+ companyId: string | null;
47
+ companyRelation: Contact | null;
48
+ statusId: string | null;
49
+ status: Status | null;
50
+ assignedCommercialId: string | null;
51
+ assignedCommercial: User | null;
52
+ assignedTeleproId: string | null;
53
+ assignedTelepro: User | null;
54
+ updatedAt?: string;
55
+ }
56
+
57
+ interface ClosingColumn {
58
+ id: string;
59
+ statusId: string | null;
60
+ title: string;
61
+ color: string;
62
+ width: number;
63
+ order: number;
64
+ }
65
+
66
+ const MIN_WIDTH = 280;
67
+ const MAX_WIDTH = 500;
68
+ const LOCAL_STORAGE_KEY = 'closing_pipeline_columns_v1';
69
+
70
+ function formatRelativeDate(dateString: string): string {
71
+ const date = new Date(dateString);
72
+ const now = new Date();
73
+ const diffMs = now.getTime() - date.getTime();
74
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
75
+
76
+ // Réinitialiser les heures pour comparer uniquement les dates
77
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
78
+ const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate());
79
+ const daysDiff = Math.floor((today.getTime() - dateOnly.getTime()) / (1000 * 60 * 60 * 24));
80
+
81
+ if (daysDiff === 0) {
82
+ return "Aujourd'hui";
83
+ }
84
+ if (daysDiff === 1) {
85
+ return 'Hier';
86
+ }
87
+ if (daysDiff < 7) {
88
+ return `Il y a ${daysDiff} jour${daysDiff > 1 ? 's' : ''}`;
89
+ }
90
+ const weeks = Math.floor(daysDiff / 7);
91
+ if (weeks < 4) {
92
+ return `Il y a ${weeks} semaine${weeks > 1 ? 's' : ''}`;
93
+ }
94
+ const months = Math.floor(daysDiff / 30);
95
+ if (months < 12) {
96
+ return `Il y a ${months} mois`;
97
+ }
98
+ const years = Math.floor(daysDiff / 365);
99
+ return `Il y a ${years} an${years > 1 ? 's' : ''}`;
100
+ }
101
+
102
+ function KanbanContactCard({
103
+ contact,
104
+ onDragStart,
105
+ onDragEnd,
106
+ isDragging,
107
+ }: {
108
+ contact: Contact;
109
+ onDragStart: () => void;
110
+ onDragEnd: () => void;
111
+ isDragging: boolean;
112
+ }) {
113
+ const fullName = `${contact.civility ? `${contact.civility}. ` : ''}${
114
+ contact.firstName || ''
115
+ } ${contact.lastName || ''}`.trim();
116
+
117
+ const formattedUpdatedAt = contact.updatedAt && formatRelativeDate(contact.updatedAt);
118
+
119
+ return (
120
+ <div
121
+ draggable
122
+ onDragStart={onDragStart}
123
+ onDragEnd={onDragEnd}
124
+ className={cn(
125
+ 'relative rounded-lg border border-gray-200 bg-white p-4 text-[13px] shadow-sm transition-transform hover:-translate-y-0.5 hover:shadow',
126
+ 'cursor-grab active:cursor-grabbing',
127
+ isDragging && 'opacity-80 ring-2 ring-indigo-400',
128
+ )}
129
+ >
130
+ {/* Icône Eye en haut à droite */}
131
+ <Link
132
+ href={`/contacts/${contact.id}`}
133
+ className="absolute top-2 right-2 z-10 rounded p-1 transition-colors hover:bg-gray-100"
134
+ onClick={(e) => e.stopPropagation()}
135
+ >
136
+ <Eye className="h-4 w-4 stroke-gray-400" />
137
+ </Link>
138
+
139
+ <div className="block">
140
+ {contact.companyName && (
141
+ <p className="mb-1 truncate text-[10px] font-semibold text-gray-400 uppercase">
142
+ {contact.companyName}
143
+ </p>
144
+ )}
145
+ <div className="mb-3 flex items-center gap-3">
146
+ <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-xs font-semibold text-indigo-600">
147
+ {contact.isCompany ? (
148
+ <span>🏢</span>
149
+ ) : (
150
+ (contact.firstName?.[0] || contact.lastName?.[0] || '?').toUpperCase()
151
+ )}
152
+ </div>
153
+ <div className="min-w-0 flex-1">
154
+ <p className="truncate text-sm font-semibold text-gray-900">{fullName || 'Sans nom'}</p>
155
+ </div>
156
+ </div>
157
+
158
+ {contact.email && (
159
+ <div className="mt-1 flex items-center text-[11px] text-gray-700">
160
+ <Mail className="mr-1 h-3 w-3 text-gray-400" />
161
+ <span className="truncate">{contact.email}</span>
162
+ </div>
163
+ )}
164
+
165
+ {contact.phone && (
166
+ <div className="mt-1 flex items-center text-[11px] text-gray-700">
167
+ <Phone className="mr-1 h-3 w-3 text-gray-400" />
168
+ {contact.phone}
169
+ </div>
170
+ )}
171
+
172
+ {contact.secondaryPhone && (
173
+ <div className="mt-1 flex items-center text-[11px] text-gray-500">
174
+ <Phone className="mr-1 h-3 w-3 text-gray-300" />
175
+ {contact.secondaryPhone}
176
+ </div>
177
+ )}
178
+
179
+ {contact.city && (
180
+ <div className="mt-1 flex items-center text-[11px] text-gray-500">
181
+ <MapPin className="mr-1 h-3 w-3 text-gray-400" />
182
+ {contact.city}
183
+ {contact.postalCode && ` ${contact.postalCode}`}
184
+ </div>
185
+ )}
186
+
187
+ {contact.assignedTelepro && (
188
+ <div className="mt-1 flex items-center text-[11px] text-emerald-700">
189
+ <Users className="mr-1 h-3 w-3 text-emerald-500" />
190
+ <span className="font-medium">Télépro:&nbsp;</span>
191
+ <span className="truncate">{contact.assignedTelepro.name}</span>
192
+ </div>
193
+ )}
194
+
195
+ {contact.assignedCommercial && (
196
+ <div className="mt-1 flex items-center text-[11px] text-sky-700">
197
+ <Users className="mr-1 h-3 w-3 text-sky-500" />
198
+ <span className="font-medium">Commercial:&nbsp;</span>
199
+ <span className="truncate">{contact.assignedCommercial.name}</span>
200
+ </div>
201
+ )}
202
+
203
+ <div className="mt-2 w-full border-[0.2px] border-gray-300" />
204
+
205
+ {formattedUpdatedAt && (
206
+ <div className="mt-2 flex items-center text-[11px] text-gray-400">
207
+ <Calendar className="mr-1 h-3 w-3 text-gray-400" />
208
+ <span>{formattedUpdatedAt}</span>
209
+ </div>
210
+ )}
211
+ </div>
212
+ </div>
213
+ );
214
+ }
215
+
216
+ function createDefaultColumns(statuses: Status[]): ClosingColumn[] {
217
+ return statuses.map((status, idx) => ({
218
+ id: status.id,
219
+ statusId: status.id,
220
+ title: status.name,
221
+ color: status.color || '#4f46e5',
222
+ width: 320,
223
+ order: idx,
224
+ }));
225
+ }
226
+
227
+ export default function ClosingPage() {
228
+ const { toggle: toggleMobileMenu, isOpen: isMobileMenuOpen } = useMobileMenuContext();
229
+ const [statuses, setStatuses] = useState<Status[]>([]);
230
+ const [contacts, setContacts] = useState<Contact[]>([]);
231
+ const [loading, setLoading] = useState(true);
232
+ const [error, setError] = useState('');
233
+ const [columns, setColumns] = useState<ClosingColumn[]>([]);
234
+ const [draggedCard, setDraggedCard] = useState<{
235
+ contactId: string;
236
+ fromStatusId: string | null;
237
+ } | null>(null);
238
+ const [draggingContactId, setDraggingContactId] = useState<string | null>(null);
239
+ const [dragOverStatusId, setDragOverStatusId] = useState<string | null>(null);
240
+ const [isSavingDrag, setIsSavingDrag] = useState(false);
241
+
242
+ const [showConfigModal, setShowConfigModal] = useState(false);
243
+ const [configColumns, setConfigColumns] = useState<ClosingColumn[]>([]);
244
+ const [draggedConfigColumnId, setDraggedConfigColumnId] = useState<string | null>(null);
245
+ const [dragOverConfigColumnId, setDragOverConfigColumnId] = useState<string | null>(null);
246
+
247
+ // États pour le drag & drop des colonnes dans le board
248
+ const [draggedColumnId, setDraggedColumnId] = useState<string | null>(null);
249
+ const [dragOverColumnId, setDragOverColumnId] = useState<string | null>(null);
250
+
251
+ const [selectedCompanyId, setSelectedCompanyId] = useState<string | 'all'>('all');
252
+ const [selectedPeriod, setSelectedPeriod] = useState<string>('all');
253
+ const [selectedCommercialId, setSelectedCommercialId] = useState<string | 'all'>('all');
254
+ const [selectedTeleproId, setSelectedTeleproId] = useState<string | 'all'>('all');
255
+
256
+ useEffect(() => {
257
+ const fetchData = async () => {
258
+ try {
259
+ setLoading(true);
260
+ setError('');
261
+
262
+ const [statusRes, contactsRes] = await Promise.all([
263
+ fetch('/api/statuses'),
264
+ fetch('/api/contacts?limit=500'),
265
+ ]);
266
+
267
+ if (!statusRes.ok) {
268
+ throw new Error('Erreur lors du chargement des statuts');
269
+ }
270
+ if (!contactsRes.ok) {
271
+ throw new Error('Erreur lors du chargement des contacts');
272
+ }
273
+
274
+ const statusesJson: Status[] = await statusRes.json();
275
+ const contactsJson = await contactsRes.json();
276
+
277
+ setStatuses(statusesJson);
278
+ setContacts(contactsJson.contacts || []);
279
+
280
+ if (typeof window !== 'undefined') {
281
+ const stored = window.localStorage.getItem(LOCAL_STORAGE_KEY);
282
+ if (stored) {
283
+ try {
284
+ const parsed: ClosingColumn[] = JSON.parse(stored);
285
+ setColumns(
286
+ parsed
287
+ .filter((c) => !c.statusId || statusesJson.some((s) => s.id === c.statusId))
288
+ .sort((a, b) => a.order - b.order),
289
+ );
290
+ } catch {
291
+ setColumns(createDefaultColumns(statusesJson));
292
+ }
293
+ } else {
294
+ setColumns(createDefaultColumns(statusesJson));
295
+ }
296
+ } else {
297
+ setColumns(createDefaultColumns(statusesJson));
298
+ }
299
+ } catch (e: any) {
300
+ console.error(e);
301
+ setError(e.message || 'Erreur de chargement');
302
+ } finally {
303
+ setLoading(false);
304
+ }
305
+ };
306
+
307
+ fetchData();
308
+ }, []);
309
+
310
+ const statusesById = useMemo(() => {
311
+ const map: Record<string, Status> = {};
312
+ statuses.forEach((s) => {
313
+ map[s.id] = s;
314
+ });
315
+ return map;
316
+ }, [statuses]);
317
+
318
+ const sortedColumns = useMemo(() => [...columns].sort((a, b) => a.order - b.order), [columns]);
319
+
320
+ const filteredContacts = useMemo(() => {
321
+ return contacts.filter((c) => {
322
+ if (selectedCompanyId !== 'all' && c.companyId !== selectedCompanyId) {
323
+ return false;
324
+ }
325
+ if (selectedCommercialId !== 'all' && c.assignedCommercialId !== selectedCommercialId) {
326
+ return false;
327
+ }
328
+ if (selectedTeleproId !== 'all' && c.assignedTeleproId !== selectedTeleproId) {
329
+ return false;
330
+ }
331
+ // Filtre par période (basé sur updatedAt)
332
+ if (selectedPeriod !== 'all') {
333
+ const updatedDate = c.updatedAt ? new Date(c.updatedAt) : null;
334
+ if (!updatedDate) return false;
335
+ const now = new Date();
336
+ const daysDiff = Math.floor(
337
+ (now.getTime() - updatedDate.getTime()) / (1000 * 60 * 60 * 24),
338
+ );
339
+
340
+ switch (selectedPeriod) {
341
+ case 'today':
342
+ if (daysDiff !== 0) return false;
343
+ break;
344
+ case 'week':
345
+ if (daysDiff > 7) return false;
346
+ break;
347
+ case 'month':
348
+ if (daysDiff > 30) return false;
349
+ break;
350
+ }
351
+ }
352
+ return true;
353
+ });
354
+ }, [contacts, selectedCompanyId, selectedPeriod, selectedCommercialId, selectedTeleproId]);
355
+
356
+ const contactsByStatusId = useMemo(() => {
357
+ const map = new Map<string | null, Contact[]>();
358
+ sortedColumns.forEach((col) => {
359
+ map.set(col.statusId, []);
360
+ });
361
+
362
+ filteredContacts.forEach((contact) => {
363
+ const key = contact.statusId || null;
364
+ if (!map.has(key)) {
365
+ return;
366
+ }
367
+ map.get(key)!.push(contact);
368
+ });
369
+
370
+ return map;
371
+ }, [filteredContacts, sortedColumns]);
372
+
373
+ const handleCardDragStart = (contactId: string, fromStatusId: string | null) => {
374
+ setDraggedCard({ contactId, fromStatusId });
375
+ setDraggingContactId(contactId);
376
+ };
377
+
378
+ const handleColumnDragOver = (e: React.DragEvent<HTMLDivElement>, statusId: string | null) => {
379
+ e.preventDefault();
380
+ setDragOverStatusId(statusId);
381
+ };
382
+
383
+ const handleColumnDrop = async (targetStatusId: string | null) => {
384
+ if (!draggedCard) return;
385
+
386
+ const { contactId, fromStatusId } = draggedCard;
387
+ if (fromStatusId === targetStatusId) {
388
+ setDraggedCard(null);
389
+ return;
390
+ }
391
+
392
+ setContacts((prev) =>
393
+ prev.map((c) =>
394
+ c.id === contactId
395
+ ? {
396
+ ...c,
397
+ statusId: targetStatusId,
398
+ status: targetStatusId ? statusesById[targetStatusId] || c.status : c.status,
399
+ }
400
+ : c,
401
+ ),
402
+ );
403
+ setDraggedCard(null);
404
+ setDraggingContactId(null);
405
+ setDragOverStatusId(null);
406
+
407
+ if (targetStatusId) {
408
+ try {
409
+ setIsSavingDrag(true);
410
+ const res = await fetch(`/api/contacts/${contactId}`, {
411
+ method: 'PUT',
412
+ headers: { 'Content-Type': 'application/json' },
413
+ body: JSON.stringify({ statusId: targetStatusId }),
414
+ });
415
+ if (!res.ok) {
416
+ throw new Error('Erreur lors de la mise à jour du statut');
417
+ }
418
+ } catch (e) {
419
+ console.error(e);
420
+ } finally {
421
+ setIsSavingDrag(false);
422
+ }
423
+ }
424
+ };
425
+
426
+ // Handlers pour le drag & drop des colonnes dans le board
427
+ const handleColumnHeaderDragStart = (columnId: string) => {
428
+ setDraggedColumnId(columnId);
429
+ };
430
+
431
+ const handleColumnHeaderDragOver = (e: React.DragEvent<HTMLDivElement>, columnId: string) => {
432
+ e.preventDefault();
433
+ if (draggedColumnId && draggedColumnId !== columnId) {
434
+ setDragOverColumnId(columnId);
435
+ }
436
+ };
437
+
438
+ const handleColumnHeaderDrop = (targetColumnId: string) => {
439
+ if (!draggedColumnId || draggedColumnId === targetColumnId) {
440
+ setDraggedColumnId(null);
441
+ setDragOverColumnId(null);
442
+ return;
443
+ }
444
+
445
+ const draggedIndex = sortedColumns.findIndex((c) => c.id === draggedColumnId);
446
+ const targetIndex = sortedColumns.findIndex((c) => c.id === targetColumnId);
447
+
448
+ if (draggedIndex === -1 || targetIndex === -1) {
449
+ setDraggedColumnId(null);
450
+ setDragOverColumnId(null);
451
+ return;
452
+ }
453
+
454
+ // Réorganiser les colonnes
455
+ const newColumns = [...sortedColumns];
456
+ const [removed] = newColumns.splice(draggedIndex, 1);
457
+ newColumns.splice(targetIndex, 0, removed);
458
+
459
+ // Mettre à jour l'ordre
460
+ const reorderedColumns = newColumns.map((col, idx) => ({
461
+ ...col,
462
+ order: idx,
463
+ }));
464
+
465
+ setColumns(reorderedColumns);
466
+ if (typeof window !== 'undefined') {
467
+ window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(reorderedColumns));
468
+ }
469
+
470
+ setDraggedColumnId(null);
471
+ setDragOverColumnId(null);
472
+ };
473
+
474
+ const openConfigModal = () => {
475
+ setConfigColumns([...columns].sort((a, b) => a.order - b.order));
476
+ setShowConfigModal(true);
477
+ };
478
+
479
+ const addConfigColumn = () => {
480
+ const usedStatusIds = new Set(configColumns.map((c) => c.statusId).filter(Boolean) as string[]);
481
+ const availableStatus = statuses.find((s) => !usedStatusIds.has(s.id));
482
+ const statusId = availableStatus?.id || null;
483
+
484
+ const newCol: ClosingColumn = {
485
+ id: `tmp-${Date.now()}`,
486
+ statusId,
487
+ title: availableStatus?.name || 'Nouvelle colonne',
488
+ color: availableStatus?.color || '#e5e7eb',
489
+ width: 320,
490
+ order: configColumns.length,
491
+ };
492
+ setConfigColumns((prev) => [...prev, newCol]);
493
+ };
494
+
495
+ const updateConfigColumn = (id: string, patch: Partial<ClosingColumn>) => {
496
+ setConfigColumns((prev) => prev.map((col) => (col.id === id ? { ...col, ...patch } : col)));
497
+ };
498
+
499
+ const removeConfigColumn = (id: string) => {
500
+ setConfigColumns((prev) =>
501
+ prev
502
+ .filter((col) => col.id !== id)
503
+ .map((col, idx) => ({
504
+ ...col,
505
+ order: idx,
506
+ })),
507
+ );
508
+ };
509
+
510
+ const handleSaveConfig = () => {
511
+ const normalized = [...configColumns]
512
+ .filter((col) => col.statusId)
513
+ .map((col, idx) => ({
514
+ ...col,
515
+ order: idx,
516
+ id: col.id.startsWith('tmp-') ? `${Date.now()}-${idx}` : col.id,
517
+ }));
518
+
519
+ setColumns(normalized);
520
+ if (typeof window !== 'undefined') {
521
+ window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(normalized));
522
+ }
523
+ setShowConfigModal(false);
524
+ };
525
+
526
+ const handleConfigColumnDrop = (targetId: string) => {
527
+ if (!draggedConfigColumnId || draggedConfigColumnId === targetId) {
528
+ setDraggedConfigColumnId(null);
529
+ setDragOverConfigColumnId(null);
530
+ return;
531
+ }
532
+
533
+ setConfigColumns((prev) => {
534
+ const fromIndex = prev.findIndex((c) => c.id === draggedConfigColumnId);
535
+ const toIndex = prev.findIndex((c) => c.id === targetId);
536
+ if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
537
+ return prev;
538
+ }
539
+
540
+ const updated = [...prev];
541
+ const [moved] = updated.splice(fromIndex, 1);
542
+ updated.splice(toIndex, 0, moved);
543
+
544
+ return updated.map((col, idx) => ({
545
+ ...col,
546
+ order: idx,
547
+ }));
548
+ });
549
+
550
+ setDraggedConfigColumnId(null);
551
+ setDragOverConfigColumnId(null);
552
+ };
553
+
554
+ const resetToDefaultColumns = () => {
555
+ const defaults = createDefaultColumns(statuses);
556
+ setConfigColumns(defaults);
557
+ };
558
+
559
+ // Récupérer les entreprises uniques pour le filtre
560
+ const companies = useMemo(() => {
561
+ const companyMap = new Map<string, { id: string; name: string }>();
562
+ contacts.forEach((c) => {
563
+ if (c.companyId && c.companyName) {
564
+ companyMap.set(c.companyId, { id: c.companyId, name: c.companyName });
565
+ }
566
+ });
567
+ return Array.from(companyMap.values());
568
+ }, [contacts]);
569
+
570
+ if (loading) {
571
+ return (
572
+ <div className="h-full">
573
+ <div className="border-b border-gray-200 bg-white px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
574
+ <div className="flex items-start gap-3">
575
+ {/* Mobile menu button */}
576
+ <button
577
+ onClick={toggleMobileMenu}
578
+ className="mt-1 shrink-0 cursor-pointer rounded-lg p-2 text-gray-700 transition-colors hover:bg-gray-100 lg:hidden"
579
+ aria-label="Toggle menu"
580
+ >
581
+ <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
582
+ {isMobileMenuOpen ? (
583
+ <path
584
+ strokeLinecap="round"
585
+ strokeLinejoin="round"
586
+ strokeWidth={2}
587
+ d="M6 18L18 6M6 6l12 12"
588
+ />
589
+ ) : (
590
+ <path
591
+ strokeLinecap="round"
592
+ strokeLinejoin="round"
593
+ strokeWidth={2}
594
+ d="M4 6h16M4 12h16M4 18h16"
595
+ />
596
+ )}
597
+ </svg>
598
+ </button>
599
+ <div className="min-w-0 flex-1">
600
+ <h1 className="text-xl font-bold text-gray-900 sm:text-2xl">Pipeline de Closing</h1>
601
+ <p className="mt-1 text-sm text-gray-600">
602
+ Visualisez et gérez vos opportunités commerciales
603
+ </p>
604
+ </div>
605
+ </div>
606
+ </div>
607
+ <div className="p-4 sm:p-6 lg:p-8">
608
+ <div className="rounded-lg bg-white p-8 shadow">
609
+ <p className="text-gray-500">Chargement du pipeline...</p>
610
+ </div>
611
+ </div>
612
+ </div>
613
+ );
614
+ }
615
+
616
+ return (
617
+ <div className="h-full">
618
+ {' '}
619
+ {/* md:overflow-y-hidden */}
620
+ {/* En-tête personnalisé avec filtres intégrés */}
621
+ <div className="border-b border-gray-200 bg-white px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
622
+ <div className="flex items-start gap-3">
623
+ {/* Mobile menu button */}
624
+ <button
625
+ onClick={toggleMobileMenu}
626
+ className="mt-1 shrink-0 cursor-pointer rounded-lg p-2 text-gray-700 transition-colors hover:bg-gray-100 lg:hidden"
627
+ aria-label="Toggle menu"
628
+ >
629
+ <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
630
+ {isMobileMenuOpen ? (
631
+ <path
632
+ strokeLinecap="round"
633
+ strokeLinejoin="round"
634
+ strokeWidth={2}
635
+ d="M6 18L18 6M6 6l12 12"
636
+ />
637
+ ) : (
638
+ <path
639
+ strokeLinecap="round"
640
+ strokeLinejoin="round"
641
+ strokeWidth={2}
642
+ d="M4 6h16M4 12h16M4 18h16"
643
+ />
644
+ )}
645
+ </svg>
646
+ </button>
647
+ <div className="flex min-w-0 flex-1 items-start justify-between gap-4">
648
+ <div className="min-w-0 flex-1">
649
+ <h1 className="text-xl font-bold text-gray-900 sm:text-2xl">Pipeline de Closing</h1>
650
+ <p className="mt-1 text-sm text-gray-600">
651
+ Visualisez et gérez vos opportunités commerciales
652
+ </p>
653
+
654
+ {/* Filtres intégrés dans l'en-tête */}
655
+ <div className="mt-4 flex flex-col gap-3 sm:flex-row">
656
+ <div className="flex items-center gap-2">
657
+ <span className="text-xs font-medium text-gray-700">Période:</span>
658
+ <div className="relative">
659
+ <Filter className="absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
660
+ <select
661
+ className="rounded-lg border border-gray-300 py-1.5 pr-2 pl-7 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
662
+ value={selectedPeriod}
663
+ onChange={(e) => setSelectedPeriod(e.target.value)}
664
+ >
665
+ <option value="all">Toutes</option>
666
+ <option value="today">Aujourd'hui</option>
667
+ <option value="week">Cette semaine</option>
668
+ <option value="month">Ce mois</option>
669
+ </select>
670
+ </div>
671
+ </div>
672
+
673
+ <div className="flex items-center gap-2">
674
+ <span className="text-xs font-medium text-gray-700">Commercial:</span>
675
+ <select
676
+ className="rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
677
+ value={selectedCommercialId}
678
+ onChange={(e) =>
679
+ setSelectedCommercialId(e.target.value === 'all' ? 'all' : e.target.value)
680
+ }
681
+ >
682
+ <option value="all">Tous</option>
683
+ {Array.from(
684
+ new Map(
685
+ contacts
686
+ .filter((c) => c.assignedCommercial)
687
+ .map((c) => [c.assignedCommercialId, c.assignedCommercial!]),
688
+ ).values(),
689
+ ).map((user) => (
690
+ <option key={user.id} value={user.id}>
691
+ {user.name}
692
+ </option>
693
+ ))}
694
+ </select>
695
+ </div>
696
+
697
+ <div className="flex items-center gap-2">
698
+ <span className="text-xs font-medium text-gray-700">Télépro:</span>
699
+ <select
700
+ className="rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
701
+ value={selectedTeleproId}
702
+ onChange={(e) =>
703
+ setSelectedTeleproId(e.target.value === 'all' ? 'all' : e.target.value)
704
+ }
705
+ >
706
+ <option value="all">Tous</option>
707
+ {Array.from(
708
+ new Map(
709
+ contacts
710
+ .filter((c) => c.assignedTelepro)
711
+ .map((c) => [c.assignedTeleproId, c.assignedTelepro!]),
712
+ ).values(),
713
+ ).map((user) => (
714
+ <option key={user.id} value={user.id}>
715
+ {user.name}
716
+ </option>
717
+ ))}
718
+ </select>
719
+ </div>
720
+
721
+ {isSavingDrag && (
722
+ <span className="self-end text-xs text-gray-400">
723
+ Sauvegarde des changements…
724
+ </span>
725
+ )}
726
+ </div>
727
+ </div>
728
+
729
+ <div className="shrink-0">
730
+ <button
731
+ type="button"
732
+ onClick={openConfigModal}
733
+ className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-semibold text-gray-700 shadow-sm transition-colors hover:bg-gray-50 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:outline-none"
734
+ >
735
+ <SettingsIcon className="h-4 w-4" />
736
+ Configurer
737
+ </button>
738
+ </div>
739
+ </div>
740
+ </div>
741
+ </div>
742
+ {error && (
743
+ <div className="px-4 pt-4 sm:px-6 sm:pt-6">
744
+ <div className="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-600">{error}</div>
745
+ </div>
746
+ )}
747
+ {/* Board kanban : taille adaptée au contenu avec padding haut/bas */}
748
+ <div
749
+ className="flex gap-4 overflow-x-auto px-4 pt-4 pb-4 sm:px-6 sm:pt-6 sm:pb-6"
750
+ style={{ scrollbarWidth: 'none' as 'none' }} // for Firefox
751
+ >
752
+ <style jsx>{`
753
+ div::-webkit-scrollbar {
754
+ display: none;
755
+ }
756
+ `}</style>
757
+ {sortedColumns.map((column) => {
758
+ const columnContacts = contactsByStatusId.get(column.statusId) || [];
759
+ const status = column.statusId ? statusesById[column.statusId] : null;
760
+
761
+ return (
762
+ <div
763
+ key={column.id}
764
+ className={cn(
765
+ 'flex shrink-0 flex-col rounded-xl bg-gray-50 shadow-sm transition-colors',
766
+ 'h-auto max-h-[calc(100vh-220px)]',
767
+ dragOverStatusId === column.statusId && 'ring-2 ring-indigo-400 ring-offset-2',
768
+ dragOverColumnId === column.id &&
769
+ draggedColumnId &&
770
+ 'ring-2 ring-indigo-500 ring-offset-2',
771
+ )}
772
+ style={{ width: column.width }}
773
+ onDragOver={(e) => {
774
+ // Gérer le drag des colonnes ET des contacts
775
+ if (draggedColumnId) {
776
+ handleColumnHeaderDragOver(e, column.id);
777
+ } else {
778
+ handleColumnDragOver(e, column.statusId);
779
+ }
780
+ }}
781
+ onDrop={(e) => {
782
+ e.preventDefault();
783
+ if (draggedColumnId) {
784
+ handleColumnHeaderDrop(column.id);
785
+ } else {
786
+ handleColumnDrop(column.statusId);
787
+ }
788
+ }}
789
+ onDragLeave={() => {
790
+ if (!draggedColumnId) {
791
+ setDragOverStatusId(null);
792
+ }
793
+ if (draggedColumnId && dragOverColumnId === column.id) {
794
+ setDragOverColumnId(null);
795
+ }
796
+ }}
797
+ >
798
+ <div
799
+ draggable
800
+ onDragStart={(e) => {
801
+ // Ne pas drag si on drag déjà un contact
802
+ if (!draggedCard && !draggingContactId) {
803
+ handleColumnHeaderDragStart(column.id);
804
+ } else {
805
+ e.preventDefault();
806
+ }
807
+ }}
808
+ onDragEnd={() => {
809
+ setDraggedColumnId(null);
810
+ setDragOverColumnId(null);
811
+ }}
812
+ className={cn(
813
+ 'flex shrink-0 items-center justify-between rounded-t-xl px-4 py-3 text-sm font-semibold text-white',
814
+ 'cursor-grab transition-opacity active:cursor-grabbing',
815
+ draggedColumnId === column.id && 'opacity-50',
816
+ )}
817
+ style={{ backgroundColor: column.color || '#4f46e5' }}
818
+ >
819
+ <div className="flex items-center gap-2">
820
+ <span>{column.title || status?.name || 'Colonne'}</span>
821
+ <span className="rounded-full bg-white/20 px-2 py-0.5 text-xs font-medium">
822
+ {columnContacts.length}
823
+ </span>
824
+ </div>
825
+ </div>
826
+
827
+ <div
828
+ className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3"
829
+ onDragStart={(e) => {
830
+ // Empêcher le drag du header si on drag un contact
831
+ if (draggingContactId || draggedCard) {
832
+ e.stopPropagation();
833
+ }
834
+ }}
835
+ >
836
+ {columnContacts.length === 0 ? (
837
+ <div className="mt-4 rounded-lg border border-dashed border-gray-200 bg-white/40 p-4 text-center text-xs text-gray-400">
838
+ Les contacts avec ce statut apparaîtront ici
839
+ </div>
840
+ ) : (
841
+ columnContacts.map((contact) => (
842
+ <KanbanContactCard
843
+ key={contact.id}
844
+ contact={contact}
845
+ isDragging={draggingContactId === contact.id}
846
+ onDragStart={() => handleCardDragStart(contact.id, contact.statusId)}
847
+ onDragEnd={() => {
848
+ setDraggingContactId(null);
849
+ setDragOverStatusId(null);
850
+ }}
851
+ />
852
+ ))
853
+ )}
854
+ </div>
855
+ </div>
856
+ );
857
+ })}
858
+ </div>
859
+ {showConfigModal && (
860
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-500/30 p-4 backdrop-blur-sm">
861
+ <div className="flex w-full max-w-7xl flex-col rounded-2xl bg-white shadow-xl">
862
+ <div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
863
+ <div>
864
+ <h2 className="text-lg font-semibold text-gray-900">Configuration du Pipeline</h2>
865
+ <p className="mt-1 text-sm text-gray-500">
866
+ Personnalisez les colonnes de votre pipeline de closing.
867
+ </p>
868
+ </div>
869
+ <button
870
+ type="button"
871
+ onClick={() => setShowConfigModal(false)}
872
+ className="cursor-pointer rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-100"
873
+ >
874
+ <X className="h-5 w-5" />
875
+ </button>
876
+ </div>
877
+
878
+ <div className="flex-1 overflow-y-auto px-6 py-4">
879
+ <div className="mb-3 flex items-center justify-between">
880
+ <div>
881
+ <p className="text-sm text-gray-600">
882
+ {configColumns.length} colonne
883
+ {configColumns.length > 1 ? 's' : ''} configurée
884
+ {configColumns.length > 1 ? 's' : ''}.
885
+ </p>
886
+ <p className="mt-1 text-xs text-gray-400">
887
+ Glissez-déposez les colonnes ci-dessous pour organiser l&apos;ordre de votre
888
+ pipeline.
889
+ </p>
890
+ </div>
891
+ <div className="flex items-center gap-2">
892
+ <button
893
+ type="button"
894
+ onClick={resetToDefaultColumns}
895
+ className="inline-flex cursor-pointer items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
896
+ >
897
+ Réinitialiser
898
+ </button>
899
+ <button
900
+ type="button"
901
+ onClick={addConfigColumn}
902
+ className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-indigo-600 bg-white px-3 py-1.5 text-sm font-medium text-indigo-600 transition-colors hover:bg-indigo-50"
903
+ >
904
+ <Plus className="h-4 w-4" />
905
+ Ajouter une colonne
906
+ </button>
907
+ </div>
908
+ </div>
909
+
910
+ <div className="flex gap-4 overflow-x-auto p-2">
911
+ {configColumns.map((column) => (
912
+ <div
913
+ key={column.id}
914
+ draggable
915
+ onDragStart={() => setDraggedConfigColumnId(column.id)}
916
+ onDragOver={(e) => {
917
+ e.preventDefault();
918
+ setDragOverConfigColumnId(column.id);
919
+ }}
920
+ onDrop={(e) => {
921
+ e.preventDefault();
922
+ handleConfigColumnDrop(column.id);
923
+ }}
924
+ onDragLeave={() =>
925
+ setDragOverConfigColumnId((current) =>
926
+ current === column.id ? null : current,
927
+ )
928
+ }
929
+ className={cn(
930
+ 'flex w-80 shrink-0 flex-col rounded-xl border border-gray-200 bg-gray-50',
931
+ 'cursor-grab transition-all duration-150 active:cursor-grabbing',
932
+ 'hover:-translate-y-1 hover:shadow-lg',
933
+ dragOverConfigColumnId === column.id &&
934
+ 'ring-2 ring-indigo-400 ring-offset-2',
935
+ )}
936
+ >
937
+ <div className="p-4">
938
+ <div className="mb-3 flex items-center justify-between">
939
+ <input
940
+ type="text"
941
+ value={column.title}
942
+ onChange={(e) => updateConfigColumn(column.id, { title: e.target.value })}
943
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
944
+ placeholder="Nom de la colonne"
945
+ />
946
+ <button
947
+ type="button"
948
+ onClick={() => removeConfigColumn(column.id)}
949
+ className="ml-2 cursor-pointer rounded-lg p-1 text-gray-400 transition-colors hover:bg-gray-200 hover:text-red-600"
950
+ >
951
+ <X className="h-4 w-4" />
952
+ </button>
953
+ </div>
954
+
955
+ <label className="mb-2 block text-xs font-medium text-gray-600">
956
+ Statut associé
957
+ </label>
958
+ <select
959
+ value={column.statusId || ''}
960
+ onChange={(e) =>
961
+ updateConfigColumn(column.id, {
962
+ statusId: e.target.value || null,
963
+ title:
964
+ e.target.value && statusesById[e.target.value]
965
+ ? statusesById[e.target.value].name
966
+ : column.title,
967
+ color:
968
+ e.target.value && statusesById[e.target.value]
969
+ ? statusesById[e.target.value].color
970
+ : column.color,
971
+ })
972
+ }
973
+ className="mb-3 w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
974
+ >
975
+ <option value="">Sélectionnez un statut</option>
976
+ {statuses.map((status) => (
977
+ <option key={status.id} value={status.id}>
978
+ {status.name}
979
+ </option>
980
+ ))}
981
+ </select>
982
+
983
+ <label className="mb-1 block text-xs font-medium text-gray-600">
984
+ Couleur de la colonne
985
+ </label>
986
+ <div className="mb-3 flex items-center gap-2">
987
+ <input
988
+ type="color"
989
+ value={column.color}
990
+ onChange={(e) => updateConfigColumn(column.id, { color: e.target.value })}
991
+ className="h-8 w-12 cursor-pointer rounded border border-gray-300 bg-white"
992
+ />
993
+ <input
994
+ type="text"
995
+ value={column.color}
996
+ onChange={(e) => updateConfigColumn(column.id, { color: e.target.value })}
997
+ className="flex-1 rounded-lg border border-gray-300 px-2 py-1.5 text-xs text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
998
+ />
999
+ </div>
1000
+
1001
+ <label className="mb-1 block text-xs font-medium text-gray-600">
1002
+ Largeur de la colonne (px)
1003
+ </label>
1004
+ <input
1005
+ type="range"
1006
+ min={MIN_WIDTH}
1007
+ max={MAX_WIDTH}
1008
+ value={column.width}
1009
+ onChange={(e) =>
1010
+ updateConfigColumn(column.id, {
1011
+ width: Number(e.target.value),
1012
+ })
1013
+ }
1014
+ className="w-full"
1015
+ />
1016
+ <div className="mt-1 text-right text-xs text-gray-500">{column.width}px</div>
1017
+ </div>
1018
+ <div className="flex h-[250px] flex-col items-center justify-center rounded-b-xl bg-white">
1019
+ <div className="mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-gray-100">
1020
+ <Filter className="h-8 w-8 text-gray-400" />
1021
+ </div>
1022
+ <p className="px-6 text-center text-sm font-medium text-gray-400">
1023
+ Les contacts avec ce statut apparaîtront ici
1024
+ </p>
1025
+ </div>
1026
+ </div>
1027
+ ))}
1028
+ </div>
1029
+ </div>
1030
+
1031
+ <div className="flex items-center justify-between border-t border-gray-200 px-6 py-4">
1032
+ <button
1033
+ type="button"
1034
+ onClick={() => setShowConfigModal(false)}
1035
+ className="cursor-pointer rounded-lg px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100"
1036
+ >
1037
+ Annuler
1038
+ </button>
1039
+ <button
1040
+ type="button"
1041
+ onClick={handleSaveConfig}
1042
+ className="cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-700"
1043
+ >
1044
+ Enregistrer la configuration
1045
+ </button>
1046
+ </div>
1047
+ </div>
1048
+ </div>
1049
+ )}
1050
+ </div>
1051
+ );
1052
+ }