create-crm-tmp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/bin/create-crm-tmp.js +93 -0
  2. package/package.json +25 -0
  3. package/template/.prettierignore +33 -0
  4. package/template/.prettierrc.json +25 -0
  5. package/template/README.md +173 -0
  6. package/template/eslint.config.mjs +18 -0
  7. package/template/exemple-contacts.csv +11 -0
  8. package/template/next.config.ts +8 -0
  9. package/template/package.json +64 -0
  10. package/template/postcss.config.mjs +7 -0
  11. package/template/prisma/migrations/20251126144728_init/migration.sql +78 -0
  12. package/template/prisma/migrations/20251126155204_add_user_roles/migration.sql +5 -0
  13. package/template/prisma/migrations/20251128095126_add_company_info/migration.sql +19 -0
  14. package/template/prisma/migrations/20251128123321_add_smtp_config/migration.sql +22 -0
  15. package/template/prisma/migrations/20251128132303_add_status/migration.sql +23 -0
  16. package/template/prisma/migrations/20251201102207_add_user_active/migration.sql +75 -0
  17. package/template/prisma/migrations/20251201105507_add_email_signature/migration.sql +2 -0
  18. package/template/prisma/migrations/20251201151122_add_tasks/migration.sql +45 -0
  19. package/template/prisma/migrations/20251202111854_add_task_reminder/migration.sql +2 -0
  20. package/template/prisma/migrations/20251202135859_add_google_meet_integration/migration.sql +27 -0
  21. package/template/prisma/migrations/20251203103317_add_meta_lead_integration/migration.sql +20 -0
  22. package/template/prisma/migrations/20251203104002_add_google_ads_integration/migration.sql +18 -0
  23. package/template/prisma/migrations/20251203112122_add_google_sheet_integration/migration.sql +32 -0
  24. package/template/prisma/migrations/20251203153853_allow_multiple_integration_configs/migration.sql +20 -0
  25. package/template/prisma/migrations/20251205141705_update_user_roles/migration.sql +12 -0
  26. package/template/prisma/migrations/20251205150000_add_commercial_and_telepro_assignment/migration.sql +21 -0
  27. package/template/prisma/migrations/20251205160000_add_interaction_logging/migration.sql +11 -0
  28. package/template/prisma/migrations/20251208090314_add_automatic_interaction_types/migration.sql +12 -0
  29. package/template/prisma/migrations/20251208094843_mg/migration.sql +14 -0
  30. package/template/prisma/migrations/20251208100000_add_company_support/migration.sql +14 -0
  31. package/template/prisma/migrations/20251208110000_add_templates/migration.sql +26 -0
  32. package/template/prisma/migrations/20251208141304_add_video_conference_task_type/migration.sql +2 -0
  33. package/template/prisma/migrations/20251209104759_add_internal_note_to_task/migration.sql +2 -0
  34. package/template/prisma/migrations/20251209134803_add_company_field/migration.sql +2 -0
  35. package/template/prisma/migrations/20251209150000_rename_company_to_company_name/migration.sql +3 -0
  36. package/template/prisma/migrations/20251209150016_add_email_tracking/migration.sql +21 -0
  37. package/template/prisma/migrations/20251209155908_add_notify_contact_to_task/migration.sql +2 -0
  38. package/template/prisma/migrations/20251210110019_add_appointment_types/migration.sql +10 -0
  39. package/template/prisma/migrations/20251210113928_add_contact_files/migration.sql +26 -0
  40. package/template/prisma/migrations/20251212132339_add_custom_roles/migration.sql +24 -0
  41. package/template/prisma/migrations/20251215104448_add_file_interaction_types/migration.sql +11 -0
  42. package/template/prisma/migrations/20251215145616_add_closing_reasons/migration.sql +12 -0
  43. package/template/prisma/migrations/20251216140850_add_log_users/migration.sql +25 -0
  44. package/template/prisma/migrations/20251216151000_rename_perdu_to_ferme/migration.sql +8 -0
  45. package/template/prisma/migrations/20251216162318_add_column_mappings_to_google_sheet/migration.sql +2 -0
  46. package/template/prisma/migrations/20251216185127_add_workflows/migration.sql +80 -0
  47. package/template/prisma/migrations/20251216192237_add_scheduled_workflow_actions/migration.sql +32 -0
  48. package/template/prisma/migrations/migration_lock.toml +3 -0
  49. package/template/prisma/schema.prisma +582 -0
  50. package/template/prisma.config.ts +14 -0
  51. package/template/src/app/(auth)/invite/[token]/page.tsx +200 -0
  52. package/template/src/app/(auth)/layout.tsx +3 -0
  53. package/template/src/app/(auth)/reset-password/complete/page.tsx +213 -0
  54. package/template/src/app/(auth)/reset-password/page.tsx +146 -0
  55. package/template/src/app/(auth)/reset-password/verify/page.tsx +183 -0
  56. package/template/src/app/(auth)/signin/page.tsx +166 -0
  57. package/template/src/app/(dashboard)/agenda/page.tsx +3051 -0
  58. package/template/src/app/(dashboard)/automatisation/[id]/page.tsx +24 -0
  59. package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +905 -0
  60. package/template/src/app/(dashboard)/automatisation/new/page.tsx +20 -0
  61. package/template/src/app/(dashboard)/automatisation/page.tsx +337 -0
  62. package/template/src/app/(dashboard)/closing/page.tsx +1052 -0
  63. package/template/src/app/(dashboard)/contacts/[id]/page.tsx +6028 -0
  64. package/template/src/app/(dashboard)/contacts/page.tsx +3713 -0
  65. package/template/src/app/(dashboard)/dashboard/page.tsx +186 -0
  66. package/template/src/app/(dashboard)/layout.tsx +30 -0
  67. package/template/src/app/(dashboard)/settings/page.tsx +4070 -0
  68. package/template/src/app/(dashboard)/templates/page.tsx +567 -0
  69. package/template/src/app/(dashboard)/users/list/page.tsx +507 -0
  70. package/template/src/app/(dashboard)/users/page.tsx +457 -0
  71. package/template/src/app/(dashboard)/users/permissions/page.tsx +181 -0
  72. package/template/src/app/(dashboard)/users/roles/page.tsx +434 -0
  73. package/template/src/app/api/audit-logs/route.ts +57 -0
  74. package/template/src/app/api/auth/[...all]/route.ts +4 -0
  75. package/template/src/app/api/auth/check-active/route.ts +31 -0
  76. package/template/src/app/api/auth/google/callback/route.ts +94 -0
  77. package/template/src/app/api/auth/google/disconnect/route.ts +32 -0
  78. package/template/src/app/api/auth/google/route.ts +34 -0
  79. package/template/src/app/api/auth/google/status/route.ts +32 -0
  80. package/template/src/app/api/closing-reasons/route.ts +27 -0
  81. package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +94 -0
  82. package/template/src/app/api/contacts/[id]/files/route.ts +269 -0
  83. package/template/src/app/api/contacts/[id]/interactions/[interactionId]/route.ts +91 -0
  84. package/template/src/app/api/contacts/[id]/interactions/route.ts +103 -0
  85. package/template/src/app/api/contacts/[id]/meet/route.ts +296 -0
  86. package/template/src/app/api/contacts/[id]/route.ts +322 -0
  87. package/template/src/app/api/contacts/[id]/send-email/route.ts +254 -0
  88. package/template/src/app/api/contacts/export/route.ts +270 -0
  89. package/template/src/app/api/contacts/import/route.ts +381 -0
  90. package/template/src/app/api/contacts/route.ts +283 -0
  91. package/template/src/app/api/dashboard/stats/route.ts +299 -0
  92. package/template/src/app/api/email/track/[id]/route.ts +68 -0
  93. package/template/src/app/api/integrations/google-sheet/sync/route.ts +526 -0
  94. package/template/src/app/api/invite/complete/route.ts +88 -0
  95. package/template/src/app/api/invite/validate/route.ts +55 -0
  96. package/template/src/app/api/reminders/route.ts +95 -0
  97. package/template/src/app/api/reset-password/complete/route.ts +73 -0
  98. package/template/src/app/api/reset-password/request/route.ts +84 -0
  99. package/template/src/app/api/reset-password/validate/route.ts +49 -0
  100. package/template/src/app/api/reset-password/verify/route.ts +74 -0
  101. package/template/src/app/api/roles/[id]/route.ts +183 -0
  102. package/template/src/app/api/roles/route.ts +140 -0
  103. package/template/src/app/api/send/route.ts +282 -0
  104. package/template/src/app/api/settings/change-password/route.ts +95 -0
  105. package/template/src/app/api/settings/closing-reasons/[id]/route.ts +84 -0
  106. package/template/src/app/api/settings/closing-reasons/route.ts +74 -0
  107. package/template/src/app/api/settings/company/route.ts +121 -0
  108. package/template/src/app/api/settings/google-ads/[id]/route.ts +117 -0
  109. package/template/src/app/api/settings/google-ads/route.ts +122 -0
  110. package/template/src/app/api/settings/google-sheet/[id]/route.ts +230 -0
  111. package/template/src/app/api/settings/google-sheet/auto-map/route.ts +196 -0
  112. package/template/src/app/api/settings/google-sheet/route.ts +254 -0
  113. package/template/src/app/api/settings/meta-leads/[id]/route.ts +123 -0
  114. package/template/src/app/api/settings/meta-leads/route.ts +132 -0
  115. package/template/src/app/api/settings/profile/route.ts +42 -0
  116. package/template/src/app/api/settings/smtp/route.ts +130 -0
  117. package/template/src/app/api/settings/smtp/test/route.ts +121 -0
  118. package/template/src/app/api/settings/statuses/[id]/route.ts +101 -0
  119. package/template/src/app/api/settings/statuses/route.ts +83 -0
  120. package/template/src/app/api/statuses/route.ts +25 -0
  121. package/template/src/app/api/tasks/[id]/attendees/route.ts +76 -0
  122. package/template/src/app/api/tasks/[id]/route.ts +728 -0
  123. package/template/src/app/api/tasks/meet/route.ts +240 -0
  124. package/template/src/app/api/tasks/route.ts +417 -0
  125. package/template/src/app/api/templates/[id]/route.ts +140 -0
  126. package/template/src/app/api/templates/route.ts +91 -0
  127. package/template/src/app/api/users/[id]/route.ts +168 -0
  128. package/template/src/app/api/users/list/route.ts +45 -0
  129. package/template/src/app/api/users/me/route.ts +48 -0
  130. package/template/src/app/api/users/route.ts +250 -0
  131. package/template/src/app/api/webhooks/google-ads/route.ts +208 -0
  132. package/template/src/app/api/webhooks/meta-leads/route.ts +258 -0
  133. package/template/src/app/api/workflows/[id]/route.ts +192 -0
  134. package/template/src/app/api/workflows/process/route.ts +293 -0
  135. package/template/src/app/api/workflows/route.ts +124 -0
  136. package/template/src/app/favicon.ico +0 -0
  137. package/template/src/app/globals.css +1416 -0
  138. package/template/src/app/layout.tsx +31 -0
  139. package/template/src/app/page.tsx +32 -0
  140. package/template/src/components/dashboard/activity-chart.tsx +67 -0
  141. package/template/src/components/dashboard/contacts-chart.tsx +63 -0
  142. package/template/src/components/dashboard/recent-activity.tsx +164 -0
  143. package/template/src/components/dashboard/sales-analytics-chart.tsx +81 -0
  144. package/template/src/components/dashboard/stat-card.tsx +61 -0
  145. package/template/src/components/dashboard/status-distribution-chart.tsx +45 -0
  146. package/template/src/components/dashboard/tasks-pie-chart.tsx +88 -0
  147. package/template/src/components/dashboard/top-contacts-list.tsx +129 -0
  148. package/template/src/components/dashboard/upcoming-tasks-list.tsx +126 -0
  149. package/template/src/components/editor.tsx +856 -0
  150. package/template/src/components/email-template.tsx +35 -0
  151. package/template/src/components/header.tsx +320 -0
  152. package/template/src/components/invitation-email-template.tsx +79 -0
  153. package/template/src/components/meet-cancellation-email-template.tsx +120 -0
  154. package/template/src/components/meet-confirmation-email-template.tsx +156 -0
  155. package/template/src/components/meet-update-email-template.tsx +209 -0
  156. package/template/src/components/page-header.tsx +61 -0
  157. package/template/src/components/reset-password-email-template.tsx +79 -0
  158. package/template/src/components/sidebar.tsx +294 -0
  159. package/template/src/components/skeleton.tsx +380 -0
  160. package/template/src/components/ui/commands.tsx +396 -0
  161. package/template/src/components/ui/components.tsx +150 -0
  162. package/template/src/components/ui/theme.tsx +5 -0
  163. package/template/src/components/view-as-banner.tsx +45 -0
  164. package/template/src/components/view-as-modal.tsx +186 -0
  165. package/template/src/contexts/mobile-menu-context.tsx +31 -0
  166. package/template/src/contexts/sidebar-context.tsx +107 -0
  167. package/template/src/contexts/task-reminder-context.tsx +239 -0
  168. package/template/src/contexts/view-as-context.tsx +84 -0
  169. package/template/src/hooks/use-user-role.ts +82 -0
  170. package/template/src/lib/audit-log.ts +45 -0
  171. package/template/src/lib/auth-client.ts +16 -0
  172. package/template/src/lib/auth.ts +35 -0
  173. package/template/src/lib/check-permission.ts +193 -0
  174. package/template/src/lib/contact-duplicate.ts +112 -0
  175. package/template/src/lib/contact-interactions.ts +371 -0
  176. package/template/src/lib/encryption.ts +99 -0
  177. package/template/src/lib/google-calendar.ts +300 -0
  178. package/template/src/lib/google-drive.ts +372 -0
  179. package/template/src/lib/permissions.ts +412 -0
  180. package/template/src/lib/prisma.ts +32 -0
  181. package/template/src/lib/roles.ts +120 -0
  182. package/template/src/lib/template-variables.ts +76 -0
  183. package/template/src/lib/utils.ts +46 -0
  184. package/template/src/lib/workflow-executor.ts +482 -0
  185. package/template/src/proxy.ts +91 -0
  186. package/template/tsconfig.json +34 -0
  187. package/template/vercel.json +8 -0
@@ -0,0 +1,186 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { useSession } from '@/lib/auth-client';
5
+ import { useViewAs } from '@/contexts/view-as-context';
6
+ import { X, Check, User as UserIcon } from 'lucide-react';
7
+ import { useRouter } from 'next/navigation';
8
+ import { cn } from '@/lib/utils';
9
+
10
+ interface User {
11
+ id: string;
12
+ name: string;
13
+ email: string;
14
+ customRole: {
15
+ id: string;
16
+ name: string;
17
+ permissions: string[];
18
+ } | null;
19
+ }
20
+
21
+ interface ViewAsModalProps {
22
+ isOpen: boolean;
23
+ onClose: () => void;
24
+ }
25
+
26
+ export function ViewAsModal({ isOpen, onClose }: ViewAsModalProps) {
27
+ const { data: session } = useSession();
28
+ const { viewAsUser, setViewAsUser, clearViewAsUser } = useViewAs();
29
+ const [users, setUsers] = useState<User[]>([]);
30
+ const [loading, setLoading] = useState(true);
31
+ const router = useRouter();
32
+
33
+ useEffect(() => {
34
+ if (isOpen) {
35
+ fetchUsers();
36
+ }
37
+ }, [isOpen]);
38
+
39
+ const fetchUsers = async () => {
40
+ try {
41
+ setLoading(true);
42
+ const response = await fetch('/api/users/list');
43
+ if (response.ok) {
44
+ const data = await response.json();
45
+ setUsers(data);
46
+ }
47
+ } catch (error) {
48
+ console.error('Erreur lors du chargement des utilisateurs:', error);
49
+ } finally {
50
+ setLoading(false);
51
+ }
52
+ };
53
+
54
+ const handleSelectUser = (user: User) => {
55
+ setViewAsUser(user);
56
+ onClose();
57
+ // Rafraîchir la page pour appliquer les changements
58
+ router.refresh();
59
+ };
60
+
61
+ const getInitials = (name: string) => {
62
+ return name
63
+ .split(' ')
64
+ .map((n) => n[0])
65
+ .join('')
66
+ .toUpperCase()
67
+ .slice(0, 2);
68
+ };
69
+
70
+ if (!isOpen) return null;
71
+
72
+ return (
73
+ <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">
74
+ <div className="w-full max-w-2xl rounded-lg bg-white shadow-xl">
75
+ {/* En-tête */}
76
+ <div className="flex items-center justify-between rounded-t-lg border-b border-gray-200 bg-indigo-600 px-6 py-4">
77
+ <div className="flex items-center gap-3 text-white">
78
+ <UserIcon className="h-6 w-6" />
79
+ <div>
80
+ <h2 className="text-xl font-bold">Changer de vue</h2>
81
+ <p className="text-sm text-white/90">
82
+ Voir l'application avec les permissions d'un profil
83
+ </p>
84
+ </div>
85
+ </div>
86
+ <button
87
+ onClick={onClose}
88
+ className="cursor-pointer rounded-lg p-2 text-white transition-colors hover:bg-white/20"
89
+ aria-label="Fermer"
90
+ >
91
+ <X className="h-5 w-5" />
92
+ </button>
93
+ </div>
94
+
95
+ {/* Contenu */}
96
+ <div className="max-h-[60vh] overflow-y-auto p-6">
97
+ {loading ? (
98
+ <div className="py-12 text-center">
99
+ <div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-indigo-600 border-t-transparent"></div>
100
+ </div>
101
+ ) : (
102
+ <div className="space-y-2">
103
+ {/* Ma vue */}
104
+ {session?.user && (
105
+ <button
106
+ onClick={() => {
107
+ clearViewAsUser();
108
+ onClose();
109
+ router.refresh();
110
+ }}
111
+ className={cn(
112
+ 'w-full cursor-pointer rounded-lg border-2 p-4 text-left transition-all',
113
+ !viewAsUser
114
+ ? 'border-indigo-500 bg-indigo-50'
115
+ : 'border-gray-200 bg-white hover:border-indigo-300 hover:bg-indigo-50/50',
116
+ )}
117
+ >
118
+ <div className="flex items-center justify-between">
119
+ <div className="flex items-center gap-4">
120
+ <div
121
+ className={cn(
122
+ 'flex h-12 w-12 items-center justify-center rounded-full text-lg font-bold',
123
+ !viewAsUser
124
+ ? 'bg-indigo-600 text-white'
125
+ : 'bg-indigo-100 text-indigo-600',
126
+ )}
127
+ >
128
+ {getInitials(session.user.name || session.user.email)}
129
+ </div>
130
+ <div>
131
+ <div className="flex items-center gap-2">
132
+ <span className="font-semibold text-gray-900">Ma vue</span>
133
+ {!viewAsUser && <span className="text-sm text-indigo-600">← Retour</span>}
134
+ </div>
135
+ <span className="text-sm text-gray-600">{session.user.name}</span>
136
+ </div>
137
+ </div>
138
+ {!viewAsUser && <Check className="h-6 w-6 text-indigo-600" />}
139
+ </div>
140
+ </button>
141
+ )}
142
+
143
+ {/* Autres utilisateurs */}
144
+ {users
145
+ .filter((user) => user.id !== session?.user?.id)
146
+ .map((user) => (
147
+ <button
148
+ key={user.id}
149
+ onClick={() => handleSelectUser(user)}
150
+ className={cn(
151
+ 'w-full cursor-pointer rounded-lg border-2 p-4 text-left transition-all',
152
+ viewAsUser?.id === user.id
153
+ ? 'border-indigo-500 bg-indigo-50'
154
+ : 'border-gray-200 bg-white hover:border-indigo-300 hover:bg-indigo-50/50',
155
+ )}
156
+ >
157
+ <div className="flex items-center justify-between">
158
+ <div className="flex items-center gap-4">
159
+ <div
160
+ className={cn(
161
+ 'flex h-12 w-12 items-center justify-center rounded-full text-sm font-bold',
162
+ viewAsUser?.id === user.id
163
+ ? 'bg-indigo-600 text-white'
164
+ : 'bg-gray-200 text-gray-600',
165
+ )}
166
+ >
167
+ {getInitials(user.name || user.email)}
168
+ </div>
169
+ <div>
170
+ <div className="font-semibold text-gray-900">{user.name}</div>
171
+ <div className="text-sm text-gray-600">
172
+ {user.customRole?.name || 'Sans profil'} · {user.email.split('@')[0]}
173
+ </div>
174
+ </div>
175
+ </div>
176
+ {viewAsUser?.id === user.id && <Check className="h-6 w-6 text-indigo-600" />}
177
+ </div>
178
+ </button>
179
+ ))}
180
+ </div>
181
+ )}
182
+ </div>
183
+ </div>
184
+ </div>
185
+ );
186
+ }
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useState, ReactNode } from 'react';
4
+
5
+ interface MobileMenuContextType {
6
+ isOpen: boolean;
7
+ setIsOpen: (open: boolean) => void;
8
+ toggle: () => void;
9
+ }
10
+
11
+ const MobileMenuContext = createContext<MobileMenuContextType | undefined>(undefined);
12
+
13
+ export function MobileMenuProvider({ children }: { children: ReactNode }) {
14
+ const [isOpen, setIsOpen] = useState(false);
15
+
16
+ const toggle = () => setIsOpen(!isOpen);
17
+
18
+ return (
19
+ <MobileMenuContext.Provider value={{ isOpen, setIsOpen, toggle }}>
20
+ {children}
21
+ </MobileMenuContext.Provider>
22
+ );
23
+ }
24
+
25
+ export function useMobileMenuContext() {
26
+ const context = useContext(MobileMenuContext);
27
+ if (context === undefined) {
28
+ throw new Error('useMobileMenuContext must be used within a MobileMenuProvider');
29
+ }
30
+ return context;
31
+ }
@@ -0,0 +1,107 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
4
+
5
+ interface SidebarContextType {
6
+ isCollapsed: boolean;
7
+ isPinned: boolean;
8
+ setIsCollapsed: (collapsed: boolean) => void;
9
+ setIsPinned: (pinned: boolean) => void;
10
+ togglePin: () => void;
11
+ }
12
+
13
+ const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
14
+
15
+ export function SidebarProvider({ children }: { children: ReactNode }) {
16
+ // Éviter tout accès à window/localStorage pendant le rendu SSR
17
+ // Valeurs par défaut stables côté serveur et lors de la toute première
18
+ // hydratation côté client. Les vraies valeurs sont appliquées ensuite via useEffect.
19
+ const [isPinned, setIsPinnedState] = useState(false);
20
+ // Par défaut on considère la sidebar comme réduite (desktop),
21
+ // puis on ajuste après le montage en fonction de la taille d'écran et de la préférence.
22
+ const [isCollapsed, setIsCollapsedState] = useState(true);
23
+
24
+ // Lecture initiale de la préférence et de la taille d'écran après montage
25
+ useEffect(() => {
26
+ if (typeof window === 'undefined') return;
27
+
28
+ const saved = window.localStorage.getItem('sidebar-pinned');
29
+ const initialPinned = saved === 'true';
30
+ setIsPinnedState(initialPinned);
31
+
32
+ if (window.innerWidth < 1024) {
33
+ // En mobile, jamais de collapse
34
+ setIsCollapsedState(false);
35
+ } else {
36
+ // En desktop, utiliser la préférence
37
+ setIsCollapsedState(!initialPinned);
38
+ }
39
+ }, []);
40
+
41
+ // Sauvegarder la préférence dans localStorage et adapter le collapse
42
+ useEffect(() => {
43
+ if (typeof window === 'undefined') return;
44
+
45
+ window.localStorage.setItem('sidebar-pinned', String(isPinned));
46
+
47
+ // Ne réduire la sidebar qu'en desktop (>= 1024px)
48
+ if (window.innerWidth >= 1024) {
49
+ setIsCollapsedState(!isPinned);
50
+ } else {
51
+ // En mobile, toujours false (pas de collapse)
52
+ setIsCollapsedState(false);
53
+ }
54
+ }, [isPinned]);
55
+
56
+ // Écouter les changements de taille d'écran
57
+ useEffect(() => {
58
+ if (typeof window === 'undefined') return;
59
+
60
+ const handleResize = () => {
61
+ // En mobile, toujours false (pas de collapse)
62
+ if (window.innerWidth < 1024) {
63
+ setIsCollapsedState(false);
64
+ } else {
65
+ // En desktop, utiliser la préférence isPinned
66
+ setIsCollapsedState(!isPinned);
67
+ }
68
+ };
69
+
70
+ window.addEventListener('resize', handleResize);
71
+ return () => window.removeEventListener('resize', handleResize);
72
+ }, [isPinned]);
73
+
74
+ const setIsPinned = (pinned: boolean) => {
75
+ setIsPinnedState(pinned);
76
+ };
77
+
78
+ const setIsCollapsed = (collapsed: boolean) => {
79
+ setIsCollapsedState(collapsed);
80
+ };
81
+
82
+ const togglePin = () => {
83
+ setIsPinnedState(!isPinned);
84
+ };
85
+
86
+ return (
87
+ <SidebarContext.Provider
88
+ value={{
89
+ isCollapsed,
90
+ isPinned,
91
+ setIsCollapsed,
92
+ setIsPinned,
93
+ togglePin,
94
+ }}
95
+ >
96
+ {children}
97
+ </SidebarContext.Provider>
98
+ );
99
+ }
100
+
101
+ export function useSidebarContext() {
102
+ const context = useContext(SidebarContext);
103
+ if (context === undefined) {
104
+ throw new Error('useSidebarContext must be used within a SidebarProvider');
105
+ }
106
+ return context;
107
+ }
@@ -0,0 +1,239 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
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';
8
+
9
+ type Task = {
10
+ id: string;
11
+ type: 'CALL' | 'MEETING' | 'EMAIL' | 'OTHER';
12
+ title: string | null;
13
+ description: string;
14
+ priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
15
+ scheduledAt: string;
16
+ completed: boolean;
17
+ reminderMinutesBefore?: number | null;
18
+ contact: {
19
+ id: string;
20
+ firstName: string | null;
21
+ lastName: string | null;
22
+ } | null;
23
+ };
24
+
25
+ type Notification = { id: string; message: string; link?: string };
26
+
27
+ type TaskReminderContextValue = {
28
+ notifications: Notification[];
29
+ dismissNotification: (id: string) => void;
30
+ };
31
+
32
+ const TaskReminderContext = createContext<TaskReminderContextValue | undefined>(undefined);
33
+
34
+ const TASK_TYPE_LABELS: Record<Task['type'], string> = {
35
+ CALL: 'Appel téléphonique',
36
+ MEETING: 'RDV',
37
+ EMAIL: 'Email',
38
+ OTHER: 'Autre',
39
+ };
40
+
41
+ function formatTime(dateString: string) {
42
+ return new Date(dateString).toLocaleTimeString('fr-FR', {
43
+ hour: '2-digit',
44
+ minute: '2-digit',
45
+ });
46
+ }
47
+
48
+ export function TaskReminderProvider({ children }: { children: React.ReactNode }) {
49
+ const { data: session } = useSession();
50
+ const { isAdmin } = useUserRole();
51
+ const [tasks, setTasks] = useState<Task[]>([]);
52
+ const [notifications, setNotifications] = useState<Notification[]>([]);
53
+ const notifiedKeysRef = useRef<Set<string>>(new Set());
54
+
55
+ // Charger les tâches pertinentes pour les rappels
56
+ useEffect(() => {
57
+ if (!session) return;
58
+
59
+ const fetchTasks = async () => {
60
+ 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()}`);
73
+ if (response.ok) {
74
+ const data = await response.json();
75
+ setTasks(data);
76
+ }
77
+ } catch (error) {
78
+ console.error('Erreur lors du chargement des tâches pour les rappels:', error);
79
+ }
80
+ };
81
+
82
+ fetchTasks();
83
+ const interval = setInterval(fetchTasks, 5 * 60 * 1000); // rafraîchir toutes les 5 min
84
+ return () => clearInterval(interval);
85
+ }, [session, isAdmin]);
86
+
87
+ // Supprimer les notifications des tâches qui n'existent plus
88
+ useEffect(() => {
89
+ if (!session) return;
90
+
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`);
114
+ });
115
+ notifiedKeysRef.current = new Set(
116
+ Array.from(notifiedKeysRef.current).filter((key) => validKeys.has(key)),
117
+ );
118
+ }, [tasks, session]);
119
+
120
+ // Génération des notifications
121
+ useEffect(() => {
122
+ if (!session) return;
123
+
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
+ }
146
+
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
+ }
170
+ });
171
+
172
+ if (newNotifications.length > 0) {
173
+ setNotifications((prev) => [...prev, ...newNotifications]);
174
+ notifiedKeysRef.current = notified;
175
+
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
+ });
182
+ }
183
+ }, 60 * 1000); // vérif toutes les minutes
184
+
185
+ return () => clearInterval(interval);
186
+ }, [tasks, session]);
187
+
188
+ const dismissNotification = (id: string) => {
189
+ setNotifications((prev) => prev.filter((n) => n.id !== id));
190
+ };
191
+
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-indigo-100 bg-white p-4 shadow-xl"
201
+ >
202
+ <div className="mt-0.5 rounded-full bg-indigo-50 p-2 text-indigo-600">
203
+ <Bell className="h-4 w-4" />
204
+ </div>
205
+ <div className="flex-1">
206
+ <p className="text-sm font-medium text-gray-900">Rappel de tâche</p>
207
+ <p className="mt-1 text-sm text-gray-700">{notif.message}</p>
208
+ {notif.link && (
209
+ <Link
210
+ href={notif.link}
211
+ className="mt-2 inline-flex text-xs font-medium text-indigo-600 hover:text-indigo-700"
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-gray-400 hover:bg-gray-100 hover:text-gray-600"
221
+ >
222
+ <span className="sr-only">Fermer</span>
223
+ <X />
224
+ </button>
225
+ </div>
226
+ ))}
227
+ </div>
228
+ )}
229
+ </TaskReminderContext.Provider>
230
+ );
231
+ }
232
+
233
+ export function useTaskReminders() {
234
+ const ctx = useContext(TaskReminderContext);
235
+ if (!ctx) {
236
+ throw new Error('useTaskReminders doit être utilisé dans un TaskReminderProvider');
237
+ }
238
+ return ctx;
239
+ }
@@ -0,0 +1,84 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useContext, useState, useEffect } from 'react';
4
+
5
+ interface User {
6
+ id: string;
7
+ name: string;
8
+ email: string;
9
+ customRole: {
10
+ id: string;
11
+ name: string;
12
+ permissions: string[];
13
+ } | null;
14
+ permissions?: string[]; // Alias pour faciliter l'accès
15
+ }
16
+
17
+ interface ViewAsContextType {
18
+ viewAsUser: User | null;
19
+ setViewAsUser: (user: User | null) => void;
20
+ isViewingAsOther: boolean;
21
+ clearViewAsUser: () => void;
22
+ }
23
+
24
+ const ViewAsContext = createContext<ViewAsContextType | undefined>(undefined);
25
+
26
+ export function ViewAsProvider({ children }: { children: React.ReactNode }) {
27
+ const [viewAsUser, setViewAsUserState] = useState<User | null>(null);
28
+
29
+ // Charger depuis le localStorage au démarrage
30
+ useEffect(() => {
31
+ const stored = localStorage.getItem('viewAsUser');
32
+ if (stored) {
33
+ try {
34
+ setViewAsUserState(JSON.parse(stored));
35
+ } catch (e) {
36
+ localStorage.removeItem('viewAsUser');
37
+ }
38
+ }
39
+ }, []);
40
+
41
+ const setViewAsUser = (user: User | null) => {
42
+ // Si l'utilisateur a un customRole, copier les permissions dans un champ direct pour faciliter l'accès
43
+ const userWithPermissions = user
44
+ ? {
45
+ ...user,
46
+ permissions: user.customRole?.permissions || [],
47
+ }
48
+ : null;
49
+
50
+ setViewAsUserState(userWithPermissions);
51
+ if (userWithPermissions) {
52
+ localStorage.setItem('viewAsUser', JSON.stringify(userWithPermissions));
53
+ } else {
54
+ localStorage.removeItem('viewAsUser');
55
+ }
56
+ };
57
+
58
+ const clearViewAsUser = () => {
59
+ setViewAsUser(null);
60
+ };
61
+
62
+ const isViewingAsOther = viewAsUser !== null;
63
+
64
+ return (
65
+ <ViewAsContext.Provider
66
+ value={{
67
+ viewAsUser,
68
+ setViewAsUser,
69
+ isViewingAsOther,
70
+ clearViewAsUser,
71
+ }}
72
+ >
73
+ {children}
74
+ </ViewAsContext.Provider>
75
+ );
76
+ }
77
+
78
+ export function useViewAs() {
79
+ const context = useContext(ViewAsContext);
80
+ if (context === undefined) {
81
+ throw new Error('useViewAs must be used within a ViewAsProvider');
82
+ }
83
+ return context;
84
+ }