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,4 +1,5 @@
1
1
  import { prisma } from '@/lib/prisma';
2
+ import { formatFrenchAppointmentDateTime } from '@/lib/date-utils';
2
3
  import { InteractionType } from '../../generated/prisma/client';
3
4
 
4
5
  interface CreateInteractionParams {
@@ -30,6 +31,23 @@ export async function createInteraction(params: CreateInteractionParams) {
30
31
  });
31
32
  }
32
33
 
34
+ /**
35
+ * Supprime les interactions liées à une tâche (activité « Tâche », rendez-vous créé/modifié).
36
+ * Ne supprime pas APPOINTMENT_DELETED (trace d'annulation si créée après).
37
+ */
38
+ export async function deleteInteractionsLinkedToTask(contactId: string, taskId: string) {
39
+ return prisma.interaction.deleteMany({
40
+ where: {
41
+ contactId,
42
+ type: { in: ['TASK', 'APPOINTMENT_CREATED', 'APPOINTMENT_CHANGED'] },
43
+ metadata: {
44
+ path: ['taskId'],
45
+ equals: taskId,
46
+ },
47
+ },
48
+ });
49
+ }
50
+
33
51
  /**
34
52
  * Crée une interaction pour un changement de statut
35
53
  */
@@ -181,13 +199,7 @@ export async function logAppointmentCreated(
181
199
  title: string | null,
182
200
  userId: string,
183
201
  ) {
184
- const formattedDate = scheduledAt.toLocaleDateString('fr-FR', {
185
- day: 'numeric',
186
- month: 'long',
187
- year: 'numeric',
188
- hour: '2-digit',
189
- minute: '2-digit',
190
- });
202
+ const formattedDate = formatFrenchAppointmentDateTime(scheduledAt);
191
203
 
192
204
  return await createInteraction({
193
205
  contactId,
@@ -214,13 +226,7 @@ export async function logAppointmentCancelled(
214
226
  userId: string,
215
227
  isGoogleMeet: boolean = false,
216
228
  ) {
217
- const formattedDate = scheduledAt.toLocaleDateString('fr-FR', {
218
- day: 'numeric',
219
- month: 'long',
220
- year: 'numeric',
221
- hour: '2-digit',
222
- minute: '2-digit',
223
- });
229
+ const formattedDate = formatFrenchAppointmentDateTime(scheduledAt);
224
230
 
225
231
  const appointmentType = isGoogleMeet ? 'Google Meet' : 'Rendez-vous';
226
232
 
@@ -251,13 +257,7 @@ export async function logAppointmentChanged(
251
257
  userId: string,
252
258
  isGoogleMeet: boolean = false,
253
259
  ) {
254
- const formattedDate = scheduledAt.toLocaleDateString('fr-FR', {
255
- day: 'numeric',
256
- month: 'long',
257
- year: 'numeric',
258
- hour: '2-digit',
259
- minute: '2-digit',
260
- });
260
+ const formattedDate = formatFrenchAppointmentDateTime(scheduledAt);
261
261
 
262
262
  const appointmentType = isGoogleMeet ? 'Google Meet' : 'Rendez-vous';
263
263
 
@@ -1,5 +1,8 @@
1
1
  import type { ViewFilter, DatePreset } from '@/types/contact-views';
2
- import { FRENCH_DEPARTMENTS, type Department } from '@/lib/french-regions';
2
+ import {
3
+ expandRegionCodesToDepartmentCodes,
4
+ prismaPostalMatchesDepartmentsCondition,
5
+ } from '@/lib/fr-geography';
3
6
 
4
7
  function startOfDay(date: Date): Date {
5
8
  const d = new Date(date);
@@ -146,76 +149,33 @@ export function resolveDatePreset(preset: DatePreset): { gte?: Date; lte?: Date
146
149
  }
147
150
  }
148
151
 
149
- const departmentsByRegion = new Map<string, Department[]>();
150
- for (const dept of FRENCH_DEPARTMENTS) {
151
- const list = departmentsByRegion.get(dept.regionCode) ?? [];
152
- list.push(dept);
153
- departmentsByRegion.set(dept.regionCode, list);
154
- }
155
-
156
- function buildPostalCodeConditions(deptCodes: string[], negate: boolean): any | null {
157
- if (deptCodes.length === 0) return null;
158
-
159
- const startsWithConditions: any[] = [];
160
- const corsicaCodes: string[] = [];
152
+ function buildFieldCondition(filter: ViewFilter): any | null {
153
+ const { field, operator, value, preset } = filter;
161
154
 
162
- for (const code of deptCodes) {
163
- if (code === '2A' || code === '2B') {
164
- corsicaCodes.push(code);
165
- } else {
166
- startsWithConditions.push({ postalCode: { startsWith: code } });
155
+ if (field === 'department') {
156
+ if (!Array.isArray(value) || value.length === 0) return null;
157
+ if (operator === 'is_any_of') {
158
+ return prismaPostalMatchesDepartmentsCondition(value as string[]);
167
159
  }
168
- }
169
-
170
- if (corsicaCodes.length > 0) {
171
- if (corsicaCodes.includes('2A') && corsicaCodes.includes('2B')) {
172
- startsWithConditions.push({ postalCode: { startsWith: '20' } });
173
- } else if (corsicaCodes.includes('2A')) {
174
- startsWithConditions.push({
175
- AND: [{ postalCode: { startsWith: '20' } }, { postalCode: { lt: '20200' } }],
176
- });
177
- } else {
178
- startsWithConditions.push({
179
- AND: [{ postalCode: { startsWith: '20' } }, { postalCode: { gte: '20200' } }],
180
- });
160
+ if (operator === 'is_none_of') {
161
+ const inner = prismaPostalMatchesDepartmentsCondition(value as string[]);
162
+ return inner ? { NOT: inner } : null;
181
163
  }
182
- }
183
-
184
- if (startsWithConditions.length === 0) return null;
185
-
186
- const orCondition =
187
- startsWithConditions.length === 1 ? startsWithConditions[0] : { OR: startsWithConditions };
188
-
189
- return negate ? { NOT: orCondition } : orCondition;
190
- }
191
-
192
- function buildGeoCondition(filter: ViewFilter): any | null {
193
- const { field, operator, value } = filter;
194
- if (!Array.isArray(value) || value.length === 0) return null;
195
-
196
- if (field === 'department') {
197
- return buildPostalCodeConditions(value, operator === 'is_none_of');
164
+ return null;
198
165
  }
199
166
 
200
167
  if (field === 'region') {
201
- const deptCodes: string[] = [];
202
- for (const regionCode of value) {
203
- const depts = departmentsByRegion.get(regionCode);
204
- if (depts) {
205
- for (const d of depts) deptCodes.push(d.code);
206
- }
168
+ if (!Array.isArray(value) || value.length === 0) return null;
169
+ const deptCodes = expandRegionCodesToDepartmentCodes(value as string[]);
170
+ if (deptCodes.length === 0) return null;
171
+ if (operator === 'is_any_of') {
172
+ return prismaPostalMatchesDepartmentsCondition(deptCodes);
207
173
  }
208
- return buildPostalCodeConditions(deptCodes, operator === 'is_none_of');
209
- }
210
-
211
- return null;
212
- }
213
-
214
- function buildFieldCondition(filter: ViewFilter): any | null {
215
- const { field, operator, value, preset } = filter;
216
-
217
- if (field === 'region' || field === 'department') {
218
- return buildGeoCondition(filter);
174
+ if (operator === 'is_none_of') {
175
+ const inner = prismaPostalMatchesDepartmentsCondition(deptCodes);
176
+ return inner ? { NOT: inner } : null;
177
+ }
178
+ return null;
219
179
  }
220
180
 
221
181
  switch (operator) {
@@ -0,0 +1,190 @@
1
+ import type { ReadonlyURLSearchParams } from 'next/navigation';
2
+
3
+ const VALID_LIMITS = [25, 50, 100] as const;
4
+
5
+ /** Tri par défaut liste contacts : pas de paramètres d’URL (parse → createdAt desc). */
6
+ export const CONTACTS_LIST_DEFAULT_SORT_FIELD = 'createdAt';
7
+ export const CONTACTS_LIST_DEFAULT_SORT_ORDER = 'desc' as const;
8
+
9
+ export function isContactsListDefaultSort(
10
+ sortField: string,
11
+ sortOrder: 'asc' | 'desc',
12
+ ): boolean {
13
+ return (
14
+ sortField === CONTACTS_LIST_DEFAULT_SORT_FIELD &&
15
+ sortOrder === CONTACTS_LIST_DEFAULT_SORT_ORDER
16
+ );
17
+ }
18
+
19
+ /** Chevrons / filtre « tri actif » sur une colonne — masqué pour le tri implicite création ↓. */
20
+ export function contactsListColumnShowsActiveSort(
21
+ columnId: string,
22
+ sortField: string,
23
+ sortOrder: 'asc' | 'desc',
24
+ ): boolean {
25
+ if (sortField !== columnId) return false;
26
+ if (
27
+ columnId === CONTACTS_LIST_DEFAULT_SORT_FIELD &&
28
+ isContactsListDefaultSort(sortField, sortOrder)
29
+ ) {
30
+ return false;
31
+ }
32
+ return true;
33
+ }
34
+
35
+ export type ParsedContactsListUrl = {
36
+ viewEntity: 'contacts' | 'companies';
37
+ currentPage: number;
38
+ limit: number;
39
+ viewMode: 'table' | 'cards';
40
+ search: string;
41
+ statusIds: string[];
42
+ origins: string[];
43
+ assignedCommercialIds: string[];
44
+ assignedTeleproIds: string[];
45
+ createdAtStart: string;
46
+ createdAtEnd: string;
47
+ updatedAtStart: string;
48
+ updatedAtEnd: string;
49
+ regionCodes: string[];
50
+ departmentCodes: string[];
51
+ sortField: string;
52
+ sortOrder: 'asc' | 'desc';
53
+ };
54
+
55
+ function splitCsv(v: string | null): string[] {
56
+ if (!v) return [];
57
+ return v
58
+ .split(',')
59
+ .map((s) => s.trim())
60
+ .filter(Boolean);
61
+ }
62
+
63
+ /**
64
+ * Source de vérité côté client au premier rendu / après popstate : `window.location`
65
+ * peut être à jour avant `useSearchParams()` (retour arrière), sinon on retombe sur le hook.
66
+ */
67
+ export function getContactsListUrlSearchParams(
68
+ sp: URLSearchParams | ReadonlyURLSearchParams,
69
+ ): URLSearchParams {
70
+ if (typeof globalThis.window !== 'undefined') {
71
+ return new URLSearchParams(globalThis.window.location.search);
72
+ }
73
+ return new URLSearchParams(sp.toString());
74
+ }
75
+
76
+ export function readContactsListFromLocationOrParams(
77
+ sp: URLSearchParams | ReadonlyURLSearchParams,
78
+ ): ParsedContactsListUrl {
79
+ return parseContactsListFromSearchParams(getContactsListUrlSearchParams(sp));
80
+ }
81
+
82
+ export function parseContactsListFromSearchParams(
83
+ sp: URLSearchParams | ReadonlyURLSearchParams,
84
+ ): ParsedContactsListUrl {
85
+ const page = Number.parseInt(sp.get('page') || '1', 10);
86
+ const lim = Number.parseInt(sp.get('limit') || '25', 10);
87
+ return {
88
+ viewEntity: sp.get('entity') === 'companies' ? 'companies' : 'contacts',
89
+ currentPage: Number.isFinite(page) && page >= 1 ? page : 1,
90
+ limit: VALID_LIMITS.includes(lim as (typeof VALID_LIMITS)[number]) ? lim : 25,
91
+ viewMode: sp.get('view') === 'cards' ? 'cards' : 'table',
92
+ search: sp.get('search') || '',
93
+ statusIds: splitCsv(sp.get('statusIds')),
94
+ origins: splitCsv(sp.get('origins')),
95
+ assignedCommercialIds: splitCsv(sp.get('assignedCommercialIds')),
96
+ assignedTeleproIds: splitCsv(sp.get('assignedTeleproIds')),
97
+ createdAtStart: sp.get('createdAtStart') || '',
98
+ createdAtEnd: sp.get('createdAtEnd') || '',
99
+ updatedAtStart: sp.get('updatedAtStart') || '',
100
+ updatedAtEnd: sp.get('updatedAtEnd') || '',
101
+ regionCodes: splitCsv(sp.get('regionCodes')),
102
+ departmentCodes: splitCsv(sp.get('departmentCodes')),
103
+ sortField: sp.get('sortField') || CONTACTS_LIST_DEFAULT_SORT_FIELD,
104
+ sortOrder:
105
+ sp.get('sortOrder') === 'asc'
106
+ ? 'asc'
107
+ : sp.get('sortOrder') === 'desc'
108
+ ? 'desc'
109
+ : CONTACTS_LIST_DEFAULT_SORT_ORDER,
110
+ };
111
+ }
112
+
113
+ export type ContactsListUrlInput = {
114
+ viewEntity: 'contacts' | 'companies';
115
+ currentPage: number;
116
+ limit: number;
117
+ viewMode: 'table' | 'cards';
118
+ search: string;
119
+ statusFilter: Set<string>;
120
+ originFilter: Set<string>;
121
+ assignedCommercialFilter: Set<string>;
122
+ assignedTeleproFilter: Set<string>;
123
+ createdAtStart: string;
124
+ createdAtEnd: string;
125
+ updatedAtStart: string;
126
+ updatedAtEnd: string;
127
+ regionFilter: Set<string>;
128
+ departmentFilter: Set<string>;
129
+ sortField: string;
130
+ sortOrder: 'asc' | 'desc';
131
+ };
132
+
133
+ export function buildContactsListSearchParams(input: ContactsListUrlInput): URLSearchParams {
134
+ const p = new URLSearchParams();
135
+ if (input.viewEntity === 'companies') {
136
+ p.set('entity', 'companies');
137
+ }
138
+ p.set('page', String(input.currentPage));
139
+ p.set('limit', String(input.limit));
140
+ p.set('view', input.viewMode);
141
+ if (input.search.trim()) {
142
+ p.set('search', input.search.trim());
143
+ }
144
+ if (input.statusFilter.size > 0) {
145
+ p.set('statusIds', [...input.statusFilter].join(','));
146
+ }
147
+ if (input.originFilter.size > 0) {
148
+ p.set('origins', [...input.originFilter].join(','));
149
+ }
150
+ if (input.assignedCommercialFilter.size > 0) {
151
+ p.set('assignedCommercialIds', [...input.assignedCommercialFilter].join(','));
152
+ }
153
+ if (input.assignedTeleproFilter.size > 0) {
154
+ p.set('assignedTeleproIds', [...input.assignedTeleproFilter].join(','));
155
+ }
156
+ if (input.createdAtStart) p.set('createdAtStart', input.createdAtStart);
157
+ if (input.createdAtEnd) p.set('createdAtEnd', input.createdAtEnd);
158
+ if (input.updatedAtStart) p.set('updatedAtStart', input.updatedAtStart);
159
+ if (input.updatedAtEnd) p.set('updatedAtEnd', input.updatedAtEnd);
160
+ if (input.regionFilter.size > 0) {
161
+ p.set('regionCodes', [...input.regionFilter].join(','));
162
+ }
163
+ if (input.departmentFilter.size > 0) {
164
+ p.set('departmentCodes', [...input.departmentFilter].join(','));
165
+ }
166
+ if (
167
+ input.sortField &&
168
+ !isContactsListDefaultSort(input.sortField, input.sortOrder)
169
+ ) {
170
+ p.set('sortField', input.sortField);
171
+ p.set('sortOrder', input.sortOrder);
172
+ }
173
+ return p;
174
+ }
175
+
176
+ /** Comparaison stable pour éviter replace / hydrate inutiles */
177
+ export function canonicalSearchParamsString(params: URLSearchParams): string {
178
+ return [...params.entries()]
179
+ .sort(([a], [b]) => a.localeCompare(b))
180
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
181
+ .join('&');
182
+ }
183
+
184
+ export function setsEqualToIds(prev: Set<string>, ids: string[]): boolean {
185
+ if (prev.size !== ids.length) return false;
186
+ for (const id of ids) {
187
+ if (!prev.has(id)) return false;
188
+ }
189
+ return true;
190
+ }
@@ -6,9 +6,9 @@ export interface DashboardStats {
6
6
  totalContacts: number;
7
7
  contactsThisMonth: number;
8
8
  contactsGrowth: number;
9
- statusData: Array<{ name: string; value: number }>;
10
- monthlyContacts: Array<{ month: string; count: number }>;
9
+ monthsData: Array<{ month: string; count: number }>;
11
10
  };
11
+ statusDistribution: Array<{ name: string; value: number }>;
12
12
  tasks: {
13
13
  total: number;
14
14
  completed: number;
@@ -24,12 +24,29 @@ export interface DashboardStats {
24
24
  byType: Array<{ type: string; count: number }>;
25
25
  };
26
26
  interactions: {
27
- recent: any[];
27
+ recent: Array<{
28
+ id: string;
29
+ type: string;
30
+ title: string | null;
31
+ content: string;
32
+ date: string;
33
+ contact: { id: string; name: string };
34
+ }>;
28
35
  byType: Array<{ type: string; count: number }>;
29
36
  };
30
37
  activity: {
31
38
  last7Days: Array<{ date: string; interactions: number; tasks: number }>;
32
39
  };
40
+ topContacts: Array<{
41
+ id: string;
42
+ name: string;
43
+ phone: string;
44
+ email: string | null;
45
+ status: string;
46
+ interactionsCount: number;
47
+ assignedCommercial?: string;
48
+ assignedTelepro?: string;
49
+ }>;
33
50
  }
34
51
 
35
52
  export async function getDashboardStats(
@@ -80,6 +97,7 @@ export async function getDashboardStats(
80
97
  contactsLast12Months,
81
98
  interactionsLast7Days,
82
99
  tasksLast7Days,
100
+ recentContacts,
83
101
  ] = await Promise.all([
84
102
  prisma.contact.count({ where: contactFilter }),
85
103
  prisma.contact.count({
@@ -114,7 +132,7 @@ export async function getDashboardStats(
114
132
  user: { select: { id: true, name: true } },
115
133
  },
116
134
  orderBy: { createdAt: 'desc' },
117
- take: 5,
135
+ take: 10,
118
136
  }),
119
137
  prisma.task.count({ where: { assignedUserId: userId } }),
120
138
  prisma.task.count({ where: { assignedUserId: userId, completed: true } }),
@@ -140,6 +158,22 @@ export async function getDashboardStats(
140
158
  where: { assignedUserId: userId, createdAt: { gte: sevenDaysAgo } },
141
159
  select: { createdAt: true },
142
160
  }),
161
+ prisma.contact.findMany({
162
+ where: contactFilter,
163
+ select: {
164
+ id: true,
165
+ firstName: true,
166
+ lastName: true,
167
+ phone: true,
168
+ email: true,
169
+ status: { select: { name: true } },
170
+ assignedCommercial: { select: { name: true } },
171
+ assignedTelepro: { select: { name: true } },
172
+ _count: { select: { interactions: true } },
173
+ },
174
+ orderBy: { createdAt: 'desc' },
175
+ take: 8,
176
+ }),
143
177
  ]);
144
178
 
145
179
  const contactsGrowth =
@@ -184,14 +218,37 @@ export async function getDashboardStats(
184
218
  });
185
219
  }
186
220
 
221
+ const formattedRecentInteractions = recentInteractions.map((i) => ({
222
+ id: i.id,
223
+ type: i.type,
224
+ title: null,
225
+ content: i.content,
226
+ date: i.createdAt.toISOString(),
227
+ contact: {
228
+ id: i.contact.id,
229
+ name: `${i.contact.firstName || ''} ${i.contact.lastName || ''}`.trim() || i.contact.phone,
230
+ },
231
+ }));
232
+
233
+ const topContacts = recentContacts.map((c) => ({
234
+ id: c.id,
235
+ name: `${c.firstName || ''} ${c.lastName || ''}`.trim() || c.phone,
236
+ phone: c.phone,
237
+ email: c.email,
238
+ status: c.status?.name || 'N/A',
239
+ interactionsCount: c._count.interactions,
240
+ assignedCommercial: c.assignedCommercial?.name,
241
+ assignedTelepro: c.assignedTelepro?.name,
242
+ }));
243
+
187
244
  return {
188
245
  overview: {
189
246
  totalContacts,
190
247
  contactsThisMonth,
191
248
  contactsGrowth: Math.round(contactsGrowth * 10) / 10,
192
- statusData,
193
- monthlyContacts: monthsData,
249
+ monthsData,
194
250
  },
251
+ statusDistribution: statusData,
195
252
  tasks: {
196
253
  total: totalTasks,
197
254
  completed: completedTasks,
@@ -214,11 +271,12 @@ export async function getDashboardStats(
214
271
  byType: tasksByType,
215
272
  },
216
273
  interactions: {
217
- recent: recentInteractions,
274
+ recent: formattedRecentInteractions,
218
275
  byType: interactionsByType,
219
276
  },
220
277
  activity: {
221
278
  last7Days,
222
279
  },
280
+ topContacts,
223
281
  };
224
282
  }
@@ -0,0 +1,135 @@
1
+ export interface DashboardTheme {
2
+ key: string;
3
+ label: string;
4
+ hex: {
5
+ 50: string;
6
+ 100: string;
7
+ 200: string;
8
+ 300: string;
9
+ 400: string;
10
+ 500: string;
11
+ 600: string;
12
+ 700: string;
13
+ };
14
+ }
15
+
16
+ export const DASHBOARD_THEMES: DashboardTheme[] = [
17
+ {
18
+ key: 'orange',
19
+ label: 'Orange',
20
+ hex: {
21
+ 50: '#fff7ed',
22
+ 100: '#ffedd5',
23
+ 200: '#fed7aa',
24
+ 300: '#fdba74',
25
+ 400: '#fb923c',
26
+ 500: '#f97316',
27
+ 600: '#ea580c',
28
+ 700: '#c2410c',
29
+ },
30
+ },
31
+ {
32
+ key: 'blue',
33
+ label: 'Bleu',
34
+ hex: {
35
+ 50: '#eff6ff',
36
+ 100: '#dbeafe',
37
+ 200: '#bfdbfe',
38
+ 300: '#93c5fd',
39
+ 400: '#60a5fa',
40
+ 500: '#3b82f6',
41
+ 600: '#2563eb',
42
+ 700: '#1d4ed8',
43
+ },
44
+ },
45
+ {
46
+ key: 'violet',
47
+ label: 'Violet',
48
+ hex: {
49
+ 50: '#f5f3ff',
50
+ 100: '#ede9fe',
51
+ 200: '#ddd6fe',
52
+ 300: '#c4b5fd',
53
+ 400: '#a78bfa',
54
+ 500: '#8b5cf6',
55
+ 600: '#7c3aed',
56
+ 700: '#6d28d9',
57
+ },
58
+ },
59
+ {
60
+ key: 'emerald',
61
+ label: 'Émeraude',
62
+ hex: {
63
+ 50: '#ecfdf5',
64
+ 100: '#d1fae5',
65
+ 200: '#a7f3d0',
66
+ 300: '#6ee7b7',
67
+ 400: '#34d399',
68
+ 500: '#10b981',
69
+ 600: '#059669',
70
+ 700: '#047857',
71
+ },
72
+ },
73
+ {
74
+ key: 'rose',
75
+ label: 'Rose',
76
+ hex: {
77
+ 50: '#fff1f2',
78
+ 100: '#ffe4e6',
79
+ 200: '#fecdd3',
80
+ 300: '#fda4af',
81
+ 400: '#fb7185',
82
+ 500: '#f43f5e',
83
+ 600: '#e11d48',
84
+ 700: '#be123c',
85
+ },
86
+ },
87
+ {
88
+ key: 'cyan',
89
+ label: 'Cyan',
90
+ hex: {
91
+ 50: '#ecfeff',
92
+ 100: '#cffafe',
93
+ 200: '#a5f3fc',
94
+ 300: '#67e8f9',
95
+ 400: '#22d3ee',
96
+ 500: '#06b6d4',
97
+ 600: '#0891b2',
98
+ 700: '#0e7490',
99
+ },
100
+ },
101
+ {
102
+ key: 'amber',
103
+ label: 'Ambre',
104
+ hex: {
105
+ 50: '#fffbeb',
106
+ 100: '#fef3c7',
107
+ 200: '#fde68a',
108
+ 300: '#fcd34d',
109
+ 400: '#fbbf24',
110
+ 500: '#f59e0b',
111
+ 600: '#d97706',
112
+ 700: '#b45309',
113
+ },
114
+ },
115
+ {
116
+ key: 'indigo',
117
+ label: 'Indigo',
118
+ hex: {
119
+ 50: '#eef2ff',
120
+ 100: '#e0e7ff',
121
+ 200: '#c7d2fe',
122
+ 300: '#a5b4fc',
123
+ 400: '#818cf8',
124
+ 500: '#6366f1',
125
+ 600: '#4f46e5',
126
+ 700: '#4338ca',
127
+ },
128
+ },
129
+ ];
130
+
131
+ export const DEFAULT_THEME_KEY = 'orange';
132
+
133
+ export function getThemeByKey(key: string): DashboardTheme {
134
+ return DASHBOARD_THEMES.find((t) => t.key === key) || DASHBOARD_THEMES[0];
135
+ }