create-crm-tmp 1.1.3 → 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 (276) hide show
  1. package/bin/create-crm-tmp.js +56 -35
  2. package/package.json +1 -1
  3. package/template/.prettierignore +2 -0
  4. package/template/README.md +230 -115
  5. package/template/components.json +22 -0
  6. package/template/eslint.config.mjs +13 -0
  7. package/template/exemple-contacts.csv +54 -0
  8. package/template/next.config.ts +41 -1
  9. package/template/package.json +63 -15
  10. package/template/prisma/migrations/20260318095700_init_db/migration.sql +978 -0
  11. package/template/prisma/schema.prisma +311 -67
  12. package/template/src/app/(auth)/invite/[token]/page.tsx +28 -29
  13. package/template/src/app/(auth)/layout.tsx +1 -1
  14. package/template/src/app/(auth)/reset-password/complete/page.tsx +21 -27
  15. package/template/src/app/(auth)/reset-password/page.tsx +14 -10
  16. package/template/src/app/(auth)/reset-password/verify/page.tsx +14 -10
  17. package/template/src/app/(auth)/signin/page.tsx +34 -23
  18. package/template/src/app/(dashboard)/agenda/page.tsx +3655 -2357
  19. package/template/src/app/(dashboard)/automatisation/[id]/page.tsx +10 -7
  20. package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +609 -338
  21. package/template/src/app/(dashboard)/automatisation/new/page.tsx +11 -8
  22. package/template/src/app/(dashboard)/automatisation/page.tsx +463 -186
  23. package/template/src/app/(dashboard)/closing/page.tsx +517 -469
  24. package/template/src/app/(dashboard)/contacts/[id]/page.tsx +6151 -4210
  25. package/template/src/app/(dashboard)/contacts/companies/[id]/page.tsx +1702 -0
  26. package/template/src/app/(dashboard)/contacts/loading.tsx +13 -0
  27. package/template/src/app/(dashboard)/contacts/page.tsx +4124 -2130
  28. package/template/src/app/(dashboard)/dashboard/page.tsx +119 -105
  29. package/template/src/app/(dashboard)/dev/page.tsx +1291 -0
  30. package/template/src/app/(dashboard)/error.tsx +37 -0
  31. package/template/src/app/(dashboard)/layout.tsx +6 -2
  32. package/template/src/app/(dashboard)/loading.tsx +5 -0
  33. package/template/src/app/(dashboard)/settings/loading.tsx +19 -0
  34. package/template/src/app/(dashboard)/settings/page.tsx +1773 -3362
  35. package/template/src/app/(dashboard)/templates/page.tsx +504 -303
  36. package/template/src/app/(dashboard)/users/list/page.tsx +364 -355
  37. package/template/src/app/(dashboard)/users/page.tsx +279 -310
  38. package/template/src/app/(dashboard)/users/permissions/page.tsx +104 -99
  39. package/template/src/app/(dashboard)/users/roles/page.tsx +169 -140
  40. package/template/src/app/api/agenda/google-events/route.ts +92 -0
  41. package/template/src/app/api/audit-logs/route.ts +1 -1
  42. package/template/src/app/api/auth/check-active/route.ts +3 -2
  43. package/template/src/app/api/auth/google/callback/route.ts +8 -5
  44. package/template/src/app/api/auth/google/disconnect/route.ts +2 -2
  45. package/template/src/app/api/auth/google/route.ts +2 -1
  46. package/template/src/app/api/auth/google/status/route.ts +7 -31
  47. package/template/src/app/api/companies/[id]/activities/route.ts +129 -0
  48. package/template/src/app/api/companies/[id]/route.ts +194 -0
  49. package/template/src/app/api/companies/export/route.ts +206 -0
  50. package/template/src/app/api/companies/route.ts +196 -0
  51. package/template/src/app/api/contact-views/[id]/pin/route.ts +69 -0
  52. package/template/src/app/api/contact-views/[id]/route.ts +197 -0
  53. package/template/src/app/api/contact-views/route.ts +146 -0
  54. package/template/src/app/api/contacts/[id]/files/[fileId]/preview/route.ts +55 -0
  55. package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +20 -48
  56. package/template/src/app/api/contacts/[id]/files/route.ts +125 -186
  57. package/template/src/app/api/contacts/[id]/interactions/[interactionId]/route.ts +27 -1
  58. package/template/src/app/api/contacts/[id]/interactions/route.ts +45 -8
  59. package/template/src/app/api/contacts/[id]/kyc/route.ts +81 -0
  60. package/template/src/app/api/contacts/[id]/meet/route.ts +55 -29
  61. package/template/src/app/api/contacts/[id]/route.ts +184 -21
  62. package/template/src/app/api/contacts/[id]/send-email/route.ts +33 -11
  63. package/template/src/app/api/contacts/[id]/workflows/run/route.ts +67 -0
  64. package/template/src/app/api/contacts/export/route.ts +22 -31
  65. package/template/src/app/api/contacts/import/route.ts +77 -44
  66. package/template/src/app/api/contacts/import-preview/route.ts +139 -0
  67. package/template/src/app/api/contacts/origins/route.ts +63 -0
  68. package/template/src/app/api/contacts/route.ts +322 -57
  69. package/template/src/app/api/cron/cleanup-editor-images/route.ts +166 -0
  70. package/template/src/app/api/dashboard/stats/route.ts +9 -292
  71. package/template/src/app/api/dashboard/widgets/[id]/route.ts +0 -3
  72. package/template/src/app/api/dashboard/widgets/route.ts +19 -19
  73. package/template/src/app/api/dev/reminders/test/route.ts +114 -0
  74. package/template/src/app/api/editor/upload-image/route.ts +61 -0
  75. package/template/src/app/api/integrations/google-sheet/jobs/[jobId]/route.ts +47 -0
  76. package/template/src/app/api/integrations/google-sheet/jobs/usage/route.ts +50 -0
  77. package/template/src/app/api/integrations/google-sheet/sync/route.ts +28 -542
  78. package/template/src/app/api/invite/complete/route.ts +20 -23
  79. package/template/src/app/api/jobs/google-sheet/process/route.ts +84 -0
  80. package/template/src/app/api/jobs/google-sheet/schedule/route.ts +50 -0
  81. package/template/src/app/api/reminders/clear/route.ts +120 -0
  82. package/template/src/app/api/reminders/clear/undo/route.ts +112 -0
  83. package/template/src/app/api/reminders/route.ts +165 -39
  84. package/template/src/app/api/reminders/state/route.ts +164 -0
  85. package/template/src/app/api/reset-password/complete/route.ts +11 -13
  86. package/template/src/app/api/reset-password/request/route.ts +1 -1
  87. package/template/src/app/api/reset-password/verify/route.ts +1 -1
  88. package/template/src/app/api/send/route.ts +25 -47
  89. package/template/src/app/api/settings/closing-reasons/[id]/route.ts +10 -21
  90. package/template/src/app/api/settings/closing-reasons/route.ts +10 -21
  91. package/template/src/app/api/settings/company/route.ts +19 -26
  92. package/template/src/app/api/settings/google-ads/[id]/route.ts +20 -23
  93. package/template/src/app/api/settings/google-ads/route.ts +34 -23
  94. package/template/src/app/api/settings/google-calendar/calendars/route.ts +97 -0
  95. package/template/src/app/api/settings/google-calendar/route.ts +124 -0
  96. package/template/src/app/api/settings/google-sheet/[id]/route.ts +48 -23
  97. package/template/src/app/api/settings/google-sheet/auto-map/route.ts +56 -32
  98. package/template/src/app/api/settings/google-sheet/preview/route.ts +110 -0
  99. package/template/src/app/api/settings/google-sheet/route.ts +34 -23
  100. package/template/src/app/api/settings/integrations/logs/route.ts +93 -0
  101. package/template/src/app/api/settings/integrations/notifications/route.ts +67 -0
  102. package/template/src/app/api/settings/meta-leads/[id]/route.ts +20 -24
  103. package/template/src/app/api/settings/meta-leads/route.ts +34 -25
  104. package/template/src/app/api/settings/smtp/route.ts +53 -6
  105. package/template/src/app/api/settings/statuses/[id]/route.ts +29 -32
  106. package/template/src/app/api/settings/statuses/route.ts +24 -22
  107. package/template/src/app/api/statuses/route.ts +2 -5
  108. package/template/src/app/api/tasks/[id]/attendees/route.ts +36 -13
  109. package/template/src/app/api/tasks/[id]/route.ts +357 -145
  110. package/template/src/app/api/tasks/meet/route.ts +37 -26
  111. package/template/src/app/api/tasks/route.ts +201 -96
  112. package/template/src/app/api/templates/[id]/route.ts +22 -13
  113. package/template/src/app/api/templates/route.ts +22 -5
  114. package/template/src/app/api/users/[id]/resend-invite/route.ts +95 -0
  115. package/template/src/app/api/users/[id]/route.ts +22 -16
  116. package/template/src/app/api/users/commercials/route.ts +38 -0
  117. package/template/src/app/api/users/for-agenda/route.ts +1 -2
  118. package/template/src/app/api/users/list/route.ts +57 -19
  119. package/template/src/app/api/users/route.ts +89 -34
  120. package/template/src/app/api/webhooks/google-ads/route.ts +40 -1
  121. package/template/src/app/api/webhooks/meta-leads/route.ts +38 -1
  122. package/template/src/app/api/workflows/[id]/route.ts +29 -6
  123. package/template/src/app/api/workflows/process/route.ts +505 -170
  124. package/template/src/app/api/workflows/route.ts +42 -4
  125. package/template/src/app/globals.css +512 -32
  126. package/template/src/app/layout.tsx +28 -9
  127. package/template/src/app/page.tsx +37 -7
  128. package/template/src/components/address-autocomplete.tsx +233 -0
  129. package/template/src/components/config-error-alert.tsx +46 -0
  130. package/template/src/components/contacts/filter-bar.tsx +190 -0
  131. package/template/src/components/contacts/filter-builder.tsx +574 -0
  132. package/template/src/components/contacts/save-view-dialog.tsx +160 -0
  133. package/template/src/components/contacts/views-tab-bar.tsx +449 -0
  134. package/template/src/components/dashboard/activity-chart.tsx +6 -1
  135. package/template/src/components/dashboard/add-widget-dialog.tsx +13 -17
  136. package/template/src/components/dashboard/color-picker.tsx +7 -8
  137. package/template/src/components/dashboard/recent-activity.tsx +2 -5
  138. package/template/src/components/dashboard/stat-card.tsx +1 -3
  139. package/template/src/components/dashboard/status-distribution-chart.tsx +0 -1
  140. package/template/src/components/dashboard/top-contacts-list.tsx +7 -13
  141. package/template/src/components/dashboard/upcoming-tasks-list.tsx +2 -5
  142. package/template/src/components/dashboard/widget-wrapper.tsx +3 -6
  143. package/template/src/components/date-picker.tsx +399 -0
  144. package/template/src/components/editor/upload-editor-image.ts +42 -0
  145. package/template/src/components/editor.tsx +188 -35
  146. package/template/src/components/email-template.tsx +4 -2
  147. package/template/src/components/global-search.tsx +360 -0
  148. package/template/src/components/header.tsx +200 -107
  149. package/template/src/components/inactive-account-guard.tsx +58 -0
  150. package/template/src/components/integration-notifications-listener.tsx +12 -0
  151. package/template/src/components/invitation-email-template.tsx +4 -2
  152. package/template/src/components/lazy-editor.tsx +11 -0
  153. package/template/src/components/meet-cancellation-email-template.tsx +11 -3
  154. package/template/src/components/meet-confirmation-email-template.tsx +10 -3
  155. package/template/src/components/meet-update-email-template.tsx +10 -3
  156. package/template/src/components/page-header.tsx +19 -15
  157. package/template/src/components/protected-page.tsx +94 -0
  158. package/template/src/components/reset-password-email-template.tsx +4 -2
  159. package/template/src/components/settings/integrations/GoogleAdsIntegration.tsx +428 -0
  160. package/template/src/components/settings/integrations/GoogleSheetConfigMonitoringModal.tsx +680 -0
  161. package/template/src/components/settings/integrations/GoogleSheetIntegration.tsx +809 -0
  162. package/template/src/components/settings/integrations/ImportResultDialog.tsx +124 -0
  163. package/template/src/components/settings/integrations/IntegrationLogPanel.tsx +57 -0
  164. package/template/src/components/settings/integrations/IntegrationLogsTable.tsx +186 -0
  165. package/template/src/components/settings/integrations/MetaLeadIntegration.tsx +451 -0
  166. package/template/src/components/sidebar.tsx +117 -100
  167. package/template/src/components/skeleton.tsx +128 -45
  168. package/template/src/components/ui/accordion.tsx +64 -0
  169. package/template/src/components/ui/alert-dialog.tsx +139 -0
  170. package/template/src/components/ui/button.tsx +71 -0
  171. package/template/src/components/ui/components.tsx +1 -1
  172. package/template/src/components/ui/date-picker.tsx +422 -0
  173. package/template/src/components/ui/datetime-picker.tsx +338 -0
  174. package/template/src/components/ui/status-select.tsx +271 -0
  175. package/template/src/components/ui/tooltip.tsx +37 -0
  176. package/template/src/components/view-as-banner.tsx +1 -1
  177. package/template/src/components/view-as-modal.tsx +30 -19
  178. package/template/src/config/nav-pages.ts +108 -0
  179. package/template/src/contexts/app-toast-context.tsx +362 -0
  180. package/template/src/contexts/dashboard-theme-context.tsx +2 -7
  181. package/template/src/contexts/sidebar-context.tsx +27 -53
  182. package/template/src/contexts/task-reminder-context.tsx +134 -160
  183. package/template/src/contexts/view-as-context.tsx +32 -10
  184. package/template/src/hooks/use-alert.tsx +65 -0
  185. package/template/src/hooks/use-confirm.tsx +87 -0
  186. package/template/src/hooks/use-contact-views.ts +140 -0
  187. package/template/src/hooks/use-contacts.ts +69 -0
  188. package/template/src/hooks/use-fetch.ts +17 -0
  189. package/template/src/hooks/use-focus-trap.ts +73 -0
  190. package/template/src/hooks/use-statuses.ts +22 -0
  191. package/template/src/hooks/useIntegrationNotifications.ts +49 -0
  192. package/template/src/lib/address-api.ts +155 -0
  193. package/template/src/lib/auth.ts +8 -1
  194. package/template/src/lib/cache.ts +73 -0
  195. package/template/src/lib/check-permission.ts +12 -177
  196. package/template/src/lib/config-links.ts +14 -0
  197. package/template/src/lib/contact-duplicate.ts +79 -61
  198. package/template/src/lib/contact-interactions.ts +24 -22
  199. package/template/src/lib/contact-view-filters.ts +301 -0
  200. package/template/src/lib/contacts-list-url.ts +190 -0
  201. package/template/src/lib/dashboard-stats.ts +282 -0
  202. package/template/src/lib/dashboard-themes.ts +0 -5
  203. package/template/src/lib/date-utils.ts +176 -0
  204. package/template/src/lib/default-widgets.ts +0 -2
  205. package/template/src/lib/editor-html-image-dimensions.ts +172 -0
  206. package/template/src/lib/editor-image-limits.ts +19 -0
  207. package/template/src/lib/email-html-sanitize.ts +19 -0
  208. package/template/src/lib/encryption.ts +9 -6
  209. package/template/src/lib/fr-geography.ts +192 -0
  210. package/template/src/lib/get-auth-user.ts +25 -0
  211. package/template/src/lib/google-calendar-agenda.ts +201 -0
  212. package/template/src/lib/google-calendar.ts +309 -17
  213. package/template/src/lib/google-fetch.ts +63 -0
  214. package/template/src/lib/google-sheet-sync-jobs.ts +96 -0
  215. package/template/src/lib/google-sheet-sync-runner.ts +514 -0
  216. package/template/src/lib/integration-import-log.ts +21 -0
  217. package/template/src/lib/local-storage.ts +34 -0
  218. package/template/src/lib/permissions.ts +268 -40
  219. package/template/src/lib/prisma.ts +15 -12
  220. package/template/src/lib/qstash.ts +65 -0
  221. package/template/src/lib/reminder-state-server.ts +80 -0
  222. package/template/src/lib/reminder-state.ts +29 -0
  223. package/template/src/lib/roles.ts +12 -15
  224. package/template/src/lib/supabase-storage.ts +113 -0
  225. package/template/src/lib/template-variables.ts +204 -29
  226. package/template/src/lib/utils.ts +71 -11
  227. package/template/src/lib/widget-registry.ts +0 -4
  228. package/template/src/lib/workflow-executor.ts +391 -228
  229. package/template/src/proxy.ts +35 -73
  230. package/template/src/types/contact-views.ts +351 -0
  231. package/template/vercel.json +5 -0
  232. package/template/WORKFLOWS_CRON.md +0 -185
  233. package/template/prisma/migrations/20251126144728_init/migration.sql +0 -78
  234. package/template/prisma/migrations/20251126155204_add_user_roles/migration.sql +0 -5
  235. package/template/prisma/migrations/20251128095126_add_company_info/migration.sql +0 -19
  236. package/template/prisma/migrations/20251128123321_add_smtp_config/migration.sql +0 -22
  237. package/template/prisma/migrations/20251128132303_add_status/migration.sql +0 -23
  238. package/template/prisma/migrations/20251201102207_add_user_active/migration.sql +0 -75
  239. package/template/prisma/migrations/20251201105507_add_email_signature/migration.sql +0 -2
  240. package/template/prisma/migrations/20251201151122_add_tasks/migration.sql +0 -45
  241. package/template/prisma/migrations/20251202111854_add_task_reminder/migration.sql +0 -2
  242. package/template/prisma/migrations/20251202135859_add_google_meet_integration/migration.sql +0 -27
  243. package/template/prisma/migrations/20251203103317_add_meta_lead_integration/migration.sql +0 -20
  244. package/template/prisma/migrations/20251203104002_add_google_ads_integration/migration.sql +0 -18
  245. package/template/prisma/migrations/20251203112122_add_google_sheet_integration/migration.sql +0 -32
  246. package/template/prisma/migrations/20251203153853_allow_multiple_integration_configs/migration.sql +0 -20
  247. package/template/prisma/migrations/20251205141705_update_user_roles/migration.sql +0 -12
  248. package/template/prisma/migrations/20251205150000_add_commercial_and_telepro_assignment/migration.sql +0 -21
  249. package/template/prisma/migrations/20251205160000_add_interaction_logging/migration.sql +0 -11
  250. package/template/prisma/migrations/20251208090314_add_automatic_interaction_types/migration.sql +0 -12
  251. package/template/prisma/migrations/20251208094843_mg/migration.sql +0 -14
  252. package/template/prisma/migrations/20251208100000_add_company_support/migration.sql +0 -14
  253. package/template/prisma/migrations/20251208110000_add_templates/migration.sql +0 -26
  254. package/template/prisma/migrations/20251208141304_add_video_conference_task_type/migration.sql +0 -2
  255. package/template/prisma/migrations/20251209104759_add_internal_note_to_task/migration.sql +0 -2
  256. package/template/prisma/migrations/20251209134803_add_company_field/migration.sql +0 -2
  257. package/template/prisma/migrations/20251209150000_rename_company_to_company_name/migration.sql +0 -3
  258. package/template/prisma/migrations/20251209150016_add_email_tracking/migration.sql +0 -21
  259. package/template/prisma/migrations/20251209155908_add_notify_contact_to_task/migration.sql +0 -2
  260. package/template/prisma/migrations/20251210110019_add_appointment_types/migration.sql +0 -10
  261. package/template/prisma/migrations/20251210113928_add_contact_files/migration.sql +0 -26
  262. package/template/prisma/migrations/20251212132339_add_custom_roles/migration.sql +0 -24
  263. package/template/prisma/migrations/20251215104448_add_file_interaction_types/migration.sql +0 -11
  264. package/template/prisma/migrations/20251215145616_add_closing_reasons/migration.sql +0 -12
  265. package/template/prisma/migrations/20251216140850_add_log_users/migration.sql +0 -25
  266. package/template/prisma/migrations/20251216151000_rename_perdu_to_ferme/migration.sql +0 -8
  267. package/template/prisma/migrations/20251216162318_add_column_mappings_to_google_sheet/migration.sql +0 -2
  268. package/template/prisma/migrations/20251216185127_add_workflows/migration.sql +0 -80
  269. package/template/prisma/migrations/20251216192237_add_scheduled_workflow_actions/migration.sql +0 -32
  270. package/template/prisma/migrations/20251220000000_add_task_interaction_type/migration.sql +0 -4
  271. package/template/prisma/migrations/20251221000000_add_task_type/migration.sql +0 -3
  272. package/template/prisma/migrations/20251221000001_add_event_color/migration.sql +0 -23
  273. package/template/prisma/migrations/20260210114913_add_dashboard_widget/migration.sql +0 -20
  274. package/template/prisma/migrations/20260226093949_fix_cascade_on_user_delete/migration.sql +0 -69
  275. package/template/src/app/(dashboard)/users/layout.tsx +0 -30
  276. package/template/src/lib/google-drive.ts +0 -380
@@ -0,0 +1,282 @@
1
+ import { prisma } from '@/lib/prisma';
2
+ import { getCachedStatuses } from '@/lib/cache';
3
+
4
+ export interface DashboardStats {
5
+ overview: {
6
+ totalContacts: number;
7
+ contactsThisMonth: number;
8
+ contactsGrowth: number;
9
+ monthsData: Array<{ month: string; count: number }>;
10
+ };
11
+ statusDistribution: Array<{ name: string; value: number }>;
12
+ tasks: {
13
+ total: number;
14
+ completed: number;
15
+ pending: number;
16
+ upcoming: Array<{
17
+ id: string;
18
+ title: string;
19
+ type: string;
20
+ scheduledAt: string;
21
+ contact: { id: string; name: string } | null;
22
+ priority: string;
23
+ }>;
24
+ byType: Array<{ type: string; count: number }>;
25
+ };
26
+ interactions: {
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
+ }>;
35
+ byType: Array<{ type: string; count: number }>;
36
+ };
37
+ activity: {
38
+ last7Days: Array<{ date: string; interactions: number; tasks: number }>;
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
+ }>;
50
+ }
51
+
52
+ export async function getDashboardStats(
53
+ userId: string,
54
+ permissions: string[],
55
+ ): Promise<DashboardStats> {
56
+ const canViewAllContacts = permissions.includes('contacts.view_all');
57
+ const canViewOwnContacts = permissions.includes('contacts.view_own');
58
+ const canViewUnassigned = permissions.includes('contacts.view_unassigned');
59
+
60
+ let contactFilter: any = {};
61
+ if (!canViewAllContacts && canViewOwnContacts) {
62
+ const conditions: any[] = [
63
+ { assignedCommercialId: userId },
64
+ { assignedTeleproId: userId },
65
+ { createdById: userId },
66
+ ];
67
+ if (canViewUnassigned) {
68
+ conditions.push({ AND: [{ assignedCommercialId: null }, { assignedTeleproId: null }] });
69
+ }
70
+ contactFilter = { OR: conditions };
71
+ } else if (!canViewAllContacts && !canViewOwnContacts) {
72
+ contactFilter = { id: '__none__' };
73
+ }
74
+
75
+ const now = new Date();
76
+ const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
77
+ const lastMonthStart = new Date(startOfMonth);
78
+ lastMonthStart.setMonth(lastMonthStart.getMonth() - 1);
79
+
80
+ const twelveMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 11, 1);
81
+ const sevenDaysAgo = new Date(now);
82
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6);
83
+ sevenDaysAgo.setHours(0, 0, 0, 0);
84
+
85
+ const [
86
+ totalContacts,
87
+ contactsThisMonth,
88
+ contactsLastMonth,
89
+ statusDistribution,
90
+ statuses,
91
+ upcomingTasks,
92
+ recentInteractions,
93
+ totalTasks,
94
+ completedTasks,
95
+ tasksThisMonthByType,
96
+ interactionsThisMonth,
97
+ contactsLast12Months,
98
+ interactionsLast7Days,
99
+ tasksLast7Days,
100
+ recentContacts,
101
+ ] = await Promise.all([
102
+ prisma.contact.count({ where: contactFilter }),
103
+ prisma.contact.count({
104
+ where: { ...contactFilter, createdAt: { gte: startOfMonth } },
105
+ }),
106
+ prisma.contact.count({
107
+ where: { ...contactFilter, createdAt: { gte: lastMonthStart, lt: startOfMonth } },
108
+ }),
109
+ prisma.contact.groupBy({ by: ['statusId'], where: contactFilter, _count: true }),
110
+ getCachedStatuses(),
111
+ prisma.task.findMany({
112
+ where: { assignedUserId: userId, completed: false, scheduledAt: { gte: now } },
113
+ select: {
114
+ id: true,
115
+ title: true,
116
+ type: true,
117
+ scheduledAt: true,
118
+ priority: true,
119
+ contact: { select: { id: true, firstName: true, lastName: true, phone: true } },
120
+ },
121
+ orderBy: { scheduledAt: 'asc' },
122
+ take: 6,
123
+ }),
124
+ prisma.interaction.findMany({
125
+ where: { userId },
126
+ select: {
127
+ id: true,
128
+ type: true,
129
+ content: true,
130
+ createdAt: true,
131
+ contact: { select: { id: true, firstName: true, lastName: true, phone: true } },
132
+ user: { select: { id: true, name: true } },
133
+ },
134
+ orderBy: { createdAt: 'desc' },
135
+ take: 10,
136
+ }),
137
+ prisma.task.count({ where: { assignedUserId: userId } }),
138
+ prisma.task.count({ where: { assignedUserId: userId, completed: true } }),
139
+ prisma.task.groupBy({
140
+ by: ['type'],
141
+ where: { assignedUserId: userId, createdAt: { gte: startOfMonth } },
142
+ _count: true,
143
+ }),
144
+ prisma.interaction.groupBy({
145
+ by: ['type'],
146
+ where: { userId, createdAt: { gte: startOfMonth } },
147
+ _count: true,
148
+ }),
149
+ prisma.contact.findMany({
150
+ where: { ...contactFilter, createdAt: { gte: twelveMonthsAgo } },
151
+ select: { createdAt: true },
152
+ }),
153
+ prisma.interaction.findMany({
154
+ where: { userId, createdAt: { gte: sevenDaysAgo } },
155
+ select: { createdAt: true },
156
+ }),
157
+ prisma.task.findMany({
158
+ where: { assignedUserId: userId, createdAt: { gte: sevenDaysAgo } },
159
+ select: { createdAt: true },
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
+ }),
177
+ ]);
178
+
179
+ const contactsGrowth =
180
+ contactsLastMonth > 0 ? ((contactsThisMonth - contactsLastMonth) / contactsLastMonth) * 100 : 0;
181
+
182
+ const monthsData = [];
183
+ for (let i = 11; i >= 0; i--) {
184
+ const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
185
+ const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1);
186
+ const count = contactsLast12Months.filter(
187
+ (c) => c.createdAt >= monthStart && c.createdAt < monthEnd,
188
+ ).length;
189
+ monthsData.push({
190
+ month: monthStart.toLocaleString('fr-FR', { month: 'short' }),
191
+ count,
192
+ });
193
+ }
194
+
195
+ const statusData = statuses.map((status) => ({
196
+ name: status.name,
197
+ value: statusDistribution.find((s) => s.statusId === status.id)?._count || 0,
198
+ }));
199
+
200
+ const pendingTasks = totalTasks - completedTasks;
201
+ const tasksByType = tasksThisMonthByType.map((t) => ({ type: t.type, count: t._count }));
202
+ const interactionsByType = interactionsThisMonth.map((i) => ({ type: i.type, count: i._count }));
203
+
204
+ const last7Days = [];
205
+ for (let i = 6; i >= 0; i--) {
206
+ const dayStart = new Date(now);
207
+ dayStart.setDate(dayStart.getDate() - i);
208
+ dayStart.setHours(0, 0, 0, 0);
209
+ const dayEnd = new Date(dayStart);
210
+ dayEnd.setDate(dayEnd.getDate() + 1);
211
+
212
+ last7Days.push({
213
+ date: dayStart.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }),
214
+ interactions: interactionsLast7Days.filter(
215
+ (x) => x.createdAt >= dayStart && x.createdAt < dayEnd,
216
+ ).length,
217
+ tasks: tasksLast7Days.filter((x) => x.createdAt >= dayStart && x.createdAt < dayEnd).length,
218
+ });
219
+ }
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
+
244
+ return {
245
+ overview: {
246
+ totalContacts,
247
+ contactsThisMonth,
248
+ contactsGrowth: Math.round(contactsGrowth * 10) / 10,
249
+ monthsData,
250
+ },
251
+ statusDistribution: statusData,
252
+ tasks: {
253
+ total: totalTasks,
254
+ completed: completedTasks,
255
+ pending: pendingTasks,
256
+ upcoming: upcomingTasks.map((task) => ({
257
+ id: task.id,
258
+ title: task.title || 'Sans titre',
259
+ type: task.type,
260
+ scheduledAt: task.scheduledAt?.toISOString() ?? new Date().toISOString(),
261
+ contact: task.contact
262
+ ? {
263
+ id: task.contact.id,
264
+ name:
265
+ `${task.contact.firstName || ''} ${task.contact.lastName || ''}`.trim() ||
266
+ task.contact.phone,
267
+ }
268
+ : null,
269
+ priority: task.priority || 'MEDIUM',
270
+ })),
271
+ byType: tasksByType,
272
+ },
273
+ interactions: {
274
+ recent: formattedRecentInteractions,
275
+ byType: interactionsByType,
276
+ },
277
+ activity: {
278
+ last7Days,
279
+ },
280
+ topContacts,
281
+ };
282
+ }
@@ -1,8 +1,3 @@
1
- /**
2
- * Thèmes de couleur pour le tableau de bord
3
- * Chaque thème fournit des valeurs hex pour toutes les nuances nécessaires
4
- */
5
-
6
1
  export interface DashboardTheme {
7
2
  key: string;
8
3
  label: string;
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Calcule la semaine ISO 8601 pour une date donnée
3
+ * @param date - La date pour laquelle calculer la semaine ISO
4
+ * @returns Le numéro de la semaine ISO (1-53)
5
+ */
6
+ export function getISOWeek(date: Date): number {
7
+ const target = new Date(date.valueOf());
8
+ const dayNr = (date.getDay() + 6) % 7;
9
+ target.setDate(target.getDate() - dayNr + 3);
10
+ const firstThursday = target.valueOf();
11
+ target.setMonth(0, 1);
12
+ if (target.getDay() !== 4) {
13
+ target.setMonth(0, 1 + ((4 - target.getDay() + 7) % 7));
14
+ }
15
+ return 1 + Math.ceil((firstThursday - target.valueOf()) / 604800000);
16
+ }
17
+
18
+ /**
19
+ * Formate une durée en heures et minutes
20
+ * @param hours - Nombre d'heures (peut être décimal)
21
+ * @returns Une chaîne formatée (ex: "2h 30min")
22
+ */
23
+ export function formatDuration(hours: number): string {
24
+ const h = Math.floor(hours);
25
+ const m = Math.round((hours - h) * 60);
26
+ if (m === 0) return `${h}h`;
27
+ return `${h}h ${m}min`;
28
+ }
29
+
30
+ /**
31
+ * Calcule le mercredi de N semaines avant la semaine ISO d'une date donnée
32
+ * @param date - La date de référence
33
+ * @param weeksBefore - Nombre de semaines avant (par défaut 4)
34
+ * @returns Le mercredi de la semaine cible
35
+ */
36
+ export function getWednesdayBeforeISOWeek(date: Date, weeksBefore: number = 4): Date {
37
+ // Obtenir le lundi de la semaine ISO de la date donnée
38
+ const target = new Date(date.valueOf());
39
+ const dayNr = (date.getDay() + 6) % 7; // 0 = lundi, 6 = dimanche
40
+ target.setDate(target.getDate() - dayNr); // Aller au lundi de cette semaine
41
+
42
+ // Reculer de N semaines
43
+ target.setDate(target.getDate() - weeksBefore * 7);
44
+
45
+ // Aller au mercredi de cette semaine (lundi + 2 jours)
46
+ target.setDate(target.getDate() + 2);
47
+
48
+ return target;
49
+ }
50
+
51
+ /** Motif pour une valeur issue de `<input type="datetime-local">` : `YYYY-MM-DDTHH:mm` ou avec secondes. */
52
+ const DATETIME_LOCAL_PAYLOAD_RE =
53
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;
54
+
55
+ /**
56
+ * Date du calendrier local (sans fuseau explicite dans la chaîne).
57
+ */
58
+ export function toLocalDateInput(value: Date | string): string {
59
+ const date = typeof value === 'string' ? new Date(value) : value;
60
+ const year = date.getFullYear();
61
+ const month = String(date.getMonth() + 1).padStart(2, '0');
62
+ const day = String(date.getDate()).padStart(2, '0');
63
+ return `${year}-${month}-${day}`;
64
+ }
65
+
66
+ /**
67
+ * Valeur pour `<input type="datetime-local">` à partir d’une Date ou d’une ISO renvoyée par l’API.
68
+ */
69
+ export function toLocalDateTimeInput(value: Date | string): string {
70
+ const date = typeof value === 'string' ? new Date(value) : value;
71
+ const localDate = toLocalDateInput(date);
72
+ const hours = String(date.getHours()).padStart(2, '0');
73
+ const minutes = String(date.getMinutes()).padStart(2, '0');
74
+ return `${localDate}T${hours}:${minutes}`;
75
+ }
76
+
77
+ /**
78
+ * Convertit une date/heure saisie en local (`YYYY-MM-DDTHH:mm`) en chaîne ISO UTC pour les APIs.
79
+ * Si la chaîne contient déjà un fuseau (`Z` ou `±hh:mm`), utilise le parseur natif.
80
+ */
81
+ export function scheduledAtPayloadToIso(value: string): string {
82
+ const trimmed = value.trim();
83
+ const m = DATETIME_LOCAL_PAYLOAD_RE.exec(trimmed);
84
+ if (m) {
85
+ const year = Number(m[1]);
86
+ const month = Number(m[2]) - 1;
87
+ const day = Number(m[3]);
88
+ const hour = Number(m[4]);
89
+ const minute = Number(m[5]);
90
+ const second = m[6] === undefined ? 0 : Number(m[6]);
91
+ const local = new Date(year, month, day, hour, minute, second, 0);
92
+ if (Number.isNaN(local.getTime())) {
93
+ throw new TypeError('Date/heure invalide');
94
+ }
95
+ return local.toISOString();
96
+ }
97
+ const parsed = new Date(trimmed);
98
+ if (Number.isNaN(parsed.getTime())) {
99
+ throw new TypeError('Date/heure invalide');
100
+ }
101
+ return parsed.toISOString();
102
+ }
103
+
104
+ /** Fuseau pour formater les textes d’activité côté serveur (Vercel = UTC par défaut). Dom Tom : surcharger APP_DISPLAY_TIMEZONE. */
105
+ const DEFAULT_APP_DISPLAY_TIMEZONE = 'Europe/Paris';
106
+
107
+ /**
108
+ * Date/heure d’un RDV pour les chaînes enregistrées en base (serveur).
109
+ * Utilise APP_DISPLAY_TIMEZONE ou Europe/Paris par défaut.
110
+ */
111
+ export function formatFrenchAppointmentDateTime(
112
+ date: Date,
113
+ timeZone: string = process.env.APP_DISPLAY_TIMEZONE ?? DEFAULT_APP_DISPLAY_TIMEZONE,
114
+ ): string {
115
+ return new Intl.DateTimeFormat('fr-FR', {
116
+ day: 'numeric',
117
+ month: 'long',
118
+ year: 'numeric',
119
+ hour: '2-digit',
120
+ minute: '2-digit',
121
+ timeZone,
122
+ }).format(date);
123
+ }
124
+
125
+ /**
126
+ * Même rendu que l’agenda / navigateur : fuseau local du client.
127
+ */
128
+ export function formatFrenchAppointmentDateTimeLocal(date: Date | string): string {
129
+ const d = typeof date === 'string' ? new Date(date) : date;
130
+ return new Intl.DateTimeFormat('fr-FR', {
131
+ day: 'numeric',
132
+ month: 'long',
133
+ year: 'numeric',
134
+ hour: '2-digit',
135
+ minute: '2-digit',
136
+ }).format(d);
137
+ }
138
+
139
+ const APPOINTMENT_INTERACTION_TYPES = new Set([
140
+ 'APPOINTMENT_CREATED',
141
+ 'APPOINTMENT_CHANGED',
142
+ 'APPOINTMENT_DELETED',
143
+ ]);
144
+
145
+ /**
146
+ * Corps d’affichage pour cartes activités RDV : heure locale navigateur, corrige l’historique UTC en base.
147
+ */
148
+ export function formatAppointmentInteractionBodyForDisplay(interaction: {
149
+ type: string;
150
+ content: string;
151
+ date: string | null;
152
+ metadata?: unknown;
153
+ }): string {
154
+ if (!APPOINTMENT_INTERACTION_TYPES.has(interaction.type) || !interaction.date) {
155
+ return interaction.content;
156
+ }
157
+ const at = formatFrenchAppointmentDateTimeLocal(interaction.date);
158
+ const meta =
159
+ interaction.metadata &&
160
+ typeof interaction.metadata === 'object' &&
161
+ interaction.metadata !== null &&
162
+ 'isGoogleMeet' in interaction.metadata
163
+ ? Boolean((interaction.metadata as { isGoogleMeet?: boolean }).isGoogleMeet)
164
+ : false;
165
+ const label = meta ? 'Google Meet' : 'Rendez-vous';
166
+ switch (interaction.type) {
167
+ case 'APPOINTMENT_CREATED':
168
+ return `Rendez-vous programmé le ${at}`;
169
+ case 'APPOINTMENT_CHANGED':
170
+ return `${label} programmé le ${at} a été modifié.`;
171
+ case 'APPOINTMENT_DELETED':
172
+ return `${label} prévu le ${at} a été annulé.`;
173
+ default:
174
+ return interaction.content;
175
+ }
176
+ }
@@ -1,5 +1,3 @@
1
- // Layout par défaut pour les nouveaux utilisateurs
2
- // Ce fichier est séparé du widget-registry pour être importable côté serveur
3
1
  export const DEFAULT_WIDGETS = [
4
2
  { type: 'stat_total_contacts', x: 0, y: 0, w: 3, h: 2 },
5
3
  { type: 'stat_new_contacts', x: 3, y: 0, w: 3, h: 2 },
@@ -0,0 +1,172 @@
1
+ /**
2
+ * LexKit : export HTML correct (figure.align-left, width/height…), mais importFromHTML
3
+ * - ne remet pas __width/__height sur le nœud image ;
4
+ * - pour <figure>, force __alignment à "center" (bug / choix amont), d’où un logo visuellement centré
5
+ * dans l’éditeur alors que le HTML sauvegardé reste à gauche.
6
+ * Ces helpers réappliquent taille + alignement après import, d’après le HTML réel.
7
+ */
8
+
9
+ import type { LexicalEditor, LexicalNode } from 'lexical';
10
+ import { $getRoot, $isElementNode } from 'lexical';
11
+
12
+ function parsePositiveInt(value: string | null | undefined): number | null {
13
+ if (value == null || value === '') return null;
14
+ const n = Number.parseInt(String(value).trim(), 10);
15
+ return Number.isFinite(n) && n > 0 ? n : null;
16
+ }
17
+
18
+ function parsePxFromStyle(style: string, prop: 'width' | 'height'): number | null {
19
+ const re = new RegExp(`${prop}\\s*:\\s*(\\d+)\\s*px`, 'i');
20
+ const m = re.exec(style);
21
+ if (!m) return null;
22
+ const n = Number.parseInt(m[1], 10);
23
+ return Number.isFinite(n) && n > 0 ? n : null;
24
+ }
25
+
26
+ export type ImgDimensions = { w: number; h: number };
27
+ export type ImgAlignment = 'left' | 'center' | 'right' | 'none';
28
+
29
+ function extractImgAlignment(img: HTMLImageElement): ImgAlignment {
30
+ const fig = img.closest('figure');
31
+ const classBlob = [fig?.className, img.className].filter(Boolean).join(' ');
32
+ const styleBlob = [fig?.getAttribute('style'), img.getAttribute('style')]
33
+ .filter(Boolean)
34
+ .join(';');
35
+
36
+ if (/\balign-left\b/.test(classBlob)) return 'left';
37
+ if (/\balign-right\b/.test(classBlob)) return 'right';
38
+ if (/\balign-center\b/.test(classBlob)) return 'center';
39
+ if (/\balign-none\b/.test(classBlob)) return 'none';
40
+
41
+ if (/float\s*:\s*left/i.test(styleBlob)) return 'left';
42
+ if (/float\s*:\s*right/i.test(styleBlob)) return 'right';
43
+ if (/text-align\s*:\s*left/i.test(styleBlob)) return 'left';
44
+ if (/text-align\s*:\s*right/i.test(styleBlob)) return 'right';
45
+ if (/text-align\s*:\s*center/i.test(styleBlob)) return 'center';
46
+
47
+ return 'none';
48
+ }
49
+
50
+ function extractImgDimensions(img: HTMLImageElement): ImgDimensions | null {
51
+ let w = parsePositiveInt(img.getAttribute('width'));
52
+ let h = parsePositiveInt(img.getAttribute('height'));
53
+ const st = img.getAttribute('style') || '';
54
+ if (w == null) w = parsePxFromStyle(st, 'width');
55
+ if (h == null) h = parsePxFromStyle(st, 'height');
56
+ if (w != null && h != null) return { w, h };
57
+ return null;
58
+ }
59
+
60
+ export function extractOrderedImgLayout(html: string): {
61
+ dimensions: Array<ImgDimensions | null>;
62
+ alignments: ImgAlignment[];
63
+ } {
64
+ if (!html.includes('<img')) {
65
+ return { dimensions: [], alignments: [] };
66
+ }
67
+ try {
68
+ const doc = new DOMParser().parseFromString(
69
+ `<div id="__editor_img_layout__">${html}</div>`,
70
+ 'text/html',
71
+ );
72
+ const root = doc.getElementById('__editor_img_layout__');
73
+ if (!root) return { dimensions: [], alignments: [] };
74
+
75
+ const dimensions: Array<ImgDimensions | null> = [];
76
+ const alignments: ImgAlignment[] = [];
77
+ root.querySelectorAll('img').forEach((img) => {
78
+ dimensions.push(extractImgDimensions(img));
79
+ alignments.push(extractImgAlignment(img));
80
+ });
81
+ return { dimensions, alignments };
82
+ } catch {
83
+ return { dimensions: [], alignments: [] };
84
+ }
85
+ }
86
+
87
+ /** Copie width/height attributs → style inline (meilleure compat import LexKit + clients mail). */
88
+ export function mergeImgDimensionAttrsIntoStyle(html: string): string {
89
+ if (!html.includes('<img')) return html;
90
+ try {
91
+ const doc = new DOMParser().parseFromString(
92
+ `<div id="__editor_sig_root__">${html}</div>`,
93
+ 'text/html',
94
+ );
95
+ const root = doc.getElementById('__editor_sig_root__');
96
+ if (!root) return html;
97
+
98
+ root.querySelectorAll('img').forEach((img) => {
99
+ const wAttr = parsePositiveInt(img.getAttribute('width'));
100
+ const hAttr = parsePositiveInt(img.getAttribute('height'));
101
+ const style = (img.getAttribute('style') || '').trim();
102
+
103
+ const parts: string[] = [];
104
+ if (wAttr != null && !/\bwidth\s*:/i.test(style)) {
105
+ parts.push(`width:${wAttr}px`);
106
+ }
107
+ if (hAttr != null && !/\bheight\s*:/i.test(style)) {
108
+ parts.push(`height:${hAttr}px`);
109
+ }
110
+ if (parts.length === 0) return;
111
+
112
+ const merged = [style.replace(/;+\s*$/, ''), ...parts].filter(Boolean).join(';');
113
+ img.setAttribute('style', merged);
114
+ });
115
+
116
+ return root.innerHTML;
117
+ } catch {
118
+ return html;
119
+ }
120
+ }
121
+
122
+ type ImageNodeWithLayout = {
123
+ getType(): string;
124
+ setWidthAndHeight(width: number, height: number): void;
125
+ setAlignment(alignment: ImgAlignment): void;
126
+ };
127
+
128
+ function collectImageNodesInDocumentOrder(node: LexicalNode): ImageNodeWithLayout[] {
129
+ const list: ImageNodeWithLayout[] = [];
130
+
131
+ const visit = (n: LexicalNode) => {
132
+ if (n.getType() === 'image') {
133
+ list.push(n as unknown as ImageNodeWithLayout);
134
+ }
135
+ if ($isElementNode(n)) {
136
+ n.getChildren().forEach(visit);
137
+ }
138
+ };
139
+
140
+ visit(node);
141
+ return list;
142
+ }
143
+
144
+ /** Après importFromHTML : restaure __width/__height et __alignment (corrige le « center » forcé par LexKit sur <figure>). */
145
+ export function applyOrderedImageLayoutAfterImport(editor: LexicalEditor, html: string): void {
146
+ if (!html.includes('<img')) return;
147
+
148
+ const { dimensions, alignments } = extractOrderedImgLayout(html);
149
+ if (dimensions.length === 0 || alignments.length === 0) return;
150
+
151
+ editor.update(() => {
152
+ const images = collectImageNodesInDocumentOrder($getRoot());
153
+ const count = Math.min(images.length, dimensions.length, alignments.length);
154
+ for (let i = 0; i < count; i++) {
155
+ const node = images[i];
156
+ const d = dimensions[i];
157
+ const a = alignments[i];
158
+ if (d) {
159
+ try {
160
+ node.setWidthAndHeight(d.w, d.h);
161
+ } catch {
162
+ /* ignore */
163
+ }
164
+ }
165
+ try {
166
+ node.setAlignment(a);
167
+ } catch {
168
+ /* ignore */
169
+ }
170
+ }
171
+ });
172
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Taille max recommandée pour un logo en signature (HTML en data URL = base64,
3
+ * donc ~33 % plus lourd que le fichier binaire — mieux vaut rester léger).
4
+ */
5
+ export const SIGNATURE_MAX_IMAGE_BYTES = 350 * 1024;
6
+
7
+ export function formatImageFileSize(bytes: number): string {
8
+ if (bytes < 1024) return `${bytes} o`;
9
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} Ko`;
10
+ return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
11
+ }
12
+
13
+ /** Lève une erreur avec un message utilisateur si le fichier dépasse la limite. */
14
+ export function assertImageFileWithinLimit(file: File, maxBytes: number): void {
15
+ if (file.size <= maxBytes) return;
16
+ throw new Error(
17
+ `Image trop lourde (maximum ${formatImageFileSize(maxBytes)}, fichier : ${formatImageFileSize(file.size)}). Compressez ou réduisez le logo.`,
18
+ );
19
+ }
@@ -0,0 +1,19 @@
1
+ import sanitize from 'sanitize-html';
2
+
3
+ export function sanitizeEmailHtml(input: string): string {
4
+ if (!input) return '';
5
+ return sanitize(input, {
6
+ allowedTags: [
7
+ 'p', 'br', 'strong', 'em', 'u', 'a', 'div', 'span',
8
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
9
+ 'ul', 'ol', 'li', 'blockquote', 'pre', 'code',
10
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
11
+ 'img', 'hr', 'sub', 'sup', 's',
12
+ ],
13
+ allowedAttributes: {
14
+ a: ['href', 'title', 'target', 'rel'],
15
+ img: ['src', 'alt', 'width', 'height'],
16
+ '*': ['style', 'class'],
17
+ },
18
+ });
19
+ }