create-crm-tmp 1.1.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/package.json +1 -1
  2. package/template/.prettierignore +2 -0
  3. package/template/README.md +53 -67
  4. package/template/components.json +22 -0
  5. package/template/exemple-contacts.csv +54 -0
  6. package/template/next.config.ts +27 -1
  7. package/template/package.json +64 -27
  8. package/template/prisma/schema.prisma +821 -72
  9. package/template/skills-lock.json +25 -0
  10. package/template/src/app/(auth)/invite/[token]/page.tsx +21 -24
  11. package/template/src/app/(auth)/reset-password/complete/page.tsx +12 -21
  12. package/template/src/app/(auth)/reset-password/page.tsx +12 -8
  13. package/template/src/app/(auth)/reset-password/verify/page.tsx +12 -8
  14. package/template/src/app/(auth)/signin/page.tsx +20 -17
  15. package/template/src/app/(dashboard)/agenda/page.tsx +2231 -2188
  16. package/template/src/app/(dashboard)/automatisation/[id]/page.tsx +10 -7
  17. package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +680 -323
  18. package/template/src/app/(dashboard)/automatisation/new/page.tsx +11 -8
  19. package/template/src/app/(dashboard)/automatisation/page.tsx +473 -180
  20. package/template/src/app/(dashboard)/closing/page.tsx +500 -468
  21. package/template/src/app/(dashboard)/contacts/[id]/page.tsx +5035 -4126
  22. package/template/src/app/(dashboard)/contacts/companies/[id]/page.tsx +1703 -0
  23. package/template/src/app/(dashboard)/contacts/loading.tsx +13 -0
  24. package/template/src/app/(dashboard)/contacts/page.tsx +3776 -2064
  25. package/template/src/app/(dashboard)/dashboard/page.tsx +37 -519
  26. package/template/src/app/(dashboard)/error.tsx +37 -0
  27. package/template/src/app/(dashboard)/layout.tsx +1 -1
  28. package/template/src/app/(dashboard)/loading.tsx +5 -0
  29. package/template/src/app/(dashboard)/settings/loading.tsx +19 -0
  30. package/template/src/app/(dashboard)/settings/page.tsx +2685 -2489
  31. package/template/src/app/(dashboard)/templates/page.tsx +500 -300
  32. package/template/src/app/(dashboard)/users/list/page.tsx +356 -350
  33. package/template/src/app/(dashboard)/users/page.tsx +279 -310
  34. package/template/src/app/(dashboard)/users/permissions/page.tsx +104 -99
  35. package/template/src/app/(dashboard)/users/roles/page.tsx +164 -137
  36. package/template/src/app/api/audit-logs/route.ts +1 -1
  37. package/template/src/app/api/auth/google/callback/route.ts +8 -5
  38. package/template/src/app/api/auth/google/disconnect/route.ts +2 -2
  39. package/template/src/app/api/companies/[id]/activities/route.ts +131 -0
  40. package/template/src/app/api/companies/[id]/route.ts +195 -0
  41. package/template/src/app/api/companies/export/route.ts +206 -0
  42. package/template/src/app/api/companies/route.ts +166 -0
  43. package/template/src/app/api/contact-views/[id]/pin/route.ts +69 -0
  44. package/template/src/app/api/contact-views/[id]/route.ts +197 -0
  45. package/template/src/app/api/contact-views/route.ts +146 -0
  46. package/template/src/app/api/contacts/[id]/files/[fileId]/preview/route.ts +77 -0
  47. package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +7 -17
  48. package/template/src/app/api/contacts/[id]/files/route.ts +83 -44
  49. package/template/src/app/api/contacts/[id]/interactions/route.ts +37 -0
  50. package/template/src/app/api/contacts/[id]/kyc/route.ts +71 -0
  51. package/template/src/app/api/contacts/[id]/meet/route.ts +38 -29
  52. package/template/src/app/api/contacts/[id]/route.ts +111 -20
  53. package/template/src/app/api/contacts/[id]/send-email/route.ts +6 -0
  54. package/template/src/app/api/contacts/[id]/workflows/run/route.ts +61 -0
  55. package/template/src/app/api/contacts/export/route.ts +12 -17
  56. package/template/src/app/api/contacts/import/route.ts +22 -19
  57. package/template/src/app/api/contacts/import-preview/route.ts +139 -0
  58. package/template/src/app/api/contacts/route.ts +202 -49
  59. package/template/src/app/api/dashboard/stats/route.ts +9 -292
  60. package/template/src/app/api/integrations/google-sheet/sync/route.ts +203 -185
  61. package/template/src/app/api/invite/complete/route.ts +20 -23
  62. package/template/src/app/api/reminders/route.ts +1 -0
  63. package/template/src/app/api/reset-password/complete/route.ts +11 -13
  64. package/template/src/app/api/send/route.ts +9 -85
  65. package/template/src/app/api/settings/closing-reasons/[id]/route.ts +10 -21
  66. package/template/src/app/api/settings/closing-reasons/route.ts +10 -21
  67. package/template/src/app/api/settings/company/route.ts +19 -26
  68. package/template/src/app/api/settings/google-ads/[id]/route.ts +20 -23
  69. package/template/src/app/api/settings/google-ads/route.ts +20 -23
  70. package/template/src/app/api/settings/google-sheet/[id]/route.ts +20 -23
  71. package/template/src/app/api/settings/google-sheet/auto-map/route.ts +23 -32
  72. package/template/src/app/api/settings/google-sheet/preview/route.ts +104 -0
  73. package/template/src/app/api/settings/google-sheet/route.ts +20 -23
  74. package/template/src/app/api/settings/meta-leads/[id]/route.ts +20 -23
  75. package/template/src/app/api/settings/meta-leads/route.ts +20 -23
  76. package/template/src/app/api/settings/statuses/[id]/route.ts +33 -23
  77. package/template/src/app/api/settings/statuses/route.ts +24 -22
  78. package/template/src/app/api/statuses/route.ts +2 -5
  79. package/template/src/app/api/tasks/[id]/attendees/route.ts +14 -7
  80. package/template/src/app/api/tasks/[id]/route.ts +161 -137
  81. package/template/src/app/api/tasks/meet/route.ts +11 -8
  82. package/template/src/app/api/tasks/route.ts +155 -95
  83. package/template/src/app/api/templates/[id]/route.ts +22 -13
  84. package/template/src/app/api/templates/route.ts +22 -5
  85. package/template/src/app/api/users/[id]/resend-invite/route.ts +95 -0
  86. package/template/src/app/api/users/[id]/route.ts +16 -1
  87. package/template/src/app/api/users/commercials/route.ts +38 -0
  88. package/template/src/app/api/users/for-agenda/route.ts +1 -2
  89. package/template/src/app/api/users/route.ts +94 -55
  90. package/template/src/app/api/webhooks/google-ads/route.ts +20 -1
  91. package/template/src/app/api/webhooks/meta-leads/route.ts +18 -1
  92. package/template/src/app/api/workflows/[id]/route.ts +33 -6
  93. package/template/src/app/api/workflows/process/route.ts +509 -146
  94. package/template/src/app/api/workflows/route.ts +46 -4
  95. package/template/src/app/globals.css +210 -101
  96. package/template/src/app/layout.tsx +19 -8
  97. package/template/src/app/page.tsx +37 -7
  98. package/template/src/components/address-autocomplete.tsx +232 -0
  99. package/template/src/components/contacts/filter-bar.tsx +181 -0
  100. package/template/src/components/contacts/filter-builder.tsx +589 -0
  101. package/template/src/components/contacts/save-view-dialog.tsx +160 -0
  102. package/template/src/components/contacts/views-tab-bar.tsx +440 -0
  103. package/template/src/components/dashboard/activity-chart.tsx +31 -39
  104. package/template/src/components/dashboard/dashboard-content.tsx +79 -0
  105. package/template/src/components/dashboard/stat-card.tsx +40 -42
  106. package/template/src/components/dashboard/tasks-pie-chart.tsx +34 -37
  107. package/template/src/components/dashboard/upcoming-tasks-list.tsx +78 -72
  108. package/template/src/components/date-picker.tsx +396 -0
  109. package/template/src/components/editor.tsx +27 -13
  110. package/template/src/components/email-template.tsx +4 -2
  111. package/template/src/components/global-search.tsx +358 -0
  112. package/template/src/components/header.tsx +57 -62
  113. package/template/src/components/invitation-email-template.tsx +4 -2
  114. package/template/src/components/lazy-editor.tsx +11 -0
  115. package/template/src/components/meet-cancellation-email-template.tsx +11 -3
  116. package/template/src/components/meet-confirmation-email-template.tsx +10 -3
  117. package/template/src/components/meet-update-email-template.tsx +10 -3
  118. package/template/src/components/page-header.tsx +19 -15
  119. package/template/src/components/protected-page.tsx +94 -0
  120. package/template/src/components/reset-password-email-template.tsx +4 -2
  121. package/template/src/components/sidebar.tsx +92 -94
  122. package/template/src/components/skeleton.tsx +128 -42
  123. package/template/src/components/ui/accordion.tsx +64 -0
  124. package/template/src/components/ui/alert-dialog.tsx +139 -0
  125. package/template/src/components/ui/button.tsx +60 -0
  126. package/template/src/components/view-as-banner.tsx +1 -1
  127. package/template/src/components/view-as-modal.tsx +21 -16
  128. package/template/src/config/nav-pages.ts +108 -0
  129. package/template/src/contexts/app-toast-context.tsx +174 -0
  130. package/template/src/contexts/sidebar-context.tsx +16 -47
  131. package/template/src/contexts/task-reminder-context.tsx +6 -6
  132. package/template/src/contexts/view-as-context.tsx +11 -16
  133. package/template/src/hooks/use-alert.tsx +65 -0
  134. package/template/src/hooks/use-confirm.tsx +87 -0
  135. package/template/src/hooks/use-contact-views.ts +140 -0
  136. package/template/src/hooks/use-contacts.ts +69 -0
  137. package/template/src/hooks/use-fetch.ts +17 -0
  138. package/template/src/hooks/use-focus-trap.ts +73 -0
  139. package/template/src/hooks/use-statuses.ts +22 -0
  140. package/template/src/lib/address-api.ts +155 -0
  141. package/template/src/lib/cache.ts +73 -0
  142. package/template/src/lib/check-permission.ts +12 -177
  143. package/template/src/lib/contact-interactions.ts +3 -1
  144. package/template/src/lib/contact-view-filters.ts +341 -0
  145. package/template/src/lib/dashboard-stats.ts +224 -0
  146. package/template/src/lib/date-utils.ts +49 -0
  147. package/template/src/lib/get-auth-user.ts +25 -0
  148. package/template/src/lib/google-calendar.ts +54 -12
  149. package/template/src/lib/google-drive.ts +796 -75
  150. package/template/src/lib/google-fetch.ts +63 -0
  151. package/template/src/lib/local-storage.ts +34 -0
  152. package/template/src/lib/permissions.ts +245 -47
  153. package/template/src/lib/prisma.ts +11 -11
  154. package/template/src/lib/roles.ts +14 -39
  155. package/template/src/lib/template-variables.ts +67 -33
  156. package/template/src/lib/utils.ts +26 -2
  157. package/template/src/lib/workflow-executor.ts +445 -229
  158. package/template/src/proxy.ts +34 -73
  159. package/template/src/types/contact-views.ts +351 -0
  160. package/template/src/types/yousign.ts +52 -0
  161. package/template/vercel.json +12 -0
  162. package/template/WORKFLOWS_CRON.md +0 -185
  163. package/template/prisma/migrations/20251126144728_init/migration.sql +0 -78
  164. package/template/prisma/migrations/20251126155204_add_user_roles/migration.sql +0 -5
  165. package/template/prisma/migrations/20251128095126_add_company_info/migration.sql +0 -19
  166. package/template/prisma/migrations/20251128123321_add_smtp_config/migration.sql +0 -22
  167. package/template/prisma/migrations/20251128132303_add_status/migration.sql +0 -23
  168. package/template/prisma/migrations/20251201102207_add_user_active/migration.sql +0 -75
  169. package/template/prisma/migrations/20251201105507_add_email_signature/migration.sql +0 -2
  170. package/template/prisma/migrations/20251201151122_add_tasks/migration.sql +0 -45
  171. package/template/prisma/migrations/20251202111854_add_task_reminder/migration.sql +0 -2
  172. package/template/prisma/migrations/20251202135859_add_google_meet_integration/migration.sql +0 -27
  173. package/template/prisma/migrations/20251203103317_add_meta_lead_integration/migration.sql +0 -20
  174. package/template/prisma/migrations/20251203104002_add_google_ads_integration/migration.sql +0 -18
  175. package/template/prisma/migrations/20251203112122_add_google_sheet_integration/migration.sql +0 -32
  176. package/template/prisma/migrations/20251203153853_allow_multiple_integration_configs/migration.sql +0 -20
  177. package/template/prisma/migrations/20251205141705_update_user_roles/migration.sql +0 -12
  178. package/template/prisma/migrations/20251205150000_add_commercial_and_telepro_assignment/migration.sql +0 -21
  179. package/template/prisma/migrations/20251205160000_add_interaction_logging/migration.sql +0 -11
  180. package/template/prisma/migrations/20251208090314_add_automatic_interaction_types/migration.sql +0 -12
  181. package/template/prisma/migrations/20251208094843_mg/migration.sql +0 -14
  182. package/template/prisma/migrations/20251208100000_add_company_support/migration.sql +0 -14
  183. package/template/prisma/migrations/20251208110000_add_templates/migration.sql +0 -26
  184. package/template/prisma/migrations/20251208141304_add_video_conference_task_type/migration.sql +0 -2
  185. package/template/prisma/migrations/20251209104759_add_internal_note_to_task/migration.sql +0 -2
  186. package/template/prisma/migrations/20251209134803_add_company_field/migration.sql +0 -2
  187. package/template/prisma/migrations/20251209150000_rename_company_to_company_name/migration.sql +0 -3
  188. package/template/prisma/migrations/20251209150016_add_email_tracking/migration.sql +0 -21
  189. package/template/prisma/migrations/20251209155908_add_notify_contact_to_task/migration.sql +0 -2
  190. package/template/prisma/migrations/20251210110019_add_appointment_types/migration.sql +0 -10
  191. package/template/prisma/migrations/20251210113928_add_contact_files/migration.sql +0 -26
  192. package/template/prisma/migrations/20251212132339_add_custom_roles/migration.sql +0 -24
  193. package/template/prisma/migrations/20251215104448_add_file_interaction_types/migration.sql +0 -11
  194. package/template/prisma/migrations/20251215145616_add_closing_reasons/migration.sql +0 -12
  195. package/template/prisma/migrations/20251216140850_add_log_users/migration.sql +0 -25
  196. package/template/prisma/migrations/20251216151000_rename_perdu_to_ferme/migration.sql +0 -8
  197. package/template/prisma/migrations/20251216162318_add_column_mappings_to_google_sheet/migration.sql +0 -2
  198. package/template/prisma/migrations/20251216185127_add_workflows/migration.sql +0 -80
  199. package/template/prisma/migrations/20251216192237_add_scheduled_workflow_actions/migration.sql +0 -32
  200. package/template/prisma/migrations/20251220000000_add_task_interaction_type/migration.sql +0 -4
  201. package/template/prisma/migrations/20251221000000_add_task_type/migration.sql +0 -3
  202. package/template/prisma/migrations/20251221000001_add_event_color/migration.sql +0 -23
  203. package/template/prisma/migrations/20260210114913_add_dashboard_widget/migration.sql +0 -20
  204. package/template/prisma/migrations/migration_lock.toml +0 -3
  205. package/template/src/app/(dashboard)/users/layout.tsx +0 -30
  206. package/template/src/app/api/dashboard/widgets/[id]/route.ts +0 -47
  207. package/template/src/app/api/dashboard/widgets/route.ts +0 -181
  208. package/template/src/components/dashboard/add-widget-dialog.tsx +0 -161
  209. package/template/src/components/dashboard/color-picker.tsx +0 -65
  210. package/template/src/components/dashboard/contacts-chart.tsx +0 -69
  211. package/template/src/components/dashboard/interactions-by-type-chart.tsx +0 -121
  212. package/template/src/components/dashboard/recent-activity.tsx +0 -157
  213. package/template/src/components/dashboard/sales-analytics-chart.tsx +0 -77
  214. package/template/src/components/dashboard/status-distribution-chart.tsx +0 -82
  215. package/template/src/components/dashboard/top-contacts-list.tsx +0 -119
  216. package/template/src/components/dashboard/widget-wrapper.tsx +0 -39
  217. package/template/src/contexts/dashboard-theme-context.tsx +0 -58
  218. package/template/src/lib/dashboard-themes.ts +0 -140
  219. package/template/src/lib/default-widgets.ts +0 -14
  220. package/template/src/lib/widget-registry.ts +0 -177
@@ -15,6 +15,10 @@ import {
15
15
  Eye,
16
16
  } from 'lucide-react';
17
17
  import { cn } from '@/lib/utils';
18
+ import { PageLoader } from '@/components/skeleton';
19
+ import { useUserRole } from '@/hooks/use-user-role';
20
+ import { ProtectedPage } from '@/components/protected-page';
21
+ import { useFetch } from '@/hooks/use-fetch';
18
22
 
19
23
  interface Status {
20
24
  id: string;
@@ -40,10 +44,8 @@ interface Contact {
40
44
  city: string | null;
41
45
  postalCode: string | null;
42
46
  origin: string | null;
43
- companyName: string | null;
44
- isCompany: boolean;
45
47
  companyId: string | null;
46
- companyRelation: Contact | null;
48
+ company: { id: string; name: string | null } | null;
47
49
  statusId: string | null;
48
50
  status: Status | null;
49
51
  assignedCommercialId: string | null;
@@ -53,6 +55,10 @@ interface Contact {
53
55
  updatedAt?: string;
54
56
  }
55
57
 
58
+ interface ContactsResponse {
59
+ contacts: Contact[];
60
+ }
61
+
56
62
  interface ClosingColumn {
57
63
  id: string;
58
64
  statusId: string | null;
@@ -66,11 +72,11 @@ const MIN_WIDTH = 280;
66
72
  const MAX_WIDTH = 500;
67
73
  const LOCAL_STORAGE_KEY = 'closing_pipeline_columns_v1';
68
74
 
75
+ const SCROLLBAR_HIDDEN = { scrollbarWidth: 'none' } as const;
76
+
69
77
  function formatRelativeDate(dateString: string): string {
70
78
  const date = new Date(dateString);
71
79
  const now = new Date();
72
- const diffMs = now.getTime() - date.getTime();
73
- const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
74
80
 
75
81
  // Réinitialiser les heures pour comparer uniquement les dates
76
82
  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
@@ -103,11 +109,13 @@ function KanbanContactCard({
103
109
  onDragStart,
104
110
  onDragEnd,
105
111
  isDragging,
112
+ canDrag = true,
106
113
  }: {
107
114
  contact: Contact;
108
115
  onDragStart: () => void;
109
116
  onDragEnd: () => void;
110
117
  isDragging: boolean;
118
+ canDrag?: boolean;
111
119
  }) {
112
120
  const fullName = `${contact.civility ? `${contact.civility}. ` : ''}${
113
121
  contact.firstName || ''
@@ -117,40 +125,37 @@ function KanbanContactCard({
117
125
 
118
126
  return (
119
127
  <div
120
- draggable
121
- onDragStart={onDragStart}
122
- onDragEnd={onDragEnd}
128
+ draggable={canDrag}
129
+ onDragStart={canDrag ? onDragStart : undefined}
130
+ onDragEnd={canDrag ? onDragEnd : undefined}
123
131
  className={cn(
124
- 'relative rounded-lg border border-gray-200 bg-white p-4 text-[13px] shadow-sm transition-transform hover:-translate-y-0.5 hover:shadow',
125
- 'cursor-grab active:cursor-grabbing',
126
- isDragging && 'opacity-80 ring-2 ring-indigo-400',
132
+ 'relative rounded-lg border border-border bg-card p-4 text-[13px] shadow-(--shadow-card) transition-transform hover:-translate-y-0.5',
133
+ canDrag && 'cursor-grab active:cursor-grabbing',
134
+ !canDrag && 'cursor-default',
135
+ isDragging && 'opacity-80 ring-2 ring-blue-400',
127
136
  )}
128
137
  >
129
138
  {/* Icône Eye en haut à droite */}
130
139
  <Link
131
140
  href={`/contacts/${contact.id}`}
132
- className="absolute top-2 right-2 z-10 rounded p-1 transition-colors hover:bg-gray-100"
141
+ className="absolute top-2 right-2 z-10 rounded p-1 transition-colors duration-200 hover:bg-muted"
133
142
  onClick={(e) => e.stopPropagation()}
134
143
  >
135
144
  <Eye className="h-4 w-4 stroke-gray-400" />
136
145
  </Link>
137
146
 
138
147
  <div className="block">
139
- {contact.companyName && (
148
+ {contact.company?.name && (
140
149
  <p className="mb-1 truncate text-[10px] font-semibold text-gray-400 uppercase">
141
- {contact.companyName}
150
+ {contact.company.name}
142
151
  </p>
143
152
  )}
144
153
  <div className="mb-3 flex items-center gap-3">
145
- <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">
146
- {contact.isCompany ? (
147
- <span>🏢</span>
148
- ) : (
149
- (contact.firstName?.[0] || contact.lastName?.[0] || '?').toUpperCase()
150
- )}
154
+ <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700">
155
+ {(contact.firstName?.[0] || contact.lastName?.[0] || '?').toUpperCase()}
151
156
  </div>
152
157
  <div className="min-w-0 flex-1">
153
- <p className="truncate text-sm font-semibold text-gray-900">{fullName || 'Sans nom'}</p>
158
+ <p className="truncate text-sm font-semibold text-foreground">{fullName || 'Sans nom'}</p>
154
159
  </div>
155
160
  </div>
156
161
 
@@ -224,11 +229,21 @@ function createDefaultColumns(statuses: Status[]): ClosingColumn[] {
224
229
  }
225
230
 
226
231
  export default function ClosingPage() {
227
- const [statuses, setStatuses] = useState<Status[]>([]);
232
+ const { hasPermission, isLoading: roleLoading } = useUserRole();
233
+ const {
234
+ data: statuses = [],
235
+ isLoading: statusesLoading,
236
+ error: statusesError,
237
+ } = useFetch<Status[]>('/api/statuses');
238
+ const {
239
+ data: contactsData,
240
+ isLoading: contactsLoading,
241
+ error: contactsError,
242
+ } = useFetch<ContactsResponse>('/api/contacts?limit=500');
228
243
  const [contacts, setContacts] = useState<Contact[]>([]);
229
- const [loading, setLoading] = useState(true);
230
- const [error, setError] = useState('');
231
244
  const [columns, setColumns] = useState<ClosingColumn[]>([]);
245
+
246
+ const canManagePipeline = hasPermission('contacts.manage_pipeline');
232
247
  const [draggedCard, setDraggedCard] = useState<{
233
248
  contactId: string;
234
249
  fromStatusId: string | null;
@@ -246,64 +261,39 @@ export default function ClosingPage() {
246
261
  const [draggedColumnId, setDraggedColumnId] = useState<string | null>(null);
247
262
  const [dragOverColumnId, setDragOverColumnId] = useState<string | null>(null);
248
263
 
249
- const [selectedCompanyId, setSelectedCompanyId] = useState<string | 'all'>('all');
264
+ const [selectedCompanyId] = useState<string | 'all'>('all');
250
265
  const [selectedPeriod, setSelectedPeriod] = useState<string>('all');
251
266
  const [selectedCommercialId, setSelectedCommercialId] = useState<string | 'all'>('all');
252
267
  const [selectedTeleproId, setSelectedTeleproId] = useState<string | 'all'>('all');
253
268
 
254
269
  useEffect(() => {
255
- const fetchData = async () => {
256
- try {
257
- setLoading(true);
258
- setError('');
259
-
260
- const [statusRes, contactsRes] = await Promise.all([
261
- fetch('/api/statuses'),
262
- fetch('/api/contacts?limit=500'),
263
- ]);
270
+ setContacts(contactsData?.contacts || []);
271
+ }, [contactsData]);
264
272
 
265
- if (!statusRes.ok) {
266
- throw new Error('Erreur lors du chargement des statuts');
267
- }
268
- if (!contactsRes.ok) {
269
- throw new Error('Erreur lors du chargement des contacts');
270
- }
273
+ useEffect(() => {
274
+ if (!statuses.length) return;
275
+ if (typeof window === 'undefined') {
276
+ setColumns(createDefaultColumns(statuses));
277
+ return;
278
+ }
271
279
 
272
- const statusesJson: Status[] = await statusRes.json();
273
- const contactsJson = await contactsRes.json();
274
-
275
- setStatuses(statusesJson);
276
- setContacts(contactsJson.contacts || []);
277
-
278
- if (typeof window !== 'undefined') {
279
- const stored = window.localStorage.getItem(LOCAL_STORAGE_KEY);
280
- if (stored) {
281
- try {
282
- const parsed: ClosingColumn[] = JSON.parse(stored);
283
- setColumns(
284
- parsed
285
- .filter((c) => !c.statusId || statusesJson.some((s) => s.id === c.statusId))
286
- .sort((a, b) => a.order - b.order),
287
- );
288
- } catch {
289
- setColumns(createDefaultColumns(statusesJson));
290
- }
291
- } else {
292
- setColumns(createDefaultColumns(statusesJson));
293
- }
294
- } else {
295
- setColumns(createDefaultColumns(statusesJson));
296
- }
297
- } catch (e: any) {
298
- console.error(e);
299
- setError(e.message || 'Erreur de chargement');
300
- } finally {
301
- setLoading(false);
302
- }
303
- };
280
+ const stored = window.localStorage.getItem(LOCAL_STORAGE_KEY);
281
+ if (!stored) {
282
+ setColumns(createDefaultColumns(statuses));
283
+ return;
284
+ }
304
285
 
305
- fetchData();
306
- }, []);
286
+ try {
287
+ const parsed: ClosingColumn[] = JSON.parse(stored);
288
+ setColumns(
289
+ parsed
290
+ .filter((c) => !c.statusId || statuses.some((s) => s.id === c.statusId))
291
+ .sort((a, b) => a.order - b.order),
292
+ );
293
+ } catch {
294
+ setColumns(createDefaultColumns(statuses));
295
+ }
296
+ }, [statuses]);
307
297
 
308
298
  const statusesById = useMemo(() => {
309
299
  const map: Record<string, Status> = {};
@@ -402,7 +392,7 @@ export default function ClosingPage() {
402
392
  setDraggingContactId(null);
403
393
  setDragOverStatusId(null);
404
394
 
405
- if (targetStatusId) {
395
+ if (targetStatusId && canManagePipeline) {
406
396
  try {
407
397
  setIsSavingDrag(true);
408
398
  const res = await fetch(`/api/contacts/${contactId}`, {
@@ -554,447 +544,489 @@ export default function ClosingPage() {
554
544
  setConfigColumns(defaults);
555
545
  };
556
546
 
557
- // Récupérer les entreprises uniques pour le filtre
558
- const companies = useMemo(() => {
547
+ // Récupérer les entreprises uniques pour le filtre (réservé pour usage futur)
548
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- réservé pour filtre entreprise
549
+ const _companies = useMemo(() => {
559
550
  const companyMap = new Map<string, { id: string; name: string }>();
560
551
  contacts.forEach((c) => {
561
- if (c.companyId && c.companyName) {
562
- companyMap.set(c.companyId, { id: c.companyId, name: c.companyName });
552
+ if (c.companyId && c.company?.name) {
553
+ companyMap.set(c.companyId, { id: c.companyId, name: c.company.name });
563
554
  }
564
555
  });
565
556
  return Array.from(companyMap.values());
566
557
  }, [contacts]);
567
558
 
568
- if (loading) {
569
- return (
570
- <div className="h-full">
571
- <div className="border-b border-gray-200 bg-white px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
572
- <div>
573
- <h1 className="text-xl font-bold text-gray-900 sm:text-2xl">Pipeline de Closing</h1>
574
- <p className="mt-1 text-sm text-gray-600">
575
- Visualisez et gérez vos opportunités commerciales
576
- </p>
559
+ return (
560
+ <ProtectedPage requiredPermission="contacts.view_closing_pipeline">
561
+ {statusesLoading || contactsLoading || roleLoading ? (
562
+ <div className="h-full">
563
+ <div className="border-b border-border bg-background/95 px-4 py-4 backdrop-blur-sm sm:px-6 lg:px-8 lg:py-6">
564
+ <div className="flex items-start gap-3">
565
+ <div className="min-w-0 flex-1">
566
+ <h1 className="text-xl font-bold text-foreground sm:text-2xl">Pipeline de Closing</h1>
567
+ <p className="mt-1 text-sm text-muted-foreground">
568
+ Visualisez et gérez vos opportunités commerciales
569
+ </p>
570
+ </div>
571
+ </div>
577
572
  </div>
578
- </div>
579
- <div className="p-4 sm:p-6 lg:p-8">
580
- <div className="rounded-lg bg-white p-8 shadow">
581
- <p className="text-gray-500">Chargement du pipeline...</p>
573
+ <div className="flex-1">
574
+ <PageLoader text="Chargement du pipeline..." />
582
575
  </div>
583
576
  </div>
584
- </div>
585
- );
586
- }
577
+ ) : (
578
+ <div className="h-full">
579
+ {' '}
580
+ {/* md:overflow-y-hidden */}
581
+ {/* En-tête personnalisé avec filtres intégrés */}
582
+ <div className="border-b border-border bg-background/95 px-4 py-4 backdrop-blur-sm sm:px-6 lg:px-8 lg:py-6">
583
+ <div className="flex items-start gap-3">
584
+ <div className="flex min-w-0 flex-1 items-start justify-between gap-4">
585
+ <div className="min-w-0 flex-1">
586
+ <h1 className="text-xl font-bold text-foreground sm:text-2xl">
587
+ Pipeline de Closing
588
+ </h1>
589
+ <p className="mt-1 text-sm text-muted-foreground">
590
+ Visualisez et gérez vos opportunités commerciales
591
+ </p>
587
592
 
588
- return (
589
- <div className="h-full">
590
- {' '}
591
- {/* md:overflow-y-hidden */}
592
- {/* En-tête personnalisé avec filtres intégrés */}
593
- <div className="border-b border-gray-200 bg-white px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
594
- <div className="flex items-start gap-3">
595
- <div className="flex min-w-0 flex-1 items-start justify-between gap-4">
596
- <div className="min-w-0 flex-1">
597
- <h1 className="text-xl font-bold text-gray-900 sm:text-2xl">Pipeline de Closing</h1>
598
- <p className="mt-1 text-sm text-gray-600">
599
- Visualisez et gérez vos opportunités commerciales
600
- </p>
601
-
602
- {/* Filtres intégrés dans l'en-tête */}
603
- <div className="mt-4 flex flex-col gap-3 sm:flex-row">
604
- <div className="flex items-center gap-2">
605
- <span className="text-xs font-medium text-gray-700">Période:</span>
606
- <div className="relative">
607
- <Filter className="absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
608
- <select
609
- 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"
610
- value={selectedPeriod}
611
- onChange={(e) => setSelectedPeriod(e.target.value)}
612
- >
613
- <option value="all">Toutes</option>
614
- <option value="today">Aujourd'hui</option>
615
- <option value="week">Cette semaine</option>
616
- <option value="month">Ce mois</option>
617
- </select>
618
- </div>
619
- </div>
593
+ {/* Filtres intégrés dans l'en-tête */}
594
+ <div className="mt-4 flex flex-col gap-3 sm:flex-row">
595
+ <div className="flex items-center gap-2">
596
+ <span className="text-xs font-medium text-muted-foreground">Période:</span>
597
+ <div className="relative">
598
+ <Filter className="absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
599
+ <select
600
+ className="rounded-lg border border-border bg-card py-1.5 pr-2 pl-7 text-sm text-foreground focus:border-primary/50 focus:ring-2 focus:ring-primary/20 focus:outline-none"
601
+ value={selectedPeriod}
602
+ onChange={(e) => setSelectedPeriod(e.target.value)}
603
+ >
604
+ <option value="all">Toutes</option>
605
+ <option value="today">Aujourd&apos;hui</option>
606
+ <option value="week">Cette semaine</option>
607
+ <option value="month">Ce mois</option>
608
+ </select>
609
+ </div>
610
+ </div>
620
611
 
621
- <div className="flex items-center gap-2">
622
- <span className="text-xs font-medium text-gray-700">Commercial:</span>
623
- <select
624
- 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"
625
- value={selectedCommercialId}
626
- onChange={(e) =>
627
- setSelectedCommercialId(e.target.value === 'all' ? 'all' : e.target.value)
628
- }
629
- >
630
- <option value="all">Tous</option>
631
- {Array.from(
632
- new Map(
633
- contacts
634
- .filter((c) => c.assignedCommercial)
635
- .map((c) => [c.assignedCommercialId, c.assignedCommercial!]),
636
- ).values(),
637
- ).map((user) => (
638
- <option key={user.id} value={user.id}>
639
- {user.name}
640
- </option>
641
- ))}
642
- </select>
612
+ <div className="flex items-center gap-2">
613
+ <span className="text-xs font-medium text-muted-foreground">Commercial:</span>
614
+ <select
615
+ className="rounded-lg border border-border bg-card px-2 py-1.5 text-sm text-foreground focus:border-primary/50 focus:ring-2 focus:ring-primary/20 focus:outline-none"
616
+ value={selectedCommercialId}
617
+ onChange={(e) =>
618
+ setSelectedCommercialId(e.target.value === 'all' ? 'all' : e.target.value)
619
+ }
620
+ >
621
+ <option value="all">Tous</option>
622
+ {Array.from(
623
+ new Map(
624
+ contacts
625
+ .filter((c) => c.assignedCommercial)
626
+ .map((c) => [c.assignedCommercialId, c.assignedCommercial!]),
627
+ ).values(),
628
+ ).map((user) => (
629
+ <option key={user.id} value={user.id}>
630
+ {user.name}
631
+ </option>
632
+ ))}
633
+ </select>
634
+ </div>
635
+
636
+ <div className="flex items-center gap-2">
637
+ <span className="text-xs font-medium text-muted-foreground">Télépro:</span>
638
+ <select
639
+ className="rounded-lg border border-border bg-card px-2 py-1.5 text-sm text-foreground focus:border-primary/50 focus:ring-2 focus:ring-primary/20 focus:outline-none"
640
+ value={selectedTeleproId}
641
+ onChange={(e) =>
642
+ setSelectedTeleproId(e.target.value === 'all' ? 'all' : e.target.value)
643
+ }
644
+ >
645
+ <option value="all">Tous</option>
646
+ {Array.from(
647
+ new Map(
648
+ contacts
649
+ .filter((c) => c.assignedTelepro)
650
+ .map((c) => [c.assignedTeleproId, c.assignedTelepro!]),
651
+ ).values(),
652
+ ).map((user) => (
653
+ <option key={user.id} value={user.id}>
654
+ {user.name}
655
+ </option>
656
+ ))}
657
+ </select>
658
+ </div>
659
+
660
+ {isSavingDrag && (
661
+ <span className="self-end text-xs text-muted-foreground">
662
+ Sauvegarde des changements…
663
+ </span>
664
+ )}
665
+ </div>
643
666
  </div>
644
667
 
645
- <div className="flex items-center gap-2">
646
- <span className="text-xs font-medium text-gray-700">Télépro:</span>
647
- <select
648
- 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"
649
- value={selectedTeleproId}
650
- onChange={(e) =>
651
- setSelectedTeleproId(e.target.value === 'all' ? 'all' : e.target.value)
652
- }
668
+ <div className="shrink-0">
669
+ <button
670
+ type="button"
671
+ onClick={openConfigModal}
672
+ className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm font-semibold text-foreground shadow-sm transition-colors duration-200 hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:outline-none"
653
673
  >
654
- <option value="all">Tous</option>
655
- {Array.from(
656
- new Map(
657
- contacts
658
- .filter((c) => c.assignedTelepro)
659
- .map((c) => [c.assignedTeleproId, c.assignedTelepro!]),
660
- ).values(),
661
- ).map((user) => (
662
- <option key={user.id} value={user.id}>
663
- {user.name}
664
- </option>
665
- ))}
666
- </select>
674
+ <SettingsIcon className="h-4 w-4" />
675
+ Configurer
676
+ </button>
667
677
  </div>
668
-
669
- {isSavingDrag && (
670
- <span className="self-end text-xs text-gray-400">
671
- Sauvegarde des changements…
672
- </span>
673
- )}
674
678
  </div>
675
679
  </div>
676
-
677
- <div className="shrink-0">
678
- <button
679
- type="button"
680
- onClick={openConfigModal}
681
- 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"
682
- >
683
- <SettingsIcon className="h-4 w-4" />
684
- Configurer
685
- </button>
686
- </div>
687
680
  </div>
688
- </div>
689
- </div>
690
- {error && (
691
- <div className="px-4 pt-4 sm:px-6 sm:pt-6">
692
- <div className="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-600">{error}</div>
693
- </div>
694
- )}
695
- {/* Board kanban : taille adaptée au contenu avec padding haut/bas */}
696
- <div
697
- className="flex gap-4 overflow-x-auto px-4 pt-4 pb-4 sm:px-6 sm:pt-6 sm:pb-6"
698
- style={{ scrollbarWidth: 'none' as 'none' }} // for Firefox
699
- >
700
- <style jsx>{`
701
- div::-webkit-scrollbar {
702
- display: none;
703
- }
704
- `}</style>
705
- {sortedColumns.map((column) => {
706
- const columnContacts = contactsByStatusId.get(column.statusId) || [];
707
- const status = column.statusId ? statusesById[column.statusId] : null;
708
-
709
- return (
710
- <div
711
- key={column.id}
712
- className={cn(
713
- 'flex shrink-0 flex-col rounded-xl bg-gray-50 shadow-sm transition-colors',
714
- 'h-auto max-h-[calc(100vh-220px)]',
715
- dragOverStatusId === column.statusId && 'ring-2 ring-indigo-400 ring-offset-2',
716
- dragOverColumnId === column.id &&
717
- draggedColumnId &&
718
- 'ring-2 ring-indigo-500 ring-offset-2',
719
- )}
720
- style={{ width: column.width }}
721
- onDragOver={(e) => {
722
- // Gérer le drag des colonnes ET des contacts
723
- if (draggedColumnId) {
724
- handleColumnHeaderDragOver(e, column.id);
725
- } else {
726
- handleColumnDragOver(e, column.statusId);
727
- }
728
- }}
729
- onDrop={(e) => {
730
- e.preventDefault();
731
- if (draggedColumnId) {
732
- handleColumnHeaderDrop(column.id);
733
- } else {
734
- handleColumnDrop(column.statusId);
735
- }
736
- }}
737
- onDragLeave={() => {
738
- if (!draggedColumnId) {
739
- setDragOverStatusId(null);
740
- }
741
- if (draggedColumnId && dragOverColumnId === column.id) {
742
- setDragOverColumnId(null);
743
- }
744
- }}
745
- >
746
- <div
747
- draggable
748
- onDragStart={(e) => {
749
- // Ne pas drag si on drag déjà un contact
750
- if (!draggedCard && !draggingContactId) {
751
- handleColumnHeaderDragStart(column.id);
752
- } else {
753
- e.preventDefault();
754
- }
755
- }}
756
- onDragEnd={() => {
757
- setDraggedColumnId(null);
758
- setDragOverColumnId(null);
759
- }}
760
- className={cn(
761
- 'flex shrink-0 items-center justify-between rounded-t-xl px-4 py-3 text-sm font-semibold text-white',
762
- 'cursor-grab transition-opacity active:cursor-grabbing',
763
- draggedColumnId === column.id && 'opacity-50',
764
- )}
765
- style={{ backgroundColor: column.color || '#4f46e5' }}
766
- >
767
- <div className="flex items-center gap-2">
768
- <span>{column.title || status?.name || 'Colonne'}</span>
769
- <span className="rounded-full bg-white/20 px-2 py-0.5 text-xs font-medium">
770
- {columnContacts.length}
771
- </span>
772
- </div>
773
- </div>
774
-
775
- <div
776
- className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3"
777
- onDragStart={(e) => {
778
- // Empêcher le drag du header si on drag un contact
779
- if (draggingContactId || draggedCard) {
780
- e.stopPropagation();
781
- }
782
- }}
783
- >
784
- {columnContacts.length === 0 ? (
785
- <div className="mt-4 rounded-lg border border-dashed border-gray-200 bg-white/40 p-4 text-center text-xs text-gray-400">
786
- Les contacts avec ce statut apparaîtront ici
787
- </div>
788
- ) : (
789
- columnContacts.map((contact) => (
790
- <KanbanContactCard
791
- key={contact.id}
792
- contact={contact}
793
- isDragging={draggingContactId === contact.id}
794
- onDragStart={() => handleCardDragStart(contact.id, contact.statusId)}
795
- onDragEnd={() => {
796
- setDraggingContactId(null);
797
- setDragOverStatusId(null);
798
- }}
799
- />
800
- ))
801
- )}
802
- </div>
803
- </div>
804
- );
805
- })}
806
- </div>
807
- {showConfigModal && (
808
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-500/30 p-4 backdrop-blur-sm">
809
- <div className="flex w-full max-w-7xl flex-col rounded-2xl bg-white shadow-xl">
810
- <div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
811
- <div>
812
- <h2 className="text-lg font-semibold text-gray-900">Configuration du Pipeline</h2>
813
- <p className="mt-1 text-sm text-gray-500">
814
- Personnalisez les colonnes de votre pipeline de closing.
815
- </p>
681
+ {(statusesError || contactsError) && (
682
+ <div className="px-4 pt-4 sm:px-6 sm:pt-6">
683
+ <div className="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-600">
684
+ Erreur de chargement des donnees du pipeline.
816
685
  </div>
817
- <button
818
- type="button"
819
- onClick={() => setShowConfigModal(false)}
820
- className="cursor-pointer rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-100"
821
- >
822
- <X className="h-5 w-5" />
823
- </button>
824
686
  </div>
825
-
826
- <div className="flex-1 overflow-y-auto px-6 py-4">
827
- <div className="mb-3 flex items-center justify-between">
828
- <div>
829
- <p className="text-sm text-gray-600">
830
- {configColumns.length} colonne
831
- {configColumns.length > 1 ? 's' : ''} configurée
832
- {configColumns.length > 1 ? 's' : ''}.
833
- </p>
834
- <p className="mt-1 text-xs text-gray-400">
835
- Glissez-déposez les colonnes ci-dessous pour organiser l&apos;ordre de votre
836
- pipeline.
687
+ )}
688
+ {/* Board kanban : taille adaptée au contenu avec padding haut/bas */}
689
+ <div
690
+ className="flex gap-4 overflow-x-auto px-4 pt-4 pb-4 sm:px-6 sm:pt-6 sm:pb-6"
691
+ style={SCROLLBAR_HIDDEN}
692
+ >
693
+ <style jsx>{`
694
+ div::-webkit-scrollbar {
695
+ display: none;
696
+ }
697
+ `}</style>
698
+ {sortedColumns.length === 0 ? (
699
+ <div className="flex w-full items-center justify-center py-16">
700
+ <div className="max-w-md rounded-xl border-2 border-dashed border-border bg-card p-8 text-center shadow-(--shadow-card)">
701
+ <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/15">
702
+ <Filter className="h-8 w-8 text-primary" />
703
+ </div>
704
+ <h3 className="mb-2 text-lg font-semibold text-foreground">
705
+ Votre pipeline est vide
706
+ </h3>
707
+ <p className="mb-6 text-sm text-muted-foreground">
708
+ Commencez par configurer les colonnes de votre pipeline de closing en cliquant
709
+ sur le bouton ci-dessous.
837
710
  </p>
838
- </div>
839
- <div className="flex items-center gap-2">
840
- <button
841
- type="button"
842
- onClick={resetToDefaultColumns}
843
- 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"
844
- >
845
- Réinitialiser
846
- </button>
847
711
  <button
848
712
  type="button"
849
- onClick={addConfigColumn}
850
- 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"
713
+ onClick={openConfigModal}
714
+ className="inline-flex cursor-pointer items-center gap-2 rounded-lg bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground shadow-(--shadow-card) transition-colors duration-200 hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:outline-none"
851
715
  >
852
- <Plus className="h-4 w-4" />
853
- Ajouter une colonne
716
+ <SettingsIcon className="h-4 w-4" />
717
+ Configurer ma pipeline
854
718
  </button>
855
719
  </div>
856
720
  </div>
721
+ ) : (
722
+ sortedColumns.map((column) => {
723
+ const columnContacts = contactsByStatusId.get(column.statusId) || [];
724
+ const status = column.statusId ? statusesById[column.statusId] : null;
857
725
 
858
- <div className="flex gap-4 overflow-x-auto p-2">
859
- {configColumns.map((column) => (
726
+ return (
860
727
  <div
861
728
  key={column.id}
862
- draggable
863
- onDragStart={() => setDraggedConfigColumnId(column.id)}
729
+ className={cn(
730
+ 'flex shrink-0 flex-col rounded-xl bg-muted shadow-sm transition-colors',
731
+ 'h-auto max-h-[calc(100vh-220px)]',
732
+ dragOverStatusId === column.statusId &&
733
+ 'ring-2 ring-blue-400 ring-offset-2',
734
+ dragOverColumnId === column.id &&
735
+ draggedColumnId &&
736
+ 'ring-2 ring-blue-500 ring-offset-2',
737
+ )}
738
+ style={{ width: column.width }}
864
739
  onDragOver={(e) => {
865
- e.preventDefault();
866
- setDragOverConfigColumnId(column.id);
740
+ // Gérer le drag des colonnes ET des contacts
741
+ if (draggedColumnId) {
742
+ handleColumnHeaderDragOver(e, column.id);
743
+ } else {
744
+ handleColumnDragOver(e, column.statusId);
745
+ }
867
746
  }}
868
747
  onDrop={(e) => {
869
748
  e.preventDefault();
870
- handleConfigColumnDrop(column.id);
749
+ if (draggedColumnId) {
750
+ handleColumnHeaderDrop(column.id);
751
+ } else {
752
+ handleColumnDrop(column.statusId);
753
+ }
754
+ }}
755
+ onDragLeave={() => {
756
+ if (!draggedColumnId) {
757
+ setDragOverStatusId(null);
758
+ }
759
+ if (draggedColumnId && dragOverColumnId === column.id) {
760
+ setDragOverColumnId(null);
761
+ }
871
762
  }}
872
- onDragLeave={() =>
873
- setDragOverConfigColumnId((current) =>
874
- current === column.id ? null : current,
875
- )
876
- }
877
- className={cn(
878
- 'flex w-80 shrink-0 flex-col rounded-xl border border-gray-200 bg-gray-50',
879
- 'cursor-grab transition-all duration-150 active:cursor-grabbing',
880
- 'hover:-translate-y-1 hover:shadow-lg',
881
- dragOverConfigColumnId === column.id &&
882
- 'ring-2 ring-indigo-400 ring-offset-2',
883
- )}
884
763
  >
885
- <div className="p-4">
886
- <div className="mb-3 flex items-center justify-between">
887
- <input
888
- type="text"
889
- value={column.title}
890
- onChange={(e) => updateConfigColumn(column.id, { title: e.target.value })}
891
- 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"
892
- placeholder="Nom de la colonne"
893
- />
894
- <button
895
- type="button"
896
- onClick={() => removeConfigColumn(column.id)}
897
- className="ml-2 cursor-pointer rounded-lg p-1 text-gray-400 transition-colors hover:bg-gray-200 hover:text-red-600"
898
- >
899
- <X className="h-4 w-4" />
900
- </button>
901
- </div>
902
-
903
- <label className="mb-2 block text-xs font-medium text-gray-600">
904
- Statut associé
905
- </label>
906
- <select
907
- value={column.statusId || ''}
908
- onChange={(e) =>
909
- updateConfigColumn(column.id, {
910
- statusId: e.target.value || null,
911
- title:
912
- e.target.value && statusesById[e.target.value]
913
- ? statusesById[e.target.value].name
914
- : column.title,
915
- color:
916
- e.target.value && statusesById[e.target.value]
917
- ? statusesById[e.target.value].color
918
- : column.color,
919
- })
764
+ <div
765
+ draggable
766
+ onDragStart={(e) => {
767
+ // Ne pas drag si on drag déjà un contact
768
+ if (!draggedCard && !draggingContactId) {
769
+ handleColumnHeaderDragStart(column.id);
770
+ } else {
771
+ e.preventDefault();
920
772
  }
921
- 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"
922
- >
923
- <option value="">Sélectionnez un statut</option>
924
- {statuses.map((status) => (
925
- <option key={status.id} value={status.id}>
926
- {status.name}
927
- </option>
928
- ))}
929
- </select>
930
-
931
- <label className="mb-1 block text-xs font-medium text-gray-600">
932
- Couleur de la colonne
933
- </label>
934
- <div className="mb-3 flex items-center gap-2">
935
- <input
936
- type="color"
937
- value={column.color}
938
- onChange={(e) => updateConfigColumn(column.id, { color: e.target.value })}
939
- className="h-8 w-12 cursor-pointer rounded border border-gray-300 bg-white"
940
- />
941
- <input
942
- type="text"
943
- value={column.color}
944
- onChange={(e) => updateConfigColumn(column.id, { color: e.target.value })}
945
- 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"
946
- />
773
+ }}
774
+ onDragEnd={() => {
775
+ setDraggedColumnId(null);
776
+ setDragOverColumnId(null);
777
+ }}
778
+ className={cn(
779
+ 'flex shrink-0 items-center justify-between rounded-t-xl px-4 py-3 text-sm font-semibold text-white',
780
+ 'cursor-grab transition-opacity active:cursor-grabbing',
781
+ draggedColumnId === column.id && 'opacity-50',
782
+ )}
783
+ style={{ backgroundColor: column.color || '#4f46e5' }}
784
+ >
785
+ <div className="flex items-center gap-2">
786
+ <span>{column.title || status?.name || 'Colonne'}</span>
787
+ <span className="rounded-full bg-white/20 px-2 py-0.5 text-xs font-medium">
788
+ {columnContacts.length}
789
+ </span>
947
790
  </div>
791
+ </div>
948
792
 
949
- <label className="mb-1 block text-xs font-medium text-gray-600">
950
- Largeur de la colonne (px)
951
- </label>
952
- <input
953
- type="range"
954
- min={MIN_WIDTH}
955
- max={MAX_WIDTH}
956
- value={column.width}
957
- onChange={(e) =>
958
- updateConfigColumn(column.id, {
959
- width: Number(e.target.value),
960
- })
793
+ <div
794
+ className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3"
795
+ onDragStart={(e) => {
796
+ // Empêcher le drag du header si on drag un contact
797
+ if (draggingContactId || draggedCard) {
798
+ e.stopPropagation();
961
799
  }
962
- className="w-full"
963
- />
964
- <div className="mt-1 text-right text-xs text-gray-500">{column.width}px</div>
800
+ }}
801
+ >
802
+ {columnContacts.length === 0 ? (
803
+ <div className="mt-4 rounded-lg border border-dashed border-gray-200 bg-white/40 p-4 text-center text-xs text-gray-400">
804
+ Les contacts avec ce statut apparaîtront ici
805
+ </div>
806
+ ) : (
807
+ columnContacts.map((contact) => (
808
+ <KanbanContactCard
809
+ key={contact.id}
810
+ contact={contact}
811
+ isDragging={draggingContactId === contact.id}
812
+ canDrag={canManagePipeline}
813
+ onDragStart={() => handleCardDragStart(contact.id, contact.statusId)}
814
+ onDragEnd={() => {
815
+ setDraggingContactId(null);
816
+ setDragOverStatusId(null);
817
+ }}
818
+ />
819
+ ))
820
+ )}
965
821
  </div>
966
- <div className="flex h-[250px] flex-col items-center justify-center rounded-b-xl bg-white">
967
- <div className="mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-gray-100">
968
- <Filter className="h-8 w-8 text-gray-400" />
969
- </div>
970
- <p className="px-6 text-center text-sm font-medium text-gray-400">
971
- Les contacts avec ce statut apparaîtront ici
822
+ </div>
823
+ );
824
+ })
825
+ )}
826
+ </div>
827
+ {showConfigModal && (
828
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-500/30 p-4 backdrop-blur-sm">
829
+ <div className="flex w-full max-w-7xl flex-col rounded-2xl bg-white shadow-xl">
830
+ <div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
831
+ <div>
832
+ <h2 className="text-lg font-semibold text-gray-900">
833
+ Configuration du Pipeline
834
+ </h2>
835
+ <p className="mt-1 text-sm text-gray-500">
836
+ Personnalisez les colonnes de votre pipeline de closing.
837
+ </p>
838
+ </div>
839
+ <button
840
+ type="button"
841
+ onClick={() => setShowConfigModal(false)}
842
+ className="cursor-pointer rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-100"
843
+ >
844
+ <X className="h-5 w-5" />
845
+ </button>
846
+ </div>
847
+
848
+ <div className="flex-1 overflow-y-auto px-6 py-4">
849
+ <div className="mb-3 flex items-center justify-between">
850
+ <div>
851
+ <p className="text-sm text-gray-600">
852
+ {configColumns.length} colonne
853
+ {configColumns.length > 1 ? 's' : ''} configurée
854
+ {configColumns.length > 1 ? 's' : ''}.
855
+ </p>
856
+ <p className="mt-1 text-xs text-gray-400">
857
+ Glissez-déposez les colonnes ci-dessous pour organiser l&apos;ordre de votre
858
+ pipeline.
972
859
  </p>
973
860
  </div>
861
+ <div className="flex items-center gap-2">
862
+ <button
863
+ type="button"
864
+ onClick={resetToDefaultColumns}
865
+ 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"
866
+ >
867
+ Réinitialiser
868
+ </button>
869
+ <button
870
+ type="button"
871
+ onClick={addConfigColumn}
872
+ className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-blue-600 bg-white px-3 py-1.5 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-50"
873
+ >
874
+ <Plus className="h-4 w-4" />
875
+ Ajouter une colonne
876
+ </button>
877
+ </div>
974
878
  </div>
975
- ))}
976
- </div>
977
- </div>
978
879
 
979
- <div className="flex items-center justify-between border-t border-gray-200 px-6 py-4">
980
- <button
981
- type="button"
982
- onClick={() => setShowConfigModal(false)}
983
- className="cursor-pointer rounded-lg px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100"
984
- >
985
- Annuler
986
- </button>
987
- <button
988
- type="button"
989
- onClick={handleSaveConfig}
990
- 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"
991
- >
992
- Enregistrer la configuration
993
- </button>
880
+ <div className="flex gap-4 overflow-x-auto p-2">
881
+ {configColumns.map((column) => (
882
+ <div
883
+ key={column.id}
884
+ draggable
885
+ onDragStart={() => setDraggedConfigColumnId(column.id)}
886
+ onDragOver={(e) => {
887
+ e.preventDefault();
888
+ setDragOverConfigColumnId(column.id);
889
+ }}
890
+ onDrop={(e) => {
891
+ e.preventDefault();
892
+ handleConfigColumnDrop(column.id);
893
+ }}
894
+ onDragLeave={() =>
895
+ setDragOverConfigColumnId((current) =>
896
+ current === column.id ? null : current,
897
+ )
898
+ }
899
+ className={cn(
900
+ 'flex w-80 shrink-0 flex-col rounded-xl border border-gray-200 bg-gray-50',
901
+ 'cursor-grab transition-all duration-150 active:cursor-grabbing',
902
+ 'hover:-translate-y-1 hover:shadow-lg',
903
+ dragOverConfigColumnId === column.id &&
904
+ 'ring-2 ring-blue-400 ring-offset-2',
905
+ )}
906
+ >
907
+ <div className="p-4">
908
+ <div className="mb-3 flex items-center justify-between">
909
+ <input
910
+ type="text"
911
+ value={column.title}
912
+ onChange={(e) =>
913
+ updateConfigColumn(column.id, { title: e.target.value })
914
+ }
915
+ className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
916
+ placeholder="Nom de la colonne"
917
+ />
918
+ <button
919
+ type="button"
920
+ onClick={() => removeConfigColumn(column.id)}
921
+ className="ml-2 cursor-pointer rounded-lg p-1 text-gray-400 transition-colors hover:bg-gray-200 hover:text-red-600"
922
+ >
923
+ <X className="h-4 w-4" />
924
+ </button>
925
+ </div>
926
+
927
+ <label className="mb-2 block text-xs font-medium text-gray-600">
928
+ Statut associé
929
+ </label>
930
+ <select
931
+ value={column.statusId || ''}
932
+ onChange={(e) =>
933
+ updateConfigColumn(column.id, {
934
+ statusId: e.target.value || null,
935
+ title:
936
+ e.target.value && statusesById[e.target.value]
937
+ ? statusesById[e.target.value].name
938
+ : column.title,
939
+ color:
940
+ e.target.value && statusesById[e.target.value]
941
+ ? statusesById[e.target.value].color
942
+ : column.color,
943
+ })
944
+ }
945
+ className="mb-3 w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
946
+ >
947
+ <option value="">Sélectionnez un statut</option>
948
+ {statuses.map((status) => (
949
+ <option key={status.id} value={status.id}>
950
+ {status.name}
951
+ </option>
952
+ ))}
953
+ </select>
954
+
955
+ <label className="mb-1 block text-xs font-medium text-gray-600">
956
+ Couleur de la colonne
957
+ </label>
958
+ <div className="mb-3 flex items-center gap-2">
959
+ <input
960
+ type="color"
961
+ value={column.color}
962
+ onChange={(e) =>
963
+ updateConfigColumn(column.id, { color: e.target.value })
964
+ }
965
+ className="h-8 w-12 cursor-pointer rounded border border-gray-300 bg-white"
966
+ />
967
+ <input
968
+ type="text"
969
+ value={column.color}
970
+ onChange={(e) =>
971
+ updateConfigColumn(column.id, { color: e.target.value })
972
+ }
973
+ className="flex-1 rounded-lg border border-gray-300 px-2 py-1.5 text-xs text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
974
+ />
975
+ </div>
976
+
977
+ <label className="mb-1 block text-xs font-medium text-gray-600">
978
+ Largeur de la colonne (px)
979
+ </label>
980
+ <input
981
+ type="range"
982
+ min={MIN_WIDTH}
983
+ max={MAX_WIDTH}
984
+ value={column.width}
985
+ onChange={(e) =>
986
+ updateConfigColumn(column.id, {
987
+ width: Number(e.target.value),
988
+ })
989
+ }
990
+ className="w-full"
991
+ />
992
+ <div className="mt-1 text-right text-xs text-gray-500">
993
+ {column.width}px
994
+ </div>
995
+ </div>
996
+ <div className="flex h-[250px] flex-col items-center justify-center rounded-b-xl bg-white">
997
+ <div className="mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-gray-100">
998
+ <Filter className="h-8 w-8 text-gray-400" />
999
+ </div>
1000
+ <p className="px-6 text-center text-sm font-medium text-gray-400">
1001
+ Les contacts avec ce statut apparaîtront ici
1002
+ </p>
1003
+ </div>
1004
+ </div>
1005
+ ))}
1006
+ </div>
1007
+ </div>
1008
+
1009
+ <div className="flex items-center justify-between border-t border-gray-200 px-6 py-4">
1010
+ <button
1011
+ type="button"
1012
+ onClick={() => setShowConfigModal(false)}
1013
+ className="cursor-pointer rounded-lg px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100"
1014
+ >
1015
+ Annuler
1016
+ </button>
1017
+ <button
1018
+ type="button"
1019
+ onClick={handleSaveConfig}
1020
+ className="cursor-pointer rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
1021
+ >
1022
+ Enregistrer la configuration
1023
+ </button>
1024
+ </div>
1025
+ </div>
994
1026
  </div>
995
- </div>
1027
+ )}
996
1028
  </div>
997
1029
  )}
998
- </div>
1030
+ </ProtectedPage>
999
1031
  );
1000
1032
  }