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,589 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect, useMemo } from 'react';
4
+ import { X, Search, ChevronRight, Check } from 'lucide-react';
5
+ import { cn } from '@/lib/utils';
6
+ import type {
7
+ ViewFilter,
8
+ FilterOperator,
9
+ DatePreset,
10
+ FilterFieldDefinition,
11
+ } from '@/types/contact-views';
12
+ import {
13
+ FILTER_FIELD_DEFINITIONS,
14
+ OPERATOR_LABELS,
15
+ DATE_PRESET_LABELS,
16
+ } from '@/types/contact-views';
17
+ import type { Region, Department } from '@/lib/french-regions';
18
+ import { FRENCH_REGIONS, FRENCH_DEPARTMENTS } from '@/lib/french-regions';
19
+ import { useFocusTrap } from '@/hooks/use-focus-trap';
20
+
21
+ type Step = 'field' | 'operator' | 'value';
22
+
23
+ interface FilterBuilderProps {
24
+ open: boolean;
25
+ onClose: () => void;
26
+ onApplyFilter: (filter: ViewFilter) => void;
27
+ editingFilter?: { filter: ViewFilter; index: number } | null;
28
+ statusOptions?: { id: string; name: string; color: string }[];
29
+ userOptions?: { id: string; name: string }[];
30
+ originOptions?: string[];
31
+ entityType?: 'contacts' | 'companies';
32
+ }
33
+
34
+ const DATE_PRESETS_GROUPED = [
35
+ {
36
+ label: 'Relatif',
37
+ presets: ['today', 'yesterday', 'tomorrow'] as DatePreset[],
38
+ },
39
+ {
40
+ label: 'Semaine',
41
+ presets: ['this_week', 'this_week_so_far', 'last_week', 'next_week'] as DatePreset[],
42
+ },
43
+ {
44
+ label: 'Mois',
45
+ presets: ['this_month', 'this_month_so_far', 'last_month', 'next_month'] as DatePreset[],
46
+ },
47
+ {
48
+ label: 'Trimestre',
49
+ presets: ['this_quarter', 'this_quarter_so_far', 'last_quarter'] as DatePreset[],
50
+ },
51
+ {
52
+ label: 'Année',
53
+ presets: ['this_year', 'this_year_so_far', 'last_year'] as DatePreset[],
54
+ },
55
+ {
56
+ label: 'Période glissante',
57
+ presets: [
58
+ 'last_7_days',
59
+ 'last_14_days',
60
+ 'last_30_days',
61
+ 'last_60_days',
62
+ 'last_90_days',
63
+ 'last_180_days',
64
+ 'last_365_days',
65
+ ] as DatePreset[],
66
+ },
67
+ ];
68
+
69
+ export function FilterBuilder({
70
+ open,
71
+ onClose,
72
+ onApplyFilter,
73
+ editingFilter,
74
+ statusOptions = [],
75
+ userOptions = [],
76
+ originOptions = [],
77
+ entityType = 'contacts',
78
+ }: FilterBuilderProps) {
79
+ const [step, setStep] = useState<Step>('field');
80
+ const [fieldSearch, setFieldSearch] = useState('');
81
+ const [selectedField, setSelectedField] = useState<FilterFieldDefinition | null>(null);
82
+ const [selectedOperator, setSelectedOperator] = useState<FilterOperator | null>(null);
83
+ const [selectedValue, setSelectedValue] = useState<string | string[] | null>(null);
84
+ const [selectedPreset, setSelectedPreset] = useState<DatePreset | null>(null);
85
+ const [textInput, setTextInput] = useState('');
86
+ const [dateInputStart, setDateInputStart] = useState('');
87
+ const [dateInputEnd, setDateInputEnd] = useState('');
88
+ const [selectSearch, setSelectSearch] = useState('');
89
+ const panelRef = useRef<HTMLDivElement>(null);
90
+
91
+ useFocusTrap(open, panelRef, { onClose, skipInitialFocus: true });
92
+
93
+ useEffect(() => {
94
+ if (open && editingFilter) {
95
+ const def = FILTER_FIELD_DEFINITIONS.find((d) => d.field === editingFilter.filter.field);
96
+ if (def) {
97
+ setSelectedField(def);
98
+ setSelectedOperator(editingFilter.filter.operator);
99
+ setSelectedValue(editingFilter.filter.value);
100
+ setSelectedPreset(editingFilter.filter.preset || null);
101
+ if (typeof editingFilter.filter.value === 'string') {
102
+ setTextInput(editingFilter.filter.value);
103
+ }
104
+ if (
105
+ editingFilter.filter.operator === 'between' &&
106
+ Array.isArray(editingFilter.filter.value) &&
107
+ editingFilter.filter.value.length === 2
108
+ ) {
109
+ setDateInputStart(editingFilter.filter.value[0]);
110
+ setDateInputEnd(editingFilter.filter.value[1]);
111
+ }
112
+ setStep('value');
113
+ }
114
+ } else if (open) {
115
+ resetState();
116
+ }
117
+ }, [open, editingFilter]);
118
+
119
+ useEffect(() => {
120
+ function handleClickOutside(e: MouseEvent) {
121
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
122
+ onClose();
123
+ }
124
+ }
125
+ if (open) {
126
+ document.addEventListener('mousedown', handleClickOutside);
127
+ }
128
+ return () => document.removeEventListener('mousedown', handleClickOutside);
129
+ }, [open, onClose]);
130
+
131
+ function resetState() {
132
+ setStep('field');
133
+ setFieldSearch('');
134
+ setSelectedField(null);
135
+ setSelectedOperator(null);
136
+ setSelectedValue(null);
137
+ setSelectedPreset(null);
138
+ setTextInput('');
139
+ setDateInputStart('');
140
+ setDateInputEnd('');
141
+ setSelectSearch('');
142
+ }
143
+
144
+ const groupedFields = useMemo(() => {
145
+ const groups: Record<string, FilterFieldDefinition[]> = {};
146
+ const search = fieldSearch.toLowerCase();
147
+ for (const def of FILTER_FIELD_DEFINITIONS) {
148
+ if (def.entityTypes && !def.entityTypes.includes(entityType)) continue;
149
+ if (search && !def.label.toLowerCase().includes(search)) continue;
150
+ if (!groups[def.group]) groups[def.group] = [];
151
+ groups[def.group].push(def);
152
+ }
153
+ return groups;
154
+ }, [fieldSearch, entityType]);
155
+
156
+ function handleSelectField(def: FilterFieldDefinition) {
157
+ setSelectedField(def);
158
+ setSelectedOperator(null);
159
+ setSelectedValue(null);
160
+ setSelectedPreset(null);
161
+ setTextInput('');
162
+ setSelectSearch('');
163
+ setStep('operator');
164
+ }
165
+
166
+ function handleSelectOperator(op: FilterOperator) {
167
+ setSelectedOperator(op);
168
+ if (op === 'is_known' || op === 'is_unknown') {
169
+ applyFilter(op, null, null);
170
+ return;
171
+ }
172
+ if (op === 'date_preset') {
173
+ setStep('value');
174
+ return;
175
+ }
176
+ setStep('value');
177
+ }
178
+
179
+ function applyFilter(
180
+ operator?: FilterOperator,
181
+ value?: string | string[] | null,
182
+ preset?: DatePreset | null,
183
+ ) {
184
+ if (!selectedField) return;
185
+ const op = operator || selectedOperator;
186
+ if (!op) return;
187
+
188
+ const filter: ViewFilter = {
189
+ field: selectedField.field,
190
+ operator: op,
191
+ value: value !== undefined ? value : selectedValue,
192
+ ...(preset !== undefined && preset
193
+ ? { preset }
194
+ : selectedPreset
195
+ ? { preset: selectedPreset }
196
+ : {}),
197
+ };
198
+ onApplyFilter(filter);
199
+ resetState();
200
+ onClose();
201
+ }
202
+
203
+ function getSelectOptions(): { value: string; label: string }[] {
204
+ if (!selectedField) return [];
205
+
206
+ if (selectedField.staticOptions) return selectedField.staticOptions;
207
+
208
+ if (selectedField.field === 'statusId') {
209
+ return statusOptions.map((s) => ({ value: s.id, label: s.name }));
210
+ }
211
+ if (
212
+ selectedField.field === 'assignedCommercialId' ||
213
+ selectedField.field === 'assignedTeleproId' ||
214
+ selectedField.field === 'createdById'
215
+ ) {
216
+ const opts = userOptions.map((u) => ({ value: u.id, label: u.name }));
217
+ if (
218
+ selectedField.field === 'assignedCommercialId' ||
219
+ selectedField.field === 'assignedTeleproId'
220
+ ) {
221
+ opts.unshift({ value: 'UNASSIGNED', label: 'Non assigné' });
222
+ }
223
+ return opts;
224
+ }
225
+ if (selectedField.field === 'origin') {
226
+ return originOptions.map((o) => ({ value: o, label: o }));
227
+ }
228
+ if (selectedField.field === 'region') {
229
+ return (FRENCH_REGIONS as Region[]).map((r) => ({
230
+ value: r.code,
231
+ label: r.name,
232
+ }));
233
+ }
234
+ if (selectedField.field === 'department') {
235
+ return (FRENCH_DEPARTMENTS as Department[]).map((d) => ({
236
+ value: d.code,
237
+ label: `${d.code} - ${d.name}`,
238
+ }));
239
+ }
240
+ return [];
241
+ }
242
+
243
+ function handleToggleSelectValue(val: string) {
244
+ const current = Array.isArray(selectedValue) ? [...selectedValue] : [];
245
+ const idx = current.indexOf(val);
246
+ if (idx >= 0) {
247
+ current.splice(idx, 1);
248
+ } else {
249
+ current.push(val);
250
+ }
251
+ setSelectedValue(current);
252
+ }
253
+
254
+ if (!open) return null;
255
+
256
+ return (
257
+ <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/20 pt-32">
258
+ <div
259
+ ref={panelRef}
260
+ className="w-full max-w-md rounded-xl border border-gray-200 bg-white shadow-xl"
261
+ >
262
+ {/* Header */}
263
+ <div className="flex items-center justify-between border-b border-gray-100 px-4 py-3">
264
+ <div className="flex items-center gap-2">
265
+ {step !== 'field' && (
266
+ <button
267
+ onClick={() => {
268
+ if (step === 'value') setStep('operator');
269
+ else if (step === 'operator') setStep('field');
270
+ }}
271
+ className="cursor-pointer rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
272
+ >
273
+ <ChevronRight className="h-4 w-4 rotate-180" />
274
+ </button>
275
+ )}
276
+ <h3 className="text-sm font-semibold text-gray-900">
277
+ {step === 'field' && 'Choisir une propriété'}
278
+ {step === 'operator' && selectedField?.label}
279
+ {step === 'value' && (
280
+ <>
281
+ {selectedField?.label}{' '}
282
+ <span className="font-normal text-gray-500">
283
+ {selectedOperator && OPERATOR_LABELS[selectedOperator]}
284
+ </span>
285
+ </>
286
+ )}
287
+ </h3>
288
+ </div>
289
+ <button
290
+ onClick={() => {
291
+ resetState();
292
+ onClose();
293
+ }}
294
+ className="cursor-pointer rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
295
+ >
296
+ <X className="h-4 w-4" />
297
+ </button>
298
+ </div>
299
+
300
+ {/* Step: Field selection */}
301
+ {step === 'field' && (
302
+ <div className="max-h-80 overflow-y-auto">
303
+ <div className="sticky top-0 border-b border-gray-100 bg-white px-4 py-2">
304
+ <div className="relative">
305
+ <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
306
+ <input
307
+ type="text"
308
+ placeholder="Rechercher une propriété..."
309
+ value={fieldSearch}
310
+ onChange={(e) => setFieldSearch(e.target.value)}
311
+ className="w-full rounded-md border border-gray-200 py-2 pr-3 pl-9 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
312
+ autoFocus
313
+ />
314
+ </div>
315
+ </div>
316
+ {Object.entries(groupedFields).map(([group, fields]) => (
317
+ <div key={group}>
318
+ <div className="px-4 pt-3 pb-1 text-xs font-semibold tracking-wide text-gray-400 uppercase">
319
+ {group}
320
+ </div>
321
+ {fields.map((def) => (
322
+ <button
323
+ key={def.field}
324
+ onClick={() => handleSelectField(def)}
325
+ className="flex w-full cursor-pointer items-center justify-between px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
326
+ >
327
+ {def.label}
328
+ <ChevronRight className="h-3.5 w-3.5 text-gray-400" />
329
+ </button>
330
+ ))}
331
+ </div>
332
+ ))}
333
+ {Object.keys(groupedFields).length === 0 && (
334
+ <div className="px-4 py-6 text-center text-sm text-gray-400">
335
+ Aucune propriété trouvée
336
+ </div>
337
+ )}
338
+ </div>
339
+ )}
340
+
341
+ {/* Step: Operator selection */}
342
+ {step === 'operator' && selectedField && (
343
+ <div className="max-h-80 overflow-y-auto py-1">
344
+ {selectedField.operators.map((op) => (
345
+ <button
346
+ key={op}
347
+ onClick={() => handleSelectOperator(op)}
348
+ className="flex w-full cursor-pointer items-center justify-between px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
349
+ >
350
+ {OPERATOR_LABELS[op]}
351
+ <ChevronRight className="h-3.5 w-3.5 text-gray-400" />
352
+ </button>
353
+ ))}
354
+ </div>
355
+ )}
356
+
357
+ {/* Step: Value input */}
358
+ {step === 'value' && selectedField && selectedOperator && (
359
+ <div className="max-h-96 overflow-y-auto">
360
+ {/* Date preset */}
361
+ {selectedOperator === 'date_preset' && (
362
+ <div className="py-1">
363
+ {DATE_PRESETS_GROUPED.map((group) => (
364
+ <div key={group.label}>
365
+ <div className="px-4 pt-3 pb-1 text-xs font-semibold tracking-wide text-gray-400 uppercase">
366
+ {group.label}
367
+ </div>
368
+ {group.presets.map((preset) => (
369
+ <button
370
+ key={preset}
371
+ onClick={() => applyFilter('date_preset', null, preset)}
372
+ className={cn(
373
+ 'flex w-full cursor-pointer items-center justify-between px-4 py-2 text-sm hover:bg-gray-50',
374
+ selectedPreset === preset ? 'font-medium text-blue-600' : 'text-gray-700',
375
+ )}
376
+ >
377
+ {DATE_PRESET_LABELS[preset]}
378
+ {selectedPreset === preset && <Check className="h-4 w-4 text-blue-600" />}
379
+ </button>
380
+ ))}
381
+ </div>
382
+ ))}
383
+ </div>
384
+ )}
385
+
386
+ {/* Select (multi) */}
387
+ {(selectedOperator === 'is_any_of' || selectedOperator === 'is_none_of') && (
388
+ <div>
389
+ <div className="sticky top-0 border-b border-gray-100 bg-white px-4 py-2">
390
+ <div className="relative">
391
+ <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
392
+ <input
393
+ type="text"
394
+ placeholder="Rechercher..."
395
+ value={selectSearch}
396
+ onChange={(e) => setSelectSearch(e.target.value)}
397
+ className="w-full rounded-md border border-gray-200 py-2 pr-3 pl-9 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
398
+ autoFocus
399
+ />
400
+ </div>
401
+ </div>
402
+ <div className="max-h-60 overflow-y-auto py-1">
403
+ {getSelectOptions()
404
+ .filter((o) => o.label.toLowerCase().includes(selectSearch.toLowerCase()))
405
+ .map((option) => {
406
+ const isSelected =
407
+ Array.isArray(selectedValue) && selectedValue.includes(option.value);
408
+ return (
409
+ <button
410
+ key={option.value}
411
+ onClick={() => handleToggleSelectValue(option.value)}
412
+ className={cn(
413
+ 'flex w-full cursor-pointer items-center gap-2 px-4 py-2 text-sm hover:bg-gray-50',
414
+ isSelected ? 'text-blue-600' : 'text-gray-700',
415
+ )}
416
+ >
417
+ <div
418
+ className={cn(
419
+ 'flex h-4 w-4 shrink-0 items-center justify-center rounded border',
420
+ isSelected ? 'border-blue-600 bg-blue-600' : 'border-gray-300',
421
+ )}
422
+ >
423
+ {isSelected && <Check className="h-3 w-3 text-white" />}
424
+ </div>
425
+ {selectedField?.field === 'statusId' && (
426
+ <span
427
+ className="h-2.5 w-2.5 shrink-0 rounded-full"
428
+ style={{
429
+ backgroundColor:
430
+ statusOptions?.find((s) => s.id === option.value)?.color ||
431
+ '#999',
432
+ }}
433
+ />
434
+ )}
435
+ {option.label}
436
+ </button>
437
+ );
438
+ })}
439
+ </div>
440
+ <div className="border-t border-gray-100 p-3">
441
+ <button
442
+ onClick={() => applyFilter()}
443
+ disabled={!Array.isArray(selectedValue) || selectedValue.length === 0}
444
+ className="w-full cursor-pointer rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
445
+ >
446
+ Appliquer le filtre
447
+ {Array.isArray(selectedValue) &&
448
+ selectedValue.length > 0 &&
449
+ ` (${selectedValue.length})`}
450
+ </button>
451
+ </div>
452
+ </div>
453
+ )}
454
+
455
+ {/* Text input */}
456
+ {selectedField.type === 'text' &&
457
+ selectedOperator !== 'is_known' &&
458
+ selectedOperator !== 'is_unknown' &&
459
+ selectedOperator !== 'is_any_of' &&
460
+ selectedOperator !== 'is_none_of' && (
461
+ <div className="p-4">
462
+ <input
463
+ type="text"
464
+ placeholder="Saisir une valeur..."
465
+ value={textInput}
466
+ onChange={(e) => setTextInput(e.target.value)}
467
+ onKeyDown={(e) => {
468
+ if (e.key === 'Enter' && textInput.trim()) {
469
+ applyFilter(undefined, textInput.trim(), null);
470
+ }
471
+ }}
472
+ className="w-full rounded-md border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
473
+ autoFocus
474
+ />
475
+ <button
476
+ onClick={() => {
477
+ if (textInput.trim()) {
478
+ applyFilter(undefined, textInput.trim(), null);
479
+ }
480
+ }}
481
+ disabled={!textInput.trim()}
482
+ className="mt-3 w-full cursor-pointer rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
483
+ >
484
+ Appliquer le filtre
485
+ </button>
486
+ </div>
487
+ )}
488
+
489
+ {/* select_or_text - text operators */}
490
+ {selectedField.type === 'select_or_text' &&
491
+ selectedOperator !== 'is_known' &&
492
+ selectedOperator !== 'is_unknown' &&
493
+ selectedOperator !== 'is_any_of' &&
494
+ selectedOperator !== 'is_none_of' && (
495
+ <div className="p-4">
496
+ <input
497
+ type="text"
498
+ placeholder="Saisir une valeur..."
499
+ value={textInput}
500
+ onChange={(e) => setTextInput(e.target.value)}
501
+ onKeyDown={(e) => {
502
+ if (e.key === 'Enter' && textInput.trim()) {
503
+ applyFilter(undefined, textInput.trim(), null);
504
+ }
505
+ }}
506
+ className="w-full rounded-md border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
507
+ autoFocus
508
+ />
509
+ <button
510
+ onClick={() => {
511
+ if (textInput.trim()) {
512
+ applyFilter(undefined, textInput.trim(), null);
513
+ }
514
+ }}
515
+ disabled={!textInput.trim()}
516
+ className="mt-3 w-full cursor-pointer rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
517
+ >
518
+ Appliquer le filtre
519
+ </button>
520
+ </div>
521
+ )}
522
+
523
+ {/* Date exact / gt / gte / lt / lte */}
524
+ {selectedField.type === 'date' &&
525
+ selectedOperator !== 'date_preset' &&
526
+ selectedOperator !== 'between' && (
527
+ <div className="p-4">
528
+ <input
529
+ type="date"
530
+ value={dateInputStart}
531
+ onChange={(e) => setDateInputStart(e.target.value)}
532
+ className="w-full rounded-md border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
533
+ autoFocus
534
+ />
535
+ <button
536
+ onClick={() => {
537
+ if (dateInputStart) {
538
+ applyFilter(undefined, dateInputStart, null);
539
+ }
540
+ }}
541
+ disabled={!dateInputStart}
542
+ className="mt-3 w-full cursor-pointer rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
543
+ >
544
+ Appliquer le filtre
545
+ </button>
546
+ </div>
547
+ )}
548
+
549
+ {/* Date between */}
550
+ {selectedField.type === 'date' && selectedOperator === 'between' && (
551
+ <div className="space-y-3 p-4">
552
+ <div>
553
+ <label className="mb-1 block text-xs font-medium text-gray-500">Du</label>
554
+ <input
555
+ type="date"
556
+ value={dateInputStart}
557
+ onChange={(e) => setDateInputStart(e.target.value)}
558
+ className="w-full rounded-md border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
559
+ autoFocus
560
+ />
561
+ </div>
562
+ <div>
563
+ <label className="mb-1 block text-xs font-medium text-gray-500">Au</label>
564
+ <input
565
+ type="date"
566
+ value={dateInputEnd}
567
+ onChange={(e) => setDateInputEnd(e.target.value)}
568
+ className="w-full rounded-md border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400/30 focus:outline-none"
569
+ />
570
+ </div>
571
+ <button
572
+ onClick={() => {
573
+ if (dateInputStart && dateInputEnd) {
574
+ applyFilter(undefined, [dateInputStart, dateInputEnd], null);
575
+ }
576
+ }}
577
+ disabled={!dateInputStart || !dateInputEnd}
578
+ className="w-full cursor-pointer rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
579
+ >
580
+ Appliquer le filtre
581
+ </button>
582
+ </div>
583
+ )}
584
+ </div>
585
+ )}
586
+ </div>
587
+ </div>
588
+ );
589
+ }