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
@@ -1,20 +1,23 @@
1
1
  'use client';
2
2
 
3
- import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
3
+ import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { useSession } from '@/lib/auth-client';
5
- import { useUserRole } from '@/hooks/use-user-role';
6
- import Link from 'next/link';
7
- import { Bell, X } from 'lucide-react';
5
+ import { useAppToast } from '@/contexts/app-toast-context';
6
+ import { REMINDERS_POLL_INTERVAL_MS, REMINDERS_REFRESH_EVENT } from '@/lib/reminder-state';
8
7
 
9
- type Task = {
8
+ type Reminder = {
10
9
  id: string;
11
- type: 'CALL' | 'MEETING' | 'EMAIL' | 'OTHER';
10
+ kind: 'due' | 'reminder';
11
+ type: 'CALL' | 'MEETING' | 'EMAIL' | 'OTHER' | 'VIDEO_CONFERENCE';
12
12
  title: string | null;
13
13
  description: string;
14
14
  priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
15
15
  scheduledAt: string;
16
- completed: boolean;
17
- reminderMinutesBefore?: number | null;
16
+ reminderTime: string;
17
+ reminderMinutesBefore: number | null;
18
+ isRead: boolean;
19
+ isDismissed: boolean;
20
+ isClearedByCutoff: boolean;
18
21
  contact: {
19
22
  id: string;
20
23
  firstName: string | null;
@@ -31,11 +34,12 @@ type TaskReminderContextValue = {
31
34
 
32
35
  const TaskReminderContext = createContext<TaskReminderContextValue | undefined>(undefined);
33
36
 
34
- const TASK_TYPE_LABELS: Record<Task['type'], string> = {
37
+ const TASK_TYPE_LABELS: Record<Reminder['type'], string> = {
35
38
  CALL: 'Appel téléphonique',
36
39
  MEETING: 'RDV',
37
40
  EMAIL: 'Email',
38
41
  OTHER: 'Autre',
42
+ VIDEO_CONFERENCE: 'Google Meet',
39
43
  };
40
44
 
41
45
  function formatTime(dateString: string) {
@@ -45,189 +49,159 @@ function formatTime(dateString: string) {
45
49
  });
46
50
  }
47
51
 
48
- export function TaskReminderProvider({ children }: { children: React.ReactNode }) {
52
+ export function TaskReminderProvider({ children }: Readonly<{ children: React.ReactNode }>) {
49
53
  const { data: session } = useSession();
50
- const { isAdmin } = useUserRole();
51
- const [tasks, setTasks] = useState<Task[]>([]);
54
+ const toast = useAppToast();
55
+ const [reminders, setReminders] = useState<Reminder[]>([]);
52
56
  const [notifications, setNotifications] = useState<Notification[]>([]);
53
- const notifiedKeysRef = useRef<Set<string>>(new Set());
57
+ const activeToastIdsRef = useRef<Map<string, string>>(new Map());
58
+
59
+ const markReminderState = async (reminderId: string, status: 'READ' | 'DISMISSED') => {
60
+ try {
61
+ await fetch('/api/reminders/state', {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify({ reminderId, status }),
65
+ });
66
+ } catch (error) {
67
+ console.error("Erreur lors de la synchronisation de l'état du rappel:", error);
68
+ }
69
+ };
54
70
 
55
- // Charger les tâches pertinentes pour les rappels
71
+ // Charger les rappels synchronisés avec le backend (polling + onglet + refresh explicite)
56
72
  useEffect(() => {
57
73
  if (!session) return;
58
74
 
59
- const fetchTasks = async () => {
75
+ const fetchReminders = async () => {
60
76
  try {
61
- const now = new Date();
62
- const start = new Date(now);
63
- start.setDate(start.getDate() - 1); // hier
64
- const end = new Date(now);
65
- end.setDate(end.getDate() + 1); // demain
66
-
67
- const params = new URLSearchParams({
68
- startDate: start.toISOString(),
69
- endDate: end.toISOString(),
70
- });
71
-
72
- const response = await fetch(`/api/tasks?${params.toString()}`);
77
+ const response = await fetch('/api/reminders');
73
78
  if (response.ok) {
74
- const data = await response.json();
75
- setTasks(data);
79
+ const data = (await response.json()) as Reminder[];
80
+ setReminders(data);
76
81
  }
77
82
  } catch (error) {
78
- console.error('Erreur lors du chargement des tâches pour les rappels:', error);
83
+ console.error('Erreur lors du chargement des rappels:', error);
79
84
  }
80
85
  };
81
86
 
82
- fetchTasks();
83
- const interval = setInterval(fetchTasks, 5 * 60 * 1000); // rafraîchir toutes les 5 min
84
- return () => clearInterval(interval);
85
- }, [session, isAdmin]);
87
+ void fetchReminders();
88
+ const interval = setInterval(fetchReminders, REMINDERS_POLL_INTERVAL_MS);
89
+
90
+ const onVisible = () => {
91
+ if (document.visibilityState === 'visible') {
92
+ void fetchReminders();
93
+ }
94
+ };
95
+ const onRefresh = () => void fetchReminders();
96
+
97
+ document.addEventListener('visibilitychange', onVisible);
98
+ globalThis.addEventListener(REMINDERS_REFRESH_EVENT, onRefresh);
99
+ return () => {
100
+ clearInterval(interval);
101
+ document.removeEventListener('visibilitychange', onVisible);
102
+ globalThis.removeEventListener(REMINDERS_REFRESH_EVENT, onRefresh);
103
+ };
104
+ }, [session]);
86
105
 
87
- // Supprimer les notifications des tâches qui n'existent plus
106
+ // Afficher/retirer les toasts selon la source reminders + états read/dismissed
88
107
  useEffect(() => {
89
108
  if (!session) return;
90
109
 
91
- const taskIds = new Set(tasks.map((task) => task.id));
92
- setNotifications((prev) =>
93
- prev.filter((notif) => {
94
- // Extraire l'ID de la tâche depuis l'ID de la notification
95
- // Format: `${task.id}-due` ou `${task.id}-reminder`
96
- let taskId: string;
97
- if (notif.id.endsWith('-due')) {
98
- taskId = notif.id.slice(0, -4); // Retirer '-due'
99
- } else if (notif.id.endsWith('-reminder')) {
100
- taskId = notif.id.slice(0, -9); // Retirer '-reminder'
101
- } else {
102
- // Format inattendu, on garde la notification pour éviter de la supprimer par erreur
103
- return true;
104
- }
105
- return taskIds.has(taskId);
106
- }),
107
- );
108
-
109
- // Nettoyer aussi les clés de notification des tâches supprimées
110
- const validKeys = new Set<string>();
111
- tasks.forEach((task) => {
112
- validKeys.add(`${task.id}-due`);
113
- validKeys.add(`${task.id}-reminder`);
110
+ const activeReminderIds = new Set<string>();
111
+ const visibleNotifications: Notification[] = [];
112
+
113
+ reminders.forEach((reminder) => {
114
+ if (reminder.isDismissed || reminder.isRead || reminder.isClearedByCutoff) return;
115
+ activeReminderIds.add(reminder.id);
116
+ const notification: Notification = {
117
+ id: reminder.id,
118
+ message:
119
+ reminder.kind === 'due'
120
+ ? `Vous avez une tâche maintenant : ${
121
+ reminder.title || TASK_TYPE_LABELS[reminder.type]
122
+ } (${formatTime(reminder.scheduledAt)})`
123
+ : `Rappel dans ${reminder.reminderMinutesBefore} min : ${
124
+ reminder.title || TASK_TYPE_LABELS[reminder.type]
125
+ } (${formatTime(reminder.scheduledAt)})`,
126
+ link: reminder.contact ? `/contacts/${reminder.contact.id}` : undefined,
127
+ };
128
+ visibleNotifications.push(notification);
129
+
130
+ if (activeToastIdsRef.current.has(reminder.id)) return;
131
+ const toastId = toast.persistent(reminder.kind === 'due' ? 'warning' : 'info', notification.message, {
132
+ actionLink: notification.link,
133
+ actionLabel: notification.link ? 'Ouvrir le contact' : undefined,
134
+ onDismiss: () => {
135
+ activeToastIdsRef.current.delete(reminder.id);
136
+ setNotifications((prev) => prev.filter((n) => n.id !== reminder.id));
137
+ void markReminderState(reminder.id, 'DISMISSED');
138
+ },
139
+ });
140
+ activeToastIdsRef.current.set(reminder.id, toastId);
114
141
  });
115
- notifiedKeysRef.current = new Set(
116
- Array.from(notifiedKeysRef.current).filter((key) => validKeys.has(key)),
117
- );
118
- }, [tasks, session]);
119
142
 
120
- // Génération des notifications
121
- useEffect(() => {
122
- if (!session) return;
143
+ // Si un rappel a disparu de la source serveur (clear-all, read, etc), on ferme le toast lié.
144
+ Array.from(activeToastIdsRef.current.entries()).forEach(([reminderId, toastId]) => {
145
+ if (activeReminderIds.has(reminderId)) return;
146
+ toast.dismissById(toastId);
147
+ activeToastIdsRef.current.delete(reminderId);
148
+ });
123
149
 
124
- const interval = setInterval(() => {
125
- const now = new Date();
126
- const newNotifications: Notification[] = [];
127
- const notified = new Set(notifiedKeysRef.current);
128
-
129
- tasks.forEach((task) => {
130
- if (task.completed) return;
131
- const scheduled = new Date(task.scheduledAt);
132
-
133
- // Notification à l'heure exacte de la tâche (fenêtre de 5 minutes)
134
- const dueKey = `${task.id}-due`;
135
- const diffMs = now.getTime() - scheduled.getTime();
136
- if (diffMs >= 0 && diffMs < 5 * 60 * 1000 && !notified.has(dueKey)) {
137
- notified.add(dueKey);
138
- newNotifications.push({
139
- id: dueKey,
140
- message: `Vous avez une tâche maintenant : ${
141
- task.title || TASK_TYPE_LABELS[task.type]
142
- } (${formatTime(task.scheduledAt)})`,
143
- link: task.contact ? `/contacts/${task.contact.id}` : undefined,
144
- });
145
- }
150
+ setNotifications(visibleNotifications);
151
+ }, [reminders, session, toast]);
146
152
 
147
- // Notification de rappel avant l'heure
148
- if (task.reminderMinutesBefore != null && task.reminderMinutesBefore > 0) {
149
- const reminderMs = task.reminderMinutesBefore * 60 * 1000;
150
- const reminderTime = new Date(scheduled.getTime() - reminderMs);
151
- const reminderKey = `${task.id}-reminder`;
152
- const diffReminderMs = now.getTime() - reminderTime.getTime();
153
-
154
- if (
155
- diffReminderMs >= 0 &&
156
- diffReminderMs < 5 * 60 * 1000 &&
157
- now < scheduled &&
158
- !notified.has(reminderKey)
159
- ) {
160
- notified.add(reminderKey);
161
- newNotifications.push({
162
- id: reminderKey,
163
- message: `Rappel dans ${task.reminderMinutesBefore} min : ${
164
- task.title || TASK_TYPE_LABELS[task.type]
165
- } (${formatTime(task.scheduledAt)})`,
166
- link: task.contact ? `/contacts/${task.contact.id}` : undefined,
167
- });
168
- }
169
- }
153
+ // Réagir au clear-all lancé depuis la cloche
154
+ useEffect(() => {
155
+ const handleRemindersCleared = () => {
156
+ Array.from(activeToastIdsRef.current.values()).forEach((toastId) => {
157
+ toast.dismissById(toastId);
170
158
  });
159
+ activeToastIdsRef.current.clear();
160
+ setNotifications([]);
161
+ setReminders([]);
162
+ };
171
163
 
172
- if (newNotifications.length > 0) {
173
- setNotifications((prev) => [...prev, ...newNotifications]);
174
- notifiedKeysRef.current = notified;
164
+ globalThis.addEventListener('reminders:cleared', handleRemindersCleared as EventListener);
165
+ return () => {
166
+ globalThis.removeEventListener('reminders:cleared', handleRemindersCleared as EventListener);
167
+ };
168
+ }, [toast]);
175
169
 
176
- // Faire disparaître automatiquement les nouvelles notifications après 5 secondes
177
- newNotifications.forEach((notif) => {
178
- setTimeout(() => {
179
- setNotifications((prev) => prev.filter((n) => n.id !== notif.id));
180
- }, 5000);
181
- });
170
+ useEffect(() => {
171
+ const handleReminderRead = (event: Event) => {
172
+ const customEvent = event as CustomEvent<{ reminderId?: string }>;
173
+ const reminderId = customEvent.detail?.reminderId;
174
+ if (!reminderId) return;
175
+ const toastId = activeToastIdsRef.current.get(reminderId);
176
+ if (toastId) {
177
+ toast.dismissById(toastId);
178
+ activeToastIdsRef.current.delete(reminderId);
182
179
  }
183
- }, 60 * 1000); // vérif toutes les minutes
180
+ setNotifications((prev) => prev.filter((n) => n.id !== reminderId));
181
+ };
184
182
 
185
- return () => clearInterval(interval);
186
- }, [tasks, session]);
183
+ globalThis.addEventListener('reminders:read', handleReminderRead as EventListener);
184
+ return () => {
185
+ globalThis.removeEventListener('reminders:read', handleReminderRead as EventListener);
186
+ };
187
+ }, [toast]);
187
188
 
188
189
  const dismissNotification = (id: string) => {
190
+ const toastId = activeToastIdsRef.current.get(id);
191
+ if (toastId) {
192
+ toast.dismissById(toastId);
193
+ activeToastIdsRef.current.delete(id);
194
+ }
189
195
  setNotifications((prev) => prev.filter((n) => n.id !== id));
196
+ void markReminderState(id, 'DISMISSED');
190
197
  };
191
198
 
192
- return (
193
- <TaskReminderContext.Provider value={{ notifications, dismissNotification }}>
194
- {children}
195
- {notifications.length > 0 && (
196
- <div className="pointer-events-none fixed right-4 bottom-4 z-50 space-y-3">
197
- {notifications.map((notif) => (
198
- <div
199
- key={notif.id}
200
- className="pointer-events-auto flex max-w-sm items-start gap-3 rounded-xl border border-blue-200 bg-card p-4 shadow-(--shadow-dropdown)"
201
- >
202
- <div className="mt-0.5 rounded-full bg-blue-100 p-2 text-blue-700">
203
- <Bell className="h-4 w-4" />
204
- </div>
205
- <div className="flex-1">
206
- <p className="text-sm font-medium text-foreground">Rappel de tâche</p>
207
- <p className="mt-1 text-sm text-muted-foreground">{notif.message}</p>
208
- {notif.link && (
209
- <Link
210
- href={notif.link}
211
- className="mt-2 inline-flex text-xs font-medium text-blue-700 hover:text-blue-800"
212
- >
213
- Ouvrir le contact
214
- </Link>
215
- )}
216
- </div>
217
- <button
218
- type="button"
219
- onClick={() => dismissNotification(notif.id)}
220
- className="ml-2 inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
221
- >
222
- <span className="sr-only">Fermer</span>
223
- <X />
224
- </button>
225
- </div>
226
- ))}
227
- </div>
228
- )}
229
- </TaskReminderContext.Provider>
199
+ const contextValue = useMemo(
200
+ () => ({ notifications, dismissNotification }),
201
+ [notifications],
230
202
  );
203
+
204
+ return <TaskReminderContext.Provider value={contextValue}>{children}</TaskReminderContext.Provider>;
231
205
  }
232
206
 
233
207
  export function useTaskReminders() {
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import React, { createContext, useContext, useState } from 'react';
3
+ import React, { createContext, useContext, useState, useEffect } from 'react';
4
4
  import {
5
5
  safeLocalStorageGet,
6
6
  safeLocalStorageSet,
@@ -29,9 +29,35 @@ interface ViewAsContextType {
29
29
  const ViewAsContext = createContext<ViewAsContextType | undefined>(undefined);
30
30
 
31
31
  export function ViewAsProvider({ children }: { children: React.ReactNode }) {
32
- const [viewAsUser, setViewAsUserState] = useState<User | null>(() =>
33
- safeLocalStorageGet<User | null>('viewAsUser', null),
34
- );
32
+ const [viewAsUser, setViewAsUserState] = useState<User | null>(null);
33
+
34
+ // On mount, check if a viewAsUserId is stored and fetch the full user data
35
+ useEffect(() => {
36
+ const storedUserId = safeLocalStorageGet<string | null>(
37
+ 'viewAsUserId',
38
+ null,
39
+ );
40
+ if (storedUserId) {
41
+ fetch('/api/users/list')
42
+ .then((res) => (res.ok ? res.json() : Promise.reject()))
43
+ .then((users: User[]) => {
44
+ const found = users.find((u) => u.id === storedUserId);
45
+ if (found) {
46
+ setViewAsUserState({
47
+ ...found,
48
+ permissions: found.customRole?.permissions || [],
49
+ });
50
+ } else {
51
+ // User no longer exists or is inaccessible — clear stale ID
52
+ safeLocalStorageRemove('viewAsUserId');
53
+ }
54
+ })
55
+ .catch(() => {
56
+ // API error — clear stale ID to avoid broken state
57
+ safeLocalStorageRemove('viewAsUserId');
58
+ });
59
+ }
60
+ }, []);
35
61
 
36
62
  const setViewAsUser = (user: User | null) => {
37
63
  // Si l'utilisateur a un customRole, copier les permissions dans un champ direct pour faciliter l'accès
@@ -44,9 +70,10 @@ export function ViewAsProvider({ children }: { children: React.ReactNode }) {
44
70
 
45
71
  setViewAsUserState(userWithPermissions);
46
72
  if (userWithPermissions) {
47
- safeLocalStorageSet('viewAsUser', userWithPermissions);
73
+ // Only persist the user ID — never store permissions in localStorage
74
+ safeLocalStorageSet('viewAsUserId', userWithPermissions.id);
48
75
  } else {
49
- safeLocalStorageRemove('viewAsUser');
76
+ safeLocalStorageRemove('viewAsUserId');
50
77
  }
51
78
  };
52
79
 
@@ -7,7 +7,7 @@ const FOCUSABLE_SELECTOR =
7
7
 
8
8
  function getFocusables(container: HTMLElement): HTMLElement[] {
9
9
  return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
10
- (el) => !el.hasAttribute('disabled') && el.offsetParent !== null
10
+ (el) => !el.hasAttribute('disabled') && el.offsetParent !== null,
11
11
  );
12
12
  }
13
13
 
@@ -18,7 +18,7 @@ export function useFocusTrap(
18
18
  onClose?: () => void;
19
19
  initialFocusRef?: React.RefObject<HTMLElement | null>;
20
20
  skipInitialFocus?: boolean;
21
- }
21
+ },
22
22
  ) {
23
23
  const prevFocusRef = useRef<HTMLElement | null>(null);
24
24
  const onCloseRef = useRef(options?.onClose);
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import { useUserRole } from '@/hooks/use-user-role';
5
+
6
+ const POLL_INTERVAL_MS = 30_000;
7
+
8
+ /**
9
+ * Polls integration import notifications for admins (e.g. background imports).
10
+ * Toasts have been removed to avoid accumulation on the page.
11
+ */
12
+ export function useIntegrationNotifications() {
13
+ const { isAdmin } = useUserRole();
14
+ const lastPollRef = useRef<string>(new Date().toISOString());
15
+ const mountedRef = useRef(true);
16
+
17
+ useEffect(() => {
18
+ mountedRef.current = true;
19
+ return () => {
20
+ mountedRef.current = false;
21
+ };
22
+ }, []);
23
+
24
+ useEffect(() => {
25
+ if (!isAdmin) return;
26
+
27
+ const poll = async () => {
28
+ const since = lastPollRef.current;
29
+ try {
30
+ const res = await fetch(
31
+ `/api/settings/integrations/notifications?since=${encodeURIComponent(since)}`,
32
+ );
33
+ if (!mountedRef.current) return;
34
+ await res.json();
35
+ lastPollRef.current = new Date().toISOString();
36
+ } catch {
37
+ // Ignore errors (e.g. network), next poll will retry
38
+ }
39
+ };
40
+
41
+ const interval = setInterval(poll, POLL_INTERVAL_MS);
42
+ const timeout = setTimeout(poll, 5000);
43
+
44
+ return () => {
45
+ clearInterval(interval);
46
+ clearTimeout(timeout);
47
+ };
48
+ }, [isAdmin]);
49
+ }
@@ -12,9 +12,16 @@ export const auth = betterAuth({
12
12
  database: prismaAdapter(prisma, {
13
13
  provider: 'postgresql',
14
14
  }),
15
+ // Désactivé pour que la révocation des sessions (deleteMany) soit effective immédiatement.
16
+ // Avec le cache cookie JWE par défaut, get-session peut ignorer la base tant que le cookie est valide.
17
+ session: {
18
+ cookieCache: {
19
+ enabled: false,
20
+ },
21
+ },
15
22
  emailAndPassword: {
16
23
  enabled: true,
17
- minPasswordLength: 6,
24
+ minPasswordLength: 12,
18
25
  password: {
19
26
  hash(password) {
20
27
  return hashPassword(password);
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Liens vers les sections de configuration dans les paramètres.
3
+ * Utilisés pour rediriger l'utilisateur quand une action nécessite une config manquante.
4
+ */
5
+ export const CONFIG_LINKS = {
6
+ /** Configuration SMTP (Paramètres Système) */
7
+ smtp: '/settings?section=system',
8
+ /** Google Calendar & Meet (Intégrations) */
9
+ googleCalendar: '/settings?section=integrations',
10
+ /** Google Sheets (Intégrations) */
11
+ googleSheet: '/settings?section=integrations',
12
+ } as const;
13
+
14
+ export type ConfigType = keyof typeof CONFIG_LINKS;
@@ -1,5 +1,83 @@
1
1
  import { prisma } from '@/lib/prisma';
2
2
 
3
+ async function getOrCreateDuplicateStatus(db: any) {
4
+ let duplicateStatus = await db.status.findUnique({
5
+ where: { name: 'Doublon' },
6
+ });
7
+
8
+ if (duplicateStatus && !duplicateStatus.isSystem) {
9
+ duplicateStatus = await db.status.update({
10
+ where: { id: duplicateStatus.id },
11
+ data: { isSystem: true },
12
+ });
13
+ }
14
+
15
+ if (!duplicateStatus) {
16
+ const lastStatus = await db.status.findFirst({
17
+ orderBy: { order: 'desc' },
18
+ });
19
+ const newOrder = lastStatus ? lastStatus.order + 1 : 100;
20
+
21
+ duplicateStatus = await db.status.create({
22
+ data: {
23
+ name: 'Doublon',
24
+ color: '#EF4444',
25
+ order: newOrder,
26
+ isSystem: true,
27
+ },
28
+ });
29
+ }
30
+
31
+ return duplicateStatus;
32
+ }
33
+
34
+ export async function markContactAsDuplicate(
35
+ contactId: string,
36
+ origin: string | null | undefined,
37
+ userId: string,
38
+ db: any = prisma,
39
+ ): Promise<void> {
40
+ const duplicateStatus = await getOrCreateDuplicateStatus(db);
41
+
42
+ const duplicateCount = await db.interaction.count({
43
+ where: {
44
+ contactId,
45
+ type: 'NOTE',
46
+ title: 'Contact enregistré à nouveau',
47
+ },
48
+ });
49
+
50
+ const occurrenceNumber = duplicateCount + 2; // +2 car c'est la 2ème fois minimum
51
+
52
+ await db.contact.update({
53
+ where: { id: contactId },
54
+ data: {
55
+ statusId: duplicateStatus.id,
56
+ updatedAt: new Date(),
57
+ },
58
+ });
59
+
60
+ await db.interaction.create({
61
+ data: {
62
+ contactId,
63
+ type: 'NOTE',
64
+ title: 'Contact enregistré à nouveau',
65
+ content: `Ce contact a été enregistré une ${occurrenceNumber}${occurrenceNumber === 1 ? 'ère' : 'ème'} fois${origin ? ` depuis ${origin}` : ''} le ${new Date().toLocaleDateString(
66
+ 'fr-FR',
67
+ {
68
+ day: 'numeric',
69
+ month: 'long',
70
+ year: 'numeric',
71
+ hour: '2-digit',
72
+ minute: '2-digit',
73
+ },
74
+ )}.`,
75
+ userId: userId,
76
+ date: new Date(),
77
+ },
78
+ });
79
+ }
80
+
3
81
  /**
4
82
  * Détecte et gère les doublons de contacts basés sur nom, prénom ET email
5
83
  * Si un doublon est trouvé :
@@ -46,67 +124,7 @@ export async function handleContactDuplicate(
46
124
  return null;
47
125
  }
48
126
 
49
- // Récupérer ou créer le statut "Doublon"
50
- let duplicateStatus = await prisma.status.findUnique({
51
- where: { name: 'Doublon' },
52
- });
53
-
54
- if (!duplicateStatus) {
55
- // Créer le statut Doublon s'il n'existe pas
56
- const lastStatus = await prisma.status.findFirst({
57
- orderBy: { order: 'desc' },
58
- });
59
- const newOrder = lastStatus ? lastStatus.order + 1 : 100;
60
-
61
- duplicateStatus = await prisma.status.create({
62
- data: {
63
- name: 'Doublon',
64
- color: '#EF4444', // Rouge pour indiquer un problème
65
- order: newOrder,
66
- },
67
- });
68
- }
69
-
70
- // Compter combien de fois ce contact a été enregistré (en comptant les notes "Contact enregistré à nouveau")
71
- const duplicateCount = await prisma.interaction.count({
72
- where: {
73
- contactId: existingContact.id,
74
- type: 'NOTE',
75
- title: 'Contact enregistré à nouveau',
76
- },
77
- });
78
-
79
- const occurrenceNumber = duplicateCount + 2; // +2 car c'est la 2ème fois minimum (1ère création + cette fois)
80
-
81
- // Mettre à jour le contact : changer le statut en Doublon et mettre à jour updatedAt
82
- await prisma.contact.update({
83
- where: { id: existingContact.id },
84
- data: {
85
- statusId: duplicateStatus.id,
86
- updatedAt: new Date(), // Pour remonter le contact en haut du tableau
87
- },
88
- });
89
-
90
- // Ajouter une note indiquant que le contact a été enregistré une énième fois
91
- await prisma.interaction.create({
92
- data: {
93
- contactId: existingContact.id,
94
- type: 'NOTE',
95
- title: 'Contact enregistré à nouveau',
96
- content: `Ce contact a été enregistré une ${occurrenceNumber}${occurrenceNumber === 1 ? 'ère' : 'ème'} fois${origin ? ` depuis ${origin}` : ''} le ${new Date().toLocaleDateString(
97
- 'fr-FR',
98
- {
99
- day: 'numeric',
100
- month: 'long',
101
- year: 'numeric',
102
- hour: '2-digit',
103
- minute: '2-digit',
104
- },
105
- )}.`,
106
- userId: userId,
107
- date: new Date(),
108
- },
109
- });
127
+ await markContactAsDuplicate(existingContact.id, origin, userId, prisma);
110
128
 
111
129
  return existingContact.id;
112
130
  }