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.
- package/bin/create-crm-tmp.js +56 -35
- package/package.json +1 -1
- package/template/README.md +230 -115
- package/template/eslint.config.mjs +13 -0
- package/template/next.config.ts +14 -0
- package/template/package.json +15 -2
- package/template/prisma/migrations/20260318095700_init_db/migration.sql +978 -0
- package/template/prisma/migrations/migration_lock.toml +3 -0
- package/template/prisma/schema.prisma +132 -637
- package/template/src/app/(auth)/invite/[token]/page.tsx +10 -8
- package/template/src/app/(auth)/layout.tsx +1 -1
- package/template/src/app/(auth)/reset-password/complete/page.tsx +11 -8
- package/template/src/app/(auth)/reset-password/page.tsx +4 -4
- package/template/src/app/(auth)/reset-password/verify/page.tsx +4 -4
- package/template/src/app/(auth)/signin/page.tsx +14 -6
- package/template/src/app/(dashboard)/agenda/page.tsx +2243 -988
- package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +18 -104
- package/template/src/app/(dashboard)/automatisation/page.tsx +10 -26
- package/template/src/app/(dashboard)/closing/page.tsx +78 -62
- package/template/src/app/(dashboard)/contacts/[id]/page.tsx +2082 -1080
- package/template/src/app/(dashboard)/contacts/companies/[id]/page.tsx +46 -47
- package/template/src/app/(dashboard)/contacts/page.tsx +1062 -780
- package/template/src/app/(dashboard)/dashboard/page.tsx +533 -37
- package/template/src/app/(dashboard)/dev/page.tsx +1291 -0
- package/template/src/app/(dashboard)/layout.tsx +6 -2
- package/template/src/app/(dashboard)/settings/page.tsx +797 -2582
- package/template/src/app/(dashboard)/templates/page.tsx +55 -54
- package/template/src/app/(dashboard)/users/list/page.tsx +51 -48
- package/template/src/app/(dashboard)/users/page.tsx +1 -1
- package/template/src/app/(dashboard)/users/permissions/page.tsx +2 -2
- package/template/src/app/(dashboard)/users/roles/page.tsx +7 -5
- package/template/src/app/api/agenda/google-events/route.ts +92 -0
- package/template/src/app/api/auth/check-active/route.ts +3 -2
- package/template/src/app/api/auth/google/route.ts +2 -1
- package/template/src/app/api/auth/google/status/route.ts +7 -31
- package/template/src/app/api/companies/[id]/activities/route.ts +1 -3
- package/template/src/app/api/companies/[id]/route.ts +1 -2
- package/template/src/app/api/companies/route.ts +42 -12
- package/template/src/app/api/contacts/[id]/files/[fileId]/preview/route.ts +9 -31
- package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +14 -32
- package/template/src/app/api/contacts/[id]/files/route.ts +112 -212
- package/template/src/app/api/contacts/[id]/interactions/[interactionId]/route.ts +27 -1
- package/template/src/app/api/contacts/[id]/interactions/route.ts +16 -16
- package/template/src/app/api/contacts/[id]/kyc/route.ts +21 -11
- package/template/src/app/api/contacts/[id]/meet/route.ts +19 -2
- package/template/src/app/api/contacts/[id]/route.ts +106 -34
- package/template/src/app/api/contacts/[id]/send-email/route.ts +27 -11
- package/template/src/app/api/contacts/[id]/workflows/run/route.ts +6 -0
- package/template/src/app/api/contacts/export/route.ts +9 -13
- package/template/src/app/api/contacts/import/route.ts +55 -25
- package/template/src/app/api/contacts/import-preview/route.ts +1 -1
- package/template/src/app/api/contacts/origins/route.ts +63 -0
- package/template/src/app/api/contacts/route.ts +153 -41
- package/template/src/app/api/cron/cleanup-editor-images/route.ts +166 -0
- package/template/src/app/api/dashboard/widgets/[id]/route.ts +44 -0
- package/template/src/app/api/dashboard/widgets/route.ts +181 -0
- package/template/src/app/api/dev/reminders/test/route.ts +114 -0
- package/template/src/app/api/editor/upload-image/route.ts +61 -0
- package/template/src/app/api/integrations/google-sheet/jobs/[jobId]/route.ts +47 -0
- package/template/src/app/api/integrations/google-sheet/jobs/usage/route.ts +50 -0
- package/template/src/app/api/integrations/google-sheet/sync/route.ts +24 -556
- package/template/src/app/api/jobs/google-sheet/process/route.ts +84 -0
- package/template/src/app/api/jobs/google-sheet/schedule/route.ts +50 -0
- package/template/src/app/api/reminders/clear/route.ts +120 -0
- package/template/src/app/api/reminders/clear/undo/route.ts +112 -0
- package/template/src/app/api/reminders/route.ts +164 -39
- package/template/src/app/api/reminders/state/route.ts +164 -0
- package/template/src/app/api/reset-password/request/route.ts +1 -1
- package/template/src/app/api/reset-password/verify/route.ts +1 -1
- package/template/src/app/api/send/route.ts +16 -4
- package/template/src/app/api/settings/google-ads/route.ts +14 -0
- package/template/src/app/api/settings/google-calendar/calendars/route.ts +97 -0
- package/template/src/app/api/settings/google-calendar/route.ts +124 -0
- package/template/src/app/api/settings/google-sheet/[id]/route.ts +28 -0
- package/template/src/app/api/settings/google-sheet/auto-map/route.ts +37 -4
- package/template/src/app/api/settings/google-sheet/preview/route.ts +9 -3
- package/template/src/app/api/settings/google-sheet/route.ts +14 -0
- package/template/src/app/api/settings/integrations/logs/route.ts +93 -0
- package/template/src/app/api/settings/integrations/notifications/route.ts +67 -0
- package/template/src/app/api/settings/meta-leads/[id]/route.ts +0 -1
- package/template/src/app/api/settings/meta-leads/route.ts +14 -2
- package/template/src/app/api/settings/smtp/route.ts +53 -6
- package/template/src/app/api/tasks/[id]/attendees/route.ts +24 -8
- package/template/src/app/api/tasks/[id]/route.ts +234 -58
- package/template/src/app/api/tasks/meet/route.ts +27 -19
- package/template/src/app/api/tasks/route.ts +62 -17
- package/template/src/app/api/users/[id]/route.ts +20 -14
- package/template/src/app/api/users/list/route.ts +57 -19
- package/template/src/app/api/webhooks/google-ads/route.ts +34 -14
- package/template/src/app/api/webhooks/meta-leads/route.ts +32 -12
- package/template/src/app/api/workflows/[id]/route.ts +0 -4
- package/template/src/app/api/workflows/process/route.ts +22 -51
- package/template/src/app/api/workflows/route.ts +0 -4
- package/template/src/app/globals.css +342 -4
- package/template/src/app/layout.tsx +11 -3
- package/template/src/app/page.tsx +1 -1
- package/template/src/components/address-autocomplete.tsx +7 -6
- package/template/src/components/config-error-alert.tsx +46 -0
- package/template/src/components/contacts/filter-bar.tsx +12 -3
- package/template/src/components/contacts/filter-builder.tsx +28 -43
- package/template/src/components/contacts/save-view-dialog.tsx +1 -1
- package/template/src/components/contacts/views-tab-bar.tsx +15 -6
- package/template/src/components/dashboard/activity-chart.tsx +41 -28
- package/template/src/components/dashboard/add-widget-dialog.tsx +157 -0
- package/template/src/components/dashboard/color-picker.tsx +64 -0
- package/template/src/components/dashboard/contacts-chart.tsx +69 -0
- package/template/src/components/dashboard/interactions-by-type-chart.tsx +121 -0
- package/template/src/components/dashboard/recent-activity.tsx +154 -0
- package/template/src/components/dashboard/stat-card.tsx +40 -40
- package/template/src/components/dashboard/status-distribution-chart.tsx +81 -0
- package/template/src/components/dashboard/tasks-pie-chart.tsx +37 -34
- package/template/src/components/dashboard/top-contacts-list.tsx +113 -0
- package/template/src/components/dashboard/upcoming-tasks-list.tsx +72 -81
- package/template/src/components/dashboard/widget-wrapper.tsx +36 -0
- package/template/src/components/date-picker.tsx +9 -6
- package/template/src/components/editor/upload-editor-image.ts +42 -0
- package/template/src/components/editor.tsx +161 -22
- package/template/src/components/email-template.tsx +2 -2
- package/template/src/components/global-search.tsx +30 -28
- package/template/src/components/header.tsx +178 -80
- package/template/src/components/inactive-account-guard.tsx +58 -0
- package/template/src/components/integration-notifications-listener.tsx +12 -0
- package/template/src/components/invitation-email-template.tsx +2 -2
- package/template/src/components/meet-cancellation-email-template.tsx +3 -3
- package/template/src/components/meet-confirmation-email-template.tsx +3 -3
- package/template/src/components/meet-update-email-template.tsx +3 -3
- package/template/src/components/page-header.tsx +5 -5
- package/template/src/components/protected-page.tsx +1 -1
- package/template/src/components/reset-password-email-template.tsx +2 -2
- package/template/src/components/settings/integrations/GoogleAdsIntegration.tsx +428 -0
- package/template/src/components/settings/integrations/GoogleSheetConfigMonitoringModal.tsx +680 -0
- package/template/src/components/settings/integrations/GoogleSheetIntegration.tsx +809 -0
- package/template/src/components/settings/integrations/ImportResultDialog.tsx +124 -0
- package/template/src/components/settings/integrations/IntegrationLogPanel.tsx +57 -0
- package/template/src/components/settings/integrations/IntegrationLogsTable.tsx +186 -0
- package/template/src/components/settings/integrations/MetaLeadIntegration.tsx +451 -0
- package/template/src/components/sidebar.tsx +45 -26
- package/template/src/components/skeleton.tsx +40 -43
- package/template/src/components/ui/accordion.tsx +2 -2
- package/template/src/components/ui/alert-dialog.tsx +1 -1
- package/template/src/components/ui/button.tsx +20 -9
- package/template/src/components/ui/components.tsx +1 -1
- package/template/src/components/ui/date-picker.tsx +422 -0
- package/template/src/components/ui/datetime-picker.tsx +338 -0
- package/template/src/components/ui/status-select.tsx +271 -0
- package/template/src/components/ui/tooltip.tsx +37 -0
- package/template/src/components/view-as-modal.tsx +13 -7
- package/template/src/contexts/app-toast-context.tsx +245 -57
- package/template/src/contexts/dashboard-theme-context.tsx +53 -0
- package/template/src/contexts/sidebar-context.tsx +22 -17
- package/template/src/contexts/task-reminder-context.tsx +134 -160
- package/template/src/contexts/view-as-context.tsx +33 -6
- package/template/src/hooks/use-focus-trap.ts +2 -2
- package/template/src/hooks/useIntegrationNotifications.ts +49 -0
- package/template/src/lib/auth.ts +8 -1
- package/template/src/lib/config-links.ts +14 -0
- package/template/src/lib/contact-duplicate.ts +79 -61
- package/template/src/lib/contact-interactions.ts +21 -21
- package/template/src/lib/contact-view-filters.ts +24 -64
- package/template/src/lib/contacts-list-url.ts +190 -0
- package/template/src/lib/dashboard-stats.ts +65 -7
- package/template/src/lib/dashboard-themes.ts +135 -0
- package/template/src/lib/date-utils.ts +127 -0
- package/template/src/lib/default-widgets.ts +12 -0
- package/template/src/lib/editor-html-image-dimensions.ts +172 -0
- package/template/src/lib/editor-image-limits.ts +19 -0
- package/template/src/lib/email-html-sanitize.ts +19 -0
- package/template/src/lib/encryption.ts +9 -6
- package/template/src/lib/fr-geography.ts +192 -0
- package/template/src/lib/google-calendar-agenda.ts +201 -0
- package/template/src/lib/google-calendar.ts +255 -5
- package/template/src/lib/google-sheet-sync-jobs.ts +96 -0
- package/template/src/lib/google-sheet-sync-runner.ts +514 -0
- package/template/src/lib/integration-import-log.ts +21 -0
- package/template/src/lib/permissions.ts +40 -10
- package/template/src/lib/prisma.ts +4 -1
- package/template/src/lib/qstash.ts +65 -0
- package/template/src/lib/reminder-state-server.ts +80 -0
- package/template/src/lib/reminder-state.ts +29 -0
- package/template/src/lib/supabase-storage.ts +113 -0
- package/template/src/lib/template-variables.ts +164 -23
- package/template/src/lib/utils.ts +45 -0
- package/template/src/lib/widget-registry.ts +173 -0
- package/template/src/lib/workflow-executor.ts +16 -70
- package/template/src/proxy.ts +1 -0
- package/template/vercel.json +3 -10
- package/template/skills-lock.json +0 -25
- package/template/src/components/dashboard/dashboard-content.tsx +0 -79
- package/template/src/lib/google-drive.ts +0 -1101
- 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 {
|
|
6
|
-
import
|
|
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
|
|
8
|
+
type Reminder = {
|
|
10
9
|
id: string;
|
|
11
|
-
|
|
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
|
-
|
|
17
|
-
reminderMinutesBefore
|
|
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<
|
|
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
|
|
51
|
-
const [
|
|
54
|
+
const toast = useAppToast();
|
|
55
|
+
const [reminders, setReminders] = useState<Reminder[]>([]);
|
|
52
56
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
|
53
|
-
const
|
|
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
|
|
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
|
|
75
|
+
const fetchReminders = async () => {
|
|
60
76
|
try {
|
|
61
|
-
const
|
|
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
|
-
|
|
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
|
|
83
|
+
console.error('Erreur lors du chargement des rappels:', error);
|
|
79
84
|
}
|
|
80
85
|
};
|
|
81
86
|
|
|
82
|
-
|
|
83
|
-
const interval = setInterval(
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
//
|
|
106
|
+
// Afficher/retirer les toasts selon la source reminders + états read/dismissed
|
|
88
107
|
useEffect(() => {
|
|
89
108
|
if (!session) return;
|
|
90
109
|
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
164
|
+
globalThis.addEventListener('reminders:cleared', handleRemindersCleared as EventListener);
|
|
165
|
+
return () => {
|
|
166
|
+
globalThis.removeEventListener('reminders:cleared', handleRemindersCleared as EventListener);
|
|
167
|
+
};
|
|
168
|
+
}, [toast]);
|
|
175
169
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
180
|
+
setNotifications((prev) => prev.filter((n) => n.id !== reminderId));
|
|
181
|
+
};
|
|
184
182
|
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
+
// Only persist the user ID — never store permissions in localStorage
|
|
74
|
+
safeLocalStorageSet('viewAsUserId', userWithPermissions.id);
|
|
48
75
|
} else {
|
|
49
|
-
safeLocalStorageRemove('
|
|
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
|
+
}
|
package/template/src/lib/auth.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
}
|