create-crm-tmp 1.1.3 → 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 +1 -1
  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 +51 -16
  8. package/template/prisma/schema.prisma +807 -58
  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 +2232 -2189
  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 +5049 -4110
  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 +13 -18
  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 -43
  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 +29 -32
  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 +173 -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 +2 -2
  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 +89 -34
  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 +510 -146
  94. package/template/src/app/api/workflows/route.ts +46 -4
  95. package/template/src/app/globals.css +243 -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 +12 -15
  155. package/template/src/lib/template-variables.ts +67 -33
  156. package/template/src/lib/utils.ts +26 -11
  157. package/template/src/lib/workflow-executor.ts +445 -228
  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/20260226093949_fix_cascade_on_user_delete/migration.sql +0 -69
  205. package/template/prisma/migrations/migration_lock.toml +0 -3
  206. package/template/src/app/(dashboard)/users/layout.tsx +0 -30
  207. package/template/src/app/api/dashboard/widgets/[id]/route.ts +0 -47
  208. package/template/src/app/api/dashboard/widgets/route.ts +0 -181
  209. package/template/src/components/dashboard/add-widget-dialog.tsx +0 -161
  210. package/template/src/components/dashboard/color-picker.tsx +0 -65
  211. package/template/src/components/dashboard/contacts-chart.tsx +0 -69
  212. package/template/src/components/dashboard/interactions-by-type-chart.tsx +0 -121
  213. package/template/src/components/dashboard/recent-activity.tsx +0 -157
  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,160 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect } from 'react';
4
+ import { X, Globe, Lock } from 'lucide-react';
5
+ import { useFocusTrap } from '@/hooks/use-focus-trap';
6
+
7
+ interface SaveViewDialogProps {
8
+ open: boolean;
9
+ onClose: () => void;
10
+ onSave: (name: string, isPublic: boolean) => void;
11
+ mode: 'create' | 'save_as' | 'rename';
12
+ initialName?: string;
13
+ initialPublic?: boolean;
14
+ loading?: boolean;
15
+ }
16
+
17
+ export function SaveViewDialog({
18
+ open,
19
+ onClose,
20
+ onSave,
21
+ mode,
22
+ initialName = '',
23
+ initialPublic = false,
24
+ loading = false,
25
+ }: SaveViewDialogProps) {
26
+ const [name, setName] = useState(initialName);
27
+ const [isPublic, setIsPublic] = useState(initialPublic);
28
+ const inputRef = useRef<HTMLInputElement>(null);
29
+ const panelRef = useRef<HTMLDivElement>(null);
30
+
31
+ useEffect(() => {
32
+ if (open) {
33
+ setName(initialName);
34
+ setIsPublic(initialPublic);
35
+ }
36
+ }, [open, initialName, initialPublic]);
37
+
38
+ useFocusTrap(open, panelRef, { onClose, initialFocusRef: inputRef });
39
+
40
+ useEffect(() => {
41
+ function handleClickOutside(e: MouseEvent) {
42
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
43
+ onClose();
44
+ }
45
+ }
46
+ if (open) {
47
+ document.addEventListener('mousedown', handleClickOutside);
48
+ }
49
+ return () => document.removeEventListener('mousedown', handleClickOutside);
50
+ }, [open, onClose]);
51
+
52
+ function handleSubmit(e: React.FormEvent) {
53
+ e.preventDefault();
54
+ if (!name.trim()) return;
55
+ onSave(name.trim(), isPublic);
56
+ }
57
+
58
+ if (!open) return null;
59
+
60
+ const titles = {
61
+ create: 'Créer une vue',
62
+ save_as: 'Enregistrer sous',
63
+ rename: 'Renommer la vue',
64
+ };
65
+
66
+ return (
67
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
68
+ <div
69
+ ref={panelRef}
70
+ className="w-full max-w-sm rounded-xl border border-gray-200 bg-white shadow-xl"
71
+ >
72
+ <div className="flex items-center justify-between border-b border-gray-100 px-5 py-4">
73
+ <h3 className="text-base font-semibold text-gray-900">{titles[mode]}</h3>
74
+ <button
75
+ onClick={onClose}
76
+ className="cursor-pointer rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
77
+ >
78
+ <X className="h-4 w-4" />
79
+ </button>
80
+ </div>
81
+
82
+ <form onSubmit={handleSubmit} className="space-y-4 p-5">
83
+ <div>
84
+ <label htmlFor="view-name" className="mb-1.5 block text-sm font-medium text-gray-700">
85
+ Nom de la vue
86
+ </label>
87
+ <input
88
+ ref={inputRef}
89
+ id="view-name"
90
+ type="text"
91
+ value={name}
92
+ onChange={(e) => setName(e.target.value)}
93
+ placeholder="Ex: Nouveaux contacts cette semaine"
94
+ className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
95
+ />
96
+ </div>
97
+
98
+ {mode !== 'rename' && (
99
+ <div>
100
+ <label className="mb-2 block text-sm font-medium text-gray-700">Visibilité</label>
101
+ <div className="space-y-2">
102
+ <label className="flex cursor-pointer items-start gap-3 rounded-lg border border-gray-200 p-3 transition-colors hover:bg-gray-50 has-[:checked]:border-blue-200 has-[:checked]:bg-blue-50">
103
+ <input
104
+ type="radio"
105
+ name="visibility"
106
+ checked={!isPublic}
107
+ onChange={() => setIsPublic(false)}
108
+ className="mt-0.5 accent-blue-600"
109
+ />
110
+ <div className="flex-1">
111
+ <div className="flex items-center gap-1.5 text-sm font-medium text-gray-900">
112
+ <Lock className="h-3.5 w-3.5" />
113
+ Privée
114
+ </div>
115
+ <p className="mt-0.5 text-xs text-gray-500">Visible uniquement par vous</p>
116
+ </div>
117
+ </label>
118
+ <label className="flex cursor-pointer items-start gap-3 rounded-lg border border-gray-200 p-3 transition-colors hover:bg-gray-50 has-[:checked]:border-blue-200 has-[:checked]:bg-blue-50">
119
+ <input
120
+ type="radio"
121
+ name="visibility"
122
+ checked={isPublic}
123
+ onChange={() => setIsPublic(true)}
124
+ className="mt-0.5 accent-blue-600"
125
+ />
126
+ <div className="flex-1">
127
+ <div className="flex items-center gap-1.5 text-sm font-medium text-gray-900">
128
+ <Globe className="h-3.5 w-3.5" />
129
+ Publique
130
+ </div>
131
+ <p className="mt-0.5 text-xs text-gray-500">
132
+ Visible par tous les utilisateurs du CRM
133
+ </p>
134
+ </div>
135
+ </label>
136
+ </div>
137
+ </div>
138
+ )}
139
+
140
+ <div className="flex items-center justify-end gap-3 pt-2">
141
+ <button
142
+ type="button"
143
+ onClick={onClose}
144
+ className="cursor-pointer rounded-lg px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100"
145
+ >
146
+ Annuler
147
+ </button>
148
+ <button
149
+ type="submit"
150
+ disabled={!name.trim() || loading}
151
+ className="cursor-pointer rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
152
+ >
153
+ {loading ? 'Enregistrement...' : mode === 'rename' ? 'Renommer' : 'Enregistrer'}
154
+ </button>
155
+ </div>
156
+ </form>
157
+ </div>
158
+ </div>
159
+ );
160
+ }
@@ -0,0 +1,440 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect } from 'react';
4
+ import {
5
+ Plus,
6
+ ChevronDown,
7
+ MoreHorizontal,
8
+ Pencil,
9
+ Copy,
10
+ Trash2,
11
+ Globe,
12
+ Lock,
13
+ Pin,
14
+ PinOff,
15
+ Users,
16
+ Building2,
17
+ } from 'lucide-react';
18
+ import { cn } from '@/lib/utils';
19
+ import type { ContactViewData, ViewFilter } from '@/types/contact-views';
20
+
21
+ interface ViewPermissions {
22
+ canCreate: boolean;
23
+ canEditOwn: boolean;
24
+ canEditAll: boolean;
25
+ canDeleteOwn: boolean;
26
+ canDeleteAll: boolean;
27
+ canShare: boolean;
28
+ }
29
+
30
+ interface ViewsTabBarProps {
31
+ views: ContactViewData[];
32
+ activeViewId: string | null;
33
+ currentUserId: string;
34
+ permissions: ViewPermissions;
35
+ onSelectView: (viewId: string | null) => void;
36
+ onCreateView: () => void;
37
+ onRenameView: (view: ContactViewData) => void;
38
+ onCloneView: (view: ContactViewData) => void;
39
+ onDeleteView: (view: ContactViewData) => void;
40
+ onTogglePublic: (view: ContactViewData) => void;
41
+ onTogglePin: (view: ContactViewData) => void;
42
+ onDropdownOpenChange?: (open: boolean) => void;
43
+ onContextMenuOpenChange?: (open: boolean) => void;
44
+ hasUnsavedChanges?: boolean;
45
+ activeFilters?: ViewFilter[];
46
+ entityType?: 'contacts' | 'companies';
47
+ }
48
+
49
+ export type { ViewPermissions };
50
+
51
+ export function ViewsTabBar({
52
+ views,
53
+ activeViewId,
54
+ currentUserId,
55
+ permissions,
56
+ onSelectView,
57
+ onCreateView,
58
+ onRenameView,
59
+ onCloneView,
60
+ onDeleteView,
61
+ onTogglePublic,
62
+ onTogglePin,
63
+ onDropdownOpenChange,
64
+ onContextMenuOpenChange,
65
+ hasUnsavedChanges,
66
+ activeFilters,
67
+ entityType = 'contacts',
68
+ }: ViewsTabBarProps) {
69
+ const [showAllViews, setShowAllViews] = useState(false);
70
+ const [contextMenu, setContextMenu] = useState<string | null>(null);
71
+ const allViewsRef = useRef<HTMLDivElement>(null);
72
+ const contextMenuRef = useRef<HTMLDivElement>(null);
73
+
74
+ useEffect(() => {
75
+ onDropdownOpenChange?.(showAllViews);
76
+ }, [showAllViews, onDropdownOpenChange]);
77
+
78
+ useEffect(() => {
79
+ onContextMenuOpenChange?.(contextMenu !== null);
80
+ }, [contextMenu, onContextMenuOpenChange]);
81
+
82
+ const pinnedViews = views
83
+ .filter((v) => v.pinOrder != null)
84
+ .sort((a, b) => (a.pinOrder ?? 0) - (b.pinOrder ?? 0));
85
+ const unpinnedViews = views
86
+ .filter((v) => v.pinOrder == null)
87
+ .sort((a, b) => {
88
+ const byCreated = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
89
+ if (byCreated !== 0) return byCreated;
90
+ return (a.name || '').localeCompare(b.name || '', 'fr');
91
+ });
92
+
93
+ useEffect(() => {
94
+ function handleClickOutside(e: MouseEvent) {
95
+ if (allViewsRef.current && !allViewsRef.current.contains(e.target as Node)) {
96
+ setShowAllViews(false);
97
+ }
98
+ if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
99
+ setContextMenu(null);
100
+ }
101
+ }
102
+ document.addEventListener('mousedown', handleClickOutside);
103
+ return () => document.removeEventListener('mousedown', handleClickOutside);
104
+ }, []);
105
+
106
+ const getFilterCount = (view: ContactViewData) => {
107
+ const filters = view.filters as ViewFilter[];
108
+ return Array.isArray(filters) ? filters.length : 0;
109
+ };
110
+
111
+ const isOwner = (view: ContactViewData) => view.userId === currentUserId;
112
+
113
+ const canEdit = (view: ContactViewData) =>
114
+ isOwner(view) ? permissions.canEditOwn : permissions.canEditAll;
115
+
116
+ const canDelete = (view: ContactViewData) =>
117
+ isOwner(view) ? permissions.canDeleteOwn : permissions.canDeleteAll;
118
+
119
+ const hasContextActions = (view: ContactViewData) =>
120
+ canEdit(view) || canDelete(view) || permissions.canCreate;
121
+
122
+ return (
123
+ <div className="flex items-center gap-1 border-b border-gray-200 bg-white px-4 sm:px-6 lg:px-8">
124
+ {/* Vue "Tous les contacts" (défaut) */}
125
+ <button
126
+ onClick={() => onSelectView(null)}
127
+ className={cn(
128
+ 'relative flex shrink-0 cursor-pointer items-center gap-1.5 border-b-2 px-3 py-2.5 text-sm font-medium whitespace-nowrap transition-colors',
129
+ activeViewId === null
130
+ ? 'border-blue-600 text-blue-600'
131
+ : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
132
+ )}
133
+ >
134
+ {entityType === 'companies' ? (
135
+ <Building2 className="h-3.5 w-3.5" />
136
+ ) : (
137
+ <Users className="h-3.5 w-3.5" />
138
+ )}
139
+ {entityType === 'companies' ? 'Toutes les entreprises' : 'Tous les contacts'}
140
+ {activeViewId === null && activeFilters && activeFilters.length > 0 && (
141
+ <span className="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-blue-100 px-1 text-[10px] font-semibold text-blue-700">
142
+ {activeFilters.length}
143
+ </span>
144
+ )}
145
+ </button>
146
+
147
+ {/* Vues épinglées */}
148
+ {pinnedViews.map((view) => (
149
+ <div key={view.id} className="group relative flex shrink-0 items-center">
150
+ <button
151
+ onClick={() => onSelectView(view.id)}
152
+ className={cn(
153
+ 'relative flex cursor-pointer items-center gap-1.5 border-b-2 px-3 py-2.5 text-sm font-medium whitespace-nowrap transition-colors',
154
+ activeViewId === view.id
155
+ ? 'border-blue-600 text-blue-600'
156
+ : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
157
+ )}
158
+ >
159
+ {view.isPublic && !isOwner(view) && <Lock className="h-3 w-3 text-gray-400" />}
160
+ {view.isPublic && isOwner(view) && <Globe className="h-3 w-3 text-gray-400" />}
161
+ {view.name}
162
+ {getFilterCount(view) > 0 && (
163
+ <span className="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-gray-100 px-1 text-[10px] font-semibold text-gray-600">
164
+ {getFilterCount(view)}
165
+ </span>
166
+ )}
167
+ {activeViewId === view.id && hasUnsavedChanges && (
168
+ <span className="ml-1 h-1.5 w-1.5 rounded-full bg-orange-400" />
169
+ )}
170
+ </button>
171
+
172
+ {/* Menu contextuel par vue épinglée */}
173
+ {hasContextActions(view) && (
174
+ <div className="relative">
175
+ <button
176
+ onClick={(e) => {
177
+ e.stopPropagation();
178
+ setContextMenu(contextMenu === view.id ? null : view.id);
179
+ }}
180
+ className={cn(
181
+ 'cursor-pointer rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600',
182
+ activeViewId === view.id ? 'visible' : 'invisible group-hover:visible',
183
+ )}
184
+ >
185
+ <MoreHorizontal className="h-3.5 w-3.5" />
186
+ </button>
187
+
188
+ {contextMenu === view.id && (
189
+ <div
190
+ ref={contextMenuRef}
191
+ className="absolute top-full right-0 z-50 mt-1 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
192
+ >
193
+ {canEdit(view) && (
194
+ <button
195
+ onClick={() => {
196
+ onRenameView(view);
197
+ setContextMenu(null);
198
+ }}
199
+ className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
200
+ >
201
+ <Pencil className="h-3.5 w-3.5" />
202
+ Renommer
203
+ </button>
204
+ )}
205
+ {permissions.canCreate && (
206
+ <button
207
+ onClick={() => {
208
+ onCloneView(view);
209
+ setContextMenu(null);
210
+ }}
211
+ className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
212
+ >
213
+ <Copy className="h-3.5 w-3.5" />
214
+ Dupliquer
215
+ </button>
216
+ )}
217
+ {canEdit(view) && permissions.canShare && (
218
+ <button
219
+ onClick={() => {
220
+ onTogglePublic(view);
221
+ setContextMenu(null);
222
+ }}
223
+ className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
224
+ >
225
+ {view.isPublic ? (
226
+ <>
227
+ <Lock className="h-3.5 w-3.5" />
228
+ Rendre privée
229
+ </>
230
+ ) : (
231
+ <>
232
+ <Globe className="h-3.5 w-3.5" />
233
+ Rendre publique
234
+ </>
235
+ )}
236
+ </button>
237
+ )}
238
+ <button
239
+ onClick={() => {
240
+ onTogglePin(view);
241
+ setContextMenu(null);
242
+ }}
243
+ className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
244
+ >
245
+ <PinOff className="h-3.5 w-3.5" />
246
+ Désépingler
247
+ </button>
248
+ {canDelete(view) && (
249
+ <>
250
+ <hr className="my-1 border-gray-100" />
251
+ <button
252
+ onClick={() => {
253
+ onDeleteView(view);
254
+ setContextMenu(null);
255
+ }}
256
+ className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-red-50"
257
+ >
258
+ <Trash2 className="h-3.5 w-3.5" />
259
+ Supprimer
260
+ </button>
261
+ </>
262
+ )}
263
+ </div>
264
+ )}
265
+ </div>
266
+ )}
267
+ </div>
268
+ ))}
269
+
270
+ {/* Dropdown "Toutes les vues" */}
271
+ {unpinnedViews.length > 0 && (
272
+ <div className="relative" ref={allViewsRef}>
273
+ <button
274
+ onClick={() => setShowAllViews(!showAllViews)}
275
+ className={cn(
276
+ 'flex shrink-0 cursor-pointer items-center gap-1 border-b-2 px-3 py-2.5 text-sm font-medium whitespace-nowrap transition-colors',
277
+ unpinnedViews.some((v) => v.id === activeViewId)
278
+ ? 'border-blue-600 text-blue-600'
279
+ : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
280
+ )}
281
+ >
282
+ {unpinnedViews.find((v) => v.id === activeViewId)?.name || 'Toutes les vues'}
283
+ <ChevronDown className="h-3.5 w-3.5" />
284
+ {unpinnedViews.length > 0 && (
285
+ <span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-gray-100 px-1 text-[10px] font-semibold text-gray-500">
286
+ {unpinnedViews.length}
287
+ </span>
288
+ )}
289
+ </button>
290
+
291
+ {showAllViews && (
292
+ <div className="absolute top-full left-0 z-50 mt-1 w-72 rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
293
+ <div className="px-3 py-2 text-xs font-semibold tracking-wide text-gray-400 uppercase">
294
+ Vues enregistrées
295
+ </div>
296
+ {unpinnedViews.map((view) => (
297
+ <div
298
+ key={view.id}
299
+ className="group flex items-center justify-between px-3 py-2 hover:bg-gray-50"
300
+ >
301
+ <button
302
+ onClick={() => {
303
+ onSelectView(view.id);
304
+ setShowAllViews(false);
305
+ }}
306
+ className={cn(
307
+ 'flex min-w-0 flex-1 cursor-pointer items-center gap-2 text-left text-sm transition-colors hover:text-gray-900',
308
+ activeViewId === view.id ? 'font-medium text-blue-600' : 'text-gray-700',
309
+ )}
310
+ >
311
+ {view.isPublic ? (
312
+ <Globe className="h-3.5 w-3.5 shrink-0 text-gray-400" />
313
+ ) : (
314
+ <Lock className="h-3.5 w-3.5 shrink-0 text-gray-400" />
315
+ )}
316
+ <span className="truncate">{view.name}</span>
317
+ {getFilterCount(view) > 0 && (
318
+ <span className="ml-1 inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-gray-100 px-1 text-[10px] font-semibold text-gray-500">
319
+ {getFilterCount(view)}
320
+ </span>
321
+ )}
322
+ {!isOwner(view) && (
323
+ <span className="shrink-0 text-xs text-gray-400">par {view.user?.name}</span>
324
+ )}
325
+ </button>
326
+ {hasContextActions(view) && (
327
+ <div className="relative shrink-0">
328
+ <button
329
+ onClick={(e) => {
330
+ e.stopPropagation();
331
+ setContextMenu(contextMenu === view.id ? null : view.id);
332
+ }}
333
+ className="cursor-pointer rounded p-1 text-gray-400 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-gray-200 hover:text-gray-600"
334
+ title="Actions"
335
+ >
336
+ <MoreHorizontal className="h-3.5 w-3.5" />
337
+ </button>
338
+ {contextMenu === view.id && (
339
+ <div
340
+ ref={contextMenuRef}
341
+ className="absolute top-full right-0 z-50 mt-1 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
342
+ >
343
+ {canEdit(view) && (
344
+ <button
345
+ onClick={() => {
346
+ onRenameView(view);
347
+ setContextMenu(null);
348
+ setShowAllViews(false);
349
+ }}
350
+ className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
351
+ >
352
+ <Pencil className="h-3.5 w-3.5" />
353
+ Renommer
354
+ </button>
355
+ )}
356
+ {permissions.canCreate && (
357
+ <button
358
+ onClick={() => {
359
+ onCloneView(view);
360
+ setContextMenu(null);
361
+ setShowAllViews(false);
362
+ }}
363
+ className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
364
+ >
365
+ <Copy className="h-3.5 w-3.5" />
366
+ Dupliquer
367
+ </button>
368
+ )}
369
+ {canEdit(view) && permissions.canShare && (
370
+ <button
371
+ onClick={() => {
372
+ onTogglePublic(view);
373
+ setContextMenu(null);
374
+ setShowAllViews(false);
375
+ }}
376
+ className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
377
+ >
378
+ {view.isPublic ? (
379
+ <>
380
+ <Lock className="h-3.5 w-3.5" />
381
+ Rendre privée
382
+ </>
383
+ ) : (
384
+ <>
385
+ <Globe className="h-3.5 w-3.5" />
386
+ Rendre publique
387
+ </>
388
+ )}
389
+ </button>
390
+ )}
391
+ <button
392
+ onClick={() => {
393
+ onTogglePin(view);
394
+ setContextMenu(null);
395
+ setShowAllViews(false);
396
+ }}
397
+ className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
398
+ >
399
+ <Pin className="h-3.5 w-3.5" />
400
+ Épingler
401
+ </button>
402
+ {canDelete(view) && (
403
+ <>
404
+ <hr className="my-1 border-gray-100" />
405
+ <button
406
+ onClick={() => {
407
+ onDeleteView(view);
408
+ setContextMenu(null);
409
+ setShowAllViews(false);
410
+ }}
411
+ className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-red-50"
412
+ >
413
+ <Trash2 className="h-3.5 w-3.5" />
414
+ Supprimer
415
+ </button>
416
+ </>
417
+ )}
418
+ </div>
419
+ )}
420
+ </div>
421
+ )}
422
+ </div>
423
+ ))}
424
+ </div>
425
+ )}
426
+ </div>
427
+ )}
428
+
429
+ {/* Bouton "+" pour créer */}
430
+ {permissions.canCreate && (
431
+ <button
432
+ onClick={onCreateView}
433
+ className="ml-1 flex shrink-0 cursor-pointer items-center gap-1 rounded-md border border-dashed border-gray-300 px-2.5 py-1.5 text-sm text-gray-500 transition-colors hover:border-gray-400 hover:bg-gray-50 hover:text-gray-700"
434
+ >
435
+ <Plus className="h-3.5 w-3.5" />
436
+ </button>
437
+ )}
438
+ </div>
439
+ );
440
+ }