create-crm-tmp 1.1.2 → 2.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 (220) hide show
  1. package/package.json +1 -1
  2. package/template/.prettierignore +2 -0
  3. package/template/README.md +53 -67
  4. package/template/components.json +22 -0
  5. package/template/exemple-contacts.csv +54 -0
  6. package/template/next.config.ts +27 -1
  7. package/template/package.json +64 -27
  8. package/template/prisma/schema.prisma +821 -72
  9. package/template/skills-lock.json +25 -0
  10. package/template/src/app/(auth)/invite/[token]/page.tsx +21 -24
  11. package/template/src/app/(auth)/reset-password/complete/page.tsx +12 -21
  12. package/template/src/app/(auth)/reset-password/page.tsx +12 -8
  13. package/template/src/app/(auth)/reset-password/verify/page.tsx +12 -8
  14. package/template/src/app/(auth)/signin/page.tsx +20 -17
  15. package/template/src/app/(dashboard)/agenda/page.tsx +2231 -2188
  16. package/template/src/app/(dashboard)/automatisation/[id]/page.tsx +10 -7
  17. package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +680 -323
  18. package/template/src/app/(dashboard)/automatisation/new/page.tsx +11 -8
  19. package/template/src/app/(dashboard)/automatisation/page.tsx +473 -180
  20. package/template/src/app/(dashboard)/closing/page.tsx +500 -468
  21. package/template/src/app/(dashboard)/contacts/[id]/page.tsx +5035 -4126
  22. package/template/src/app/(dashboard)/contacts/companies/[id]/page.tsx +1703 -0
  23. package/template/src/app/(dashboard)/contacts/loading.tsx +13 -0
  24. package/template/src/app/(dashboard)/contacts/page.tsx +3776 -2064
  25. package/template/src/app/(dashboard)/dashboard/page.tsx +37 -519
  26. package/template/src/app/(dashboard)/error.tsx +37 -0
  27. package/template/src/app/(dashboard)/layout.tsx +1 -1
  28. package/template/src/app/(dashboard)/loading.tsx +5 -0
  29. package/template/src/app/(dashboard)/settings/loading.tsx +19 -0
  30. package/template/src/app/(dashboard)/settings/page.tsx +2685 -2489
  31. package/template/src/app/(dashboard)/templates/page.tsx +500 -300
  32. package/template/src/app/(dashboard)/users/list/page.tsx +356 -350
  33. package/template/src/app/(dashboard)/users/page.tsx +279 -310
  34. package/template/src/app/(dashboard)/users/permissions/page.tsx +104 -99
  35. package/template/src/app/(dashboard)/users/roles/page.tsx +164 -137
  36. package/template/src/app/api/audit-logs/route.ts +1 -1
  37. package/template/src/app/api/auth/google/callback/route.ts +8 -5
  38. package/template/src/app/api/auth/google/disconnect/route.ts +2 -2
  39. package/template/src/app/api/companies/[id]/activities/route.ts +131 -0
  40. package/template/src/app/api/companies/[id]/route.ts +195 -0
  41. package/template/src/app/api/companies/export/route.ts +206 -0
  42. package/template/src/app/api/companies/route.ts +166 -0
  43. package/template/src/app/api/contact-views/[id]/pin/route.ts +69 -0
  44. package/template/src/app/api/contact-views/[id]/route.ts +197 -0
  45. package/template/src/app/api/contact-views/route.ts +146 -0
  46. package/template/src/app/api/contacts/[id]/files/[fileId]/preview/route.ts +77 -0
  47. package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +7 -17
  48. package/template/src/app/api/contacts/[id]/files/route.ts +83 -44
  49. package/template/src/app/api/contacts/[id]/interactions/route.ts +37 -0
  50. package/template/src/app/api/contacts/[id]/kyc/route.ts +71 -0
  51. package/template/src/app/api/contacts/[id]/meet/route.ts +38 -29
  52. package/template/src/app/api/contacts/[id]/route.ts +111 -20
  53. package/template/src/app/api/contacts/[id]/send-email/route.ts +6 -0
  54. package/template/src/app/api/contacts/[id]/workflows/run/route.ts +61 -0
  55. package/template/src/app/api/contacts/export/route.ts +12 -17
  56. package/template/src/app/api/contacts/import/route.ts +22 -19
  57. package/template/src/app/api/contacts/import-preview/route.ts +139 -0
  58. package/template/src/app/api/contacts/route.ts +202 -49
  59. package/template/src/app/api/dashboard/stats/route.ts +9 -292
  60. package/template/src/app/api/integrations/google-sheet/sync/route.ts +203 -185
  61. package/template/src/app/api/invite/complete/route.ts +20 -23
  62. package/template/src/app/api/reminders/route.ts +1 -0
  63. package/template/src/app/api/reset-password/complete/route.ts +11 -13
  64. package/template/src/app/api/send/route.ts +9 -85
  65. package/template/src/app/api/settings/closing-reasons/[id]/route.ts +10 -21
  66. package/template/src/app/api/settings/closing-reasons/route.ts +10 -21
  67. package/template/src/app/api/settings/company/route.ts +19 -26
  68. package/template/src/app/api/settings/google-ads/[id]/route.ts +20 -23
  69. package/template/src/app/api/settings/google-ads/route.ts +20 -23
  70. package/template/src/app/api/settings/google-sheet/[id]/route.ts +20 -23
  71. package/template/src/app/api/settings/google-sheet/auto-map/route.ts +23 -32
  72. package/template/src/app/api/settings/google-sheet/preview/route.ts +104 -0
  73. package/template/src/app/api/settings/google-sheet/route.ts +20 -23
  74. package/template/src/app/api/settings/meta-leads/[id]/route.ts +20 -23
  75. package/template/src/app/api/settings/meta-leads/route.ts +20 -23
  76. package/template/src/app/api/settings/statuses/[id]/route.ts +33 -23
  77. package/template/src/app/api/settings/statuses/route.ts +24 -22
  78. package/template/src/app/api/statuses/route.ts +2 -5
  79. package/template/src/app/api/tasks/[id]/attendees/route.ts +14 -7
  80. package/template/src/app/api/tasks/[id]/route.ts +161 -137
  81. package/template/src/app/api/tasks/meet/route.ts +11 -8
  82. package/template/src/app/api/tasks/route.ts +155 -95
  83. package/template/src/app/api/templates/[id]/route.ts +22 -13
  84. package/template/src/app/api/templates/route.ts +22 -5
  85. package/template/src/app/api/users/[id]/resend-invite/route.ts +95 -0
  86. package/template/src/app/api/users/[id]/route.ts +16 -1
  87. package/template/src/app/api/users/commercials/route.ts +38 -0
  88. package/template/src/app/api/users/for-agenda/route.ts +1 -2
  89. package/template/src/app/api/users/route.ts +94 -55
  90. package/template/src/app/api/webhooks/google-ads/route.ts +20 -1
  91. package/template/src/app/api/webhooks/meta-leads/route.ts +18 -1
  92. package/template/src/app/api/workflows/[id]/route.ts +33 -6
  93. package/template/src/app/api/workflows/process/route.ts +509 -146
  94. package/template/src/app/api/workflows/route.ts +46 -4
  95. package/template/src/app/globals.css +210 -101
  96. package/template/src/app/layout.tsx +19 -8
  97. package/template/src/app/page.tsx +37 -7
  98. package/template/src/components/address-autocomplete.tsx +232 -0
  99. package/template/src/components/contacts/filter-bar.tsx +181 -0
  100. package/template/src/components/contacts/filter-builder.tsx +589 -0
  101. package/template/src/components/contacts/save-view-dialog.tsx +160 -0
  102. package/template/src/components/contacts/views-tab-bar.tsx +440 -0
  103. package/template/src/components/dashboard/activity-chart.tsx +31 -39
  104. package/template/src/components/dashboard/dashboard-content.tsx +79 -0
  105. package/template/src/components/dashboard/stat-card.tsx +40 -42
  106. package/template/src/components/dashboard/tasks-pie-chart.tsx +34 -37
  107. package/template/src/components/dashboard/upcoming-tasks-list.tsx +78 -72
  108. package/template/src/components/date-picker.tsx +396 -0
  109. package/template/src/components/editor.tsx +27 -13
  110. package/template/src/components/email-template.tsx +4 -2
  111. package/template/src/components/global-search.tsx +358 -0
  112. package/template/src/components/header.tsx +57 -62
  113. package/template/src/components/invitation-email-template.tsx +4 -2
  114. package/template/src/components/lazy-editor.tsx +11 -0
  115. package/template/src/components/meet-cancellation-email-template.tsx +11 -3
  116. package/template/src/components/meet-confirmation-email-template.tsx +10 -3
  117. package/template/src/components/meet-update-email-template.tsx +10 -3
  118. package/template/src/components/page-header.tsx +19 -15
  119. package/template/src/components/protected-page.tsx +94 -0
  120. package/template/src/components/reset-password-email-template.tsx +4 -2
  121. package/template/src/components/sidebar.tsx +92 -94
  122. package/template/src/components/skeleton.tsx +128 -42
  123. package/template/src/components/ui/accordion.tsx +64 -0
  124. package/template/src/components/ui/alert-dialog.tsx +139 -0
  125. package/template/src/components/ui/button.tsx +60 -0
  126. package/template/src/components/view-as-banner.tsx +1 -1
  127. package/template/src/components/view-as-modal.tsx +21 -16
  128. package/template/src/config/nav-pages.ts +108 -0
  129. package/template/src/contexts/app-toast-context.tsx +174 -0
  130. package/template/src/contexts/sidebar-context.tsx +16 -47
  131. package/template/src/contexts/task-reminder-context.tsx +6 -6
  132. package/template/src/contexts/view-as-context.tsx +11 -16
  133. package/template/src/hooks/use-alert.tsx +65 -0
  134. package/template/src/hooks/use-confirm.tsx +87 -0
  135. package/template/src/hooks/use-contact-views.ts +140 -0
  136. package/template/src/hooks/use-contacts.ts +69 -0
  137. package/template/src/hooks/use-fetch.ts +17 -0
  138. package/template/src/hooks/use-focus-trap.ts +73 -0
  139. package/template/src/hooks/use-statuses.ts +22 -0
  140. package/template/src/lib/address-api.ts +155 -0
  141. package/template/src/lib/cache.ts +73 -0
  142. package/template/src/lib/check-permission.ts +12 -177
  143. package/template/src/lib/contact-interactions.ts +3 -1
  144. package/template/src/lib/contact-view-filters.ts +341 -0
  145. package/template/src/lib/dashboard-stats.ts +224 -0
  146. package/template/src/lib/date-utils.ts +49 -0
  147. package/template/src/lib/get-auth-user.ts +25 -0
  148. package/template/src/lib/google-calendar.ts +54 -12
  149. package/template/src/lib/google-drive.ts +796 -75
  150. package/template/src/lib/google-fetch.ts +63 -0
  151. package/template/src/lib/local-storage.ts +34 -0
  152. package/template/src/lib/permissions.ts +245 -47
  153. package/template/src/lib/prisma.ts +11 -11
  154. package/template/src/lib/roles.ts +14 -39
  155. package/template/src/lib/template-variables.ts +67 -33
  156. package/template/src/lib/utils.ts +26 -2
  157. package/template/src/lib/workflow-executor.ts +445 -229
  158. package/template/src/proxy.ts +34 -73
  159. package/template/src/types/contact-views.ts +351 -0
  160. package/template/src/types/yousign.ts +52 -0
  161. package/template/vercel.json +12 -0
  162. package/template/WORKFLOWS_CRON.md +0 -185
  163. package/template/prisma/migrations/20251126144728_init/migration.sql +0 -78
  164. package/template/prisma/migrations/20251126155204_add_user_roles/migration.sql +0 -5
  165. package/template/prisma/migrations/20251128095126_add_company_info/migration.sql +0 -19
  166. package/template/prisma/migrations/20251128123321_add_smtp_config/migration.sql +0 -22
  167. package/template/prisma/migrations/20251128132303_add_status/migration.sql +0 -23
  168. package/template/prisma/migrations/20251201102207_add_user_active/migration.sql +0 -75
  169. package/template/prisma/migrations/20251201105507_add_email_signature/migration.sql +0 -2
  170. package/template/prisma/migrations/20251201151122_add_tasks/migration.sql +0 -45
  171. package/template/prisma/migrations/20251202111854_add_task_reminder/migration.sql +0 -2
  172. package/template/prisma/migrations/20251202135859_add_google_meet_integration/migration.sql +0 -27
  173. package/template/prisma/migrations/20251203103317_add_meta_lead_integration/migration.sql +0 -20
  174. package/template/prisma/migrations/20251203104002_add_google_ads_integration/migration.sql +0 -18
  175. package/template/prisma/migrations/20251203112122_add_google_sheet_integration/migration.sql +0 -32
  176. package/template/prisma/migrations/20251203153853_allow_multiple_integration_configs/migration.sql +0 -20
  177. package/template/prisma/migrations/20251205141705_update_user_roles/migration.sql +0 -12
  178. package/template/prisma/migrations/20251205150000_add_commercial_and_telepro_assignment/migration.sql +0 -21
  179. package/template/prisma/migrations/20251205160000_add_interaction_logging/migration.sql +0 -11
  180. package/template/prisma/migrations/20251208090314_add_automatic_interaction_types/migration.sql +0 -12
  181. package/template/prisma/migrations/20251208094843_mg/migration.sql +0 -14
  182. package/template/prisma/migrations/20251208100000_add_company_support/migration.sql +0 -14
  183. package/template/prisma/migrations/20251208110000_add_templates/migration.sql +0 -26
  184. package/template/prisma/migrations/20251208141304_add_video_conference_task_type/migration.sql +0 -2
  185. package/template/prisma/migrations/20251209104759_add_internal_note_to_task/migration.sql +0 -2
  186. package/template/prisma/migrations/20251209134803_add_company_field/migration.sql +0 -2
  187. package/template/prisma/migrations/20251209150000_rename_company_to_company_name/migration.sql +0 -3
  188. package/template/prisma/migrations/20251209150016_add_email_tracking/migration.sql +0 -21
  189. package/template/prisma/migrations/20251209155908_add_notify_contact_to_task/migration.sql +0 -2
  190. package/template/prisma/migrations/20251210110019_add_appointment_types/migration.sql +0 -10
  191. package/template/prisma/migrations/20251210113928_add_contact_files/migration.sql +0 -26
  192. package/template/prisma/migrations/20251212132339_add_custom_roles/migration.sql +0 -24
  193. package/template/prisma/migrations/20251215104448_add_file_interaction_types/migration.sql +0 -11
  194. package/template/prisma/migrations/20251215145616_add_closing_reasons/migration.sql +0 -12
  195. package/template/prisma/migrations/20251216140850_add_log_users/migration.sql +0 -25
  196. package/template/prisma/migrations/20251216151000_rename_perdu_to_ferme/migration.sql +0 -8
  197. package/template/prisma/migrations/20251216162318_add_column_mappings_to_google_sheet/migration.sql +0 -2
  198. package/template/prisma/migrations/20251216185127_add_workflows/migration.sql +0 -80
  199. package/template/prisma/migrations/20251216192237_add_scheduled_workflow_actions/migration.sql +0 -32
  200. package/template/prisma/migrations/20251220000000_add_task_interaction_type/migration.sql +0 -4
  201. package/template/prisma/migrations/20251221000000_add_task_type/migration.sql +0 -3
  202. package/template/prisma/migrations/20251221000001_add_event_color/migration.sql +0 -23
  203. package/template/prisma/migrations/20260210114913_add_dashboard_widget/migration.sql +0 -20
  204. package/template/prisma/migrations/migration_lock.toml +0 -3
  205. package/template/src/app/(dashboard)/users/layout.tsx +0 -30
  206. package/template/src/app/api/dashboard/widgets/[id]/route.ts +0 -47
  207. package/template/src/app/api/dashboard/widgets/route.ts +0 -181
  208. package/template/src/components/dashboard/add-widget-dialog.tsx +0 -161
  209. package/template/src/components/dashboard/color-picker.tsx +0 -65
  210. package/template/src/components/dashboard/contacts-chart.tsx +0 -69
  211. package/template/src/components/dashboard/interactions-by-type-chart.tsx +0 -121
  212. package/template/src/components/dashboard/recent-activity.tsx +0 -157
  213. package/template/src/components/dashboard/sales-analytics-chart.tsx +0 -77
  214. package/template/src/components/dashboard/status-distribution-chart.tsx +0 -82
  215. package/template/src/components/dashboard/top-contacts-list.tsx +0 -119
  216. package/template/src/components/dashboard/widget-wrapper.tsx +0 -39
  217. package/template/src/contexts/dashboard-theme-context.tsx +0 -58
  218. package/template/src/lib/dashboard-themes.ts +0 -140
  219. package/template/src/lib/default-widgets.ts +0 -14
  220. package/template/src/lib/widget-registry.ts +0 -177
@@ -0,0 +1,87 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import {
5
+ AlertDialog,
6
+ AlertDialogAction,
7
+ AlertDialogCancel,
8
+ AlertDialogContent,
9
+ AlertDialogDescription,
10
+ AlertDialogFooter,
11
+ AlertDialogHeader,
12
+ AlertDialogTitle,
13
+ } from '@/components/ui/alert-dialog';
14
+
15
+ interface ConfirmOptions {
16
+ title: string;
17
+ description: string;
18
+ confirmText?: string;
19
+ cancelText?: string;
20
+ variant?: 'default' | 'destructive';
21
+ }
22
+
23
+ export function useConfirm() {
24
+ const [isOpen, setIsOpen] = useState(false);
25
+ const [options, setOptions] = useState<ConfirmOptions>({
26
+ title: '',
27
+ description: '',
28
+ confirmText: 'Confirmer',
29
+ cancelText: 'Annuler',
30
+ variant: 'default',
31
+ });
32
+ const [resolvePromise, setResolvePromise] = useState<((value: boolean) => void) | null>(null);
33
+
34
+ const confirm = (opts: ConfirmOptions): Promise<boolean> => {
35
+ setOptions({
36
+ confirmText: 'Confirmer',
37
+ cancelText: 'Annuler',
38
+ variant: 'default',
39
+ ...opts,
40
+ });
41
+ setIsOpen(true);
42
+
43
+ return new Promise<boolean>((resolve) => {
44
+ setResolvePromise(() => resolve);
45
+ });
46
+ };
47
+
48
+ const handleConfirm = () => {
49
+ if (resolvePromise) {
50
+ resolvePromise(true);
51
+ }
52
+ setIsOpen(false);
53
+ };
54
+
55
+ const handleCancel = () => {
56
+ if (resolvePromise) {
57
+ resolvePromise(false);
58
+ }
59
+ setIsOpen(false);
60
+ };
61
+
62
+ const ConfirmDialog = () => (
63
+ <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
64
+ <AlertDialogContent>
65
+ <AlertDialogHeader>
66
+ <AlertDialogTitle>{options.title}</AlertDialogTitle>
67
+ <AlertDialogDescription>{options.description}</AlertDialogDescription>
68
+ </AlertDialogHeader>
69
+ <AlertDialogFooter>
70
+ <AlertDialogCancel onClick={handleCancel}>{options.cancelText}</AlertDialogCancel>
71
+ <AlertDialogAction
72
+ onClick={handleConfirm}
73
+ className={
74
+ options.variant === 'destructive'
75
+ ? 'bg-red-600 hover:bg-red-700 focus:ring-red-600'
76
+ : ''
77
+ }
78
+ >
79
+ {options.confirmText}
80
+ </AlertDialogAction>
81
+ </AlertDialogFooter>
82
+ </AlertDialogContent>
83
+ </AlertDialog>
84
+ );
85
+
86
+ return { confirm, ConfirmDialog };
87
+ }
@@ -0,0 +1,140 @@
1
+ import useSWR, { mutate as globalMutate } from 'swr';
2
+ import type {
3
+ ContactViewData,
4
+ ViewFilter,
5
+ ViewColumn,
6
+ ViewSortConfig,
7
+ } from '@/types/contact-views';
8
+
9
+ async function fetcher<T>(url: string): Promise<T> {
10
+ const res = await fetch(url);
11
+ if (!res.ok) throw new Error('Erreur lors du chargement des vues');
12
+ return res.json();
13
+ }
14
+
15
+ function getViewsKey(entityType: string) {
16
+ return `/api/contact-views?entityType=${entityType}`;
17
+ }
18
+
19
+ export function useContactViews(entityType: 'contacts' | 'companies' = 'contacts') {
20
+ const viewsKey = getViewsKey(entityType);
21
+
22
+ const { data, error, isLoading, mutate } = useSWR<ContactViewData[]>(viewsKey, fetcher, {
23
+ revalidateOnFocus: false,
24
+ dedupingInterval: 60_000,
25
+ });
26
+
27
+ const createView = async (view: {
28
+ name: string;
29
+ isPublic?: boolean;
30
+ filters?: ViewFilter[];
31
+ columns?: ViewColumn[] | null;
32
+ sortConfig?: ViewSortConfig | null;
33
+ }): Promise<ContactViewData> => {
34
+ const res = await fetch('/api/contact-views', {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json' },
37
+ body: JSON.stringify({ ...view, entityType }),
38
+ });
39
+ if (!res.ok) {
40
+ const err = await res.json();
41
+ throw new Error(err.error || 'Erreur lors de la création');
42
+ }
43
+ const created = await res.json();
44
+ await mutate();
45
+ return created;
46
+ };
47
+
48
+ const updateView = async (
49
+ id: string,
50
+ updates: {
51
+ name?: string;
52
+ isPublic?: boolean;
53
+ filters?: ViewFilter[];
54
+ columns?: ViewColumn[] | null;
55
+ sortConfig?: ViewSortConfig | null;
56
+ isDefault?: boolean;
57
+ },
58
+ ): Promise<ContactViewData> => {
59
+ const res = await fetch(`/api/contact-views/${id}`, {
60
+ method: 'PUT',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify(updates),
63
+ });
64
+ if (!res.ok) {
65
+ const err = await res.json();
66
+ throw new Error(err.error || 'Erreur lors de la modification');
67
+ }
68
+ const updated = await res.json();
69
+ await mutate();
70
+ return updated;
71
+ };
72
+
73
+ const deleteView = async (id: string): Promise<void> => {
74
+ const res = await fetch(`/api/contact-views/${id}`, { method: 'DELETE' });
75
+ if (!res.ok) {
76
+ const err = await res.json();
77
+ throw new Error(err.error || 'Erreur lors de la suppression');
78
+ }
79
+ await mutate();
80
+ };
81
+
82
+ const cloneView = async (view: ContactViewData, newName?: string): Promise<ContactViewData> => {
83
+ return createView({
84
+ name: newName || `${view.name} (copie)`,
85
+ isPublic: false,
86
+ filters: view.filters,
87
+ columns: view.columns,
88
+ sortConfig: view.sortConfig,
89
+ });
90
+ };
91
+
92
+ const pinView = async (id: string): Promise<void> => {
93
+ const res = await fetch(`/api/contact-views/${id}/pin`, { method: 'POST' });
94
+ if (!res.ok) {
95
+ const err = await res.json();
96
+ throw new Error(err.error || "Erreur lors de l'épinglage");
97
+ }
98
+ await mutate();
99
+ };
100
+
101
+ const unpinView = async (id: string): Promise<void> => {
102
+ const res = await fetch(`/api/contact-views/${id}/pin`, { method: 'DELETE' });
103
+ if (!res.ok) {
104
+ const err = await res.json();
105
+ throw new Error(err.error || 'Erreur lors du désépinglage');
106
+ }
107
+ await mutate();
108
+ };
109
+
110
+ const togglePin = async (view: ContactViewData): Promise<void> => {
111
+ if (view.pinOrder != null) {
112
+ await unpinView(view.id);
113
+ } else {
114
+ await pinView(view.id);
115
+ }
116
+ };
117
+
118
+ return {
119
+ views: data || [],
120
+ error,
121
+ isLoading,
122
+ mutate,
123
+ createView,
124
+ updateView,
125
+ deleteView,
126
+ cloneView,
127
+ pinView,
128
+ unpinView,
129
+ togglePin,
130
+ };
131
+ }
132
+
133
+ export function invalidateContactViews(entityType?: string) {
134
+ if (entityType) {
135
+ globalMutate(getViewsKey(entityType));
136
+ } else {
137
+ globalMutate(getViewsKey('contacts'));
138
+ globalMutate(getViewsKey('companies'));
139
+ }
140
+ }
@@ -0,0 +1,69 @@
1
+ import useSWR from 'swr';
2
+ import type { ViewFilter } from '@/types/contact-views';
3
+
4
+ interface ContactsParams {
5
+ search?: string;
6
+ viewId?: string | null;
7
+ filters?: ViewFilter[];
8
+ page?: number;
9
+ limit?: number;
10
+ sortField?: string;
11
+ sortDir?: 'asc' | 'desc';
12
+ }
13
+
14
+ interface ContactsResponse {
15
+ contacts: any[];
16
+ pagination: {
17
+ page: number;
18
+ limit: number;
19
+ total: number;
20
+ totalPages: number;
21
+ };
22
+ }
23
+
24
+ function buildContactsUrl(params: ContactsParams): string {
25
+ const sp = new URLSearchParams();
26
+
27
+ if (params.search) sp.set('search', params.search);
28
+ if (params.page) sp.set('page', params.page.toString());
29
+ if (params.limit) sp.set('limit', params.limit.toString());
30
+ if (params.sortField) sp.set('sortField', params.sortField);
31
+ if (params.sortDir) sp.set('sortDir', params.sortDir);
32
+
33
+ if (params.viewId) {
34
+ sp.set('viewId', params.viewId);
35
+ } else if (params.filters && params.filters.length > 0) {
36
+ sp.set('filters', JSON.stringify(params.filters));
37
+ }
38
+
39
+ return `/api/contacts?${sp.toString()}`;
40
+ }
41
+
42
+ async function fetcher<T>(url: string): Promise<T> {
43
+ const res = await fetch(url);
44
+ if (!res.ok) throw new Error('Erreur lors du chargement des contacts');
45
+ return res.json();
46
+ }
47
+
48
+ export function useContacts(params: ContactsParams) {
49
+ const url = buildContactsUrl(params);
50
+
51
+ const { data, error, isLoading, isValidating, mutate } = useSWR<ContactsResponse>(url, fetcher, {
52
+ revalidateOnFocus: false,
53
+ keepPreviousData: true,
54
+ });
55
+
56
+ return {
57
+ contacts: data?.contacts || [],
58
+ pagination: data?.pagination || {
59
+ page: 1,
60
+ limit: 25,
61
+ total: 0,
62
+ totalPages: 1,
63
+ },
64
+ error,
65
+ isLoading,
66
+ isValidating,
67
+ mutate,
68
+ };
69
+ }
@@ -0,0 +1,17 @@
1
+ import useSWR, { type SWRConfiguration } from 'swr';
2
+
3
+ async function fetcher<T>(url: string): Promise<T> {
4
+ const res = await fetch(url);
5
+ if (!res.ok) {
6
+ const error = new Error('Erreur lors du chargement des données');
7
+ throw error;
8
+ }
9
+ return res.json();
10
+ }
11
+
12
+ export function useFetch<T>(key: string | null, config?: SWRConfiguration<T>) {
13
+ return useSWR<T>(key, fetcher, {
14
+ revalidateOnFocus: false,
15
+ ...config,
16
+ });
17
+ }
@@ -0,0 +1,73 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+
5
+ const FOCUSABLE_SELECTOR =
6
+ 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
7
+
8
+ function getFocusables(container: HTMLElement): HTMLElement[] {
9
+ return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
10
+ (el) => !el.hasAttribute('disabled') && el.offsetParent !== null
11
+ );
12
+ }
13
+
14
+ export function useFocusTrap(
15
+ isActive: boolean,
16
+ containerRef: React.RefObject<HTMLElement | null>,
17
+ options?: {
18
+ onClose?: () => void;
19
+ initialFocusRef?: React.RefObject<HTMLElement | null>;
20
+ skipInitialFocus?: boolean;
21
+ }
22
+ ) {
23
+ const prevFocusRef = useRef<HTMLElement | null>(null);
24
+ const onCloseRef = useRef(options?.onClose);
25
+ onCloseRef.current = options?.onClose;
26
+
27
+ useEffect(() => {
28
+ if (!isActive || !containerRef.current) return;
29
+
30
+ const container = containerRef.current;
31
+ prevFocusRef.current = document.activeElement as HTMLElement | null;
32
+
33
+ const focusables = getFocusables(container);
34
+ const first = options?.initialFocusRef?.current ?? focusables[0];
35
+ const last = focusables[focusables.length - 1];
36
+
37
+ if (!options?.skipInitialFocus && first) first.focus();
38
+
39
+ function handleKeyDown(e: KeyboardEvent) {
40
+ if (e.key === 'Escape') {
41
+ e.preventDefault();
42
+ options?.onClose?.();
43
+ prevFocusRef.current?.focus();
44
+ return;
45
+ }
46
+ if (e.key !== 'Tab') return;
47
+
48
+ const focusablesNow = getFocusables(container);
49
+ if (focusablesNow.length === 0) return;
50
+
51
+ const firstEl = focusablesNow[0];
52
+ const lastEl = focusablesNow[focusablesNow.length - 1];
53
+
54
+ if (e.shiftKey) {
55
+ if (document.activeElement === firstEl) {
56
+ e.preventDefault();
57
+ lastEl.focus();
58
+ }
59
+ } else {
60
+ if (document.activeElement === lastEl) {
61
+ e.preventDefault();
62
+ firstEl.focus();
63
+ }
64
+ }
65
+ }
66
+
67
+ container.addEventListener('keydown', handleKeyDown);
68
+ return () => {
69
+ container.removeEventListener('keydown', handleKeyDown);
70
+ prevFocusRef.current?.focus();
71
+ };
72
+ }, [isActive, options?.onClose]);
73
+ }
@@ -0,0 +1,22 @@
1
+ import { useFetch } from './use-fetch';
2
+
3
+ export function useStatuses() {
4
+ return useFetch<any[]>('/api/statuses', {
5
+ revalidateOnFocus: false,
6
+ dedupingInterval: 300_000,
7
+ });
8
+ }
9
+
10
+ export function useClosingReasons() {
11
+ return useFetch<any[]>('/api/closing-reasons', {
12
+ revalidateOnFocus: false,
13
+ dedupingInterval: 300_000,
14
+ });
15
+ }
16
+
17
+ export function useUsersList() {
18
+ return useFetch<any[]>('/api/users/list', {
19
+ revalidateOnFocus: false,
20
+ dedupingInterval: 300_000,
21
+ });
22
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Service pour interroger l'API Adresse française
3
+ * https://api-adresse.data.gouv.fr
4
+ */
5
+
6
+ export interface AddressFeature {
7
+ type: string;
8
+ geometry: {
9
+ type: string;
10
+ coordinates: number[];
11
+ };
12
+ properties: {
13
+ label: string;
14
+ score: number;
15
+ housenumber?: string;
16
+ id: string;
17
+ banId: string;
18
+ name: string;
19
+ postcode: string;
20
+ citycode: string;
21
+ x: number;
22
+ y: number;
23
+ city: string;
24
+ context: string;
25
+ type: string;
26
+ importance: number;
27
+ street?: string;
28
+ _type: string;
29
+ };
30
+ }
31
+
32
+ export interface AddressSearchResult {
33
+ type: string;
34
+ version: string;
35
+ features: AddressFeature[];
36
+ attribution: string;
37
+ licence: string;
38
+ query: string;
39
+ limit: number;
40
+ }
41
+
42
+ const API_BASE_URL = 'https://api-adresse.data.gouv.fr';
43
+
44
+ /**
45
+ * Recherche une adresse
46
+ * @param query - Texte de recherche
47
+ * @param limit - Nombre maximum de résultats (défaut: 5)
48
+ */
49
+ export async function searchAddress(
50
+ query: string,
51
+ limit: number = 5,
52
+ ): Promise<AddressSearchResult> {
53
+ if (!query || query.trim().length < 3) {
54
+ return {
55
+ type: 'FeatureCollection',
56
+ version: 'draft',
57
+ features: [],
58
+ attribution: '',
59
+ licence: '',
60
+ query: query,
61
+ limit: 0,
62
+ };
63
+ }
64
+
65
+ const encodedQuery = encodeURIComponent(query.trim());
66
+ const url = `${API_BASE_URL}/search?q=${encodedQuery}&limit=${limit}`;
67
+
68
+ try {
69
+ const controller = new AbortController();
70
+ const timeoutId = setTimeout(() => controller.abort(), 15_000);
71
+ const response = await fetch(url, { signal: controller.signal });
72
+ clearTimeout(timeoutId);
73
+
74
+ if (!response.ok) {
75
+ const message =
76
+ response.status === 504 || response.status === 502 || response.status === 503
77
+ ? "Le service de recherche d'adresses est temporairement indisponible. Réessayez dans un instant."
78
+ : `Erreur HTTP: ${response.status}`;
79
+ throw new Error(message);
80
+ }
81
+
82
+ const data: AddressSearchResult = await response.json();
83
+ return data;
84
+ } catch (error) {
85
+ if (error instanceof Error) {
86
+ if (error.name === 'AbortError') {
87
+ const timeoutMessage =
88
+ "La recherche d'adresse a pris trop de temps. Réessayez dans un instant.";
89
+ console.error("Erreur lors de la recherche d'adresse:", timeoutMessage);
90
+ throw new Error(timeoutMessage);
91
+ }
92
+ }
93
+ console.error("Erreur lors de la recherche d'adresse:", error);
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Extrait les composants d'une adresse
100
+ */
101
+ export function extractAddressComponents(feature: AddressFeature): {
102
+ street: string;
103
+ city: string;
104
+ postalCode: string;
105
+ fullAddress: string;
106
+ citycode: string;
107
+ } {
108
+ const props = feature.properties;
109
+ return {
110
+ street: props.name || '',
111
+ city: props.city || '',
112
+ postalCode: props.postcode || '',
113
+ fullAddress: props.label || '',
114
+ citycode: props.citycode || '',
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Recherche inversée : trouve une adresse à partir de coordonnées GPS
120
+ * @param latitude - Latitude (ex: 48.8566)
121
+ * @param longitude - Longitude (ex: 2.3522)
122
+ */
123
+ export async function reverseGeocode(
124
+ latitude: number,
125
+ longitude: number,
126
+ ): Promise<AddressSearchResult> {
127
+ const url = `${API_BASE_URL}/reverse?lon=${longitude}&lat=${latitude}`;
128
+
129
+ try {
130
+ const controller = new AbortController();
131
+ const timeoutId = setTimeout(() => controller.abort(), 15_000);
132
+ const response = await fetch(url, { signal: controller.signal });
133
+ clearTimeout(timeoutId);
134
+
135
+ if (!response.ok) {
136
+ const message =
137
+ response.status === 504 || response.status === 502 || response.status === 503
138
+ ? "Le service de recherche d'adresses est temporairement indisponible. Réessayez dans un instant."
139
+ : `Erreur HTTP: ${response.status}`;
140
+ throw new Error(message);
141
+ }
142
+
143
+ const data: AddressSearchResult = await response.json();
144
+ return data;
145
+ } catch (error) {
146
+ if (error instanceof Error && error.name === 'AbortError') {
147
+ const timeoutMessage =
148
+ "La recherche d'adresse a pris trop de temps. Réessayez dans un instant.";
149
+ console.error('Erreur lors du reverse geocoding:', timeoutMessage);
150
+ throw new Error(timeoutMessage);
151
+ }
152
+ console.error('Erreur lors du reverse geocoding:', error);
153
+ throw error;
154
+ }
155
+ }
@@ -0,0 +1,73 @@
1
+ import { LRUCache } from 'lru-cache';
2
+ import { prisma } from '@/lib/prisma';
3
+
4
+ const cache = new LRUCache<string, any>({
5
+ max: 100,
6
+ ttl: 5 * 60 * 1000,
7
+ allowStale: false,
8
+ });
9
+
10
+ const pendingFetches = new Map<string, Promise<any>>();
11
+
12
+ function getCached<T>(key: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
13
+ const cached = cache.get(key);
14
+ if (cached !== undefined) return Promise.resolve(cached as T);
15
+
16
+ const pending = pendingFetches.get(key);
17
+ if (pending) return pending as Promise<T>;
18
+
19
+ const promise = fetcher()
20
+ .then((data) => {
21
+ cache.set(key, data, { ttl: ttlMs });
22
+ pendingFetches.delete(key);
23
+ return data;
24
+ })
25
+ .catch((err) => {
26
+ pendingFetches.delete(key);
27
+ throw err;
28
+ });
29
+
30
+ pendingFetches.set(key, promise);
31
+ return promise;
32
+ }
33
+
34
+ export function invalidateCache(key: string) {
35
+ cache.delete(key);
36
+ }
37
+
38
+ export function invalidateCachePrefix(prefix: string) {
39
+ for (const key of cache.keys()) {
40
+ if (key.startsWith(prefix)) {
41
+ cache.delete(key);
42
+ }
43
+ }
44
+ }
45
+
46
+ const FIVE_MINUTES = 5 * 60 * 1000;
47
+ const ONE_HOUR = 60 * 60 * 1000;
48
+
49
+ export function getCachedStatuses() {
50
+ return getCached('statuses', FIVE_MINUTES, () =>
51
+ prisma.status.findMany({ orderBy: { order: 'asc' } }),
52
+ );
53
+ }
54
+
55
+ export function getCachedCompany() {
56
+ return getCached('company', ONE_HOUR, () =>
57
+ prisma.organization.findUnique({ where: { id: 'company' } }),
58
+ );
59
+ }
60
+
61
+ export function getCachedStatusByName(name: string) {
62
+ return getCached(`status:${name}`, FIVE_MINUTES, () =>
63
+ prisma.status.findFirst({
64
+ where: { name: { equals: name, mode: 'insensitive' } },
65
+ }),
66
+ );
67
+ }
68
+
69
+ export function getCachedClosingReasons() {
70
+ return getCached('closing-reasons', FIVE_MINUTES, () =>
71
+ prisma.closingReason.findMany({ orderBy: { name: 'asc' } }),
72
+ );
73
+ }