create-crm-tmp 2.0.0 → 2.1.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 (190) hide show
  1. package/bin/create-crm-tmp.js +56 -35
  2. package/package.json +1 -1
  3. package/template/README.md +230 -115
  4. package/template/eslint.config.mjs +13 -0
  5. package/template/next.config.ts +14 -0
  6. package/template/package.json +15 -2
  7. package/template/prisma/migrations/20260318095700_init_db/migration.sql +978 -0
  8. package/template/prisma/migrations/migration_lock.toml +3 -0
  9. package/template/prisma/schema.prisma +132 -637
  10. package/template/src/app/(auth)/invite/[token]/page.tsx +10 -8
  11. package/template/src/app/(auth)/layout.tsx +1 -1
  12. package/template/src/app/(auth)/reset-password/complete/page.tsx +11 -8
  13. package/template/src/app/(auth)/reset-password/page.tsx +4 -4
  14. package/template/src/app/(auth)/reset-password/verify/page.tsx +4 -4
  15. package/template/src/app/(auth)/signin/page.tsx +14 -6
  16. package/template/src/app/(dashboard)/agenda/page.tsx +2243 -988
  17. package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +18 -104
  18. package/template/src/app/(dashboard)/automatisation/page.tsx +10 -26
  19. package/template/src/app/(dashboard)/closing/page.tsx +78 -62
  20. package/template/src/app/(dashboard)/contacts/[id]/page.tsx +2082 -1080
  21. package/template/src/app/(dashboard)/contacts/companies/[id]/page.tsx +46 -47
  22. package/template/src/app/(dashboard)/contacts/page.tsx +1062 -780
  23. package/template/src/app/(dashboard)/dashboard/page.tsx +533 -37
  24. package/template/src/app/(dashboard)/dev/page.tsx +1291 -0
  25. package/template/src/app/(dashboard)/layout.tsx +6 -2
  26. package/template/src/app/(dashboard)/settings/page.tsx +797 -2582
  27. package/template/src/app/(dashboard)/templates/page.tsx +55 -54
  28. package/template/src/app/(dashboard)/users/list/page.tsx +51 -48
  29. package/template/src/app/(dashboard)/users/page.tsx +1 -1
  30. package/template/src/app/(dashboard)/users/permissions/page.tsx +2 -2
  31. package/template/src/app/(dashboard)/users/roles/page.tsx +7 -5
  32. package/template/src/app/api/agenda/google-events/route.ts +92 -0
  33. package/template/src/app/api/auth/check-active/route.ts +3 -2
  34. package/template/src/app/api/auth/google/route.ts +2 -1
  35. package/template/src/app/api/auth/google/status/route.ts +7 -31
  36. package/template/src/app/api/companies/[id]/activities/route.ts +1 -3
  37. package/template/src/app/api/companies/[id]/route.ts +1 -2
  38. package/template/src/app/api/companies/route.ts +42 -12
  39. package/template/src/app/api/contacts/[id]/files/[fileId]/preview/route.ts +9 -31
  40. package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +14 -32
  41. package/template/src/app/api/contacts/[id]/files/route.ts +112 -212
  42. package/template/src/app/api/contacts/[id]/interactions/[interactionId]/route.ts +27 -1
  43. package/template/src/app/api/contacts/[id]/interactions/route.ts +16 -16
  44. package/template/src/app/api/contacts/[id]/kyc/route.ts +21 -11
  45. package/template/src/app/api/contacts/[id]/meet/route.ts +19 -2
  46. package/template/src/app/api/contacts/[id]/route.ts +106 -34
  47. package/template/src/app/api/contacts/[id]/send-email/route.ts +27 -11
  48. package/template/src/app/api/contacts/[id]/workflows/run/route.ts +6 -0
  49. package/template/src/app/api/contacts/export/route.ts +9 -13
  50. package/template/src/app/api/contacts/import/route.ts +55 -25
  51. package/template/src/app/api/contacts/import-preview/route.ts +1 -1
  52. package/template/src/app/api/contacts/origins/route.ts +63 -0
  53. package/template/src/app/api/contacts/route.ts +153 -41
  54. package/template/src/app/api/cron/cleanup-editor-images/route.ts +166 -0
  55. package/template/src/app/api/dashboard/widgets/[id]/route.ts +44 -0
  56. package/template/src/app/api/dashboard/widgets/route.ts +181 -0
  57. package/template/src/app/api/dev/reminders/test/route.ts +114 -0
  58. package/template/src/app/api/editor/upload-image/route.ts +61 -0
  59. package/template/src/app/api/integrations/google-sheet/jobs/[jobId]/route.ts +47 -0
  60. package/template/src/app/api/integrations/google-sheet/jobs/usage/route.ts +50 -0
  61. package/template/src/app/api/integrations/google-sheet/sync/route.ts +24 -556
  62. package/template/src/app/api/jobs/google-sheet/process/route.ts +84 -0
  63. package/template/src/app/api/jobs/google-sheet/schedule/route.ts +50 -0
  64. package/template/src/app/api/reminders/clear/route.ts +120 -0
  65. package/template/src/app/api/reminders/clear/undo/route.ts +112 -0
  66. package/template/src/app/api/reminders/route.ts +164 -39
  67. package/template/src/app/api/reminders/state/route.ts +164 -0
  68. package/template/src/app/api/reset-password/request/route.ts +1 -1
  69. package/template/src/app/api/reset-password/verify/route.ts +1 -1
  70. package/template/src/app/api/send/route.ts +16 -4
  71. package/template/src/app/api/settings/google-ads/route.ts +14 -0
  72. package/template/src/app/api/settings/google-calendar/calendars/route.ts +97 -0
  73. package/template/src/app/api/settings/google-calendar/route.ts +124 -0
  74. package/template/src/app/api/settings/google-sheet/[id]/route.ts +28 -0
  75. package/template/src/app/api/settings/google-sheet/auto-map/route.ts +37 -4
  76. package/template/src/app/api/settings/google-sheet/preview/route.ts +9 -3
  77. package/template/src/app/api/settings/google-sheet/route.ts +14 -0
  78. package/template/src/app/api/settings/integrations/logs/route.ts +93 -0
  79. package/template/src/app/api/settings/integrations/notifications/route.ts +67 -0
  80. package/template/src/app/api/settings/meta-leads/[id]/route.ts +0 -1
  81. package/template/src/app/api/settings/meta-leads/route.ts +14 -2
  82. package/template/src/app/api/settings/smtp/route.ts +53 -6
  83. package/template/src/app/api/tasks/[id]/attendees/route.ts +24 -8
  84. package/template/src/app/api/tasks/[id]/route.ts +234 -58
  85. package/template/src/app/api/tasks/meet/route.ts +27 -19
  86. package/template/src/app/api/tasks/route.ts +62 -17
  87. package/template/src/app/api/users/[id]/route.ts +20 -14
  88. package/template/src/app/api/users/list/route.ts +57 -19
  89. package/template/src/app/api/webhooks/google-ads/route.ts +34 -14
  90. package/template/src/app/api/webhooks/meta-leads/route.ts +32 -12
  91. package/template/src/app/api/workflows/[id]/route.ts +0 -4
  92. package/template/src/app/api/workflows/process/route.ts +22 -51
  93. package/template/src/app/api/workflows/route.ts +0 -4
  94. package/template/src/app/globals.css +342 -4
  95. package/template/src/app/layout.tsx +11 -3
  96. package/template/src/app/page.tsx +1 -1
  97. package/template/src/components/address-autocomplete.tsx +7 -6
  98. package/template/src/components/config-error-alert.tsx +46 -0
  99. package/template/src/components/contacts/filter-bar.tsx +12 -3
  100. package/template/src/components/contacts/filter-builder.tsx +28 -43
  101. package/template/src/components/contacts/save-view-dialog.tsx +1 -1
  102. package/template/src/components/contacts/views-tab-bar.tsx +15 -6
  103. package/template/src/components/dashboard/activity-chart.tsx +41 -28
  104. package/template/src/components/dashboard/add-widget-dialog.tsx +157 -0
  105. package/template/src/components/dashboard/color-picker.tsx +64 -0
  106. package/template/src/components/dashboard/contacts-chart.tsx +69 -0
  107. package/template/src/components/dashboard/interactions-by-type-chart.tsx +121 -0
  108. package/template/src/components/dashboard/recent-activity.tsx +154 -0
  109. package/template/src/components/dashboard/stat-card.tsx +40 -40
  110. package/template/src/components/dashboard/status-distribution-chart.tsx +81 -0
  111. package/template/src/components/dashboard/tasks-pie-chart.tsx +37 -34
  112. package/template/src/components/dashboard/top-contacts-list.tsx +113 -0
  113. package/template/src/components/dashboard/upcoming-tasks-list.tsx +72 -81
  114. package/template/src/components/dashboard/widget-wrapper.tsx +36 -0
  115. package/template/src/components/date-picker.tsx +9 -6
  116. package/template/src/components/editor/upload-editor-image.ts +42 -0
  117. package/template/src/components/editor.tsx +161 -22
  118. package/template/src/components/email-template.tsx +2 -2
  119. package/template/src/components/global-search.tsx +30 -28
  120. package/template/src/components/header.tsx +178 -80
  121. package/template/src/components/inactive-account-guard.tsx +58 -0
  122. package/template/src/components/integration-notifications-listener.tsx +12 -0
  123. package/template/src/components/invitation-email-template.tsx +2 -2
  124. package/template/src/components/meet-cancellation-email-template.tsx +3 -3
  125. package/template/src/components/meet-confirmation-email-template.tsx +3 -3
  126. package/template/src/components/meet-update-email-template.tsx +3 -3
  127. package/template/src/components/page-header.tsx +5 -5
  128. package/template/src/components/protected-page.tsx +1 -1
  129. package/template/src/components/reset-password-email-template.tsx +2 -2
  130. package/template/src/components/settings/integrations/GoogleAdsIntegration.tsx +428 -0
  131. package/template/src/components/settings/integrations/GoogleSheetConfigMonitoringModal.tsx +680 -0
  132. package/template/src/components/settings/integrations/GoogleSheetIntegration.tsx +809 -0
  133. package/template/src/components/settings/integrations/ImportResultDialog.tsx +124 -0
  134. package/template/src/components/settings/integrations/IntegrationLogPanel.tsx +57 -0
  135. package/template/src/components/settings/integrations/IntegrationLogsTable.tsx +186 -0
  136. package/template/src/components/settings/integrations/MetaLeadIntegration.tsx +451 -0
  137. package/template/src/components/sidebar.tsx +45 -26
  138. package/template/src/components/skeleton.tsx +40 -43
  139. package/template/src/components/ui/accordion.tsx +2 -2
  140. package/template/src/components/ui/alert-dialog.tsx +1 -1
  141. package/template/src/components/ui/button.tsx +20 -9
  142. package/template/src/components/ui/components.tsx +1 -1
  143. package/template/src/components/ui/date-picker.tsx +422 -0
  144. package/template/src/components/ui/datetime-picker.tsx +338 -0
  145. package/template/src/components/ui/status-select.tsx +271 -0
  146. package/template/src/components/ui/tooltip.tsx +37 -0
  147. package/template/src/components/view-as-modal.tsx +13 -7
  148. package/template/src/contexts/app-toast-context.tsx +245 -57
  149. package/template/src/contexts/dashboard-theme-context.tsx +53 -0
  150. package/template/src/contexts/sidebar-context.tsx +22 -17
  151. package/template/src/contexts/task-reminder-context.tsx +134 -160
  152. package/template/src/contexts/view-as-context.tsx +33 -6
  153. package/template/src/hooks/use-focus-trap.ts +2 -2
  154. package/template/src/hooks/useIntegrationNotifications.ts +49 -0
  155. package/template/src/lib/auth.ts +8 -1
  156. package/template/src/lib/config-links.ts +14 -0
  157. package/template/src/lib/contact-duplicate.ts +79 -61
  158. package/template/src/lib/contact-interactions.ts +21 -21
  159. package/template/src/lib/contact-view-filters.ts +24 -64
  160. package/template/src/lib/contacts-list-url.ts +190 -0
  161. package/template/src/lib/dashboard-stats.ts +65 -7
  162. package/template/src/lib/dashboard-themes.ts +135 -0
  163. package/template/src/lib/date-utils.ts +127 -0
  164. package/template/src/lib/default-widgets.ts +12 -0
  165. package/template/src/lib/editor-html-image-dimensions.ts +172 -0
  166. package/template/src/lib/editor-image-limits.ts +19 -0
  167. package/template/src/lib/email-html-sanitize.ts +19 -0
  168. package/template/src/lib/encryption.ts +9 -6
  169. package/template/src/lib/fr-geography.ts +192 -0
  170. package/template/src/lib/google-calendar-agenda.ts +201 -0
  171. package/template/src/lib/google-calendar.ts +255 -5
  172. package/template/src/lib/google-sheet-sync-jobs.ts +96 -0
  173. package/template/src/lib/google-sheet-sync-runner.ts +514 -0
  174. package/template/src/lib/integration-import-log.ts +21 -0
  175. package/template/src/lib/permissions.ts +40 -10
  176. package/template/src/lib/prisma.ts +4 -1
  177. package/template/src/lib/qstash.ts +65 -0
  178. package/template/src/lib/reminder-state-server.ts +80 -0
  179. package/template/src/lib/reminder-state.ts +29 -0
  180. package/template/src/lib/supabase-storage.ts +113 -0
  181. package/template/src/lib/template-variables.ts +164 -23
  182. package/template/src/lib/utils.ts +45 -0
  183. package/template/src/lib/widget-registry.ts +173 -0
  184. package/template/src/lib/workflow-executor.ts +16 -70
  185. package/template/src/proxy.ts +1 -0
  186. package/template/vercel.json +3 -10
  187. package/template/skills-lock.json +0 -25
  188. package/template/src/components/dashboard/dashboard-content.tsx +0 -79
  189. package/template/src/lib/google-drive.ts +0 -1101
  190. package/template/src/types/yousign.ts +0 -52
@@ -0,0 +1,338 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { Calendar, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
6
+ import { cn } from '@/lib/utils';
7
+
8
+ export interface DateTimePickerProps {
9
+ value: string;
10
+ onChange: (value: string) => void;
11
+ placeholder?: string;
12
+ className?: string;
13
+ required?: boolean;
14
+ id?: string;
15
+ }
16
+
17
+ const DAYS = ['Lu', 'Ma', 'Me', 'Je', 'Ve', 'Sa', 'Di'];
18
+ const MONTHS = [
19
+ 'Janvier',
20
+ 'Février',
21
+ 'Mars',
22
+ 'Avril',
23
+ 'Mai',
24
+ 'Juin',
25
+ 'Juillet',
26
+ 'Août',
27
+ 'Septembre',
28
+ 'Octobre',
29
+ 'Novembre',
30
+ 'Décembre',
31
+ ];
32
+
33
+ function pad(n: number) {
34
+ return String(n).padStart(2, '0');
35
+ }
36
+
37
+ function parseValue(value: string): { date: Date | null; hour: number; minute: number } {
38
+ if (!value) return { date: null, hour: 9, minute: 0 };
39
+ const d = new Date(value);
40
+ if (Number.isNaN(d.getTime())) return { date: null, hour: 9, minute: 0 };
41
+ return { date: d, hour: d.getHours(), minute: d.getMinutes() };
42
+ }
43
+
44
+ function formatDisplay(value: string): string {
45
+ if (!value) return '';
46
+ const d = new Date(value);
47
+ if (Number.isNaN(d.getTime())) return '';
48
+ return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} à ${pad(d.getHours())}:${pad(d.getMinutes())}`;
49
+ }
50
+
51
+ function getDaysInMonth(date: Date): (Date | null)[] {
52
+ const year = date.getFullYear();
53
+ const month = date.getMonth();
54
+ const firstDay = new Date(year, month, 1);
55
+ const lastDay = new Date(year, month + 1, 0);
56
+ const firstDayOfWeek = firstDay.getDay();
57
+ const adjustedFirstDay = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
58
+
59
+ const days: (Date | null)[] = [];
60
+ for (let i = 0; i < adjustedFirstDay; i++) {
61
+ days.push(null);
62
+ }
63
+ for (let i = 1; i <= lastDay.getDate(); i++) {
64
+ days.push(new Date(year, month, i));
65
+ }
66
+ return days;
67
+ }
68
+
69
+ function isSameDay(a: Date, b: Date): boolean {
70
+ return (
71
+ a.getFullYear() === b.getFullYear() &&
72
+ a.getMonth() === b.getMonth() &&
73
+ a.getDate() === b.getDate()
74
+ );
75
+ }
76
+
77
+ export function DateTimePicker({
78
+ value,
79
+ onChange,
80
+ placeholder = 'Sélectionner date et heure',
81
+ className,
82
+ required,
83
+ id,
84
+ }: DateTimePickerProps) {
85
+ const [isOpen, setIsOpen] = useState(false);
86
+ const parsed = parseValue(value);
87
+ const [currentMonth, setCurrentMonth] = useState(parsed.date || new Date());
88
+ const [selectedDate, setSelectedDate] = useState<Date | null>(parsed.date);
89
+ const [hour, setHour] = useState(parsed.hour);
90
+ const [minute, setMinute] = useState(parsed.minute);
91
+ const [isMounted, setIsMounted] = useState(false);
92
+
93
+ const buttonRef = useRef<HTMLButtonElement>(null);
94
+ const panelRef = useRef<HTMLDivElement>(null);
95
+ const [position, setPosition] = useState({ top: 0, left: 0 });
96
+
97
+ useEffect(() => {
98
+ setIsMounted(true);
99
+ }, []);
100
+
101
+ useEffect(() => {
102
+ const p = parseValue(value);
103
+ setSelectedDate(p.date);
104
+ setHour(p.hour);
105
+ setMinute(p.minute);
106
+ if (p.date) setCurrentMonth(p.date);
107
+ }, [value]);
108
+
109
+ useEffect(() => {
110
+ if (isOpen && buttonRef.current) {
111
+ const rect = buttonRef.current.getBoundingClientRect();
112
+ const panelH = 420;
113
+ const spaceBelow = window.innerHeight - rect.bottom;
114
+ const top =
115
+ spaceBelow < panelH && rect.top > panelH
116
+ ? rect.top + window.scrollY - panelH - 4
117
+ : rect.bottom + window.scrollY + 4;
118
+ setPosition({
119
+ top,
120
+ left: rect.left + window.scrollX,
121
+ });
122
+ }
123
+ }, [isOpen]);
124
+
125
+ useEffect(() => {
126
+ if (!isOpen) return;
127
+ const handler = (e: MouseEvent) => {
128
+ if (
129
+ panelRef.current &&
130
+ !panelRef.current.contains(e.target as Node) &&
131
+ buttonRef.current &&
132
+ !buttonRef.current.contains(e.target as Node)
133
+ ) {
134
+ setIsOpen(false);
135
+ }
136
+ };
137
+ document.addEventListener('mousedown', handler);
138
+ return () => document.removeEventListener('mousedown', handler);
139
+ }, [isOpen]);
140
+
141
+ useEffect(() => {
142
+ if (!isOpen) return;
143
+ const handler = (e: KeyboardEvent) => {
144
+ if (e.key === 'Escape') setIsOpen(false);
145
+ };
146
+ document.addEventListener('keydown', handler);
147
+ return () => document.removeEventListener('keydown', handler);
148
+ }, [isOpen]);
149
+
150
+ const emitChange = useCallback(
151
+ (date: Date | null, h: number, m: number) => {
152
+ if (!date) return;
153
+ const d = new Date(date);
154
+ d.setHours(h, m, 0, 0);
155
+ const iso = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(h)}:${pad(m)}`;
156
+ onChange(iso);
157
+ },
158
+ [onChange],
159
+ );
160
+
161
+ const handleDateClick = (date: Date) => {
162
+ setSelectedDate(date);
163
+ emitChange(date, hour, minute);
164
+ };
165
+
166
+ const handleHourChange = (h: number) => {
167
+ setHour(h);
168
+ emitChange(selectedDate, h, minute);
169
+ };
170
+
171
+ const handleMinuteChange = (m: number) => {
172
+ setMinute(m);
173
+ emitChange(selectedDate, hour, m);
174
+ };
175
+
176
+ const handleSetNow = () => {
177
+ const now = new Date();
178
+ setSelectedDate(now);
179
+ setHour(now.getHours());
180
+ setMinute(now.getMinutes());
181
+ setCurrentMonth(now);
182
+ emitChange(now, now.getHours(), now.getMinutes());
183
+ };
184
+
185
+ const today = new Date();
186
+ today.setHours(0, 0, 0, 0);
187
+ const days = getDaysInMonth(currentMonth);
188
+
189
+ const panel =
190
+ isOpen && isMounted
191
+ ? createPortal(
192
+ <div
193
+ ref={panelRef}
194
+ className="ui-scale-in fixed z-[100] w-[320px] rounded-xl border border-border bg-popover text-popover-foreground shadow-xl"
195
+ style={{ top: position.top, left: position.left }}
196
+ >
197
+ <div className="flex items-center justify-between border-b border-border px-4 py-3">
198
+ <button
199
+ type="button"
200
+ onClick={() =>
201
+ setCurrentMonth(
202
+ new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1),
203
+ )
204
+ }
205
+ className="text-muted-foreground hover:bg-muted rounded-lg p-1.5 transition-colors"
206
+ >
207
+ <ChevronLeft className="h-4 w-4" />
208
+ </button>
209
+ <span className="text-sm font-semibold text-foreground">
210
+ {MONTHS[currentMonth.getMonth()]} {currentMonth.getFullYear()}
211
+ </span>
212
+ <button
213
+ type="button"
214
+ onClick={() =>
215
+ setCurrentMonth(
216
+ new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1),
217
+ )
218
+ }
219
+ className="text-muted-foreground hover:bg-muted rounded-lg p-1.5 transition-colors"
220
+ >
221
+ <ChevronRight className="h-4 w-4" />
222
+ </button>
223
+ </div>
224
+
225
+ <div className="grid grid-cols-7 gap-0.5 px-3 pt-2">
226
+ {DAYS.map((d) => (
227
+ <div key={d} className="text-muted-foreground py-1 text-center text-xs font-medium">
228
+ {d}
229
+ </div>
230
+ ))}
231
+ </div>
232
+
233
+ <div className="grid grid-cols-7 gap-0.5 px-3 pb-2">
234
+ {days.map((day, i) => {
235
+ if (!day) return <div key={`empty-${i}`} />;
236
+ const isTodayCell = isSameDay(day, today);
237
+ const isSelected = selectedDate && isSameDay(day, selectedDate);
238
+ return (
239
+ <button
240
+ key={day.toISOString()}
241
+ type="button"
242
+ onClick={() => handleDateClick(day)}
243
+ className={cn(
244
+ 'flex h-8 w-full items-center justify-center rounded-lg text-sm transition-colors',
245
+ isSelected
246
+ ? 'bg-primary text-primary-foreground font-semibold shadow-sm'
247
+ : isTodayCell
248
+ ? 'border-primary/40 text-primary hover:bg-primary/10 border font-medium'
249
+ : 'text-foreground hover:bg-muted',
250
+ )}
251
+ >
252
+ {day.getDate()}
253
+ </button>
254
+ );
255
+ })}
256
+ </div>
257
+
258
+ <div className="border-border border-t px-4 py-3">
259
+ <div className="flex items-center gap-3">
260
+ <Clock className="text-muted-foreground h-4 w-4 shrink-0" />
261
+ <div className="flex items-center gap-1">
262
+ <select
263
+ value={hour}
264
+ onChange={(e) => handleHourChange(Number(e.target.value))}
265
+ aria-label="Heure"
266
+ className="border-border bg-muted text-foreground focus-visible:border-ring focus-visible:ring-ring/20 rounded-lg border px-2 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
267
+ >
268
+ {Array.from({ length: 24 }, (_, i) => (
269
+ <option key={i} value={i}>
270
+ {pad(i)}
271
+ </option>
272
+ ))}
273
+ </select>
274
+ <span className="text-muted-foreground text-sm font-bold">:</span>
275
+ <select
276
+ value={minute}
277
+ onChange={(e) => handleMinuteChange(Number(e.target.value))}
278
+ aria-label="Minute"
279
+ className="border-border bg-muted text-foreground focus-visible:border-ring focus-visible:ring-ring/20 rounded-lg border px-2 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
280
+ >
281
+ {Array.from({ length: 12 }, (_, i) => i * 5).map((m) => (
282
+ <option key={m} value={m}>
283
+ {pad(m)}
284
+ </option>
285
+ ))}
286
+ </select>
287
+ </div>
288
+ <button
289
+ type="button"
290
+ onClick={handleSetNow}
291
+ className="text-primary ml-auto rounded-lg px-2.5 py-1 text-xs font-medium transition-colors hover:bg-primary/10"
292
+ >
293
+ Maintenant
294
+ </button>
295
+ </div>
296
+ </div>
297
+
298
+ <div className="border-border border-t px-4 py-2.5">
299
+ <button
300
+ type="button"
301
+ onClick={() => setIsOpen(false)}
302
+ disabled={!selectedDate}
303
+ className="bg-primary text-primary-foreground hover:bg-primary/90 w-full rounded-lg px-3 py-2 text-sm font-medium shadow-sm transition-colors disabled:opacity-50"
304
+ >
305
+ Confirmer
306
+ </button>
307
+ </div>
308
+ </div>,
309
+ document.body,
310
+ )
311
+ : null;
312
+
313
+ return (
314
+ <>
315
+ <button
316
+ ref={buttonRef}
317
+ id={id}
318
+ type="button"
319
+ onClick={() => setIsOpen(!isOpen)}
320
+ className={cn(
321
+ 'border-input bg-background text-foreground flex w-full items-center gap-2 rounded-xl border px-3 py-2 text-sm shadow-sm',
322
+ 'transition-[border-color,box-shadow] duration-200',
323
+ 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none',
324
+ isOpen && 'border-ring ring-ring/50 ring-[3px]',
325
+ !value && 'text-muted-foreground',
326
+ className,
327
+ )}
328
+ >
329
+ <Calendar className="text-muted-foreground h-4 w-4 shrink-0" />
330
+ <span className="flex-1 truncate text-left">
331
+ {value ? formatDisplay(value) : placeholder}
332
+ </span>
333
+ {required && !value && <span className="text-xs text-red-500">*</span>}
334
+ </button>
335
+ {panel}
336
+ </>
337
+ );
338
+ }
@@ -0,0 +1,271 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { ChevronDown } from 'lucide-react';
6
+ import { motion, AnimatePresence } from 'motion/react';
7
+ import { cn } from '@/lib/utils';
8
+
9
+ export interface StatusOption {
10
+ id: string;
11
+ name: string;
12
+ color: string;
13
+ }
14
+
15
+ const DROPDOWN_MAX_HEIGHT = 240; // max-h-60
16
+ const DROPDOWN_GAP = 4;
17
+
18
+ interface StatusSelectProps {
19
+ value: string;
20
+ onChange: (statusId: string) => void;
21
+ statuses: StatusOption[];
22
+ placeholder?: string;
23
+ className?: string;
24
+ size?: 'sm' | 'md';
25
+ disabled?: boolean;
26
+ id?: string;
27
+ /** Optionnel : callback appelé au changement (ex. pour gérer motif de fermeture) */
28
+ onStatusChange?: (statusId: string) => void;
29
+ }
30
+
31
+ export function StatusSelect({
32
+ value,
33
+ onChange,
34
+ statuses,
35
+ placeholder = 'Aucun statut',
36
+ className,
37
+ size = 'md',
38
+ disabled = false,
39
+ id,
40
+ onStatusChange,
41
+ }: StatusSelectProps) {
42
+ const [open, setOpen] = useState(false);
43
+ const [focusedIndex, setFocusedIndex] = useState(-1);
44
+ const [dropdownStyle, setDropdownStyle] = useState<{ top?: number; bottom?: number; left: number; width: number } | null>(null);
45
+ const containerRef = useRef<HTMLDivElement>(null);
46
+ const triggerRef = useRef<HTMLButtonElement>(null);
47
+ const dropdownRef = useRef<HTMLDivElement>(null);
48
+
49
+ const selectedStatus = statuses.find((s) => s.id === value);
50
+ const options = [{ id: '', name: placeholder }, ...statuses];
51
+
52
+ const updateDropdownPosition = useCallback(() => {
53
+ if (!triggerRef.current || typeof document === 'undefined') return;
54
+ const rect = triggerRef.current.getBoundingClientRect();
55
+ const spaceBelow = window.innerHeight - rect.bottom - DROPDOWN_GAP;
56
+ const spaceAbove = rect.top - DROPDOWN_GAP;
57
+ const openAbove = spaceBelow < DROPDOWN_MAX_HEIGHT && spaceAbove > spaceBelow;
58
+
59
+ setDropdownStyle({
60
+ left: rect.left,
61
+ width: rect.width,
62
+ ...(openAbove
63
+ ? { bottom: window.innerHeight - rect.top + DROPDOWN_GAP }
64
+ : { top: rect.bottom + DROPDOWN_GAP }),
65
+ });
66
+ }, []);
67
+
68
+ useLayoutEffect(() => {
69
+ if (open) {
70
+ updateDropdownPosition();
71
+ } else {
72
+ setDropdownStyle(null);
73
+ }
74
+ }, [open, updateDropdownPosition]);
75
+
76
+ const handleSelect = useCallback(
77
+ (statusId: string) => {
78
+ onChange(statusId);
79
+ onStatusChange?.(statusId);
80
+ setOpen(false);
81
+ setFocusedIndex(-1);
82
+ },
83
+ [onChange, onStatusChange],
84
+ );
85
+
86
+ const handleKeyDown = useCallback(
87
+ (e: React.KeyboardEvent) => {
88
+ if (!open) {
89
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
90
+ e.preventDefault();
91
+ setOpen(true);
92
+ setFocusedIndex(value ? options.findIndex((o) => o.id === value) : 0);
93
+ }
94
+ return;
95
+ }
96
+ switch (e.key) {
97
+ case 'Escape':
98
+ e.preventDefault();
99
+ setOpen(false);
100
+ setFocusedIndex(-1);
101
+ break;
102
+ case 'ArrowDown':
103
+ e.preventDefault();
104
+ setFocusedIndex((i) => (i < options.length - 1 ? i + 1 : 0));
105
+ break;
106
+ case 'ArrowUp':
107
+ e.preventDefault();
108
+ setFocusedIndex((i) => (i > 0 ? i - 1 : options.length - 1));
109
+ break;
110
+ case 'Enter':
111
+ e.preventDefault();
112
+ if (focusedIndex >= 0 && options[focusedIndex]) {
113
+ handleSelect(options[focusedIndex].id);
114
+ }
115
+ break;
116
+ default:
117
+ break;
118
+ }
119
+ },
120
+ [open, value, options, focusedIndex, handleSelect],
121
+ );
122
+
123
+ useLayoutEffect(() => {
124
+ if (open) {
125
+ updateDropdownPosition();
126
+ } else {
127
+ setDropdownStyle(null);
128
+ }
129
+ }, [open, updateDropdownPosition]);
130
+
131
+ useEffect(() => {
132
+ if (!open) return;
133
+ const handleScrollOrResize = () => {
134
+ setOpen(false);
135
+ setFocusedIndex(-1);
136
+ };
137
+ // window.addEventListener('scroll', handleScrollOrResize, true);
138
+ window.addEventListener('resize', handleScrollOrResize, { passive: true });
139
+ return () => {
140
+ // window.removeEventListener('scroll', handleScrollOrResize, true);
141
+ window.removeEventListener('resize', handleScrollOrResize);
142
+ };
143
+ }, [open]);
144
+
145
+ useEffect(() => {
146
+ const handleClickOutside = (event: MouseEvent) => {
147
+ const target = event.target as Node;
148
+ if (containerRef.current?.contains(target)) return;
149
+ if (dropdownRef.current?.contains(target)) return;
150
+ setOpen(false);
151
+ setFocusedIndex(-1);
152
+ };
153
+ document.addEventListener('mousedown', handleClickOutside);
154
+ return () => document.removeEventListener('mousedown', handleClickOutside);
155
+ }, []);
156
+
157
+ const sizeClasses = size === 'sm' ? 'px-2 py-1.5 text-sm' : 'px-4 py-2 text-sm';
158
+
159
+ return (
160
+ <div ref={containerRef} className={cn('relative', className)}>
161
+ <button
162
+ ref={triggerRef}
163
+ type="button"
164
+ id={id}
165
+ role="combobox"
166
+ aria-expanded={open}
167
+ aria-haspopup="listbox"
168
+ aria-label="Sélectionner un statut"
169
+ disabled={disabled}
170
+ onClick={() => !disabled && setOpen(!open)}
171
+ onKeyDown={handleKeyDown}
172
+ className={cn(
173
+ 'flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg border text-left transition-colors focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
174
+ sizeClasses,
175
+ selectedStatus
176
+ ? ''
177
+ : 'border-gray-300 bg-white text-gray-900 hover:bg-gray-50',
178
+ )}
179
+ style={
180
+ selectedStatus
181
+ ? {
182
+ backgroundColor: `${selectedStatus.color}20`,
183
+ color: selectedStatus.color,
184
+ borderColor: selectedStatus.color,
185
+ }
186
+ : undefined
187
+ }
188
+ >
189
+ <span className="flex min-w-0 items-center gap-2 truncate">
190
+ {selectedStatus ? (
191
+ <>
192
+ <span
193
+ className="h-2.5 w-2.5 shrink-0 rounded-full"
194
+ style={{ backgroundColor: selectedStatus.color }}
195
+ aria-hidden
196
+ />
197
+ <span className="truncate">{selectedStatus.name}</span>
198
+ </>
199
+ ) : (
200
+ <span className="text-gray-500">{placeholder}</span>
201
+ )}
202
+ </span>
203
+ <ChevronDown
204
+ aria-hidden="true"
205
+ className={cn('h-4 w-4 shrink-0 text-gray-400 transition-transform', open && 'rotate-180')}
206
+ />
207
+ </button>
208
+
209
+ {open &&
210
+ dropdownStyle &&
211
+ typeof document !== 'undefined' &&
212
+ createPortal(
213
+ <AnimatePresence>
214
+ <motion.div
215
+ ref={dropdownRef}
216
+ role="listbox"
217
+ initial={{ opacity: 0, scale: 0.96, y: -4 }}
218
+ animate={{ opacity: 1, scale: 1, y: 0 }}
219
+ exit={{ opacity: 0, scale: 0.96, y: -4 }}
220
+ transition={{ duration: 0.15, ease: [0.25, 0.4, 0.25, 1] }}
221
+ className="fixed z-[9999] max-h-60 min-w-[160px] overflow-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
222
+ style={{
223
+ left: dropdownStyle.left,
224
+ width: dropdownStyle.width,
225
+ transformOrigin: dropdownStyle.top !== undefined ? 'top' : 'bottom',
226
+ ...(dropdownStyle.top !== undefined
227
+ ? { top: dropdownStyle.top }
228
+ : { bottom: dropdownStyle.bottom }),
229
+ }}
230
+ >
231
+ {options.map((opt, idx) => (
232
+ <motion.button
233
+ key={opt.id || '__empty__'}
234
+ initial={{ opacity: 0, x: -4 }}
235
+ animate={{ opacity: 1, x: 0 }}
236
+ transition={{ delay: idx * 0.02, duration: 0.12 }}
237
+ type="button"
238
+ role="option"
239
+ aria-selected={opt.id === value}
240
+ className={cn(
241
+ 'flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm transition-colors',
242
+ idx === focusedIndex ? 'bg-gray-100' : 'hover:bg-gray-50',
243
+ opt.id === value ? 'bg-blue-50 font-medium text-blue-700' : 'text-gray-900',
244
+ )}
245
+ onClick={() => handleSelect(opt.id)}
246
+ onMouseEnter={() => setFocusedIndex(idx)}
247
+ >
248
+ {opt.id ? (
249
+ <>
250
+ <span
251
+ className="h-2.5 w-2.5 shrink-0 rounded-full"
252
+ style={{
253
+ backgroundColor:
254
+ statuses.find((s) => s.id === opt.id)?.color ?? '#999',
255
+ }}
256
+ aria-hidden
257
+ />
258
+ <span className="truncate">{opt.name}</span>
259
+ </>
260
+ ) : (
261
+ <span className="text-gray-500">{opt.name}</span>
262
+ )}
263
+ </motion.button>
264
+ ))}
265
+ </motion.div>
266
+ </AnimatePresence>,
267
+ document.body,
268
+ )}
269
+ </div>
270
+ );
271
+ }
@@ -0,0 +1,37 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import * as TooltipPrimitive from '@radix-ui/react-tooltip';
5
+
6
+ import { cn } from '@/lib/utils';
7
+
8
+ function TooltipProvider({
9
+ ...props
10
+ }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
11
+ return <TooltipPrimitive.Provider {...props} />;
12
+ }
13
+
14
+ const Tooltip = TooltipPrimitive.Root;
15
+
16
+ const TooltipTrigger = TooltipPrimitive.TooltipTrigger;
17
+
18
+ const TooltipContent = React.forwardRef<
19
+ React.ElementRef<typeof TooltipPrimitive.Content>,
20
+ React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
21
+ >(({ className, sideOffset = 6, ...props }, ref) => (
22
+ <TooltipPrimitive.Portal>
23
+ <TooltipPrimitive.Content
24
+ ref={ref}
25
+ sideOffset={sideOffset}
26
+ className={cn(
27
+ 'bg-popover text-popover-foreground border-border z-[200] max-w-[min(280px,calc(100vw-16px))] rounded-md border px-3 py-2 text-xs shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
28
+ 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
29
+ className,
30
+ )}
31
+ {...props}
32
+ />
33
+ </TooltipPrimitive.Portal>
34
+ ));
35
+ TooltipContent.displayName = TooltipPrimitive.Content.displayName;
36
+
37
+ export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
@@ -76,13 +76,21 @@ export function ViewAsModal({ isOpen, onClose }: ViewAsModalProps) {
76
76
 
77
77
  return (
78
78
  <div className="fixed inset-0 z-50 flex items-center justify-center rounded-lg bg-gray-500/20 p-4 shadow-xl backdrop-blur-sm">
79
- <div ref={contentRef} className="w-full max-w-2xl rounded-lg bg-white shadow-xl" role="dialog" aria-modal="true" aria-labelledby="view-as-title">
79
+ <div
80
+ ref={contentRef}
81
+ className="w-full max-w-2xl rounded-lg bg-white shadow-xl overscroll-contain"
82
+ role="dialog"
83
+ aria-modal="true"
84
+ aria-labelledby="view-as-title"
85
+ >
80
86
  {/* En-tête */}
81
87
  <div className="flex items-center justify-between rounded-t-lg border-b border-gray-200 bg-blue-600 px-6 py-4">
82
88
  <div className="flex items-center gap-3 text-white">
83
89
  <UserIcon className="h-6 w-6" />
84
90
  <div>
85
- <h2 id="view-as-title" className="text-xl font-bold">Changer de vue</h2>
91
+ <h2 id="view-as-title" className="text-xl font-bold">
92
+ Changer de vue
93
+ </h2>
86
94
  <p className="text-sm text-white/90">
87
95
  Voir l'application avec les permissions d'un profil
88
96
  </p>
@@ -114,7 +122,7 @@ export function ViewAsModal({ isOpen, onClose }: ViewAsModalProps) {
114
122
  router.refresh();
115
123
  }}
116
124
  className={cn(
117
- 'w-full cursor-pointer rounded-lg border-2 p-4 text-left transition-all',
125
+ 'w-full cursor-pointer rounded-lg border-2 p-4 text-left transition-colors',
118
126
  !viewAsUser
119
127
  ? 'border-blue-500 bg-blue-50'
120
128
  : 'border-gray-200 bg-white hover:border-blue-300 hover:bg-blue-50/50',
@@ -125,9 +133,7 @@ export function ViewAsModal({ isOpen, onClose }: ViewAsModalProps) {
125
133
  <div
126
134
  className={cn(
127
135
  'flex h-12 w-12 items-center justify-center rounded-full text-lg font-bold',
128
- !viewAsUser
129
- ? 'bg-blue-600 text-white'
130
- : 'bg-blue-100 text-blue-800',
136
+ !viewAsUser ? 'bg-blue-600 text-white' : 'bg-blue-100 text-blue-800',
131
137
  )}
132
138
  >
133
139
  {getInitials(session.user.name || session.user.email)}
@@ -153,7 +159,7 @@ export function ViewAsModal({ isOpen, onClose }: ViewAsModalProps) {
153
159
  key={user.id}
154
160
  onClick={() => handleSelectUser(user)}
155
161
  className={cn(
156
- 'w-full cursor-pointer rounded-lg border-2 p-4 text-left transition-all',
162
+ 'w-full cursor-pointer rounded-lg border-2 p-4 text-left transition-colors',
157
163
  viewAsUser?.id === user.id
158
164
  ? 'border-blue-500 bg-blue-50'
159
165
  : 'border-gray-200 bg-white hover:border-blue-300 hover:bg-blue-50/50',