create-crm-tmp 1.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.
- package/bin/create-crm-tmp.js +93 -0
- package/package.json +25 -0
- package/template/.prettierignore +33 -0
- package/template/.prettierrc.json +25 -0
- package/template/README.md +173 -0
- package/template/eslint.config.mjs +18 -0
- package/template/exemple-contacts.csv +11 -0
- package/template/next.config.ts +8 -0
- package/template/package.json +64 -0
- package/template/postcss.config.mjs +7 -0
- package/template/prisma/migrations/20251126144728_init/migration.sql +78 -0
- package/template/prisma/migrations/20251126155204_add_user_roles/migration.sql +5 -0
- package/template/prisma/migrations/20251128095126_add_company_info/migration.sql +19 -0
- package/template/prisma/migrations/20251128123321_add_smtp_config/migration.sql +22 -0
- package/template/prisma/migrations/20251128132303_add_status/migration.sql +23 -0
- package/template/prisma/migrations/20251201102207_add_user_active/migration.sql +75 -0
- package/template/prisma/migrations/20251201105507_add_email_signature/migration.sql +2 -0
- package/template/prisma/migrations/20251201151122_add_tasks/migration.sql +45 -0
- package/template/prisma/migrations/20251202111854_add_task_reminder/migration.sql +2 -0
- package/template/prisma/migrations/20251202135859_add_google_meet_integration/migration.sql +27 -0
- package/template/prisma/migrations/20251203103317_add_meta_lead_integration/migration.sql +20 -0
- package/template/prisma/migrations/20251203104002_add_google_ads_integration/migration.sql +18 -0
- package/template/prisma/migrations/20251203112122_add_google_sheet_integration/migration.sql +32 -0
- package/template/prisma/migrations/20251203153853_allow_multiple_integration_configs/migration.sql +20 -0
- package/template/prisma/migrations/20251205141705_update_user_roles/migration.sql +12 -0
- package/template/prisma/migrations/20251205150000_add_commercial_and_telepro_assignment/migration.sql +21 -0
- package/template/prisma/migrations/20251205160000_add_interaction_logging/migration.sql +11 -0
- package/template/prisma/migrations/20251208090314_add_automatic_interaction_types/migration.sql +12 -0
- package/template/prisma/migrations/20251208094843_mg/migration.sql +14 -0
- package/template/prisma/migrations/20251208100000_add_company_support/migration.sql +14 -0
- package/template/prisma/migrations/20251208110000_add_templates/migration.sql +26 -0
- package/template/prisma/migrations/20251208141304_add_video_conference_task_type/migration.sql +2 -0
- package/template/prisma/migrations/20251209104759_add_internal_note_to_task/migration.sql +2 -0
- package/template/prisma/migrations/20251209134803_add_company_field/migration.sql +2 -0
- package/template/prisma/migrations/20251209150000_rename_company_to_company_name/migration.sql +3 -0
- package/template/prisma/migrations/20251209150016_add_email_tracking/migration.sql +21 -0
- package/template/prisma/migrations/20251209155908_add_notify_contact_to_task/migration.sql +2 -0
- package/template/prisma/migrations/20251210110019_add_appointment_types/migration.sql +10 -0
- package/template/prisma/migrations/20251210113928_add_contact_files/migration.sql +26 -0
- package/template/prisma/migrations/20251212132339_add_custom_roles/migration.sql +24 -0
- package/template/prisma/migrations/20251215104448_add_file_interaction_types/migration.sql +11 -0
- package/template/prisma/migrations/20251215145616_add_closing_reasons/migration.sql +12 -0
- package/template/prisma/migrations/20251216140850_add_log_users/migration.sql +25 -0
- package/template/prisma/migrations/20251216151000_rename_perdu_to_ferme/migration.sql +8 -0
- package/template/prisma/migrations/20251216162318_add_column_mappings_to_google_sheet/migration.sql +2 -0
- package/template/prisma/migrations/20251216185127_add_workflows/migration.sql +80 -0
- package/template/prisma/migrations/20251216192237_add_scheduled_workflow_actions/migration.sql +32 -0
- package/template/prisma/migrations/migration_lock.toml +3 -0
- package/template/prisma/schema.prisma +582 -0
- package/template/prisma.config.ts +14 -0
- package/template/src/app/(auth)/invite/[token]/page.tsx +200 -0
- package/template/src/app/(auth)/layout.tsx +3 -0
- package/template/src/app/(auth)/reset-password/complete/page.tsx +213 -0
- package/template/src/app/(auth)/reset-password/page.tsx +146 -0
- package/template/src/app/(auth)/reset-password/verify/page.tsx +183 -0
- package/template/src/app/(auth)/signin/page.tsx +166 -0
- package/template/src/app/(dashboard)/agenda/page.tsx +3051 -0
- package/template/src/app/(dashboard)/automatisation/[id]/page.tsx +24 -0
- package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +905 -0
- package/template/src/app/(dashboard)/automatisation/new/page.tsx +20 -0
- package/template/src/app/(dashboard)/automatisation/page.tsx +337 -0
- package/template/src/app/(dashboard)/closing/page.tsx +1052 -0
- package/template/src/app/(dashboard)/contacts/[id]/page.tsx +6028 -0
- package/template/src/app/(dashboard)/contacts/page.tsx +3713 -0
- package/template/src/app/(dashboard)/dashboard/page.tsx +186 -0
- package/template/src/app/(dashboard)/layout.tsx +30 -0
- package/template/src/app/(dashboard)/settings/page.tsx +4070 -0
- package/template/src/app/(dashboard)/templates/page.tsx +567 -0
- package/template/src/app/(dashboard)/users/list/page.tsx +507 -0
- package/template/src/app/(dashboard)/users/page.tsx +457 -0
- package/template/src/app/(dashboard)/users/permissions/page.tsx +181 -0
- package/template/src/app/(dashboard)/users/roles/page.tsx +434 -0
- package/template/src/app/api/audit-logs/route.ts +57 -0
- package/template/src/app/api/auth/[...all]/route.ts +4 -0
- package/template/src/app/api/auth/check-active/route.ts +31 -0
- package/template/src/app/api/auth/google/callback/route.ts +94 -0
- package/template/src/app/api/auth/google/disconnect/route.ts +32 -0
- package/template/src/app/api/auth/google/route.ts +34 -0
- package/template/src/app/api/auth/google/status/route.ts +32 -0
- package/template/src/app/api/closing-reasons/route.ts +27 -0
- package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +94 -0
- package/template/src/app/api/contacts/[id]/files/route.ts +269 -0
- package/template/src/app/api/contacts/[id]/interactions/[interactionId]/route.ts +91 -0
- package/template/src/app/api/contacts/[id]/interactions/route.ts +103 -0
- package/template/src/app/api/contacts/[id]/meet/route.ts +296 -0
- package/template/src/app/api/contacts/[id]/route.ts +322 -0
- package/template/src/app/api/contacts/[id]/send-email/route.ts +254 -0
- package/template/src/app/api/contacts/export/route.ts +270 -0
- package/template/src/app/api/contacts/import/route.ts +381 -0
- package/template/src/app/api/contacts/route.ts +283 -0
- package/template/src/app/api/dashboard/stats/route.ts +299 -0
- package/template/src/app/api/email/track/[id]/route.ts +68 -0
- package/template/src/app/api/integrations/google-sheet/sync/route.ts +526 -0
- package/template/src/app/api/invite/complete/route.ts +88 -0
- package/template/src/app/api/invite/validate/route.ts +55 -0
- package/template/src/app/api/reminders/route.ts +95 -0
- package/template/src/app/api/reset-password/complete/route.ts +73 -0
- package/template/src/app/api/reset-password/request/route.ts +84 -0
- package/template/src/app/api/reset-password/validate/route.ts +49 -0
- package/template/src/app/api/reset-password/verify/route.ts +74 -0
- package/template/src/app/api/roles/[id]/route.ts +183 -0
- package/template/src/app/api/roles/route.ts +140 -0
- package/template/src/app/api/send/route.ts +282 -0
- package/template/src/app/api/settings/change-password/route.ts +95 -0
- package/template/src/app/api/settings/closing-reasons/[id]/route.ts +84 -0
- package/template/src/app/api/settings/closing-reasons/route.ts +74 -0
- package/template/src/app/api/settings/company/route.ts +121 -0
- package/template/src/app/api/settings/google-ads/[id]/route.ts +117 -0
- package/template/src/app/api/settings/google-ads/route.ts +122 -0
- package/template/src/app/api/settings/google-sheet/[id]/route.ts +230 -0
- package/template/src/app/api/settings/google-sheet/auto-map/route.ts +196 -0
- package/template/src/app/api/settings/google-sheet/route.ts +254 -0
- package/template/src/app/api/settings/meta-leads/[id]/route.ts +123 -0
- package/template/src/app/api/settings/meta-leads/route.ts +132 -0
- package/template/src/app/api/settings/profile/route.ts +42 -0
- package/template/src/app/api/settings/smtp/route.ts +130 -0
- package/template/src/app/api/settings/smtp/test/route.ts +121 -0
- package/template/src/app/api/settings/statuses/[id]/route.ts +101 -0
- package/template/src/app/api/settings/statuses/route.ts +83 -0
- package/template/src/app/api/statuses/route.ts +25 -0
- package/template/src/app/api/tasks/[id]/attendees/route.ts +76 -0
- package/template/src/app/api/tasks/[id]/route.ts +728 -0
- package/template/src/app/api/tasks/meet/route.ts +240 -0
- package/template/src/app/api/tasks/route.ts +417 -0
- package/template/src/app/api/templates/[id]/route.ts +140 -0
- package/template/src/app/api/templates/route.ts +91 -0
- package/template/src/app/api/users/[id]/route.ts +168 -0
- package/template/src/app/api/users/list/route.ts +45 -0
- package/template/src/app/api/users/me/route.ts +48 -0
- package/template/src/app/api/users/route.ts +250 -0
- package/template/src/app/api/webhooks/google-ads/route.ts +208 -0
- package/template/src/app/api/webhooks/meta-leads/route.ts +258 -0
- package/template/src/app/api/workflows/[id]/route.ts +192 -0
- package/template/src/app/api/workflows/process/route.ts +293 -0
- package/template/src/app/api/workflows/route.ts +124 -0
- package/template/src/app/favicon.ico +0 -0
- package/template/src/app/globals.css +1416 -0
- package/template/src/app/layout.tsx +31 -0
- package/template/src/app/page.tsx +32 -0
- package/template/src/components/dashboard/activity-chart.tsx +67 -0
- package/template/src/components/dashboard/contacts-chart.tsx +63 -0
- package/template/src/components/dashboard/recent-activity.tsx +164 -0
- package/template/src/components/dashboard/sales-analytics-chart.tsx +81 -0
- package/template/src/components/dashboard/stat-card.tsx +61 -0
- package/template/src/components/dashboard/status-distribution-chart.tsx +45 -0
- package/template/src/components/dashboard/tasks-pie-chart.tsx +88 -0
- package/template/src/components/dashboard/top-contacts-list.tsx +129 -0
- package/template/src/components/dashboard/upcoming-tasks-list.tsx +126 -0
- package/template/src/components/editor.tsx +856 -0
- package/template/src/components/email-template.tsx +35 -0
- package/template/src/components/header.tsx +320 -0
- package/template/src/components/invitation-email-template.tsx +79 -0
- package/template/src/components/meet-cancellation-email-template.tsx +120 -0
- package/template/src/components/meet-confirmation-email-template.tsx +156 -0
- package/template/src/components/meet-update-email-template.tsx +209 -0
- package/template/src/components/page-header.tsx +61 -0
- package/template/src/components/reset-password-email-template.tsx +79 -0
- package/template/src/components/sidebar.tsx +294 -0
- package/template/src/components/skeleton.tsx +380 -0
- package/template/src/components/ui/commands.tsx +396 -0
- package/template/src/components/ui/components.tsx +150 -0
- package/template/src/components/ui/theme.tsx +5 -0
- package/template/src/components/view-as-banner.tsx +45 -0
- package/template/src/components/view-as-modal.tsx +186 -0
- package/template/src/contexts/mobile-menu-context.tsx +31 -0
- package/template/src/contexts/sidebar-context.tsx +107 -0
- package/template/src/contexts/task-reminder-context.tsx +239 -0
- package/template/src/contexts/view-as-context.tsx +84 -0
- package/template/src/hooks/use-user-role.ts +82 -0
- package/template/src/lib/audit-log.ts +45 -0
- package/template/src/lib/auth-client.ts +16 -0
- package/template/src/lib/auth.ts +35 -0
- package/template/src/lib/check-permission.ts +193 -0
- package/template/src/lib/contact-duplicate.ts +112 -0
- package/template/src/lib/contact-interactions.ts +371 -0
- package/template/src/lib/encryption.ts +99 -0
- package/template/src/lib/google-calendar.ts +300 -0
- package/template/src/lib/google-drive.ts +372 -0
- package/template/src/lib/permissions.ts +412 -0
- package/template/src/lib/prisma.ts +32 -0
- package/template/src/lib/roles.ts +120 -0
- package/template/src/lib/template-variables.ts +76 -0
- package/template/src/lib/utils.ts +46 -0
- package/template/src/lib/workflow-executor.ts +482 -0
- package/template/src/proxy.ts +91 -0
- package/template/tsconfig.json +34 -0
- package/template/vercel.json +8 -0
|
@@ -0,0 +1,3713 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useMemo } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { useUserRole } from '@/hooks/use-user-role';
|
|
6
|
+
import { useMobileMenuContext } from '@/contexts/mobile-menu-context';
|
|
7
|
+
import {
|
|
8
|
+
Search,
|
|
9
|
+
Plus,
|
|
10
|
+
Trash2,
|
|
11
|
+
Phone,
|
|
12
|
+
Mail,
|
|
13
|
+
MapPin,
|
|
14
|
+
Upload,
|
|
15
|
+
X,
|
|
16
|
+
Filter,
|
|
17
|
+
LayoutGrid,
|
|
18
|
+
List,
|
|
19
|
+
ChevronsLeft,
|
|
20
|
+
ChevronsRight,
|
|
21
|
+
Eye,
|
|
22
|
+
EyeOff,
|
|
23
|
+
GripVertical,
|
|
24
|
+
Users,
|
|
25
|
+
Tag,
|
|
26
|
+
ArrowRight,
|
|
27
|
+
Download,
|
|
28
|
+
RefreshCw,
|
|
29
|
+
Calendar,
|
|
30
|
+
Building2,
|
|
31
|
+
ChevronDown,
|
|
32
|
+
ChevronUp,
|
|
33
|
+
} from 'lucide-react';
|
|
34
|
+
import { ContactTableSkeleton, ContactCardsSkeleton } from '@/components/skeleton';
|
|
35
|
+
import { cn, normalizePhoneNumber } from '@/lib/utils';
|
|
36
|
+
|
|
37
|
+
interface Status {
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
color: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface User {
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
email: string;
|
|
47
|
+
role?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface Contact {
|
|
51
|
+
id: string;
|
|
52
|
+
civility: string | null;
|
|
53
|
+
firstName: string | null;
|
|
54
|
+
lastName: string | null;
|
|
55
|
+
phone: string;
|
|
56
|
+
secondaryPhone: string | null;
|
|
57
|
+
email: string | null;
|
|
58
|
+
address: string | null;
|
|
59
|
+
city: string | null;
|
|
60
|
+
postalCode: string | null;
|
|
61
|
+
origin: string | null;
|
|
62
|
+
companyName: string | null;
|
|
63
|
+
isCompany: boolean;
|
|
64
|
+
companyId: string | null;
|
|
65
|
+
companyRelation: Contact | null;
|
|
66
|
+
statusId: string | null;
|
|
67
|
+
status: Status | null;
|
|
68
|
+
assignedCommercialId: string | null;
|
|
69
|
+
assignedCommercial: User | null;
|
|
70
|
+
assignedTeleproId: string | null;
|
|
71
|
+
assignedTelepro: User | null;
|
|
72
|
+
createdById: string;
|
|
73
|
+
createdBy: User;
|
|
74
|
+
createdAt: string;
|
|
75
|
+
updatedAt: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface TableColumn {
|
|
79
|
+
id: string;
|
|
80
|
+
label: string;
|
|
81
|
+
visible: boolean;
|
|
82
|
+
order: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const DEFAULT_COLUMNS: TableColumn[] = [
|
|
86
|
+
{ id: 'contact', label: 'Contact', visible: true, order: 0 },
|
|
87
|
+
{ id: 'phone', label: 'Téléphone', visible: true, order: 1 },
|
|
88
|
+
{ id: 'email', label: 'Email', visible: true, order: 2 },
|
|
89
|
+
{ id: 'status', label: 'Statut', visible: true, order: 3 },
|
|
90
|
+
{ id: 'origin', label: 'Origine', visible: true, order: 4 },
|
|
91
|
+
{ id: 'commercial', label: 'Commercial', visible: true, order: 5 },
|
|
92
|
+
{ id: 'telepro', label: 'Télépro', visible: true, order: 6 },
|
|
93
|
+
{ id: 'createdAt', label: 'CRÉÉ LE', visible: true, order: 7 },
|
|
94
|
+
{ id: 'updatedAt', label: 'MODIFIÉ LE', visible: true, order: 8 },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
export default function ContactsPage() {
|
|
98
|
+
const router = useRouter();
|
|
99
|
+
const { isAdmin } = useUserRole();
|
|
100
|
+
const { toggle: toggleMobileMenu, isOpen: isMobileMenuOpen } = useMobileMenuContext();
|
|
101
|
+
|
|
102
|
+
// Fonction pour formater les dates en français
|
|
103
|
+
const formatDate = (dateString: string) => {
|
|
104
|
+
const date = new Date(dateString);
|
|
105
|
+
return new Intl.DateTimeFormat('fr-FR', {
|
|
106
|
+
day: '2-digit',
|
|
107
|
+
month: '2-digit',
|
|
108
|
+
year: 'numeric',
|
|
109
|
+
hour: '2-digit',
|
|
110
|
+
minute: '2-digit',
|
|
111
|
+
}).format(date);
|
|
112
|
+
};
|
|
113
|
+
const [contacts, setContacts] = useState<Contact[]>([]);
|
|
114
|
+
const [loading, setLoading] = useState(true);
|
|
115
|
+
const [showModal, setShowModal] = useState(false);
|
|
116
|
+
const [editingContact, setEditingContact] = useState<Contact | null>(null);
|
|
117
|
+
const [statuses, setStatuses] = useState<Status[]>([]);
|
|
118
|
+
const [users, setUsers] = useState<User[]>([]);
|
|
119
|
+
const [closingReasons, setClosingReasons] = useState<{ id: string; name: string }[]>([]);
|
|
120
|
+
const [error, setError] = useState('');
|
|
121
|
+
const [success, setSuccess] = useState('');
|
|
122
|
+
const [showImportModal, setShowImportModal] = useState(false);
|
|
123
|
+
const [importFile, setImportFile] = useState<File | null>(null);
|
|
124
|
+
const [importPreview, setImportPreview] = useState<any[]>([]);
|
|
125
|
+
const [importHeaders, setImportHeaders] = useState<string[]>([]);
|
|
126
|
+
const [importFieldMappings, setImportFieldMappings] = useState<
|
|
127
|
+
Record<
|
|
128
|
+
string,
|
|
129
|
+
{
|
|
130
|
+
action: 'map' | 'note' | 'ignore';
|
|
131
|
+
crmField?: string;
|
|
132
|
+
}
|
|
133
|
+
>
|
|
134
|
+
>({});
|
|
135
|
+
const [importSkipFirstRow, setImportSkipFirstRow] = useState(false);
|
|
136
|
+
const [importing, setImporting] = useState(false);
|
|
137
|
+
const [importResult, setImportResult] = useState<any>(null);
|
|
138
|
+
const [showImportResultModal, setShowImportResultModal] = useState(false);
|
|
139
|
+
const [importResultData, setImportResultData] = useState<any>(null);
|
|
140
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
141
|
+
|
|
142
|
+
// Valeurs par défaut pour l'import
|
|
143
|
+
const [defaultStatusId, setDefaultStatusId] = useState('');
|
|
144
|
+
const [defaultCommercialId, setDefaultCommercialId] = useState('');
|
|
145
|
+
const [defaultOrigin, setDefaultOrigin] = useState('');
|
|
146
|
+
|
|
147
|
+
// Filtres
|
|
148
|
+
const [search, setSearch] = useState('');
|
|
149
|
+
const [statusFilter, setStatusFilter] = useState<string>('');
|
|
150
|
+
const [originFilter, setOriginFilter] = useState<string>('');
|
|
151
|
+
const [assignedCommercialFilter, setAssignedCommercialFilter] = useState<string>('');
|
|
152
|
+
const [assignedTeleproFilter, setAssignedTeleproFilter] = useState<string>('');
|
|
153
|
+
|
|
154
|
+
// Filtres de date
|
|
155
|
+
const [dateFilterModal, setDateFilterModal] = useState<'createdAt' | 'updatedAt' | null>(null);
|
|
156
|
+
const [dateFilterPosition, setDateFilterPosition] = useState<{
|
|
157
|
+
top: number;
|
|
158
|
+
left: number;
|
|
159
|
+
} | null>(null);
|
|
160
|
+
const dateFilterModalRef = useRef<HTMLDivElement>(null);
|
|
161
|
+
const listFilterModalRef = useRef<HTMLDivElement>(null);
|
|
162
|
+
const [listFilterModal, setListFilterModal] = useState<
|
|
163
|
+
null | 'status' | 'origin' | 'commercial' | 'telepro'
|
|
164
|
+
>(null);
|
|
165
|
+
const [listFilterPosition, setListFilterPosition] = useState<{
|
|
166
|
+
top: number;
|
|
167
|
+
left: number;
|
|
168
|
+
} | null>(null);
|
|
169
|
+
const [createdAtStart, setCreatedAtStart] = useState('');
|
|
170
|
+
const [createdAtEnd, setCreatedAtEnd] = useState('');
|
|
171
|
+
const [updatedAtStart, setUpdatedAtStart] = useState('');
|
|
172
|
+
const [updatedAtEnd, setUpdatedAtEnd] = useState('');
|
|
173
|
+
|
|
174
|
+
// Tri
|
|
175
|
+
const [sortField, setSortField] = useState<
|
|
176
|
+
'' | 'createdAt' | 'updatedAt' | 'status' | 'commercial' | 'telepro'
|
|
177
|
+
>('');
|
|
178
|
+
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
|
179
|
+
const [showSortMenu, setShowSortMenu] = useState(false);
|
|
180
|
+
const [showDatePicker, setShowDatePicker] = useState(false);
|
|
181
|
+
const [dateRangeStart, setDateRangeStart] = useState('');
|
|
182
|
+
const [dateRangeEnd, setDateRangeEnd] = useState('');
|
|
183
|
+
const sortMenuRef = useRef<HTMLDivElement>(null);
|
|
184
|
+
const datePickerRef = useRef<HTMLDivElement>(null);
|
|
185
|
+
|
|
186
|
+
// Pagination
|
|
187
|
+
const [currentPage, setCurrentPage] = useState(1);
|
|
188
|
+
const [totalPages, setTotalPages] = useState(1);
|
|
189
|
+
const [totalContacts, setTotalContacts] = useState(0);
|
|
190
|
+
const [limit, setLimit] = useState(25);
|
|
191
|
+
|
|
192
|
+
// Vue (table ou cards) - Persistée dans localStorage
|
|
193
|
+
// Initialiser avec 'table' pour éviter l'erreur d'hydratation
|
|
194
|
+
const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
|
|
195
|
+
|
|
196
|
+
// Gestion des colonnes du tableau (avec récupération initiale depuis localStorage)
|
|
197
|
+
const [tableColumns, setTableColumns] = useState<TableColumn[]>(() => {
|
|
198
|
+
if (typeof window === 'undefined') {
|
|
199
|
+
return DEFAULT_COLUMNS;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const saved = window.localStorage.getItem('contactsTableColumns');
|
|
204
|
+
if (!saved) {
|
|
205
|
+
return DEFAULT_COLUMNS;
|
|
206
|
+
}
|
|
207
|
+
const parsed = JSON.parse(saved) as TableColumn[];
|
|
208
|
+
const byId = new Map(parsed.map((col) => [col.id, col]));
|
|
209
|
+
|
|
210
|
+
// On merge toujours avec DEFAULT_COLUMNS pour intégrer les nouvelles colonnes éventuelles
|
|
211
|
+
const merged = DEFAULT_COLUMNS.map((col) => {
|
|
212
|
+
const savedCol = byId.get(col.id);
|
|
213
|
+
if (!savedCol) return col;
|
|
214
|
+
return {
|
|
215
|
+
...col,
|
|
216
|
+
...savedCol,
|
|
217
|
+
// S'assurer que l'id reste celui du défaut
|
|
218
|
+
id: col.id,
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return merged;
|
|
223
|
+
} catch (e) {
|
|
224
|
+
console.error('Erreur lors du chargement des colonnes depuis localStorage:', e);
|
|
225
|
+
return DEFAULT_COLUMNS;
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
const [showColumnPanel, setShowColumnPanel] = useState(false);
|
|
229
|
+
const [draggedColumn, setDraggedColumn] = useState<string | null>(null);
|
|
230
|
+
|
|
231
|
+
// Regroupement des informations de contact
|
|
232
|
+
const [groupContactInfo, setGroupContactInfo] = useState<boolean>(() => {
|
|
233
|
+
if (typeof window === 'undefined') {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const saved = window.localStorage.getItem('contactsGroupContactInfo');
|
|
238
|
+
return saved === 'true';
|
|
239
|
+
} catch (e) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Sélection multiple et actions groupées
|
|
245
|
+
const [selectedContactIds, setSelectedContactIds] = useState<Set<string>>(new Set());
|
|
246
|
+
const [showBulkCommercialModal, setShowBulkCommercialModal] = useState(false);
|
|
247
|
+
const [showBulkStatusModal, setShowBulkStatusModal] = useState(false);
|
|
248
|
+
const [bulkCommercialId, setBulkCommercialId] = useState('');
|
|
249
|
+
const [bulkStatusId, setBulkStatusId] = useState('');
|
|
250
|
+
const [bulkActionLoading, setBulkActionLoading] = useState(false);
|
|
251
|
+
const [showExportModal, setShowExportModal] = useState(false);
|
|
252
|
+
const [exporting, setExporting] = useState(false);
|
|
253
|
+
const [exportAll, setExportAll] = useState(false);
|
|
254
|
+
|
|
255
|
+
// Charger les préférences depuis localStorage après le montage
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
const savedView = localStorage.getItem('contactsViewMode');
|
|
258
|
+
if (savedView === 'cards' || savedView === 'table') {
|
|
259
|
+
setViewMode(savedView);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const savedLimit = localStorage.getItem('contactsPageLimit');
|
|
263
|
+
if (savedLimit && ['25', '50', '100'].includes(savedLimit)) {
|
|
264
|
+
setLimit(parseInt(savedLimit, 10));
|
|
265
|
+
}
|
|
266
|
+
}, []);
|
|
267
|
+
|
|
268
|
+
// Formulaire
|
|
269
|
+
const [formData, setFormData] = useState({
|
|
270
|
+
civility: '',
|
|
271
|
+
firstName: '',
|
|
272
|
+
lastName: '',
|
|
273
|
+
phone: '',
|
|
274
|
+
secondaryPhone: '',
|
|
275
|
+
email: '',
|
|
276
|
+
address: '',
|
|
277
|
+
city: '',
|
|
278
|
+
postalCode: '',
|
|
279
|
+
origin: '',
|
|
280
|
+
company: '',
|
|
281
|
+
isCompany: false,
|
|
282
|
+
companyId: '',
|
|
283
|
+
statusId: '',
|
|
284
|
+
closingReason: '',
|
|
285
|
+
assignedCommercialId: '',
|
|
286
|
+
assignedTeleproId: '',
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Synchronisation automatique Google Sheets
|
|
290
|
+
useEffect(() => {
|
|
291
|
+
const syncGoogleSheet = async () => {
|
|
292
|
+
try {
|
|
293
|
+
await fetch('/api/integrations/google-sheet/sync', {
|
|
294
|
+
method: 'POST',
|
|
295
|
+
});
|
|
296
|
+
} catch (err) {
|
|
297
|
+
console.error('Erreur lors de la synchronisation Google Sheets:', err);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
syncGoogleSheet();
|
|
302
|
+
}, []);
|
|
303
|
+
|
|
304
|
+
// Charger les statuts et utilisateurs
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
const fetchData = async () => {
|
|
307
|
+
try {
|
|
308
|
+
const [statusesRes, usersRes, closingReasonsRes] = await Promise.all([
|
|
309
|
+
fetch('/api/statuses'),
|
|
310
|
+
fetch('/api/users/list'),
|
|
311
|
+
fetch('/api/closing-reasons'),
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
if (statusesRes.ok) {
|
|
315
|
+
const statusesData = await statusesRes.json();
|
|
316
|
+
setStatuses(statusesData);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (usersRes.ok) {
|
|
320
|
+
const usersData = await usersRes.json();
|
|
321
|
+
setUsers(usersData);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (closingReasonsRes.ok) {
|
|
325
|
+
const reasonsData = await closingReasonsRes.json();
|
|
326
|
+
setClosingReasons(reasonsData || []);
|
|
327
|
+
}
|
|
328
|
+
} catch (error) {
|
|
329
|
+
console.error('Erreur lors du chargement des données:', error);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
fetchData();
|
|
333
|
+
}, []);
|
|
334
|
+
|
|
335
|
+
// Persister les préférences
|
|
336
|
+
useEffect(() => {
|
|
337
|
+
localStorage.setItem('contactsViewMode', viewMode);
|
|
338
|
+
}, [viewMode]);
|
|
339
|
+
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
localStorage.setItem('contactsPageLimit', limit.toString());
|
|
342
|
+
}, [limit]);
|
|
343
|
+
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
localStorage.setItem('contactsTableColumns', JSON.stringify(tableColumns));
|
|
346
|
+
}, [tableColumns]);
|
|
347
|
+
|
|
348
|
+
// Sauvegarder le regroupement dans localStorage quand il change
|
|
349
|
+
useEffect(() => {
|
|
350
|
+
localStorage.setItem('contactsGroupContactInfo', String(groupContactInfo));
|
|
351
|
+
}, [groupContactInfo]);
|
|
352
|
+
|
|
353
|
+
const fetchContacts = async () => {
|
|
354
|
+
try {
|
|
355
|
+
setLoading(true);
|
|
356
|
+
setSelectedContactIds(new Set()); // Réinitialiser la sélection lors du chargement
|
|
357
|
+
const params = new URLSearchParams();
|
|
358
|
+
if (search) params.append('search', search);
|
|
359
|
+
if (statusFilter) params.append('statusId', statusFilter);
|
|
360
|
+
if (originFilter) params.append('origin', originFilter);
|
|
361
|
+
if (assignedCommercialFilter) params.append('assignedCommercialId', assignedCommercialFilter);
|
|
362
|
+
if (assignedTeleproFilter) params.append('assignedTeleproId', assignedTeleproFilter);
|
|
363
|
+
if (createdAtStart) params.append('createdAtStart', createdAtStart);
|
|
364
|
+
if (createdAtEnd) params.append('createdAtEnd', createdAtEnd);
|
|
365
|
+
if (updatedAtStart) params.append('updatedAtStart', updatedAtStart);
|
|
366
|
+
if (updatedAtEnd) params.append('updatedAtEnd', updatedAtEnd);
|
|
367
|
+
params.append('page', currentPage.toString());
|
|
368
|
+
params.append('limit', limit.toString());
|
|
369
|
+
|
|
370
|
+
const response = await fetch(`/api/contacts?${params.toString()}`);
|
|
371
|
+
if (response.ok) {
|
|
372
|
+
const data = await response.json();
|
|
373
|
+
setContacts(data.contacts || []);
|
|
374
|
+
if (data.pagination) {
|
|
375
|
+
setTotalPages(data.pagination.totalPages);
|
|
376
|
+
setTotalContacts(data.pagination.total);
|
|
377
|
+
}
|
|
378
|
+
} else {
|
|
379
|
+
setError('Erreur lors du chargement des contacts');
|
|
380
|
+
}
|
|
381
|
+
} catch (error) {
|
|
382
|
+
console.error('Erreur:', error);
|
|
383
|
+
setError('Erreur lors du chargement des contacts');
|
|
384
|
+
} finally {
|
|
385
|
+
setLoading(false);
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// Réinitialiser à la page 1 quand les filtres ou le limit changent
|
|
390
|
+
useEffect(() => {
|
|
391
|
+
if (currentPage !== 1) {
|
|
392
|
+
setCurrentPage(1);
|
|
393
|
+
}
|
|
394
|
+
}, [
|
|
395
|
+
search,
|
|
396
|
+
statusFilter,
|
|
397
|
+
assignedCommercialFilter,
|
|
398
|
+
assignedTeleproFilter,
|
|
399
|
+
createdAtStart,
|
|
400
|
+
createdAtEnd,
|
|
401
|
+
updatedAtStart,
|
|
402
|
+
updatedAtEnd,
|
|
403
|
+
limit,
|
|
404
|
+
]);
|
|
405
|
+
|
|
406
|
+
// Charger les contacts quand la page, les filtres ou le limit changent
|
|
407
|
+
useEffect(() => {
|
|
408
|
+
fetchContacts();
|
|
409
|
+
}, [
|
|
410
|
+
currentPage,
|
|
411
|
+
search,
|
|
412
|
+
statusFilter,
|
|
413
|
+
originFilter,
|
|
414
|
+
assignedCommercialFilter,
|
|
415
|
+
assignedTeleproFilter,
|
|
416
|
+
createdAtStart,
|
|
417
|
+
createdAtEnd,
|
|
418
|
+
updatedAtStart,
|
|
419
|
+
updatedAtEnd,
|
|
420
|
+
limit,
|
|
421
|
+
]);
|
|
422
|
+
|
|
423
|
+
// Fermer la modal de filtre de date au clic en dehors
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
426
|
+
if (
|
|
427
|
+
dateFilterModalRef.current &&
|
|
428
|
+
!dateFilterModalRef.current.contains(event.target as Node)
|
|
429
|
+
) {
|
|
430
|
+
// Vérifier si le clic n'est pas sur l'icône de filtre elle-même
|
|
431
|
+
const target = event.target as HTMLElement;
|
|
432
|
+
if (!target.closest('button[data-date-filter-icon]')) {
|
|
433
|
+
setDateFilterModal(null);
|
|
434
|
+
setDateFilterPosition(null);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
if (dateFilterModal) {
|
|
440
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return () => {
|
|
444
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
445
|
+
};
|
|
446
|
+
}, [dateFilterModal]);
|
|
447
|
+
|
|
448
|
+
// Fermer la modal de filtre de liste (statut, origine, commercial, télépro) au clic en dehors
|
|
449
|
+
useEffect(() => {
|
|
450
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
451
|
+
if (
|
|
452
|
+
listFilterModalRef.current &&
|
|
453
|
+
!listFilterModalRef.current.contains(event.target as Node)
|
|
454
|
+
) {
|
|
455
|
+
const target = event.target as HTMLElement;
|
|
456
|
+
if (!target.closest('button[data-date-filter-icon]')) {
|
|
457
|
+
setListFilterModal(null);
|
|
458
|
+
setListFilterPosition(null);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
if (listFilterModal) {
|
|
464
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return () => {
|
|
468
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
469
|
+
};
|
|
470
|
+
}, [listFilterModal]);
|
|
471
|
+
|
|
472
|
+
// Fermer le menu de tri au clic en dehors
|
|
473
|
+
useEffect(() => {
|
|
474
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
475
|
+
if (sortMenuRef.current && !sortMenuRef.current.contains(event.target as Node)) {
|
|
476
|
+
setShowSortMenu(false);
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
if (showSortMenu) {
|
|
481
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return () => {
|
|
485
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
486
|
+
};
|
|
487
|
+
}, [showSortMenu]);
|
|
488
|
+
|
|
489
|
+
// Fermer le sélecteur de date au clic en dehors
|
|
490
|
+
useEffect(() => {
|
|
491
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
492
|
+
if (datePickerRef.current && !datePickerRef.current.contains(event.target as Node)) {
|
|
493
|
+
setShowDatePicker(false);
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
if (showDatePicker) {
|
|
498
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return () => {
|
|
502
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
503
|
+
};
|
|
504
|
+
}, [showDatePicker]);
|
|
505
|
+
|
|
506
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
507
|
+
e.preventDefault();
|
|
508
|
+
setError('');
|
|
509
|
+
setSuccess('');
|
|
510
|
+
|
|
511
|
+
if (!formData.phone) {
|
|
512
|
+
setError('Le téléphone est obligatoire');
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
const url = editingContact ? `/api/contacts/${editingContact.id}` : '/api/contacts';
|
|
518
|
+
const method = editingContact ? 'PUT' : 'POST';
|
|
519
|
+
|
|
520
|
+
const selectedStatus = statuses.find((s) => s.id === formData.statusId);
|
|
521
|
+
const isLostStatus = selectedStatus?.name?.toLowerCase() === 'fermé';
|
|
522
|
+
|
|
523
|
+
if (isLostStatus && !formData.closingReason) {
|
|
524
|
+
setError('Veuillez renseigner le motif de fermeture pour un contact fermé.');
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const response = await fetch(url, {
|
|
529
|
+
method,
|
|
530
|
+
headers: { 'Content-Type': 'application/json' },
|
|
531
|
+
body: JSON.stringify({
|
|
532
|
+
...formData,
|
|
533
|
+
civility: formData.civility || null,
|
|
534
|
+
companyName: formData.company || null,
|
|
535
|
+
isCompany: formData.isCompany || false,
|
|
536
|
+
companyId: formData.companyId || null,
|
|
537
|
+
closingReason: isLostStatus ? formData.closingReason || null : null,
|
|
538
|
+
assignedCommercialId: formData.assignedCommercialId || null,
|
|
539
|
+
assignedTeleproId: formData.assignedTeleproId || null,
|
|
540
|
+
}),
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
const data = await response.json();
|
|
544
|
+
|
|
545
|
+
if (!response.ok) {
|
|
546
|
+
throw new Error(data.error || 'Erreur lors de la sauvegarde');
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
setSuccess(editingContact ? 'Contact modifié avec succès !' : 'Contact créé avec succès !');
|
|
550
|
+
setShowModal(false);
|
|
551
|
+
setEditingContact(null);
|
|
552
|
+
setFormData({
|
|
553
|
+
civility: '',
|
|
554
|
+
firstName: '',
|
|
555
|
+
lastName: '',
|
|
556
|
+
phone: '',
|
|
557
|
+
secondaryPhone: '',
|
|
558
|
+
email: '',
|
|
559
|
+
address: '',
|
|
560
|
+
city: '',
|
|
561
|
+
postalCode: '',
|
|
562
|
+
origin: '',
|
|
563
|
+
company: '',
|
|
564
|
+
isCompany: false,
|
|
565
|
+
companyId: '',
|
|
566
|
+
statusId: '',
|
|
567
|
+
closingReason: '',
|
|
568
|
+
assignedCommercialId: '',
|
|
569
|
+
assignedTeleproId: '',
|
|
570
|
+
});
|
|
571
|
+
fetchContacts();
|
|
572
|
+
|
|
573
|
+
setTimeout(() => setSuccess(''), 5000);
|
|
574
|
+
} catch (err: any) {
|
|
575
|
+
setError(err.message);
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const handleSort = (field: string) => {
|
|
580
|
+
setCurrentPage(1);
|
|
581
|
+
|
|
582
|
+
if (sortField === field) {
|
|
583
|
+
// Même colonne : toggle l'ordre
|
|
584
|
+
setSortOrder((prevOrder) => (prevOrder === 'asc' ? 'desc' : 'asc'));
|
|
585
|
+
} else {
|
|
586
|
+
// Nouvelle colonne : définir la colonne et mettre l'ordre à 'asc'
|
|
587
|
+
setSortField(field as any);
|
|
588
|
+
setSortOrder('asc');
|
|
589
|
+
}
|
|
590
|
+
setShowSortMenu(false);
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const handleSortByField = (
|
|
594
|
+
field: 'status' | 'commercial' | 'telepro' | 'createdAt' | 'updatedAt' | '',
|
|
595
|
+
) => {
|
|
596
|
+
handleSort(field);
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
const handleDateFilterClick = (e: React.MouseEvent, contactId: string) => {
|
|
600
|
+
e.stopPropagation();
|
|
601
|
+
if (contactId === 'createdAt' || contactId === 'updatedAt') {
|
|
602
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
603
|
+
const modalWidth = 288; // w-72 = 18rem = 288px
|
|
604
|
+
const padding = 16; // Padding pour éviter que la modal touche les bords
|
|
605
|
+
|
|
606
|
+
// Position initiale (centrée sous l'icône)
|
|
607
|
+
let left = rect.left + rect.width / 2 - modalWidth / 2;
|
|
608
|
+
|
|
609
|
+
// Vérifier si la modal dépasse à droite
|
|
610
|
+
if (left + modalWidth > window.innerWidth - padding) {
|
|
611
|
+
left = window.innerWidth - modalWidth - padding;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Vérifier si la modal dépasse à gauche
|
|
615
|
+
if (left < padding) {
|
|
616
|
+
left = padding;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
setDateFilterPosition({
|
|
620
|
+
top: rect.bottom + 18,
|
|
621
|
+
left,
|
|
622
|
+
});
|
|
623
|
+
setDateFilterModal(contactId as 'createdAt' | 'updatedAt');
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
const handleListFilterClick = (e: React.MouseEvent, contactId: string) => {
|
|
628
|
+
e.stopPropagation();
|
|
629
|
+
if (
|
|
630
|
+
contactId === 'status' ||
|
|
631
|
+
contactId === 'origin' ||
|
|
632
|
+
contactId === 'commercial' ||
|
|
633
|
+
contactId === 'telepro'
|
|
634
|
+
) {
|
|
635
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
636
|
+
const modalWidth = 320;
|
|
637
|
+
const padding = 16;
|
|
638
|
+
|
|
639
|
+
let left = rect.left + rect.width / 2 - modalWidth / 2;
|
|
640
|
+
if (left + modalWidth > window.innerWidth - padding) {
|
|
641
|
+
left = window.innerWidth - modalWidth - padding;
|
|
642
|
+
}
|
|
643
|
+
if (left < padding) {
|
|
644
|
+
left = padding;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
setListFilterPosition({
|
|
648
|
+
top: rect.bottom + 8,
|
|
649
|
+
left,
|
|
650
|
+
});
|
|
651
|
+
setListFilterModal(contactId as 'status' | 'origin' | 'commercial' | 'telepro');
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
const handleApplyDateFilter = () => {
|
|
656
|
+
setDateFilterModal(null);
|
|
657
|
+
setDateFilterPosition(null);
|
|
658
|
+
setCurrentPage(1);
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const handleClearDateFilter = (type: 'createdAt' | 'updatedAt') => {
|
|
662
|
+
if (type === 'createdAt') {
|
|
663
|
+
setCreatedAtStart('');
|
|
664
|
+
setCreatedAtEnd('');
|
|
665
|
+
} else {
|
|
666
|
+
setUpdatedAtStart('');
|
|
667
|
+
setUpdatedAtEnd('');
|
|
668
|
+
}
|
|
669
|
+
setDateFilterModal(null);
|
|
670
|
+
setDateFilterPosition(null);
|
|
671
|
+
setCurrentPage(1);
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
// Vérifier si des filtres sont actifs
|
|
675
|
+
const hasActiveFilters = () => {
|
|
676
|
+
return (
|
|
677
|
+
!!search ||
|
|
678
|
+
!!statusFilter ||
|
|
679
|
+
!!originFilter ||
|
|
680
|
+
!!assignedCommercialFilter ||
|
|
681
|
+
!!assignedTeleproFilter ||
|
|
682
|
+
!!createdAtStart ||
|
|
683
|
+
!!createdAtEnd ||
|
|
684
|
+
!!updatedAtStart ||
|
|
685
|
+
!!updatedAtEnd ||
|
|
686
|
+
!!sortField
|
|
687
|
+
);
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
// Réinitialiser tous les filtres
|
|
691
|
+
const handleResetAllFilters = () => {
|
|
692
|
+
setSearch('');
|
|
693
|
+
setStatusFilter('');
|
|
694
|
+
setOriginFilter('');
|
|
695
|
+
setAssignedCommercialFilter('');
|
|
696
|
+
setAssignedTeleproFilter('');
|
|
697
|
+
setCreatedAtStart('');
|
|
698
|
+
setCreatedAtEnd('');
|
|
699
|
+
setUpdatedAtStart('');
|
|
700
|
+
setUpdatedAtEnd('');
|
|
701
|
+
setSortField('');
|
|
702
|
+
setSortOrder('asc');
|
|
703
|
+
setCurrentPage(1);
|
|
704
|
+
setListFilterModal(null);
|
|
705
|
+
setDateFilterModal(null);
|
|
706
|
+
setDateFilterPosition(null);
|
|
707
|
+
setListFilterPosition(null);
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
const sortedContacts = useMemo(() => {
|
|
711
|
+
if (!sortField) return contacts;
|
|
712
|
+
const sorted = [...contacts].sort((a, b) => {
|
|
713
|
+
let aValue: any;
|
|
714
|
+
let bValue: any;
|
|
715
|
+
|
|
716
|
+
if (sortField === 'createdAt' || sortField === 'updatedAt') {
|
|
717
|
+
aValue = new Date(a[sortField]);
|
|
718
|
+
bValue = new Date(b[sortField]);
|
|
719
|
+
const diff = aValue.getTime() - bValue.getTime();
|
|
720
|
+
return sortOrder === 'asc' ? diff : -diff;
|
|
721
|
+
} else if (sortField === 'status') {
|
|
722
|
+
aValue = a.status?.name || '';
|
|
723
|
+
bValue = b.status?.name || '';
|
|
724
|
+
} else if (sortField === 'commercial') {
|
|
725
|
+
aValue = a.assignedCommercial?.name || '';
|
|
726
|
+
bValue = b.assignedCommercial?.name || '';
|
|
727
|
+
} else if (sortField === 'telepro') {
|
|
728
|
+
aValue = a.assignedTelepro?.name || '';
|
|
729
|
+
bValue = b.assignedTelepro?.name || '';
|
|
730
|
+
} else {
|
|
731
|
+
return 0;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1;
|
|
735
|
+
if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1;
|
|
736
|
+
return 0;
|
|
737
|
+
});
|
|
738
|
+
return sorted;
|
|
739
|
+
}, [contacts, sortField, sortOrder]);
|
|
740
|
+
|
|
741
|
+
const handleNewContact = () => {
|
|
742
|
+
setEditingContact(null);
|
|
743
|
+
setFormData({
|
|
744
|
+
civility: '',
|
|
745
|
+
firstName: '',
|
|
746
|
+
lastName: '',
|
|
747
|
+
phone: '',
|
|
748
|
+
secondaryPhone: '',
|
|
749
|
+
email: '',
|
|
750
|
+
address: '',
|
|
751
|
+
city: '',
|
|
752
|
+
postalCode: '',
|
|
753
|
+
origin: '',
|
|
754
|
+
isCompany: false,
|
|
755
|
+
companyId: '',
|
|
756
|
+
company: '',
|
|
757
|
+
statusId: '',
|
|
758
|
+
closingReason: '',
|
|
759
|
+
assignedCommercialId: '',
|
|
760
|
+
assignedTeleproId: '',
|
|
761
|
+
});
|
|
762
|
+
setShowModal(true);
|
|
763
|
+
setError('');
|
|
764
|
+
setSuccess('');
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
// Fonctions d'import
|
|
768
|
+
// Fonction d'auto-mapping intelligent
|
|
769
|
+
const autoMapHeaders = (headers: string[]) => {
|
|
770
|
+
const mappings: Record<
|
|
771
|
+
string,
|
|
772
|
+
{
|
|
773
|
+
action: 'map' | 'note' | 'ignore';
|
|
774
|
+
crmField?: string;
|
|
775
|
+
}
|
|
776
|
+
> = {};
|
|
777
|
+
|
|
778
|
+
// Définir les correspondances possibles pour chaque champ
|
|
779
|
+
const fieldMappings: { [key: string]: string[] } = {
|
|
780
|
+
phone: ['téléphone', 'telephone', 'phone', 'tel', 'tél', 'mobile', 'portable'],
|
|
781
|
+
firstName: ['prénom', 'prenom', 'firstname', 'first name', 'first_name'],
|
|
782
|
+
lastName: ['nom', 'lastname', 'last name', 'last_name', 'nom de famille', 'surname'],
|
|
783
|
+
email: ['email', 'e-mail', 'mail', 'courriel'],
|
|
784
|
+
civility: ['civilité', 'civilite', 'civility', 'titre', 'title'],
|
|
785
|
+
secondaryPhone: [
|
|
786
|
+
'téléphone 2',
|
|
787
|
+
'telephone 2',
|
|
788
|
+
'tel 2',
|
|
789
|
+
'tél 2',
|
|
790
|
+
'secondary phone',
|
|
791
|
+
'phone 2',
|
|
792
|
+
'téléphone secondaire',
|
|
793
|
+
'telephone secondaire',
|
|
794
|
+
],
|
|
795
|
+
address: ['adresse', 'address', 'rue', 'street'],
|
|
796
|
+
city: ['ville', 'city', 'localité', 'locality'],
|
|
797
|
+
postalCode: ['code postal', 'postal code', 'cp', 'zip', 'zipcode', 'code_postal'],
|
|
798
|
+
origin: ['origine', 'origin', 'source', 'campagne', 'campaign', 'origine de la campagne'],
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
headers.forEach((header) => {
|
|
802
|
+
const normalizedHeader = header.toLowerCase().trim();
|
|
803
|
+
let mapped = false;
|
|
804
|
+
|
|
805
|
+
// Chercher une correspondance pour chaque champ
|
|
806
|
+
for (const [field, aliases] of Object.entries(fieldMappings)) {
|
|
807
|
+
if (
|
|
808
|
+
aliases.some(
|
|
809
|
+
(alias) => normalizedHeader.includes(alias) || alias.includes(normalizedHeader),
|
|
810
|
+
)
|
|
811
|
+
) {
|
|
812
|
+
mappings[header] = { action: 'map', crmField: field };
|
|
813
|
+
mapped = true;
|
|
814
|
+
break;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Si aucune correspondance trouvée, ignorer par défaut
|
|
819
|
+
if (!mapped) {
|
|
820
|
+
mappings[header] = { action: 'ignore' };
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
return mappings;
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
828
|
+
const file = e.target.files?.[0];
|
|
829
|
+
if (!file) return;
|
|
830
|
+
|
|
831
|
+
setImportFile(file);
|
|
832
|
+
setImportResult(null);
|
|
833
|
+
setError('');
|
|
834
|
+
|
|
835
|
+
try {
|
|
836
|
+
// Parser le fichier pour obtenir les en-têtes et un aperçu
|
|
837
|
+
const fileName = file.name.toLowerCase();
|
|
838
|
+
const fileExtension = fileName.split('.').pop();
|
|
839
|
+
|
|
840
|
+
if (fileExtension === 'csv') {
|
|
841
|
+
const text = await file.text();
|
|
842
|
+
const lines = text.split('\n').filter((line) => line.trim() !== '');
|
|
843
|
+
if (lines.length === 0) {
|
|
844
|
+
setError('Le fichier est vide');
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const delimiter = lines[0].includes(';') ? ';' : ',';
|
|
849
|
+
const headers = lines[0].split(delimiter).map((h) => h.trim().replace(/^"|"$/g, ''));
|
|
850
|
+
setImportHeaders(headers);
|
|
851
|
+
|
|
852
|
+
// Auto-mapper les colonnes
|
|
853
|
+
const autoMappings = autoMapHeaders(headers);
|
|
854
|
+
setImportFieldMappings(autoMappings);
|
|
855
|
+
|
|
856
|
+
// Prévisualiser les 5 premières lignes
|
|
857
|
+
const preview: any[] = [];
|
|
858
|
+
for (let i = 1; i < Math.min(6, lines.length); i++) {
|
|
859
|
+
const values = lines[i].split(delimiter).map((v) => v.trim().replace(/^"|"$/g, ''));
|
|
860
|
+
const row: any = {};
|
|
861
|
+
headers.forEach((header, index) => {
|
|
862
|
+
row[header] = values[index] || '';
|
|
863
|
+
});
|
|
864
|
+
preview.push(row);
|
|
865
|
+
}
|
|
866
|
+
setImportPreview(preview);
|
|
867
|
+
} else if (fileExtension === 'xlsx' || fileExtension === 'xls') {
|
|
868
|
+
// Pour Excel, on a besoin de xlsx
|
|
869
|
+
try {
|
|
870
|
+
const XLSX = require('xlsx');
|
|
871
|
+
const buffer = await file.arrayBuffer();
|
|
872
|
+
const workbook = XLSX.read(buffer, { type: 'array' });
|
|
873
|
+
const sheetName = workbook.SheetNames[0];
|
|
874
|
+
const worksheet = workbook.Sheets[sheetName];
|
|
875
|
+
const data = XLSX.utils.sheet_to_json(worksheet, { raw: false });
|
|
876
|
+
|
|
877
|
+
if (data.length === 0) {
|
|
878
|
+
setError('Le fichier est vide');
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const headers: any = Object.keys(data[0] as any);
|
|
883
|
+
setImportHeaders(headers);
|
|
884
|
+
setImportPreview(data.slice(0, 5));
|
|
885
|
+
|
|
886
|
+
// Auto-mapper les colonnes pour Excel
|
|
887
|
+
const autoMappings = autoMapHeaders(headers);
|
|
888
|
+
setImportFieldMappings(autoMappings);
|
|
889
|
+
} catch (error) {
|
|
890
|
+
setError(
|
|
891
|
+
'Erreur lors du parsing Excel. Assurez-vous que xlsx est installé (npm install xlsx)',
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
} else {
|
|
895
|
+
setError('Format de fichier non supporté. Utilisez CSV ou Excel (.xlsx, .xls)');
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
} catch (error: any) {
|
|
899
|
+
setError(`Erreur lors de la lecture du fichier: ${error.message}`);
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
const handleImport = async () => {
|
|
904
|
+
if (!importFile) {
|
|
905
|
+
setError('Veuillez sélectionner un fichier');
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Vérifier qu'au moins un champ téléphone est mappé
|
|
910
|
+
const phoneMapped = Object.entries(importFieldMappings).some(
|
|
911
|
+
([header, mapping]) => mapping.action === 'map' && mapping.crmField === 'phone',
|
|
912
|
+
);
|
|
913
|
+
|
|
914
|
+
if (!phoneMapped) {
|
|
915
|
+
setError('Le mapping du téléphone est obligatoire');
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
setImporting(true);
|
|
920
|
+
setError('');
|
|
921
|
+
setImportResult(null);
|
|
922
|
+
|
|
923
|
+
try {
|
|
924
|
+
const formData = new FormData();
|
|
925
|
+
formData.append('file', importFile);
|
|
926
|
+
formData.append('fieldMappings', JSON.stringify(importFieldMappings));
|
|
927
|
+
formData.append('skipFirstRow', importSkipFirstRow.toString());
|
|
928
|
+
|
|
929
|
+
// Ajouter les valeurs par défaut
|
|
930
|
+
if (defaultStatusId) {
|
|
931
|
+
formData.append('defaultStatusId', defaultStatusId);
|
|
932
|
+
}
|
|
933
|
+
if (defaultCommercialId) {
|
|
934
|
+
formData.append('defaultCommercialId', defaultCommercialId);
|
|
935
|
+
}
|
|
936
|
+
if (defaultOrigin) {
|
|
937
|
+
formData.append('defaultOrigin', defaultOrigin);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const response = await fetch('/api/contacts/import', {
|
|
941
|
+
method: 'POST',
|
|
942
|
+
body: formData,
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
const result = await response.json();
|
|
946
|
+
|
|
947
|
+
if (!response.ok) {
|
|
948
|
+
throw new Error(result.error || "Erreur lors de l'import");
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
setImportResult(result);
|
|
952
|
+
fetchContacts();
|
|
953
|
+
|
|
954
|
+
// Stocker les résultats et fermer la modal d'import
|
|
955
|
+
setImportResultData(result);
|
|
956
|
+
setShowImportModal(false);
|
|
957
|
+
setImportFile(null);
|
|
958
|
+
setImportPreview([]);
|
|
959
|
+
setImportHeaders([]);
|
|
960
|
+
setImportFieldMappings({});
|
|
961
|
+
setImportResult(null);
|
|
962
|
+
setDefaultStatusId('');
|
|
963
|
+
setDefaultCommercialId('');
|
|
964
|
+
setDefaultOrigin('');
|
|
965
|
+
setSuccess('');
|
|
966
|
+
|
|
967
|
+
// Afficher la modal de résultats
|
|
968
|
+
setShowImportResultModal(true);
|
|
969
|
+
} catch (err: any) {
|
|
970
|
+
setError(err.message);
|
|
971
|
+
} finally {
|
|
972
|
+
setImporting(false);
|
|
973
|
+
}
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
// Fonctions de gestion des colonnes
|
|
977
|
+
const toggleColumnVisibility = (contactId: string) => {
|
|
978
|
+
setTableColumns((prev) =>
|
|
979
|
+
prev.map((col) => (col.id === contactId ? { ...col, visible: !col.visible } : col)),
|
|
980
|
+
);
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
const handleDragStart = (contactId: string) => {
|
|
984
|
+
setDraggedColumn(contactId);
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
const handleDragOver = (e: React.DragEvent, contactId: string) => {
|
|
988
|
+
e.preventDefault();
|
|
989
|
+
if (!draggedColumn || draggedColumn === contactId) return;
|
|
990
|
+
|
|
991
|
+
const draggedIndex = tableColumns.findIndex((col) => col.id === draggedColumn);
|
|
992
|
+
const targetIndex = tableColumns.findIndex((col) => col.id === contactId);
|
|
993
|
+
|
|
994
|
+
const newColumns = [...tableColumns];
|
|
995
|
+
const [removed] = newColumns.splice(draggedIndex, 1);
|
|
996
|
+
newColumns.splice(targetIndex, 0, removed);
|
|
997
|
+
|
|
998
|
+
// Réordonner les order
|
|
999
|
+
newColumns.forEach((col, idx) => {
|
|
1000
|
+
col.order = idx;
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
setTableColumns(newColumns);
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
const handleDragEnd = () => {
|
|
1007
|
+
setDraggedColumn(null);
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
const resetColumns = () => {
|
|
1011
|
+
setTableColumns(DEFAULT_COLUMNS);
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
// Gestion de la sélection multiple
|
|
1015
|
+
const handleSelectAll = () => {
|
|
1016
|
+
if (selectedContactIds.size === contacts.length) {
|
|
1017
|
+
setSelectedContactIds(new Set());
|
|
1018
|
+
} else {
|
|
1019
|
+
setSelectedContactIds(new Set(contacts.map((c) => c.id)));
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
const handleSelectContact = (contactId: string) => {
|
|
1024
|
+
const newSelected = new Set(selectedContactIds);
|
|
1025
|
+
if (newSelected.has(contactId)) {
|
|
1026
|
+
newSelected.delete(contactId);
|
|
1027
|
+
} else {
|
|
1028
|
+
newSelected.add(contactId);
|
|
1029
|
+
}
|
|
1030
|
+
setSelectedContactIds(newSelected);
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
const handleDeselectAll = () => {
|
|
1034
|
+
setSelectedContactIds(new Set());
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
// Actions groupées
|
|
1038
|
+
const handleBulkChangeCommercial = async () => {
|
|
1039
|
+
if (!bulkCommercialId) {
|
|
1040
|
+
setError('Veuillez sélectionner un commercial');
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
setBulkActionLoading(true);
|
|
1045
|
+
setError('');
|
|
1046
|
+
|
|
1047
|
+
try {
|
|
1048
|
+
const updatePromises = Array.from(selectedContactIds).map(async (contactId) => {
|
|
1049
|
+
const response = await fetch(`/api/contacts/${contactId}`, {
|
|
1050
|
+
method: 'PUT',
|
|
1051
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1052
|
+
body: JSON.stringify({ assignedCommercialId: bulkCommercialId }),
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
if (!response.ok) {
|
|
1056
|
+
const errorData = await response.json().catch(() => ({}));
|
|
1057
|
+
throw new Error(
|
|
1058
|
+
errorData.error || `Erreur lors de la mise à jour du contact ${contactId}`,
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
return response.json();
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
await Promise.all(updatePromises);
|
|
1066
|
+
|
|
1067
|
+
setSuccess(`${selectedContactIds.size} contact(s) mis à jour avec succès !`);
|
|
1068
|
+
setShowBulkCommercialModal(false);
|
|
1069
|
+
setBulkCommercialId('');
|
|
1070
|
+
setSelectedContactIds(new Set());
|
|
1071
|
+
fetchContacts();
|
|
1072
|
+
setTimeout(() => setSuccess(''), 5000);
|
|
1073
|
+
} catch (err: any) {
|
|
1074
|
+
setError(err.message || 'Erreur lors de la mise à jour');
|
|
1075
|
+
} finally {
|
|
1076
|
+
setBulkActionLoading(false);
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
|
|
1080
|
+
const handleBulkChangeStatus = async () => {
|
|
1081
|
+
if (!bulkStatusId) {
|
|
1082
|
+
setError('Veuillez sélectionner un statut');
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
setBulkActionLoading(true);
|
|
1087
|
+
setError('');
|
|
1088
|
+
|
|
1089
|
+
try {
|
|
1090
|
+
const updatePromises = Array.from(selectedContactIds).map(async (contactId) => {
|
|
1091
|
+
const response = await fetch(`/api/contacts/${contactId}`, {
|
|
1092
|
+
method: 'PUT',
|
|
1093
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1094
|
+
body: JSON.stringify({ statusId: bulkStatusId }),
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
if (!response.ok) {
|
|
1098
|
+
const errorData = await response.json().catch(() => ({}));
|
|
1099
|
+
throw new Error(
|
|
1100
|
+
errorData.error || `Erreur lors de la mise à jour du contact ${contactId}`,
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
return response.json();
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
await Promise.all(updatePromises);
|
|
1108
|
+
|
|
1109
|
+
setSuccess(`${selectedContactIds.size} contact(s) mis à jour avec succès !`);
|
|
1110
|
+
setShowBulkStatusModal(false);
|
|
1111
|
+
setBulkStatusId('');
|
|
1112
|
+
setSelectedContactIds(new Set());
|
|
1113
|
+
fetchContacts();
|
|
1114
|
+
setTimeout(() => setSuccess(''), 5000);
|
|
1115
|
+
} catch (err: any) {
|
|
1116
|
+
setError(err.message || 'Erreur lors de la mise à jour');
|
|
1117
|
+
} finally {
|
|
1118
|
+
setBulkActionLoading(false);
|
|
1119
|
+
}
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
const handleBulkDelete = async () => {
|
|
1123
|
+
if (!isAdmin) {
|
|
1124
|
+
setError('Seuls les administrateurs peuvent supprimer des contacts');
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
if (
|
|
1129
|
+
!confirm(
|
|
1130
|
+
`Êtes-vous sûr de vouloir supprimer ${selectedContactIds.size} contact(s) ? Cette action est irréversible.`,
|
|
1131
|
+
)
|
|
1132
|
+
) {
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
setBulkActionLoading(true);
|
|
1137
|
+
setError('');
|
|
1138
|
+
|
|
1139
|
+
try {
|
|
1140
|
+
const deletePromises = Array.from(selectedContactIds).map(async (contactId) => {
|
|
1141
|
+
const response = await fetch(`/api/contacts/${contactId}`, {
|
|
1142
|
+
method: 'DELETE',
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
if (!response.ok) {
|
|
1146
|
+
const errorData = await response.json().catch(() => ({}));
|
|
1147
|
+
throw new Error(
|
|
1148
|
+
errorData.error || `Erreur lors de la suppression du contact ${contactId}`,
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
return response.json();
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
await Promise.all(deletePromises);
|
|
1156
|
+
|
|
1157
|
+
setSuccess(`${selectedContactIds.size} contact(s) supprimé(s) avec succès !`);
|
|
1158
|
+
setSelectedContactIds(new Set());
|
|
1159
|
+
fetchContacts();
|
|
1160
|
+
setTimeout(() => setSuccess(''), 5000);
|
|
1161
|
+
} catch (err: any) {
|
|
1162
|
+
setError(err.message || 'Erreur lors de la suppression');
|
|
1163
|
+
} finally {
|
|
1164
|
+
setBulkActionLoading(false);
|
|
1165
|
+
}
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1168
|
+
// Fonction d'export
|
|
1169
|
+
const handleExport = async (format: 'csv' | 'excel') => {
|
|
1170
|
+
if (!isAdmin) {
|
|
1171
|
+
setError('Seuls les administrateurs peuvent exporter des contacts');
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
setExporting(true);
|
|
1176
|
+
setError('');
|
|
1177
|
+
|
|
1178
|
+
try {
|
|
1179
|
+
const contactIds = exportAll ? null : Array.from(selectedContactIds);
|
|
1180
|
+
|
|
1181
|
+
const response = await fetch('/api/contacts/export', {
|
|
1182
|
+
method: 'POST',
|
|
1183
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1184
|
+
body: JSON.stringify({ contactIds, format }),
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
if (!response.ok) {
|
|
1188
|
+
const errorData = await response.json().catch(() => ({}));
|
|
1189
|
+
throw new Error(errorData.error || "Erreur lors de l'export");
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Récupérer le blob
|
|
1193
|
+
const blob = await response.blob();
|
|
1194
|
+
|
|
1195
|
+
// Créer un lien de téléchargement
|
|
1196
|
+
const url = window.URL.createObjectURL(blob);
|
|
1197
|
+
const a = document.createElement('a');
|
|
1198
|
+
a.href = url;
|
|
1199
|
+
a.download = `contacts_${new Date().toISOString().split('T')[0]}.${format === 'csv' ? 'csv' : 'xlsx'}`;
|
|
1200
|
+
document.body.appendChild(a);
|
|
1201
|
+
a.click();
|
|
1202
|
+
document.body.removeChild(a);
|
|
1203
|
+
window.URL.revokeObjectURL(url);
|
|
1204
|
+
|
|
1205
|
+
setSuccess(
|
|
1206
|
+
exportAll
|
|
1207
|
+
? `Tous les contacts ont été exportés en ${format.toUpperCase()} avec succès !`
|
|
1208
|
+
: `${selectedContactIds.size} contact(s) exporté(s) en ${format.toUpperCase()} avec succès !`,
|
|
1209
|
+
);
|
|
1210
|
+
setShowExportModal(false);
|
|
1211
|
+
if (!exportAll) {
|
|
1212
|
+
setSelectedContactIds(new Set());
|
|
1213
|
+
}
|
|
1214
|
+
setTimeout(() => setSuccess(''), 5000);
|
|
1215
|
+
} catch (err: any) {
|
|
1216
|
+
setError(err.message || "Erreur lors de l'export");
|
|
1217
|
+
} finally {
|
|
1218
|
+
setExporting(false);
|
|
1219
|
+
}
|
|
1220
|
+
};
|
|
1221
|
+
|
|
1222
|
+
// Obtenir les colonnes visibles triées par ordre
|
|
1223
|
+
const visibleColumns = tableColumns
|
|
1224
|
+
.filter((col) => {
|
|
1225
|
+
// Si le regroupement est activé, masquer phone et email
|
|
1226
|
+
if (groupContactInfo && (col.id === 'phone' || col.id === 'email')) {
|
|
1227
|
+
return false;
|
|
1228
|
+
}
|
|
1229
|
+
return col.visible;
|
|
1230
|
+
})
|
|
1231
|
+
.sort((a, b) => a.order - b.order);
|
|
1232
|
+
|
|
1233
|
+
// Rendu d'une cellule de tableau en fonction de la colonne
|
|
1234
|
+
const renderTableCell = (contactId: string, contact: Contact) => {
|
|
1235
|
+
switch (contactId) {
|
|
1236
|
+
case 'contact':
|
|
1237
|
+
return (
|
|
1238
|
+
<td key={contactId} className="px-3 py-4 whitespace-nowrap sm:px-6">
|
|
1239
|
+
<div className="flex items-center">
|
|
1240
|
+
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-indigo-600">
|
|
1241
|
+
{contact.isCompany ? (
|
|
1242
|
+
<span className="text-xs font-bold">🏢</span>
|
|
1243
|
+
) : (
|
|
1244
|
+
(contact.firstName?.[0] || contact.lastName?.[0] || '?').toUpperCase()
|
|
1245
|
+
)}
|
|
1246
|
+
</div>
|
|
1247
|
+
<div className="ml-3 min-w-0 sm:ml-4">
|
|
1248
|
+
<div className="flex items-center gap-2">
|
|
1249
|
+
<span className="text-base font-medium text-gray-900">
|
|
1250
|
+
{contact.civility && `${contact.civility}. `}
|
|
1251
|
+
{contact.firstName} {contact.lastName}
|
|
1252
|
+
</span>
|
|
1253
|
+
{contact.isCompany && (
|
|
1254
|
+
<span className="inline-flex rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800">
|
|
1255
|
+
Entreprise
|
|
1256
|
+
</span>
|
|
1257
|
+
)}
|
|
1258
|
+
</div>
|
|
1259
|
+
{groupContactInfo && (
|
|
1260
|
+
<>
|
|
1261
|
+
{contact.email && (
|
|
1262
|
+
<div className="mt-1 flex items-center text-sm text-gray-600">
|
|
1263
|
+
<Mail className="mr-1 h-3.5 w-3.5 text-gray-400" />
|
|
1264
|
+
<span className="max-w-[200px]">{contact.email}</span>
|
|
1265
|
+
</div>
|
|
1266
|
+
)}
|
|
1267
|
+
{contact.phone && (
|
|
1268
|
+
<div className="mt-1 flex items-center text-sm text-gray-600">
|
|
1269
|
+
<Phone className="mr-1 h-3.5 w-3.5 text-gray-400" />
|
|
1270
|
+
{contact.phone}
|
|
1271
|
+
</div>
|
|
1272
|
+
)}
|
|
1273
|
+
</>
|
|
1274
|
+
)}
|
|
1275
|
+
{contact.companyName && (
|
|
1276
|
+
<div className="flex items-center text-sm text-gray-500">
|
|
1277
|
+
<Building2 className="mr-1 h-3.5 w-3.5 text-gray-400" />
|
|
1278
|
+
{contact.companyName}
|
|
1279
|
+
</div>
|
|
1280
|
+
)}
|
|
1281
|
+
{!contact.companyName && contact.companyRelation && (
|
|
1282
|
+
<div className="flex items-center text-sm text-gray-500">
|
|
1283
|
+
<Building2 className="mr-1 h-3.5 w-3.5 text-gray-400" />
|
|
1284
|
+
{contact.companyRelation.firstName ||
|
|
1285
|
+
contact.companyRelation.lastName ||
|
|
1286
|
+
'Sans nom'}
|
|
1287
|
+
</div>
|
|
1288
|
+
)}
|
|
1289
|
+
{contact.city && (
|
|
1290
|
+
<div className="flex items-center text-sm text-gray-500">
|
|
1291
|
+
<MapPin className="mr-1 h-3.5 w-3.5" />
|
|
1292
|
+
{contact.city}
|
|
1293
|
+
{contact.postalCode && ` ${contact.postalCode}`}
|
|
1294
|
+
</div>
|
|
1295
|
+
)}
|
|
1296
|
+
</div>
|
|
1297
|
+
</div>
|
|
1298
|
+
</td>
|
|
1299
|
+
);
|
|
1300
|
+
case 'phone':
|
|
1301
|
+
return (
|
|
1302
|
+
<td key={contactId} className="px-3 py-4 text-base whitespace-nowrap sm:px-6">
|
|
1303
|
+
<div className="flex items-center text-gray-900">
|
|
1304
|
+
<Phone className="mr-2 h-4 w-4 text-gray-400" />
|
|
1305
|
+
{contact.phone}
|
|
1306
|
+
</div>
|
|
1307
|
+
{contact.secondaryPhone && (
|
|
1308
|
+
<div className="mt-1 text-sm text-gray-500">{contact.secondaryPhone}</div>
|
|
1309
|
+
)}
|
|
1310
|
+
</td>
|
|
1311
|
+
);
|
|
1312
|
+
case 'email':
|
|
1313
|
+
return (
|
|
1314
|
+
<td key={contactId} className="px-3 py-4 text-base whitespace-nowrap sm:px-6">
|
|
1315
|
+
{contact.email ? (
|
|
1316
|
+
<div className="flex items-center text-gray-900">
|
|
1317
|
+
<Mail className="mr-2 h-4 w-4 text-gray-400" />
|
|
1318
|
+
<span className="max-w-[200px]">{contact.email}</span>
|
|
1319
|
+
</div>
|
|
1320
|
+
) : (
|
|
1321
|
+
<span className="text-gray-400">-</span>
|
|
1322
|
+
)}
|
|
1323
|
+
</td>
|
|
1324
|
+
);
|
|
1325
|
+
case 'status':
|
|
1326
|
+
return (
|
|
1327
|
+
<td key={contactId} className="px-3 py-4 whitespace-nowrap sm:px-6">
|
|
1328
|
+
{contact.status ? (
|
|
1329
|
+
<span
|
|
1330
|
+
className="inline-flex rounded-full px-2 py-1 text-sm font-semibold"
|
|
1331
|
+
style={{
|
|
1332
|
+
backgroundColor: `${contact.status.color}20`,
|
|
1333
|
+
color: contact.status.color,
|
|
1334
|
+
}}
|
|
1335
|
+
>
|
|
1336
|
+
{contact.status.name}
|
|
1337
|
+
</span>
|
|
1338
|
+
) : (
|
|
1339
|
+
<span className="text-gray-400">-</span>
|
|
1340
|
+
)}
|
|
1341
|
+
</td>
|
|
1342
|
+
);
|
|
1343
|
+
case 'origin':
|
|
1344
|
+
return (
|
|
1345
|
+
<td key={contactId} className="px-3 py-4 text-base whitespace-nowrap sm:px-6">
|
|
1346
|
+
{contact.origin ? (
|
|
1347
|
+
<span className="text-gray-700">{contact.origin}</span>
|
|
1348
|
+
) : (
|
|
1349
|
+
<span className="text-gray-400">Non renseigné</span>
|
|
1350
|
+
)}
|
|
1351
|
+
</td>
|
|
1352
|
+
);
|
|
1353
|
+
case 'commercial':
|
|
1354
|
+
return (
|
|
1355
|
+
<td key={contactId} className="px-3 py-4 whitespace-nowrap sm:px-6">
|
|
1356
|
+
{contact.assignedCommercial ? (
|
|
1357
|
+
<span className="text-base text-gray-900">{contact.assignedCommercial.name}</span>
|
|
1358
|
+
) : (
|
|
1359
|
+
<span className="inline-flex rounded-full border border-orange-300 bg-orange-50 px-2 py-1 text-sm font-medium text-orange-800">
|
|
1360
|
+
Non Attribué
|
|
1361
|
+
</span>
|
|
1362
|
+
)}
|
|
1363
|
+
</td>
|
|
1364
|
+
);
|
|
1365
|
+
case 'telepro':
|
|
1366
|
+
return (
|
|
1367
|
+
<td key={contactId} className="px-3 py-4 whitespace-nowrap sm:px-6">
|
|
1368
|
+
{contact.assignedTelepro ? (
|
|
1369
|
+
<span className="text-base text-gray-900">{contact.assignedTelepro.name}</span>
|
|
1370
|
+
) : (
|
|
1371
|
+
<span className="inline-flex rounded-full border border-orange-300 bg-orange-50 px-2 py-1 text-sm font-medium text-orange-800">
|
|
1372
|
+
Non Attribué
|
|
1373
|
+
</span>
|
|
1374
|
+
)}
|
|
1375
|
+
</td>
|
|
1376
|
+
);
|
|
1377
|
+
case 'createdAt':
|
|
1378
|
+
return (
|
|
1379
|
+
<td
|
|
1380
|
+
key={contactId}
|
|
1381
|
+
className="px-3 py-4 text-base whitespace-nowrap text-gray-500 sm:px-6"
|
|
1382
|
+
>
|
|
1383
|
+
{contact.createdAt ? formatDate(contact.createdAt) : '-'}
|
|
1384
|
+
</td>
|
|
1385
|
+
);
|
|
1386
|
+
case 'updatedAt':
|
|
1387
|
+
return (
|
|
1388
|
+
<td
|
|
1389
|
+
key={contactId}
|
|
1390
|
+
className="px-3 py-4 text-base whitespace-nowrap text-gray-500 sm:px-6"
|
|
1391
|
+
>
|
|
1392
|
+
{contact.updatedAt ? formatDate(contact.updatedAt) : '-'}
|
|
1393
|
+
</td>
|
|
1394
|
+
);
|
|
1395
|
+
default:
|
|
1396
|
+
return null;
|
|
1397
|
+
}
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
return (
|
|
1401
|
+
<div className="bg-crms-bg flex h-full flex-col">
|
|
1402
|
+
{/* Header avec titre, badge et breadcrumbs */}
|
|
1403
|
+
<div className="border-b border-gray-200 bg-white px-4 py-4 sm:px-6 lg:px-8">
|
|
1404
|
+
<div className="mb-3 flex items-start justify-between gap-3">
|
|
1405
|
+
{/* Mobile menu button */}
|
|
1406
|
+
<button
|
|
1407
|
+
onClick={toggleMobileMenu}
|
|
1408
|
+
className="mt-1 shrink-0 cursor-pointer rounded-lg p-2 text-gray-700 transition-colors hover:bg-gray-100 lg:hidden"
|
|
1409
|
+
aria-label="Toggle menu"
|
|
1410
|
+
>
|
|
1411
|
+
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1412
|
+
{isMobileMenuOpen ? (
|
|
1413
|
+
<path
|
|
1414
|
+
strokeLinecap="round"
|
|
1415
|
+
strokeLinejoin="round"
|
|
1416
|
+
strokeWidth={2}
|
|
1417
|
+
d="M6 18L18 6M6 6l12 12"
|
|
1418
|
+
/>
|
|
1419
|
+
) : (
|
|
1420
|
+
<path
|
|
1421
|
+
strokeLinecap="round"
|
|
1422
|
+
strokeLinejoin="round"
|
|
1423
|
+
strokeWidth={2}
|
|
1424
|
+
d="M4 6h16M4 12h16M4 18h16"
|
|
1425
|
+
/>
|
|
1426
|
+
)}
|
|
1427
|
+
</svg>
|
|
1428
|
+
</button>
|
|
1429
|
+
|
|
1430
|
+
{/* Titre et breadcrumbs */}
|
|
1431
|
+
<div className="flex-1">
|
|
1432
|
+
<div className="mb-1 flex items-center gap-2">
|
|
1433
|
+
<h1 className="text-2xl font-bold text-gray-900">Contacts</h1>
|
|
1434
|
+
<span className="rounded-full bg-indigo-100 px-2.5 py-0.5 text-sm font-semibold text-indigo-600">
|
|
1435
|
+
{totalContacts}
|
|
1436
|
+
</span>
|
|
1437
|
+
</div>
|
|
1438
|
+
<p className="text-base text-gray-500">Home > Contacts</p>
|
|
1439
|
+
</div>
|
|
1440
|
+
|
|
1441
|
+
<button
|
|
1442
|
+
onClick={fetchContacts}
|
|
1443
|
+
className="cursor-pointer rounded-lg p-2 text-gray-600 transition-colors hover:bg-gray-100"
|
|
1444
|
+
title="Actualiser"
|
|
1445
|
+
>
|
|
1446
|
+
<RefreshCw className="h-5 w-5" />
|
|
1447
|
+
</button>
|
|
1448
|
+
</div>
|
|
1449
|
+
|
|
1450
|
+
{/* Barre d'outils avec recherche et filtres */}
|
|
1451
|
+
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
1452
|
+
{/* Recherche et filtres à gauche */}
|
|
1453
|
+
<div className="flex flex-1 flex-wrap items-center gap-2">
|
|
1454
|
+
<div className="relative min-w-[200px] flex-1">
|
|
1455
|
+
<Search className="pointer-events-none absolute top-2.5 left-3 h-4 w-4 text-gray-400" />
|
|
1456
|
+
<input
|
|
1457
|
+
type="text"
|
|
1458
|
+
value={search}
|
|
1459
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
1460
|
+
placeholder="Rechercher"
|
|
1461
|
+
className="w-full rounded-lg border border-gray-200 bg-gray-50 py-2 pr-3 pl-9 text-base text-gray-900 placeholder:text-gray-400 focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-500/30 focus:outline-none"
|
|
1462
|
+
/>
|
|
1463
|
+
</div>
|
|
1464
|
+
<div className="relative" ref={sortMenuRef}>
|
|
1465
|
+
<button
|
|
1466
|
+
type="button"
|
|
1467
|
+
onClick={() => setShowSortMenu(!showSortMenu)}
|
|
1468
|
+
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
|
1469
|
+
>
|
|
1470
|
+
<ChevronsLeft className="h-3.5 w-3.5" />
|
|
1471
|
+
<span>Trier par</span>
|
|
1472
|
+
</button>
|
|
1473
|
+
{showSortMenu && (
|
|
1474
|
+
<div
|
|
1475
|
+
className="absolute top-full right-0 z-50 mt-2 w-48 rounded-lg border border-gray-200 bg-white shadow-lg"
|
|
1476
|
+
style={{ maxWidth: 'calc(100vw - 2rem)', right: '0' }}
|
|
1477
|
+
>
|
|
1478
|
+
<div className="py-1">
|
|
1479
|
+
<button
|
|
1480
|
+
type="button"
|
|
1481
|
+
onClick={() => handleSortByField('status')}
|
|
1482
|
+
className="w-full cursor-pointer px-4 py-2 text-left text-base text-gray-700 hover:bg-gray-100"
|
|
1483
|
+
>
|
|
1484
|
+
Statut {sortField === 'status' && (sortOrder === 'asc' ? '↑' : '↓')}
|
|
1485
|
+
</button>
|
|
1486
|
+
<button
|
|
1487
|
+
type="button"
|
|
1488
|
+
onClick={() => handleSortByField('commercial')}
|
|
1489
|
+
className="w-full cursor-pointer px-4 py-2 text-left text-base text-gray-700 hover:bg-gray-100"
|
|
1490
|
+
>
|
|
1491
|
+
Commercial {sortField === 'commercial' && (sortOrder === 'asc' ? '↑' : '↓')}
|
|
1492
|
+
</button>
|
|
1493
|
+
<button
|
|
1494
|
+
type="button"
|
|
1495
|
+
onClick={() => handleSortByField('telepro')}
|
|
1496
|
+
className="w-full cursor-pointer px-4 py-2 text-left text-base text-gray-700 hover:bg-gray-100"
|
|
1497
|
+
>
|
|
1498
|
+
Télépro {sortField === 'telepro' && (sortOrder === 'asc' ? '↑' : '↓')}
|
|
1499
|
+
</button>
|
|
1500
|
+
<button
|
|
1501
|
+
type="button"
|
|
1502
|
+
onClick={() => handleSortByField('createdAt')}
|
|
1503
|
+
className="w-full cursor-pointer px-4 py-2 text-left text-base text-gray-700 hover:bg-gray-100"
|
|
1504
|
+
>
|
|
1505
|
+
Date de création{' '}
|
|
1506
|
+
{sortField === 'createdAt' && (sortOrder === 'asc' ? '↑' : '↓')}
|
|
1507
|
+
</button>
|
|
1508
|
+
<button
|
|
1509
|
+
type="button"
|
|
1510
|
+
onClick={() => handleSortByField('updatedAt')}
|
|
1511
|
+
className="w-full cursor-pointer px-4 py-2 text-left text-base text-gray-700 hover:bg-gray-100"
|
|
1512
|
+
>
|
|
1513
|
+
Date de modification{' '}
|
|
1514
|
+
{sortField === 'updatedAt' && (sortOrder === 'asc' ? '↑' : '↓')}
|
|
1515
|
+
</button>
|
|
1516
|
+
{sortField && (
|
|
1517
|
+
<button
|
|
1518
|
+
type="button"
|
|
1519
|
+
onClick={() => {
|
|
1520
|
+
setSortField('');
|
|
1521
|
+
setSortOrder('asc');
|
|
1522
|
+
setShowSortMenu(false);
|
|
1523
|
+
}}
|
|
1524
|
+
className="w-full cursor-pointer px-4 py-2 text-left text-base text-gray-500 hover:bg-gray-100"
|
|
1525
|
+
>
|
|
1526
|
+
Réinitialiser
|
|
1527
|
+
</button>
|
|
1528
|
+
)}
|
|
1529
|
+
</div>
|
|
1530
|
+
</div>
|
|
1531
|
+
)}
|
|
1532
|
+
</div>
|
|
1533
|
+
<div className="flex items-center gap-2">
|
|
1534
|
+
<div className="relative" ref={datePickerRef}>
|
|
1535
|
+
<button
|
|
1536
|
+
type="button"
|
|
1537
|
+
onClick={() => setShowDatePicker(!showDatePicker)}
|
|
1538
|
+
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
|
1539
|
+
>
|
|
1540
|
+
<Calendar className="h-3.5 w-3.5" />
|
|
1541
|
+
<span className="hidden sm:inline">
|
|
1542
|
+
{dateRangeStart && dateRangeEnd
|
|
1543
|
+
? `${new Date(dateRangeStart + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })} - ${new Date(dateRangeEnd + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })}`
|
|
1544
|
+
: createdAtStart && createdAtEnd
|
|
1545
|
+
? `${new Date(createdAtStart + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })} - ${new Date(createdAtEnd + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })}`
|
|
1546
|
+
: 'Sélectionner une date'}
|
|
1547
|
+
</span>
|
|
1548
|
+
<span className="sm:hidden">Date</span>
|
|
1549
|
+
</button>
|
|
1550
|
+
{showDatePicker && (
|
|
1551
|
+
<div
|
|
1552
|
+
className="absolute top-full right-0 z-50 mt-2 w-72 rounded-lg border border-gray-200 bg-white p-4 shadow-lg"
|
|
1553
|
+
style={{ maxWidth: 'calc(100vw - 2rem)', right: '0' }}
|
|
1554
|
+
>
|
|
1555
|
+
<div className="space-y-3">
|
|
1556
|
+
<div>
|
|
1557
|
+
<label className="mb-1 block text-xs font-medium text-gray-700">Du</label>
|
|
1558
|
+
<input
|
|
1559
|
+
type="date"
|
|
1560
|
+
value={dateRangeStart}
|
|
1561
|
+
onChange={(e) => setDateRangeStart(e.target.value)}
|
|
1562
|
+
className="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
1563
|
+
/>
|
|
1564
|
+
</div>
|
|
1565
|
+
<div>
|
|
1566
|
+
<label className="mb-1 block text-xs font-medium text-gray-700">Au</label>
|
|
1567
|
+
<input
|
|
1568
|
+
type="date"
|
|
1569
|
+
value={dateRangeEnd}
|
|
1570
|
+
onChange={(e) => setDateRangeEnd(e.target.value)}
|
|
1571
|
+
className="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
1572
|
+
/>
|
|
1573
|
+
</div>
|
|
1574
|
+
<div className="flex justify-between gap-2">
|
|
1575
|
+
<button
|
|
1576
|
+
type="button"
|
|
1577
|
+
onClick={() => {
|
|
1578
|
+
// Réinitialiser tous les filtres de date
|
|
1579
|
+
setDateRangeStart('');
|
|
1580
|
+
setDateRangeEnd('');
|
|
1581
|
+
setCreatedAtStart('');
|
|
1582
|
+
setCreatedAtEnd('');
|
|
1583
|
+
setUpdatedAtStart('');
|
|
1584
|
+
setUpdatedAtEnd('');
|
|
1585
|
+
setCurrentPage(1);
|
|
1586
|
+
setShowDatePicker(false);
|
|
1587
|
+
// Recharger les contacts sans filtres de date
|
|
1588
|
+
setTimeout(() => {
|
|
1589
|
+
fetchContacts();
|
|
1590
|
+
}, 0);
|
|
1591
|
+
}}
|
|
1592
|
+
className="cursor-pointer rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
|
1593
|
+
>
|
|
1594
|
+
Réinitialiser
|
|
1595
|
+
</button>
|
|
1596
|
+
<div className="flex gap-2">
|
|
1597
|
+
<button
|
|
1598
|
+
type="button"
|
|
1599
|
+
onClick={() => {
|
|
1600
|
+
setDateRangeStart('');
|
|
1601
|
+
setDateRangeEnd('');
|
|
1602
|
+
setShowDatePicker(false);
|
|
1603
|
+
}}
|
|
1604
|
+
className="cursor-pointer rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
|
1605
|
+
>
|
|
1606
|
+
Effacer
|
|
1607
|
+
</button>
|
|
1608
|
+
<button
|
|
1609
|
+
type="button"
|
|
1610
|
+
onClick={() => {
|
|
1611
|
+
if (dateRangeStart && dateRangeEnd) {
|
|
1612
|
+
// Valider que les dates sont valides
|
|
1613
|
+
const start = new Date(dateRangeStart + 'T00:00:00');
|
|
1614
|
+
const end = new Date(dateRangeEnd + 'T00:00:00');
|
|
1615
|
+
if (
|
|
1616
|
+
!isNaN(start.getTime()) &&
|
|
1617
|
+
!isNaN(end.getTime()) &&
|
|
1618
|
+
start <= end
|
|
1619
|
+
) {
|
|
1620
|
+
setCreatedAtStart(dateRangeStart);
|
|
1621
|
+
setCreatedAtEnd(dateRangeEnd);
|
|
1622
|
+
setCurrentPage(1);
|
|
1623
|
+
setShowDatePicker(false);
|
|
1624
|
+
} else {
|
|
1625
|
+
setError('Les dates sélectionnées sont invalides');
|
|
1626
|
+
}
|
|
1627
|
+
} else {
|
|
1628
|
+
setShowDatePicker(false);
|
|
1629
|
+
}
|
|
1630
|
+
}}
|
|
1631
|
+
className="cursor-pointer rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-indigo-700"
|
|
1632
|
+
>
|
|
1633
|
+
Appliquer
|
|
1634
|
+
</button>
|
|
1635
|
+
</div>
|
|
1636
|
+
</div>
|
|
1637
|
+
</div>
|
|
1638
|
+
</div>
|
|
1639
|
+
)}
|
|
1640
|
+
</div>
|
|
1641
|
+
{(createdAtStart || createdAtEnd || updatedAtStart || updatedAtEnd) && (
|
|
1642
|
+
<button
|
|
1643
|
+
type="button"
|
|
1644
|
+
onClick={() => {
|
|
1645
|
+
// Réinitialiser tous les filtres de date
|
|
1646
|
+
setDateRangeStart('');
|
|
1647
|
+
setDateRangeEnd('');
|
|
1648
|
+
setCreatedAtStart('');
|
|
1649
|
+
setCreatedAtEnd('');
|
|
1650
|
+
setUpdatedAtStart('');
|
|
1651
|
+
setUpdatedAtEnd('');
|
|
1652
|
+
setCurrentPage(1);
|
|
1653
|
+
// Recharger les contacts sans filtres de date
|
|
1654
|
+
setTimeout(() => {
|
|
1655
|
+
fetchContacts();
|
|
1656
|
+
}, 0);
|
|
1657
|
+
}}
|
|
1658
|
+
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
|
1659
|
+
title="Réinitialiser les filtres de date"
|
|
1660
|
+
>
|
|
1661
|
+
<X className="h-3.5 w-3.5" />
|
|
1662
|
+
<span className="hidden sm:inline">Réinitialiser</span>
|
|
1663
|
+
</button>
|
|
1664
|
+
)}
|
|
1665
|
+
</div>
|
|
1666
|
+
</div>
|
|
1667
|
+
|
|
1668
|
+
{/* Actions à droite */}
|
|
1669
|
+
<div className="flex items-center gap-2">
|
|
1670
|
+
{/* Gérer les colonnes */}
|
|
1671
|
+
{viewMode === 'table' && (
|
|
1672
|
+
<button
|
|
1673
|
+
onClick={() => setShowColumnPanel(true)}
|
|
1674
|
+
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
|
1675
|
+
title="Gérer les colonnes"
|
|
1676
|
+
>
|
|
1677
|
+
Gérer les colonnes
|
|
1678
|
+
</button>
|
|
1679
|
+
)}
|
|
1680
|
+
{/* Bouton Réinitialiser les filtres */}
|
|
1681
|
+
{hasActiveFilters() && (
|
|
1682
|
+
<button
|
|
1683
|
+
onClick={handleResetAllFilters}
|
|
1684
|
+
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
|
1685
|
+
title="Réinitialiser tous les filtres"
|
|
1686
|
+
>
|
|
1687
|
+
<X className="h-3.5 w-3.5" />
|
|
1688
|
+
Réinitialiser
|
|
1689
|
+
</button>
|
|
1690
|
+
)}
|
|
1691
|
+
{/* Groupe vue (liste / grille) */}
|
|
1692
|
+
<div className="flex items-center rounded-lg border border-gray-200 bg-white p-1">
|
|
1693
|
+
<button
|
|
1694
|
+
onClick={() => setViewMode('table')}
|
|
1695
|
+
className={cn(
|
|
1696
|
+
'cursor-pointer rounded-md px-2 py-1.5 text-gray-600 transition-colors',
|
|
1697
|
+
viewMode === 'table' ? 'bg-gray-100 text-gray-900' : 'hover:bg-gray-50',
|
|
1698
|
+
)}
|
|
1699
|
+
title="Vue liste"
|
|
1700
|
+
>
|
|
1701
|
+
<List className="h-4 w-4" />
|
|
1702
|
+
</button>
|
|
1703
|
+
<button
|
|
1704
|
+
onClick={() => setViewMode('cards')}
|
|
1705
|
+
className={cn(
|
|
1706
|
+
'cursor-pointer rounded-md px-2 py-1.5 text-gray-600 transition-colors',
|
|
1707
|
+
viewMode === 'cards' ? 'bg-gray-100 text-gray-900' : 'hover:bg-gray-50',
|
|
1708
|
+
)}
|
|
1709
|
+
title="Vue grille"
|
|
1710
|
+
>
|
|
1711
|
+
<LayoutGrid className="h-4 w-4" />
|
|
1712
|
+
</button>
|
|
1713
|
+
</div>
|
|
1714
|
+
|
|
1715
|
+
{/* Exporter tous les contacts (admin uniquement) */}
|
|
1716
|
+
{isAdmin && (
|
|
1717
|
+
<button
|
|
1718
|
+
onClick={() => {
|
|
1719
|
+
setExportAll(true);
|
|
1720
|
+
setShowExportModal(true);
|
|
1721
|
+
}}
|
|
1722
|
+
disabled={exporting}
|
|
1723
|
+
className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-indigo-600 bg-white px-4 py-2 text-xs font-semibold text-indigo-600 shadow-sm transition-colors hover:bg-indigo-50 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 sm:text-sm"
|
|
1724
|
+
>
|
|
1725
|
+
<Download className="h-4 w-4" />
|
|
1726
|
+
Exporter tous
|
|
1727
|
+
</button>
|
|
1728
|
+
)}
|
|
1729
|
+
|
|
1730
|
+
{/* Ajouter un contact */}
|
|
1731
|
+
<button
|
|
1732
|
+
onClick={handleNewContact}
|
|
1733
|
+
className="inline-flex cursor-pointer items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-xs font-semibold text-white shadow-sm transition-colors hover:bg-indigo-700 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:outline-none sm:text-sm"
|
|
1734
|
+
>
|
|
1735
|
+
<Plus className="h-4 w-4" />
|
|
1736
|
+
Ajouter un contact
|
|
1737
|
+
</button>
|
|
1738
|
+
</div>
|
|
1739
|
+
</div>
|
|
1740
|
+
</div>
|
|
1741
|
+
|
|
1742
|
+
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
|
1743
|
+
{success && (
|
|
1744
|
+
<div className="mb-4 rounded-lg bg-green-50 p-4 text-sm text-green-600">{success}</div>
|
|
1745
|
+
)}
|
|
1746
|
+
|
|
1747
|
+
{error && <div className="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-600">{error}</div>}
|
|
1748
|
+
|
|
1749
|
+
{/* Barre d'actions groupées */}
|
|
1750
|
+
{selectedContactIds.size > 0 && (
|
|
1751
|
+
<div className="mb-4 flex items-center justify-between rounded-lg border border-indigo-200 bg-indigo-50 p-4 shadow-sm">
|
|
1752
|
+
<div className="flex items-center gap-4">
|
|
1753
|
+
<span className="text-sm font-medium text-indigo-900">
|
|
1754
|
+
{selectedContactIds.size} contact(s) sélectionné(s)
|
|
1755
|
+
</span>
|
|
1756
|
+
<button
|
|
1757
|
+
onClick={handleDeselectAll}
|
|
1758
|
+
className="cursor-pointer text-sm text-indigo-600 hover:text-indigo-700 hover:underline"
|
|
1759
|
+
>
|
|
1760
|
+
Désélectionner tout
|
|
1761
|
+
</button>
|
|
1762
|
+
</div>
|
|
1763
|
+
<div className="flex items-center gap-2">
|
|
1764
|
+
<button
|
|
1765
|
+
onClick={() => setShowBulkCommercialModal(true)}
|
|
1766
|
+
className="flex cursor-pointer items-center gap-2 rounded-lg bg-orange-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-orange-700"
|
|
1767
|
+
>
|
|
1768
|
+
<Users className="h-4 w-4" />
|
|
1769
|
+
Changer commercial
|
|
1770
|
+
</button>
|
|
1771
|
+
<button
|
|
1772
|
+
onClick={() => setShowBulkStatusModal(true)}
|
|
1773
|
+
className="flex cursor-pointer items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700"
|
|
1774
|
+
>
|
|
1775
|
+
<Tag className="h-4 w-4" />
|
|
1776
|
+
Changer statut
|
|
1777
|
+
</button>
|
|
1778
|
+
{isAdmin && (
|
|
1779
|
+
<>
|
|
1780
|
+
<button
|
|
1781
|
+
onClick={() => {
|
|
1782
|
+
setExportAll(false);
|
|
1783
|
+
setShowExportModal(true);
|
|
1784
|
+
}}
|
|
1785
|
+
disabled={bulkActionLoading || exporting}
|
|
1786
|
+
className="flex cursor-pointer items-center gap-2 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"
|
|
1787
|
+
>
|
|
1788
|
+
<Download className="h-4 w-4" />
|
|
1789
|
+
Exporter
|
|
1790
|
+
</button>
|
|
1791
|
+
<button
|
|
1792
|
+
onClick={handleBulkDelete}
|
|
1793
|
+
disabled={bulkActionLoading}
|
|
1794
|
+
className="flex cursor-pointer items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
1795
|
+
>
|
|
1796
|
+
<Trash2 className="h-4 w-4" />
|
|
1797
|
+
Supprimer
|
|
1798
|
+
</button>
|
|
1799
|
+
</>
|
|
1800
|
+
)}
|
|
1801
|
+
</div>
|
|
1802
|
+
</div>
|
|
1803
|
+
)}
|
|
1804
|
+
|
|
1805
|
+
{/* Liste des contacts */}
|
|
1806
|
+
{loading ? (
|
|
1807
|
+
viewMode === 'table' ? (
|
|
1808
|
+
<ContactTableSkeleton />
|
|
1809
|
+
) : (
|
|
1810
|
+
<ContactCardsSkeleton />
|
|
1811
|
+
)
|
|
1812
|
+
) : contacts.length === 0 ? (
|
|
1813
|
+
<div className="rounded-lg bg-white p-12 text-center shadow">
|
|
1814
|
+
<div className="text-4xl sm:text-6xl">👥</div>
|
|
1815
|
+
<h2 className="mt-4 text-lg font-semibold text-gray-900 sm:text-xl">
|
|
1816
|
+
Aucun contact trouvé
|
|
1817
|
+
</h2>
|
|
1818
|
+
<p className="mt-2 text-sm text-gray-600 sm:text-base">
|
|
1819
|
+
{search || statusFilter || assignedCommercialFilter || assignedTeleproFilter
|
|
1820
|
+
? 'Aucun contact ne correspond à vos critères'
|
|
1821
|
+
: 'Commencez par ajouter votre premier contact'}
|
|
1822
|
+
</p>
|
|
1823
|
+
{!search && !statusFilter && !assignedCommercialFilter && !assignedTeleproFilter && (
|
|
1824
|
+
<button
|
|
1825
|
+
onClick={handleNewContact}
|
|
1826
|
+
className="mt-6 cursor-pointer rounded-lg bg-indigo-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 sm:text-base"
|
|
1827
|
+
>
|
|
1828
|
+
Ajouter un contact
|
|
1829
|
+
</button>
|
|
1830
|
+
)}
|
|
1831
|
+
</div>
|
|
1832
|
+
) : (
|
|
1833
|
+
<>
|
|
1834
|
+
{/* Vue Tableau */}
|
|
1835
|
+
{viewMode === 'table' && (
|
|
1836
|
+
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
|
|
1837
|
+
<div className="overflow-x-auto">
|
|
1838
|
+
<table className="min-w-full divide-y divide-gray-100 text-sm">
|
|
1839
|
+
<thead className="bg-gray-50/60">
|
|
1840
|
+
<tr>
|
|
1841
|
+
{/* Checkbox pour tout sélectionner */}
|
|
1842
|
+
<th className="px-3 py-3 text-left sm:px-6">
|
|
1843
|
+
<input
|
|
1844
|
+
type="checkbox"
|
|
1845
|
+
checked={
|
|
1846
|
+
contacts.length > 0 && selectedContactIds.size === contacts.length
|
|
1847
|
+
}
|
|
1848
|
+
onChange={handleSelectAll}
|
|
1849
|
+
className="h-4 w-4 cursor-pointer rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
|
1850
|
+
/>
|
|
1851
|
+
</th>
|
|
1852
|
+
{/* En-têtes dynamiques des colonnes */}
|
|
1853
|
+
{visibleColumns.map((column) => {
|
|
1854
|
+
const isSortable = column.id === 'createdAt' || column.id === 'updatedAt';
|
|
1855
|
+
const showFilterIcon =
|
|
1856
|
+
column.id === 'status' ||
|
|
1857
|
+
column.id === 'origin' ||
|
|
1858
|
+
column.id === 'commercial' ||
|
|
1859
|
+
column.id === 'telepro' ||
|
|
1860
|
+
column.id === 'createdAt' ||
|
|
1861
|
+
column.id === 'updatedAt';
|
|
1862
|
+
|
|
1863
|
+
return (
|
|
1864
|
+
<th
|
|
1865
|
+
key={column.id}
|
|
1866
|
+
className="px-3 py-3 text-left text-sm font-medium tracking-wider text-gray-500 uppercase sm:px-6"
|
|
1867
|
+
>
|
|
1868
|
+
{showFilterIcon ? (
|
|
1869
|
+
<div className="inline-flex items-center gap-1">
|
|
1870
|
+
{isSortable ? (
|
|
1871
|
+
<button
|
|
1872
|
+
type="button"
|
|
1873
|
+
onClick={() => handleSort(column.id)}
|
|
1874
|
+
className="inline-flex cursor-pointer items-center gap-1"
|
|
1875
|
+
>
|
|
1876
|
+
<span>{column.label}</span>
|
|
1877
|
+
{sortField === column.id && (
|
|
1878
|
+
<span className="text-indigo-600">
|
|
1879
|
+
{sortOrder === 'asc' ? <ChevronDown /> : <ChevronUp />}
|
|
1880
|
+
</span>
|
|
1881
|
+
)}
|
|
1882
|
+
</button>
|
|
1883
|
+
) : (
|
|
1884
|
+
<span>{column.label}</span>
|
|
1885
|
+
)}
|
|
1886
|
+
<button
|
|
1887
|
+
type="button"
|
|
1888
|
+
data-date-filter-icon={
|
|
1889
|
+
column.id === 'createdAt' || column.id === 'updatedAt'
|
|
1890
|
+
? true
|
|
1891
|
+
: undefined
|
|
1892
|
+
}
|
|
1893
|
+
onClick={(e) =>
|
|
1894
|
+
column.id === 'createdAt' || column.id === 'updatedAt'
|
|
1895
|
+
? handleDateFilterClick(e, column.id)
|
|
1896
|
+
: handleListFilterClick(e, column.id)
|
|
1897
|
+
}
|
|
1898
|
+
className="cursor-pointer"
|
|
1899
|
+
>
|
|
1900
|
+
<Filter
|
|
1901
|
+
className={cn(
|
|
1902
|
+
'h-3 w-3',
|
|
1903
|
+
(isSortable && sortField === column.id) ||
|
|
1904
|
+
(column.id === 'createdAt' &&
|
|
1905
|
+
(createdAtStart || createdAtEnd)) ||
|
|
1906
|
+
(column.id === 'updatedAt' &&
|
|
1907
|
+
(updatedAtStart || updatedAtEnd))
|
|
1908
|
+
? 'text-indigo-600'
|
|
1909
|
+
: 'text-gray-400',
|
|
1910
|
+
)}
|
|
1911
|
+
/>
|
|
1912
|
+
</button>
|
|
1913
|
+
</div>
|
|
1914
|
+
) : column.id === 'contact' && groupContactInfo ? (
|
|
1915
|
+
'Contact'
|
|
1916
|
+
) : (
|
|
1917
|
+
column.label
|
|
1918
|
+
)}
|
|
1919
|
+
</th>
|
|
1920
|
+
);
|
|
1921
|
+
})}
|
|
1922
|
+
</tr>
|
|
1923
|
+
</thead>
|
|
1924
|
+
<tbody className="divide-y divide-gray-200">
|
|
1925
|
+
{sortedContacts.map((contact) => (
|
|
1926
|
+
<tr
|
|
1927
|
+
key={contact.id}
|
|
1928
|
+
onClick={() => router.push(`/contacts/${contact.id}`)}
|
|
1929
|
+
className={cn(
|
|
1930
|
+
'cursor-pointer transition-colors hover:bg-gray-50',
|
|
1931
|
+
selectedContactIds.has(contact.id) && 'bg-indigo-50',
|
|
1932
|
+
)}
|
|
1933
|
+
>
|
|
1934
|
+
{/* Checkbox pour sélectionner ce contact */}
|
|
1935
|
+
<td
|
|
1936
|
+
className="px-3 py-4 whitespace-nowrap sm:px-6"
|
|
1937
|
+
onClick={(e) => {
|
|
1938
|
+
e.stopPropagation();
|
|
1939
|
+
}}
|
|
1940
|
+
>
|
|
1941
|
+
<input
|
|
1942
|
+
type="checkbox"
|
|
1943
|
+
checked={selectedContactIds.has(contact.id)}
|
|
1944
|
+
onChange={() => handleSelectContact(contact.id)}
|
|
1945
|
+
className="h-4 w-4 cursor-pointer rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
|
1946
|
+
/>
|
|
1947
|
+
</td>
|
|
1948
|
+
{/* Cellules dynamiques */}
|
|
1949
|
+
{visibleColumns.map((column) => renderTableCell(column.id, contact))}
|
|
1950
|
+
</tr>
|
|
1951
|
+
))}
|
|
1952
|
+
</tbody>
|
|
1953
|
+
</table>
|
|
1954
|
+
</div>
|
|
1955
|
+
</div>
|
|
1956
|
+
)}
|
|
1957
|
+
|
|
1958
|
+
{/* Vue Cartes */}
|
|
1959
|
+
{viewMode === 'cards' ? (
|
|
1960
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
1961
|
+
{sortedContacts.map((contact) => {
|
|
1962
|
+
return (
|
|
1963
|
+
<div
|
|
1964
|
+
key={contact.id}
|
|
1965
|
+
onClick={() => router.push(`/contacts/${contact.id}`)}
|
|
1966
|
+
className="relative cursor-pointer rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
|
1967
|
+
>
|
|
1968
|
+
{/* En-tête avec trois points */}
|
|
1969
|
+
<div className="mb-4 flex items-start justify-between">
|
|
1970
|
+
<div className="flex items-center gap-3">
|
|
1971
|
+
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-lg font-semibold text-indigo-600">
|
|
1972
|
+
{contact.isCompany ? (
|
|
1973
|
+
<span>🏢</span>
|
|
1974
|
+
) : (
|
|
1975
|
+
(contact.firstName?.[0] || contact.lastName?.[0] || '?').toUpperCase()
|
|
1976
|
+
)}
|
|
1977
|
+
</div>
|
|
1978
|
+
<div className="min-w-0">
|
|
1979
|
+
<h3 className="text-lg font-semibold text-gray-900">
|
|
1980
|
+
{contact.civility && `${contact.civility}. `}
|
|
1981
|
+
{contact.firstName} {contact.lastName}
|
|
1982
|
+
</h3>
|
|
1983
|
+
<p className="text-sm text-gray-500">
|
|
1984
|
+
CRÉÉ LE :{' '}
|
|
1985
|
+
{new Date(contact.createdAt).toLocaleDateString('fr-FR', {
|
|
1986
|
+
day: 'numeric',
|
|
1987
|
+
month: 'short',
|
|
1988
|
+
year: 'numeric',
|
|
1989
|
+
})}
|
|
1990
|
+
</p>
|
|
1991
|
+
</div>
|
|
1992
|
+
</div>
|
|
1993
|
+
</div>
|
|
1994
|
+
|
|
1995
|
+
{/* Informations de contact */}
|
|
1996
|
+
<div className="mb-4 space-y-2">
|
|
1997
|
+
<div className="flex items-center text-base text-gray-900">
|
|
1998
|
+
<Phone className="mr-2 h-4 w-4 text-gray-400" />
|
|
1999
|
+
<span className="font-medium">Tél. :</span>
|
|
2000
|
+
<span className="ml-1">{contact.phone}</span>
|
|
2001
|
+
</div>
|
|
2002
|
+
{contact.secondaryPhone && (
|
|
2003
|
+
<div className="text-sm text-gray-500">
|
|
2004
|
+
Tél. secondaire : {contact.secondaryPhone}
|
|
2005
|
+
</div>
|
|
2006
|
+
)}
|
|
2007
|
+
<div className="flex items-center text-base text-gray-900">
|
|
2008
|
+
<Mail className="mr-2 h-4 w-4 text-gray-400" />
|
|
2009
|
+
<span className="font-medium">Email :</span>
|
|
2010
|
+
<span className="ml-1">{contact.email || '-'}</span>
|
|
2011
|
+
</div>
|
|
2012
|
+
{(contact.companyName || contact.companyRelation) && (
|
|
2013
|
+
<div className="flex items-center text-base text-gray-900">
|
|
2014
|
+
<Building2 className="mr-2 h-4 w-4 text-gray-400" />
|
|
2015
|
+
<span>
|
|
2016
|
+
{contact.companyName ||
|
|
2017
|
+
(contact.companyRelation &&
|
|
2018
|
+
(contact.companyRelation.firstName ||
|
|
2019
|
+
contact.companyRelation.lastName ||
|
|
2020
|
+
'Sans nom'))}
|
|
2021
|
+
</span>
|
|
2022
|
+
</div>
|
|
2023
|
+
)}
|
|
2024
|
+
{contact.city && (
|
|
2025
|
+
<div className="flex items-center text-base text-gray-500">
|
|
2026
|
+
<MapPin className="mr-2 h-4 w-4 text-gray-400" />
|
|
2027
|
+
<span className="font-medium">Localisation :</span>
|
|
2028
|
+
<span className="ml-1">
|
|
2029
|
+
{contact.city}
|
|
2030
|
+
{contact.postalCode && `, ${contact.postalCode}`}
|
|
2031
|
+
</span>
|
|
2032
|
+
</div>
|
|
2033
|
+
)}
|
|
2034
|
+
{contact.origin && (
|
|
2035
|
+
<div className="flex items-center text-base text-gray-500">
|
|
2036
|
+
<span className="mr-2 text-sm">🎯</span>
|
|
2037
|
+
<span className="font-medium">Origine :</span>
|
|
2038
|
+
<span className="ml-1">{contact.origin}</span>
|
|
2039
|
+
</div>
|
|
2040
|
+
)}
|
|
2041
|
+
</div>
|
|
2042
|
+
|
|
2043
|
+
{/* Badges et statut */}
|
|
2044
|
+
<div className="mb-4 flex flex-wrap items-center gap-2">
|
|
2045
|
+
{contact.status && (
|
|
2046
|
+
<span
|
|
2047
|
+
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold"
|
|
2048
|
+
style={{
|
|
2049
|
+
backgroundColor: `${contact.status.color}20`,
|
|
2050
|
+
color: contact.status.color,
|
|
2051
|
+
}}
|
|
2052
|
+
>
|
|
2053
|
+
{contact.status.name}
|
|
2054
|
+
</span>
|
|
2055
|
+
)}
|
|
2056
|
+
{contact.isCompany && (
|
|
2057
|
+
<span className="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-semibold text-blue-800">
|
|
2058
|
+
Entreprise
|
|
2059
|
+
</span>
|
|
2060
|
+
)}
|
|
2061
|
+
{!contact.assignedCommercial && (
|
|
2062
|
+
<span className="inline-flex items-center rounded-full border border-orange-300 bg-orange-50 px-2.5 py-0.5 text-xs font-semibold text-orange-800">
|
|
2063
|
+
Non Attribué
|
|
2064
|
+
</span>
|
|
2065
|
+
)}
|
|
2066
|
+
</div>
|
|
2067
|
+
|
|
2068
|
+
{/* Pied de carte avec utilisateurs assignés */}
|
|
2069
|
+
<div className="flex items-start justify-between border-t border-gray-100 pt-4">
|
|
2070
|
+
<div className="space-y-2">
|
|
2071
|
+
{/* Commercial */}
|
|
2072
|
+
{contact.assignedCommercial && (
|
|
2073
|
+
<div className="flex items-center gap-2">
|
|
2074
|
+
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-xs font-semibold text-indigo-600">
|
|
2075
|
+
{contact.assignedCommercial.name
|
|
2076
|
+
.split(' ')
|
|
2077
|
+
.map((n) => n[0])
|
|
2078
|
+
.join('')
|
|
2079
|
+
.slice(0, 2)
|
|
2080
|
+
.toUpperCase()}
|
|
2081
|
+
</div>
|
|
2082
|
+
<div className="min-w-0 flex-1">
|
|
2083
|
+
<div className="flex items-center gap-1.5">
|
|
2084
|
+
<span className="text-xs font-medium text-gray-900">
|
|
2085
|
+
{contact.assignedCommercial.name}
|
|
2086
|
+
</span>
|
|
2087
|
+
<span className="inline-flex items-center rounded-full bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-700">
|
|
2088
|
+
Commercial
|
|
2089
|
+
</span>
|
|
2090
|
+
</div>
|
|
2091
|
+
</div>
|
|
2092
|
+
</div>
|
|
2093
|
+
)}
|
|
2094
|
+
|
|
2095
|
+
{/* Télépro */}
|
|
2096
|
+
{contact.assignedTelepro && (
|
|
2097
|
+
<div className="flex items-center gap-2">
|
|
2098
|
+
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-purple-100 text-xs font-semibold text-purple-600">
|
|
2099
|
+
{contact.assignedTelepro.name
|
|
2100
|
+
.split(' ')
|
|
2101
|
+
.map((n) => n[0])
|
|
2102
|
+
.join('')
|
|
2103
|
+
.slice(0, 2)
|
|
2104
|
+
.toUpperCase()}
|
|
2105
|
+
</div>
|
|
2106
|
+
<div className="min-w-0 flex-1">
|
|
2107
|
+
<div className="flex items-center gap-1.5">
|
|
2108
|
+
<span className="text-xs font-medium text-gray-900">
|
|
2109
|
+
{contact.assignedTelepro.name}
|
|
2110
|
+
</span>
|
|
2111
|
+
<span className="inline-flex items-center rounded-full bg-purple-100 px-1.5 py-0.5 text-[10px] font-medium text-purple-700">
|
|
2112
|
+
Télépro
|
|
2113
|
+
</span>
|
|
2114
|
+
</div>
|
|
2115
|
+
</div>
|
|
2116
|
+
</div>
|
|
2117
|
+
)}
|
|
2118
|
+
|
|
2119
|
+
{/* Aucun utilisateur assigné */}
|
|
2120
|
+
{!contact.assignedCommercial && !contact.assignedTelepro && (
|
|
2121
|
+
<div className="text-xs text-gray-400">Aucun utilisateur assigné</div>
|
|
2122
|
+
)}
|
|
2123
|
+
</div>
|
|
2124
|
+
</div>
|
|
2125
|
+
</div>
|
|
2126
|
+
);
|
|
2127
|
+
})}
|
|
2128
|
+
</div>
|
|
2129
|
+
) : null}
|
|
2130
|
+
|
|
2131
|
+
{/* Pagination */}
|
|
2132
|
+
{totalPages > 1 && (
|
|
2133
|
+
<div className="mt-6 flex items-center justify-center gap-2">
|
|
2134
|
+
{/* Première page */}
|
|
2135
|
+
<button
|
|
2136
|
+
onClick={() => setCurrentPage(1)}
|
|
2137
|
+
disabled={currentPage === 1}
|
|
2138
|
+
className={cn(
|
|
2139
|
+
'cursor-pointer rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50',
|
|
2140
|
+
)}
|
|
2141
|
+
title="Première page"
|
|
2142
|
+
>
|
|
2143
|
+
<ChevronsLeft className="h-4 w-4" />
|
|
2144
|
+
</button>
|
|
2145
|
+
|
|
2146
|
+
{/* Page précédente */}
|
|
2147
|
+
<button
|
|
2148
|
+
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
|
2149
|
+
disabled={currentPage === 1}
|
|
2150
|
+
className={cn(
|
|
2151
|
+
'cursor-pointer rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50',
|
|
2152
|
+
)}
|
|
2153
|
+
>
|
|
2154
|
+
Précédent
|
|
2155
|
+
</button>
|
|
2156
|
+
|
|
2157
|
+
{/* Numéros de pages */}
|
|
2158
|
+
<div className="flex items-center gap-2">
|
|
2159
|
+
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
2160
|
+
.filter((page) => {
|
|
2161
|
+
// Afficher les 5 pages autour de la page actuelle
|
|
2162
|
+
if (totalPages <= 7) return true;
|
|
2163
|
+
if (page === 1 || page === totalPages) return true;
|
|
2164
|
+
return Math.abs(page - currentPage) <= 2;
|
|
2165
|
+
})
|
|
2166
|
+
.map((page, idx, array) => {
|
|
2167
|
+
// Ajouter des ellipses si nécessaire
|
|
2168
|
+
const prevPage = array[idx - 1];
|
|
2169
|
+
const showEllipsis = prevPage && page - prevPage > 1;
|
|
2170
|
+
|
|
2171
|
+
return (
|
|
2172
|
+
<div key={page} className="flex items-center gap-2">
|
|
2173
|
+
{showEllipsis && <span className="text-gray-400">...</span>}
|
|
2174
|
+
<button
|
|
2175
|
+
onClick={() => setCurrentPage(page)}
|
|
2176
|
+
className={cn(
|
|
2177
|
+
'cursor-pointer rounded-lg border px-3 py-2 text-sm font-medium transition-colors',
|
|
2178
|
+
currentPage === page
|
|
2179
|
+
? 'border-indigo-600 bg-indigo-600 text-white'
|
|
2180
|
+
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50',
|
|
2181
|
+
)}
|
|
2182
|
+
>
|
|
2183
|
+
{page}
|
|
2184
|
+
</button>
|
|
2185
|
+
</div>
|
|
2186
|
+
);
|
|
2187
|
+
})}
|
|
2188
|
+
</div>
|
|
2189
|
+
|
|
2190
|
+
{/* Page suivante */}
|
|
2191
|
+
<button
|
|
2192
|
+
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
|
2193
|
+
disabled={currentPage === totalPages}
|
|
2194
|
+
className={cn(
|
|
2195
|
+
'cursor-pointer rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50',
|
|
2196
|
+
)}
|
|
2197
|
+
>
|
|
2198
|
+
Suivant
|
|
2199
|
+
</button>
|
|
2200
|
+
|
|
2201
|
+
{/* Dernière page */}
|
|
2202
|
+
<button
|
|
2203
|
+
onClick={() => setCurrentPage(totalPages)}
|
|
2204
|
+
disabled={currentPage === totalPages}
|
|
2205
|
+
className={cn(
|
|
2206
|
+
'cursor-pointer rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50',
|
|
2207
|
+
)}
|
|
2208
|
+
title="Dernière page"
|
|
2209
|
+
>
|
|
2210
|
+
<ChevronsRight className="h-4 w-4" />
|
|
2211
|
+
</button>
|
|
2212
|
+
</div>
|
|
2213
|
+
)}
|
|
2214
|
+
</>
|
|
2215
|
+
)}
|
|
2216
|
+
</div>
|
|
2217
|
+
|
|
2218
|
+
{/* Panneau de gestion des colonnes */}
|
|
2219
|
+
{showColumnPanel && (
|
|
2220
|
+
<>
|
|
2221
|
+
{/* Overlay */}
|
|
2222
|
+
<div
|
|
2223
|
+
className="fixed inset-0 z-40 bg-black/30"
|
|
2224
|
+
onClick={() => setShowColumnPanel(false)}
|
|
2225
|
+
/>
|
|
2226
|
+
{/* Panneau */}
|
|
2227
|
+
<div className="fixed top-0 right-0 z-50 h-full w-full max-w-md bg-white shadow-xl">
|
|
2228
|
+
<div className="flex h-full flex-col">
|
|
2229
|
+
{/* En-tête */}
|
|
2230
|
+
<div className="flex items-center justify-between border-b border-gray-200 p-6">
|
|
2231
|
+
<h2 className="text-lg font-semibold text-gray-900">Gérer les colonnes</h2>
|
|
2232
|
+
<button
|
|
2233
|
+
onClick={() => setShowColumnPanel(false)}
|
|
2234
|
+
className="cursor-pointer rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-100"
|
|
2235
|
+
>
|
|
2236
|
+
<X className="h-5 w-5" />
|
|
2237
|
+
</button>
|
|
2238
|
+
</div>
|
|
2239
|
+
|
|
2240
|
+
{/* Liste des colonnes */}
|
|
2241
|
+
<div className="flex-1 overflow-y-auto p-6">
|
|
2242
|
+
{/* Option de regroupement */}
|
|
2243
|
+
<div className="mb-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
|
2244
|
+
<h3 className="mb-3 text-sm font-semibold text-gray-900">
|
|
2245
|
+
Regrouper les informations de contact
|
|
2246
|
+
</h3>
|
|
2247
|
+
<label className="flex cursor-pointer items-center gap-3">
|
|
2248
|
+
<input
|
|
2249
|
+
type="checkbox"
|
|
2250
|
+
checked={groupContactInfo}
|
|
2251
|
+
onChange={(e) => setGroupContactInfo(e.target.checked)}
|
|
2252
|
+
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
|
2253
|
+
/>
|
|
2254
|
+
<span className="text-sm text-gray-700">
|
|
2255
|
+
Afficher nom, prénom, email et téléphone dans une seule colonne
|
|
2256
|
+
</span>
|
|
2257
|
+
</label>
|
|
2258
|
+
</div>
|
|
2259
|
+
|
|
2260
|
+
<p className="mb-4 text-sm text-gray-600">
|
|
2261
|
+
Cliquez sur l'œil pour masquer/afficher une colonne. Glissez-déposez pour
|
|
2262
|
+
réorganiser.
|
|
2263
|
+
</p>
|
|
2264
|
+
<div className="space-y-2">
|
|
2265
|
+
{tableColumns
|
|
2266
|
+
.sort((a, b) => a.order - b.order)
|
|
2267
|
+
.map((contact) => (
|
|
2268
|
+
<div
|
|
2269
|
+
key={contact.id}
|
|
2270
|
+
draggable
|
|
2271
|
+
onDragStart={() => handleDragStart(contact.id)}
|
|
2272
|
+
onDragOver={(e) => handleDragOver(e, contact.id)}
|
|
2273
|
+
onDragEnd={handleDragEnd}
|
|
2274
|
+
className={cn(
|
|
2275
|
+
'flex items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 transition-colors',
|
|
2276
|
+
draggedColumn === contact.id
|
|
2277
|
+
? 'opacity-50'
|
|
2278
|
+
: 'cursor-move hover:bg-gray-50',
|
|
2279
|
+
)}
|
|
2280
|
+
>
|
|
2281
|
+
{/* Poignée de drag */}
|
|
2282
|
+
<GripVertical className="h-5 w-5 text-gray-400" />
|
|
2283
|
+
|
|
2284
|
+
{/* Label */}
|
|
2285
|
+
<span className="flex-1 text-sm font-medium text-gray-900">
|
|
2286
|
+
{contact.label}
|
|
2287
|
+
</span>
|
|
2288
|
+
|
|
2289
|
+
{/* Toggle visibilité */}
|
|
2290
|
+
<button
|
|
2291
|
+
onClick={() => toggleColumnVisibility(contact.id)}
|
|
2292
|
+
className="cursor-pointer rounded-lg p-1 text-gray-400 transition-colors hover:bg-gray-200"
|
|
2293
|
+
title={contact.visible ? 'Masquer' : 'Afficher'}
|
|
2294
|
+
>
|
|
2295
|
+
{contact.visible ? (
|
|
2296
|
+
<Eye className="h-5 w-5 text-indigo-600" />
|
|
2297
|
+
) : (
|
|
2298
|
+
<EyeOff className="h-5 w-5" />
|
|
2299
|
+
)}
|
|
2300
|
+
</button>
|
|
2301
|
+
</div>
|
|
2302
|
+
))}
|
|
2303
|
+
</div>
|
|
2304
|
+
</div>
|
|
2305
|
+
|
|
2306
|
+
{/* Pied */}
|
|
2307
|
+
<div className="border-t border-gray-200 p-6">
|
|
2308
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between">
|
|
2309
|
+
<button
|
|
2310
|
+
onClick={resetColumns}
|
|
2311
|
+
className="cursor-pointer rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
|
2312
|
+
>
|
|
2313
|
+
Réinitialiser
|
|
2314
|
+
</button>
|
|
2315
|
+
<button
|
|
2316
|
+
onClick={() => setShowColumnPanel(false)}
|
|
2317
|
+
className="cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
|
2318
|
+
>
|
|
2319
|
+
Appliquer
|
|
2320
|
+
</button>
|
|
2321
|
+
</div>
|
|
2322
|
+
</div>
|
|
2323
|
+
</div>
|
|
2324
|
+
</div>
|
|
2325
|
+
</>
|
|
2326
|
+
)}
|
|
2327
|
+
|
|
2328
|
+
{/* Modal de création/édition */}
|
|
2329
|
+
{showModal && (
|
|
2330
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-500/20 p-4 backdrop-blur-sm sm:p-6">
|
|
2331
|
+
<div className="flex max-h-[90vh] w-full max-w-5xl flex-col rounded-lg bg-white p-6 shadow-xl sm:p-8">
|
|
2332
|
+
{/* En-tête fixe */}
|
|
2333
|
+
<div className="shrink-0 border-b border-gray-100 pb-4">
|
|
2334
|
+
<div className="flex items-center justify-between">
|
|
2335
|
+
<h2 className="text-xl font-bold text-gray-900 sm:text-2xl">
|
|
2336
|
+
{editingContact ? 'Modifier le contact' : 'Nouveau contact'}
|
|
2337
|
+
</h2>
|
|
2338
|
+
<button
|
|
2339
|
+
type="button"
|
|
2340
|
+
onClick={() => {
|
|
2341
|
+
setShowModal(false);
|
|
2342
|
+
setEditingContact(null);
|
|
2343
|
+
setError('');
|
|
2344
|
+
}}
|
|
2345
|
+
className="cursor-pointer rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-100"
|
|
2346
|
+
>
|
|
2347
|
+
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
2348
|
+
<path
|
|
2349
|
+
strokeLinecap="round"
|
|
2350
|
+
strokeLinejoin="round"
|
|
2351
|
+
strokeWidth={2}
|
|
2352
|
+
d="M6 18L18 6M6 6l12 12"
|
|
2353
|
+
/>
|
|
2354
|
+
</svg>
|
|
2355
|
+
</button>
|
|
2356
|
+
</div>
|
|
2357
|
+
</div>
|
|
2358
|
+
|
|
2359
|
+
{/* Contenu scrollable */}
|
|
2360
|
+
<form
|
|
2361
|
+
id="contact-form"
|
|
2362
|
+
onSubmit={handleSubmit}
|
|
2363
|
+
className="flex-1 space-y-6 overflow-y-auto pt-4 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
2364
|
+
>
|
|
2365
|
+
{/* Informations personnelles */}
|
|
2366
|
+
<div>
|
|
2367
|
+
<h3 className="mb-4 text-lg font-semibold text-gray-900">
|
|
2368
|
+
Informations personnelles
|
|
2369
|
+
</h3>
|
|
2370
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
2371
|
+
<div>
|
|
2372
|
+
<label className="block text-sm font-medium text-gray-700">Civilité</label>
|
|
2373
|
+
<select
|
|
2374
|
+
value={formData.civility}
|
|
2375
|
+
onChange={(e) => setFormData({ ...formData, civility: e.target.value })}
|
|
2376
|
+
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2377
|
+
>
|
|
2378
|
+
<option value="">-</option>
|
|
2379
|
+
<option value="M">M.</option>
|
|
2380
|
+
<option value="MME">Mme</option>
|
|
2381
|
+
<option value="MLLE">Mlle</option>
|
|
2382
|
+
</select>
|
|
2383
|
+
</div>
|
|
2384
|
+
|
|
2385
|
+
<div>
|
|
2386
|
+
<label className="block text-sm font-medium text-gray-700">Prénom</label>
|
|
2387
|
+
<input
|
|
2388
|
+
type="text"
|
|
2389
|
+
value={formData.firstName}
|
|
2390
|
+
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
|
|
2391
|
+
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2392
|
+
placeholder="Prénom"
|
|
2393
|
+
/>
|
|
2394
|
+
</div>
|
|
2395
|
+
|
|
2396
|
+
<div>
|
|
2397
|
+
<label className="block text-sm font-medium text-gray-700">Nom</label>
|
|
2398
|
+
<input
|
|
2399
|
+
type="text"
|
|
2400
|
+
value={formData.lastName}
|
|
2401
|
+
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
|
|
2402
|
+
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2403
|
+
placeholder="Nom"
|
|
2404
|
+
/>
|
|
2405
|
+
</div>
|
|
2406
|
+
</div>
|
|
2407
|
+
</div>
|
|
2408
|
+
|
|
2409
|
+
{/* Coordonnées */}
|
|
2410
|
+
<div>
|
|
2411
|
+
<h3 className="mb-4 text-lg font-semibold text-gray-900">Coordonnées</h3>
|
|
2412
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
2413
|
+
<div>
|
|
2414
|
+
<label className="block text-sm font-medium text-gray-700">Téléphone *</label>
|
|
2415
|
+
<input
|
|
2416
|
+
type="tel"
|
|
2417
|
+
required
|
|
2418
|
+
value={formData.phone}
|
|
2419
|
+
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
|
2420
|
+
onBlur={(e) => {
|
|
2421
|
+
const normalized = normalizePhoneNumber(e.target.value);
|
|
2422
|
+
if (normalized) {
|
|
2423
|
+
setFormData({ ...formData, phone: normalized });
|
|
2424
|
+
}
|
|
2425
|
+
}}
|
|
2426
|
+
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2427
|
+
placeholder="06 12 34 56 78"
|
|
2428
|
+
/>
|
|
2429
|
+
</div>
|
|
2430
|
+
|
|
2431
|
+
<div>
|
|
2432
|
+
<label className="block text-sm font-medium text-gray-700">
|
|
2433
|
+
Téléphone secondaire
|
|
2434
|
+
</label>
|
|
2435
|
+
<input
|
|
2436
|
+
type="tel"
|
|
2437
|
+
value={formData.secondaryPhone}
|
|
2438
|
+
onChange={(e) => setFormData({ ...formData, secondaryPhone: e.target.value })}
|
|
2439
|
+
onBlur={(e) => {
|
|
2440
|
+
const normalized = normalizePhoneNumber(e.target.value);
|
|
2441
|
+
if (normalized) {
|
|
2442
|
+
setFormData({ ...formData, secondaryPhone: normalized });
|
|
2443
|
+
}
|
|
2444
|
+
}}
|
|
2445
|
+
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2446
|
+
placeholder="06 12 34 56 78"
|
|
2447
|
+
/>
|
|
2448
|
+
</div>
|
|
2449
|
+
|
|
2450
|
+
<div className="md:col-span-2">
|
|
2451
|
+
<label className="block text-sm font-medium text-gray-700">Email</label>
|
|
2452
|
+
<input
|
|
2453
|
+
type="email"
|
|
2454
|
+
value={formData.email}
|
|
2455
|
+
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
2456
|
+
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2457
|
+
placeholder="email@exemple.com"
|
|
2458
|
+
/>
|
|
2459
|
+
</div>
|
|
2460
|
+
</div>
|
|
2461
|
+
</div>
|
|
2462
|
+
|
|
2463
|
+
{/* Adresse */}
|
|
2464
|
+
<div>
|
|
2465
|
+
<h3 className="mb-4 text-lg font-semibold text-gray-900">Adresse</h3>
|
|
2466
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
2467
|
+
<div className="md:col-span-3">
|
|
2468
|
+
<label className="block text-sm font-medium text-gray-700">
|
|
2469
|
+
Adresse complète
|
|
2470
|
+
</label>
|
|
2471
|
+
<input
|
|
2472
|
+
type="text"
|
|
2473
|
+
value={formData.address}
|
|
2474
|
+
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
|
2475
|
+
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2476
|
+
placeholder="123 Rue de la République"
|
|
2477
|
+
/>
|
|
2478
|
+
</div>
|
|
2479
|
+
|
|
2480
|
+
<div>
|
|
2481
|
+
<label className="block text-sm font-medium text-gray-700">Ville</label>
|
|
2482
|
+
<input
|
|
2483
|
+
type="text"
|
|
2484
|
+
value={formData.city}
|
|
2485
|
+
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
|
|
2486
|
+
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2487
|
+
placeholder="Paris"
|
|
2488
|
+
/>
|
|
2489
|
+
</div>
|
|
2490
|
+
|
|
2491
|
+
<div>
|
|
2492
|
+
<label className="block text-sm font-medium text-gray-700">Code postal</label>
|
|
2493
|
+
<input
|
|
2494
|
+
type="text"
|
|
2495
|
+
value={formData.postalCode}
|
|
2496
|
+
onChange={(e) => setFormData({ ...formData, postalCode: e.target.value })}
|
|
2497
|
+
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2498
|
+
placeholder="75001"
|
|
2499
|
+
/>
|
|
2500
|
+
</div>
|
|
2501
|
+
</div>
|
|
2502
|
+
</div>
|
|
2503
|
+
|
|
2504
|
+
{/* Entreprise */}
|
|
2505
|
+
<div>
|
|
2506
|
+
<h3 className="mb-4 text-lg font-semibold text-gray-900">Entreprise</h3>
|
|
2507
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
2508
|
+
<div className="flex items-center">
|
|
2509
|
+
<input
|
|
2510
|
+
type="checkbox"
|
|
2511
|
+
id="isCompany"
|
|
2512
|
+
checked={formData.isCompany}
|
|
2513
|
+
onChange={(e) =>
|
|
2514
|
+
setFormData({
|
|
2515
|
+
...formData,
|
|
2516
|
+
isCompany: e.target.checked,
|
|
2517
|
+
companyId: e.target.checked ? formData.companyId : '',
|
|
2518
|
+
})
|
|
2519
|
+
}
|
|
2520
|
+
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
|
2521
|
+
/>
|
|
2522
|
+
<label htmlFor="isCompany" className="ml-2 text-sm font-medium text-gray-700">
|
|
2523
|
+
Ce contact est une entreprise
|
|
2524
|
+
</label>
|
|
2525
|
+
</div>
|
|
2526
|
+
|
|
2527
|
+
{!formData.isCompany && (
|
|
2528
|
+
<div>
|
|
2529
|
+
<label className="block text-sm font-medium text-gray-700">
|
|
2530
|
+
Entreprise associée
|
|
2531
|
+
</label>
|
|
2532
|
+
<select
|
|
2533
|
+
value={formData.companyId}
|
|
2534
|
+
onChange={(e) => setFormData({ ...formData, companyId: e.target.value })}
|
|
2535
|
+
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2536
|
+
>
|
|
2537
|
+
<option value="">Aucune entreprise</option>
|
|
2538
|
+
{contacts
|
|
2539
|
+
.filter((c) => c.isCompany)
|
|
2540
|
+
.map((company) => (
|
|
2541
|
+
<option key={company.id} value={company.id}>
|
|
2542
|
+
{company.firstName || company.lastName || 'Entreprise sans nom'}
|
|
2543
|
+
</option>
|
|
2544
|
+
))}
|
|
2545
|
+
</select>
|
|
2546
|
+
</div>
|
|
2547
|
+
)}
|
|
2548
|
+
</div>
|
|
2549
|
+
</div>
|
|
2550
|
+
|
|
2551
|
+
{/* Autres informations */}
|
|
2552
|
+
<div>
|
|
2553
|
+
<h3 className="mb-4 text-lg font-semibold text-gray-900">Autres informations</h3>
|
|
2554
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
2555
|
+
<div>
|
|
2556
|
+
<label className="block text-sm font-medium text-gray-700">Entreprise</label>
|
|
2557
|
+
<input
|
|
2558
|
+
type="text"
|
|
2559
|
+
value={formData.company}
|
|
2560
|
+
onChange={(e) => setFormData({ ...formData, company: e.target.value })}
|
|
2561
|
+
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2562
|
+
placeholder="Nom de l'entreprise"
|
|
2563
|
+
/>
|
|
2564
|
+
</div>
|
|
2565
|
+
<div>
|
|
2566
|
+
<label className="block text-sm font-medium text-gray-700">
|
|
2567
|
+
Origine du contact
|
|
2568
|
+
</label>
|
|
2569
|
+
<input
|
|
2570
|
+
type="text"
|
|
2571
|
+
value={formData.origin}
|
|
2572
|
+
onChange={(e) => setFormData({ ...formData, origin: e.target.value })}
|
|
2573
|
+
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2574
|
+
placeholder="Site web, recommandation, etc."
|
|
2575
|
+
/>
|
|
2576
|
+
</div>
|
|
2577
|
+
|
|
2578
|
+
<div>
|
|
2579
|
+
<label className="block text-sm font-medium text-gray-700">Statut</label>
|
|
2580
|
+
<select
|
|
2581
|
+
value={formData.statusId}
|
|
2582
|
+
onChange={(e) => setFormData({ ...formData, statusId: e.target.value })}
|
|
2583
|
+
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2584
|
+
>
|
|
2585
|
+
<option value="">Aucun statut</option>
|
|
2586
|
+
{statuses.map((status) => (
|
|
2587
|
+
<option key={status.id} value={status.id}>
|
|
2588
|
+
{status.name}
|
|
2589
|
+
</option>
|
|
2590
|
+
))}
|
|
2591
|
+
</select>
|
|
2592
|
+
</div>
|
|
2593
|
+
|
|
2594
|
+
{/* Motif de fermeture (visible uniquement si statut = Fermé) */}
|
|
2595
|
+
{(() => {
|
|
2596
|
+
const selectedStatus = statuses.find((s) => s.id === formData.statusId);
|
|
2597
|
+
const isLostStatus = selectedStatus?.name?.toLowerCase() === 'fermé';
|
|
2598
|
+
if (!isLostStatus) return null;
|
|
2599
|
+
return (
|
|
2600
|
+
<div>
|
|
2601
|
+
<label className="block text-sm font-medium text-gray-700">
|
|
2602
|
+
Motif de fermeture *
|
|
2603
|
+
</label>
|
|
2604
|
+
<select
|
|
2605
|
+
value={formData.closingReason}
|
|
2606
|
+
onChange={(e) =>
|
|
2607
|
+
setFormData({ ...formData, closingReason: e.target.value })
|
|
2608
|
+
}
|
|
2609
|
+
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2610
|
+
>
|
|
2611
|
+
<option value="">Sélectionnez un motif</option>
|
|
2612
|
+
{closingReasons.map((reason) => (
|
|
2613
|
+
<option key={reason.id} value={reason.name}>
|
|
2614
|
+
{reason.name}
|
|
2615
|
+
</option>
|
|
2616
|
+
))}
|
|
2617
|
+
</select>
|
|
2618
|
+
</div>
|
|
2619
|
+
);
|
|
2620
|
+
})()}
|
|
2621
|
+
|
|
2622
|
+
<div>
|
|
2623
|
+
<label className="block text-sm font-medium text-gray-700">Commercial</label>
|
|
2624
|
+
<select
|
|
2625
|
+
value={formData.assignedCommercialId}
|
|
2626
|
+
onChange={(e) =>
|
|
2627
|
+
setFormData({ ...formData, assignedCommercialId: e.target.value })
|
|
2628
|
+
}
|
|
2629
|
+
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2630
|
+
>
|
|
2631
|
+
<option value="">Non assigné</option>
|
|
2632
|
+
{(isAdmin
|
|
2633
|
+
? users.filter((u) => u.role !== 'USER')
|
|
2634
|
+
: users.filter(
|
|
2635
|
+
(u) =>
|
|
2636
|
+
u.role === 'COMMERCIAL' || u.role === 'ADMIN' || u.role === 'MANAGER',
|
|
2637
|
+
)
|
|
2638
|
+
).map((user) => (
|
|
2639
|
+
<option key={user.id} value={user.id}>
|
|
2640
|
+
{user.name}
|
|
2641
|
+
</option>
|
|
2642
|
+
))}
|
|
2643
|
+
</select>
|
|
2644
|
+
</div>
|
|
2645
|
+
|
|
2646
|
+
<div>
|
|
2647
|
+
<label className="block text-sm font-medium text-gray-700">Télépro</label>
|
|
2648
|
+
<select
|
|
2649
|
+
value={formData.assignedTeleproId}
|
|
2650
|
+
onChange={(e) =>
|
|
2651
|
+
setFormData({ ...formData, assignedTeleproId: e.target.value })
|
|
2652
|
+
}
|
|
2653
|
+
className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2654
|
+
>
|
|
2655
|
+
<option value="">Non assigné</option>
|
|
2656
|
+
{(isAdmin
|
|
2657
|
+
? users.filter((u) => u.role !== 'USER')
|
|
2658
|
+
: users.filter(
|
|
2659
|
+
(u) =>
|
|
2660
|
+
u.role === 'TELEPRO' || u.role === 'ADMIN' || u.role === 'MANAGER',
|
|
2661
|
+
)
|
|
2662
|
+
).map((user) => (
|
|
2663
|
+
<option key={user.id} value={user.id}>
|
|
2664
|
+
{user.name}
|
|
2665
|
+
</option>
|
|
2666
|
+
))}
|
|
2667
|
+
</select>
|
|
2668
|
+
</div>
|
|
2669
|
+
</div>
|
|
2670
|
+
</div>
|
|
2671
|
+
|
|
2672
|
+
{error && (
|
|
2673
|
+
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">{error}</div>
|
|
2674
|
+
)}
|
|
2675
|
+
</form>
|
|
2676
|
+
|
|
2677
|
+
{/* Pied de modal fixe */}
|
|
2678
|
+
<div className="shrink-0 border-t border-gray-100 pt-4">
|
|
2679
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
|
2680
|
+
<button
|
|
2681
|
+
type="button"
|
|
2682
|
+
onClick={() => {
|
|
2683
|
+
setShowModal(false);
|
|
2684
|
+
setEditingContact(null);
|
|
2685
|
+
setError('');
|
|
2686
|
+
}}
|
|
2687
|
+
className="w-full cursor-pointer rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 sm:w-auto"
|
|
2688
|
+
>
|
|
2689
|
+
Annuler
|
|
2690
|
+
</button>
|
|
2691
|
+
<button
|
|
2692
|
+
type="submit"
|
|
2693
|
+
form="contact-form"
|
|
2694
|
+
className="w-full cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 sm:w-auto"
|
|
2695
|
+
>
|
|
2696
|
+
{editingContact ? 'Modifier' : 'Créer'}
|
|
2697
|
+
</button>
|
|
2698
|
+
</div>
|
|
2699
|
+
</div>
|
|
2700
|
+
</div>
|
|
2701
|
+
</div>
|
|
2702
|
+
)}
|
|
2703
|
+
|
|
2704
|
+
{/* Modal d'import */}
|
|
2705
|
+
{showImportModal && (
|
|
2706
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-500/20 p-4 backdrop-blur-sm sm:p-6">
|
|
2707
|
+
<div className="flex max-h-[90vh] w-full max-w-5xl flex-col rounded-lg bg-white shadow-xl">
|
|
2708
|
+
{/* En-tête */}
|
|
2709
|
+
<div className="shrink-0 border-b border-gray-100 px-6 py-4">
|
|
2710
|
+
<div className="flex items-center justify-between">
|
|
2711
|
+
<h2 className="text-lg font-semibold text-gray-900">Importer des contacts</h2>
|
|
2712
|
+
<button
|
|
2713
|
+
onClick={() => {
|
|
2714
|
+
setShowImportModal(false);
|
|
2715
|
+
setImportFile(null);
|
|
2716
|
+
setImportPreview([]);
|
|
2717
|
+
setImportHeaders([]);
|
|
2718
|
+
setImportFieldMappings({});
|
|
2719
|
+
setImportResult(null);
|
|
2720
|
+
setDefaultStatusId('');
|
|
2721
|
+
setDefaultCommercialId('');
|
|
2722
|
+
setDefaultOrigin('');
|
|
2723
|
+
setError('');
|
|
2724
|
+
}}
|
|
2725
|
+
className="cursor-pointer rounded-lg p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
|
2726
|
+
>
|
|
2727
|
+
<X className="h-5 w-5" />
|
|
2728
|
+
</button>
|
|
2729
|
+
</div>
|
|
2730
|
+
</div>
|
|
2731
|
+
|
|
2732
|
+
{/* Contenu */}
|
|
2733
|
+
<div className="flex-1 overflow-y-auto px-6 py-4">
|
|
2734
|
+
{!importFile ? (
|
|
2735
|
+
<div>
|
|
2736
|
+
<label className="mb-2 block text-sm font-medium text-gray-700">
|
|
2737
|
+
Sélectionner un fichier (CSV ou Excel)
|
|
2738
|
+
</label>
|
|
2739
|
+
<div
|
|
2740
|
+
className="mt-1 flex cursor-pointer justify-center rounded-lg border-2 border-dashed border-gray-300 px-6 py-10 transition-colors hover:border-indigo-400 hover:bg-indigo-50/40"
|
|
2741
|
+
role="button"
|
|
2742
|
+
tabIndex={0}
|
|
2743
|
+
onClick={() => fileInputRef.current?.click()}
|
|
2744
|
+
onKeyDown={(e) => {
|
|
2745
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
2746
|
+
e.preventDefault();
|
|
2747
|
+
fileInputRef.current?.click();
|
|
2748
|
+
}
|
|
2749
|
+
}}
|
|
2750
|
+
>
|
|
2751
|
+
<div className="text-center">
|
|
2752
|
+
<Upload className="mx-auto h-12 w-12 text-gray-400" />
|
|
2753
|
+
<div className="mt-4 flex text-sm leading-6 text-gray-600">
|
|
2754
|
+
<span className="relative rounded-md bg-white font-semibold text-indigo-600">
|
|
2755
|
+
Téléverser un fichier
|
|
2756
|
+
</span>
|
|
2757
|
+
<p className="pl-1">ou glissez-déposez</p>
|
|
2758
|
+
</div>
|
|
2759
|
+
<p className="text-xs leading-5 text-gray-600">
|
|
2760
|
+
CSV, XLSX ou XLS jusqu'à 10MB
|
|
2761
|
+
</p>
|
|
2762
|
+
<input
|
|
2763
|
+
ref={fileInputRef}
|
|
2764
|
+
type="file"
|
|
2765
|
+
accept=".csv,.xlsx,.xls"
|
|
2766
|
+
onChange={handleFileSelect}
|
|
2767
|
+
className="sr-only"
|
|
2768
|
+
/>
|
|
2769
|
+
</div>
|
|
2770
|
+
</div>
|
|
2771
|
+
</div>
|
|
2772
|
+
) : (
|
|
2773
|
+
<div className="space-y-6">
|
|
2774
|
+
{/* Fichier sélectionné */}
|
|
2775
|
+
<div className="rounded-lg border border-gray-200 p-4">
|
|
2776
|
+
<div className="flex items-center justify-between">
|
|
2777
|
+
<div>
|
|
2778
|
+
<p className="text-sm font-medium text-gray-900">{importFile.name}</p>
|
|
2779
|
+
<p className="text-xs text-gray-500">
|
|
2780
|
+
{(importFile.size / 1024).toFixed(2)} KB
|
|
2781
|
+
</p>
|
|
2782
|
+
</div>
|
|
2783
|
+
<button
|
|
2784
|
+
onClick={() => {
|
|
2785
|
+
setImportFile(null);
|
|
2786
|
+
setImportPreview([]);
|
|
2787
|
+
setImportHeaders([]);
|
|
2788
|
+
setImportFieldMappings({});
|
|
2789
|
+
setImportResult(null);
|
|
2790
|
+
setDefaultStatusId('');
|
|
2791
|
+
setDefaultCommercialId('');
|
|
2792
|
+
setDefaultOrigin('');
|
|
2793
|
+
}}
|
|
2794
|
+
className="cursor-pointer rounded-lg p-1 text-gray-400 transition-colors hover:text-gray-600"
|
|
2795
|
+
>
|
|
2796
|
+
<X className="h-5 w-5" />
|
|
2797
|
+
</button>
|
|
2798
|
+
</div>
|
|
2799
|
+
</div>
|
|
2800
|
+
|
|
2801
|
+
{/* Options */}
|
|
2802
|
+
<div>
|
|
2803
|
+
<label className="flex items-center gap-2">
|
|
2804
|
+
<input
|
|
2805
|
+
type="checkbox"
|
|
2806
|
+
checked={importSkipFirstRow}
|
|
2807
|
+
onChange={(e) => setImportSkipFirstRow(e.target.checked)}
|
|
2808
|
+
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
|
2809
|
+
/>
|
|
2810
|
+
<span className="text-sm text-gray-700">
|
|
2811
|
+
La seconde ligne contient les en-têtes
|
|
2812
|
+
</span>
|
|
2813
|
+
</label>
|
|
2814
|
+
</div>
|
|
2815
|
+
|
|
2816
|
+
{/* Valeurs par défaut */}
|
|
2817
|
+
<div>
|
|
2818
|
+
<h3 className="mb-3 text-sm font-semibold text-gray-900">
|
|
2819
|
+
Valeurs par défaut (optionnel)
|
|
2820
|
+
</h3>
|
|
2821
|
+
<p className="mb-3 text-xs text-gray-600">
|
|
2822
|
+
Ces valeurs seront appliquées aux contacts qui n'ont pas ces informations dans
|
|
2823
|
+
le fichier
|
|
2824
|
+
</p>
|
|
2825
|
+
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
|
2826
|
+
<div>
|
|
2827
|
+
<label className="mb-1 block text-sm font-medium text-gray-700">
|
|
2828
|
+
Statut par défaut
|
|
2829
|
+
</label>
|
|
2830
|
+
<select
|
|
2831
|
+
value={defaultStatusId}
|
|
2832
|
+
onChange={(e) => setDefaultStatusId(e.target.value)}
|
|
2833
|
+
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2834
|
+
>
|
|
2835
|
+
<option value="">Aucun statut par défaut</option>
|
|
2836
|
+
{statuses.map((status) => (
|
|
2837
|
+
<option key={status.id} value={status.id}>
|
|
2838
|
+
{status.name}
|
|
2839
|
+
</option>
|
|
2840
|
+
))}
|
|
2841
|
+
</select>
|
|
2842
|
+
</div>
|
|
2843
|
+
|
|
2844
|
+
<div>
|
|
2845
|
+
<label className="mb-1 block text-sm font-medium text-gray-700">
|
|
2846
|
+
Commercial par défaut
|
|
2847
|
+
</label>
|
|
2848
|
+
<select
|
|
2849
|
+
value={defaultCommercialId}
|
|
2850
|
+
onChange={(e) => setDefaultCommercialId(e.target.value)}
|
|
2851
|
+
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2852
|
+
>
|
|
2853
|
+
<option value="">Aucun commercial par défaut</option>
|
|
2854
|
+
{users
|
|
2855
|
+
.filter((u) => u.role !== 'USER')
|
|
2856
|
+
.map((user) => (
|
|
2857
|
+
<option key={user.id} value={user.id}>
|
|
2858
|
+
{user.name}
|
|
2859
|
+
</option>
|
|
2860
|
+
))}
|
|
2861
|
+
</select>
|
|
2862
|
+
</div>
|
|
2863
|
+
|
|
2864
|
+
<div>
|
|
2865
|
+
<label className="mb-1 block text-sm font-medium text-gray-700">
|
|
2866
|
+
Origine de la campagne par défaut
|
|
2867
|
+
</label>
|
|
2868
|
+
<input
|
|
2869
|
+
type="text"
|
|
2870
|
+
value={defaultOrigin}
|
|
2871
|
+
onChange={(e) => setDefaultOrigin(e.target.value)}
|
|
2872
|
+
placeholder="Ex: Facebook Ads, Google Ads, etc."
|
|
2873
|
+
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2874
|
+
/>
|
|
2875
|
+
</div>
|
|
2876
|
+
</div>
|
|
2877
|
+
</div>
|
|
2878
|
+
|
|
2879
|
+
{/* Correspondance des champs */}
|
|
2880
|
+
{importHeaders.length > 0 && (
|
|
2881
|
+
<div>
|
|
2882
|
+
<h3 className="mb-2 text-base font-semibold text-gray-900">
|
|
2883
|
+
Correspondance des champs
|
|
2884
|
+
</h3>
|
|
2885
|
+
<p className="mb-4 text-sm text-gray-600">
|
|
2886
|
+
Vérifiez et ajustez les correspondances automatiques entre vos colonnes et
|
|
2887
|
+
les champs du CRM.
|
|
2888
|
+
</p>
|
|
2889
|
+
<div className="space-y-3">
|
|
2890
|
+
{importHeaders.map((header) => {
|
|
2891
|
+
const mapping = importFieldMappings[header] || { action: 'ignore' };
|
|
2892
|
+
const exampleValue =
|
|
2893
|
+
importPreview.length > 0 && importPreview[0][header]
|
|
2894
|
+
? importPreview[0][header]
|
|
2895
|
+
: '';
|
|
2896
|
+
|
|
2897
|
+
return (
|
|
2898
|
+
<div
|
|
2899
|
+
key={header}
|
|
2900
|
+
className="rounded-lg border border-gray-200 bg-gray-50 p-4"
|
|
2901
|
+
>
|
|
2902
|
+
<div className="flex items-start gap-3">
|
|
2903
|
+
{/* Colonne du fichier */}
|
|
2904
|
+
<div className="flex-1">
|
|
2905
|
+
<div className="mb-2">
|
|
2906
|
+
<span className="text-xs font-medium text-gray-500">
|
|
2907
|
+
Colonne du fichier
|
|
2908
|
+
</span>
|
|
2909
|
+
<p className="mt-1 text-sm font-semibold text-gray-900">
|
|
2910
|
+
{header}
|
|
2911
|
+
</p>
|
|
2912
|
+
{exampleValue && (
|
|
2913
|
+
<p className="mt-1 text-xs text-gray-500">
|
|
2914
|
+
Exemple: {String(exampleValue).substring(0, 50)}
|
|
2915
|
+
{String(exampleValue).length > 50 ? '...' : ''}
|
|
2916
|
+
</p>
|
|
2917
|
+
)}
|
|
2918
|
+
</div>
|
|
2919
|
+
</div>
|
|
2920
|
+
|
|
2921
|
+
{/* Flèche */}
|
|
2922
|
+
<div className="mt-8 flex items-center">
|
|
2923
|
+
<ArrowRight className="h-5 w-5 text-gray-400" />
|
|
2924
|
+
</div>
|
|
2925
|
+
|
|
2926
|
+
{/* Action */}
|
|
2927
|
+
<div className="flex-1">
|
|
2928
|
+
<label className="mb-2 block text-xs font-medium text-gray-500">
|
|
2929
|
+
Action
|
|
2930
|
+
</label>
|
|
2931
|
+
<select
|
|
2932
|
+
value={mapping.action}
|
|
2933
|
+
onChange={(e) => {
|
|
2934
|
+
const newAction = e.target.value as 'map' | 'note' | 'ignore';
|
|
2935
|
+
setImportFieldMappings({
|
|
2936
|
+
...importFieldMappings,
|
|
2937
|
+
[header]: {
|
|
2938
|
+
action: newAction,
|
|
2939
|
+
crmField:
|
|
2940
|
+
newAction === 'map' && mapping.crmField
|
|
2941
|
+
? mapping.crmField
|
|
2942
|
+
: newAction === 'map'
|
|
2943
|
+
? 'firstName'
|
|
2944
|
+
: undefined,
|
|
2945
|
+
},
|
|
2946
|
+
});
|
|
2947
|
+
}}
|
|
2948
|
+
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2949
|
+
>
|
|
2950
|
+
<option value="map">Mapper vers un champ</option>
|
|
2951
|
+
<option value="note">Ajouter comme note</option>
|
|
2952
|
+
<option value="ignore">Ignorer</option>
|
|
2953
|
+
</select>
|
|
2954
|
+
</div>
|
|
2955
|
+
|
|
2956
|
+
{/* Flèche (si action = map) */}
|
|
2957
|
+
{mapping.action === 'map' && (
|
|
2958
|
+
<div className="mt-8 flex items-center">
|
|
2959
|
+
<ArrowRight className="h-5 w-5 text-gray-400" />
|
|
2960
|
+
</div>
|
|
2961
|
+
)}
|
|
2962
|
+
|
|
2963
|
+
{/* Champ du CRM (si action = map) */}
|
|
2964
|
+
{mapping.action === 'map' && (
|
|
2965
|
+
<div className="flex-1">
|
|
2966
|
+
<label className="mb-2 block text-xs font-medium text-gray-500">
|
|
2967
|
+
Champ du CRM
|
|
2968
|
+
</label>
|
|
2969
|
+
<select
|
|
2970
|
+
value={mapping.crmField || ''}
|
|
2971
|
+
onChange={(e) => {
|
|
2972
|
+
setImportFieldMappings({
|
|
2973
|
+
...importFieldMappings,
|
|
2974
|
+
[header]: {
|
|
2975
|
+
action: 'map',
|
|
2976
|
+
crmField: e.target.value,
|
|
2977
|
+
},
|
|
2978
|
+
});
|
|
2979
|
+
}}
|
|
2980
|
+
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
2981
|
+
>
|
|
2982
|
+
<option value="">Sélectionnez un champ</option>
|
|
2983
|
+
<option value="phone">Téléphone *</option>
|
|
2984
|
+
<option value="firstName">Prénom</option>
|
|
2985
|
+
<option value="lastName">Nom</option>
|
|
2986
|
+
<option value="email">Email</option>
|
|
2987
|
+
<option value="civility">Civilité</option>
|
|
2988
|
+
<option value="secondaryPhone">Téléphone secondaire</option>
|
|
2989
|
+
<option value="address">Adresse</option>
|
|
2990
|
+
<option value="city">Ville</option>
|
|
2991
|
+
<option value="postalCode">Code postal</option>
|
|
2992
|
+
<option value="origin">Origine</option>
|
|
2993
|
+
</select>
|
|
2994
|
+
</div>
|
|
2995
|
+
)}
|
|
2996
|
+
</div>
|
|
2997
|
+
</div>
|
|
2998
|
+
);
|
|
2999
|
+
})}
|
|
3000
|
+
</div>
|
|
3001
|
+
</div>
|
|
3002
|
+
)}
|
|
3003
|
+
|
|
3004
|
+
{/* Prévisualisation */}
|
|
3005
|
+
{importPreview.length > 0 && (
|
|
3006
|
+
<div>
|
|
3007
|
+
<h3 className="mb-3 text-sm font-semibold text-gray-900">
|
|
3008
|
+
Aperçu (5 premières lignes)
|
|
3009
|
+
</h3>
|
|
3010
|
+
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
|
3011
|
+
<table className="min-w-full divide-y divide-gray-200">
|
|
3012
|
+
<thead className="bg-gray-50">
|
|
3013
|
+
<tr>
|
|
3014
|
+
{importHeaders.map((header) => (
|
|
3015
|
+
<th
|
|
3016
|
+
key={header}
|
|
3017
|
+
className="px-4 py-2 text-left text-xs font-medium text-gray-700"
|
|
3018
|
+
>
|
|
3019
|
+
{header}
|
|
3020
|
+
</th>
|
|
3021
|
+
))}
|
|
3022
|
+
</tr>
|
|
3023
|
+
</thead>
|
|
3024
|
+
<tbody className="divide-y divide-gray-200 bg-white">
|
|
3025
|
+
{importPreview.map((row, idx) => (
|
|
3026
|
+
<tr key={idx}>
|
|
3027
|
+
{importHeaders.map((header) => (
|
|
3028
|
+
<td
|
|
3029
|
+
key={header}
|
|
3030
|
+
className="px-4 py-2 text-xs whitespace-nowrap text-gray-900"
|
|
3031
|
+
>
|
|
3032
|
+
{row[header] || '-'}
|
|
3033
|
+
</td>
|
|
3034
|
+
))}
|
|
3035
|
+
</tr>
|
|
3036
|
+
))}
|
|
3037
|
+
</tbody>
|
|
3038
|
+
</table>
|
|
3039
|
+
</div>
|
|
3040
|
+
</div>
|
|
3041
|
+
)}
|
|
3042
|
+
|
|
3043
|
+
{/* Résultat de l'import */}
|
|
3044
|
+
{importResult && (
|
|
3045
|
+
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
|
|
3046
|
+
<h3 className="mb-2 text-sm font-semibold text-green-900">
|
|
3047
|
+
Résultat de l'import
|
|
3048
|
+
</h3>
|
|
3049
|
+
<ul className="space-y-1 text-sm text-green-800">
|
|
3050
|
+
<li>✓ {importResult.imported} contact(s) importé(s)</li>
|
|
3051
|
+
{importResult.skipped > 0 && (
|
|
3052
|
+
<li className="text-yellow-700">
|
|
3053
|
+
⚠ {importResult.skipped} ligne(s) ignorée(s)
|
|
3054
|
+
</li>
|
|
3055
|
+
)}
|
|
3056
|
+
{importResult.duplicates > 0 && (
|
|
3057
|
+
<li className="text-orange-700">
|
|
3058
|
+
⚠ {importResult.duplicates} doublon(s) détecté(s)
|
|
3059
|
+
</li>
|
|
3060
|
+
)}
|
|
3061
|
+
{importResult.errors > 0 && (
|
|
3062
|
+
<li className="text-red-700">✗ {importResult.errors} erreur(s)</li>
|
|
3063
|
+
)}
|
|
3064
|
+
</ul>
|
|
3065
|
+
</div>
|
|
3066
|
+
)}
|
|
3067
|
+
|
|
3068
|
+
{error && (
|
|
3069
|
+
<div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">{error}</div>
|
|
3070
|
+
)}
|
|
3071
|
+
</div>
|
|
3072
|
+
)}
|
|
3073
|
+
</div>
|
|
3074
|
+
|
|
3075
|
+
{/* Pied de modal */}
|
|
3076
|
+
{importFile && (
|
|
3077
|
+
<div className="shrink-0 border-t border-gray-100 px-6 py-4">
|
|
3078
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
|
3079
|
+
<button
|
|
3080
|
+
type="button"
|
|
3081
|
+
onClick={() => {
|
|
3082
|
+
setShowImportModal(false);
|
|
3083
|
+
setImportFile(null);
|
|
3084
|
+
setImportPreview([]);
|
|
3085
|
+
setImportHeaders([]);
|
|
3086
|
+
setImportFieldMappings({});
|
|
3087
|
+
setImportResult(null);
|
|
3088
|
+
setDefaultStatusId('');
|
|
3089
|
+
setDefaultCommercialId('');
|
|
3090
|
+
setDefaultOrigin('');
|
|
3091
|
+
setError('');
|
|
3092
|
+
}}
|
|
3093
|
+
className="w-full cursor-pointer rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 sm:w-auto"
|
|
3094
|
+
disabled={importing}
|
|
3095
|
+
>
|
|
3096
|
+
Annuler
|
|
3097
|
+
</button>
|
|
3098
|
+
<button
|
|
3099
|
+
type="button"
|
|
3100
|
+
onClick={handleImport}
|
|
3101
|
+
disabled={
|
|
3102
|
+
importing ||
|
|
3103
|
+
!Object.entries(importFieldMappings).some(
|
|
3104
|
+
([, mapping]) => mapping.action === 'map' && mapping.crmField === 'phone',
|
|
3105
|
+
)
|
|
3106
|
+
}
|
|
3107
|
+
className="w-full cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto"
|
|
3108
|
+
>
|
|
3109
|
+
{importing ? 'Import en cours...' : 'Importer'}
|
|
3110
|
+
</button>
|
|
3111
|
+
</div>
|
|
3112
|
+
</div>
|
|
3113
|
+
)}
|
|
3114
|
+
</div>
|
|
3115
|
+
</div>
|
|
3116
|
+
)}
|
|
3117
|
+
|
|
3118
|
+
{/* Modal de résultats d'import */}
|
|
3119
|
+
{showImportResultModal && importResultData && (
|
|
3120
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-500/20 p-4 backdrop-blur-sm">
|
|
3121
|
+
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
|
3122
|
+
<div className="mb-4 flex items-center justify-between">
|
|
3123
|
+
<h3 className="text-lg font-semibold text-gray-900">Résultat de l'import</h3>
|
|
3124
|
+
<button
|
|
3125
|
+
onClick={() => {
|
|
3126
|
+
setShowImportResultModal(false);
|
|
3127
|
+
setImportResultData(null);
|
|
3128
|
+
}}
|
|
3129
|
+
className="cursor-pointer rounded-lg p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
|
3130
|
+
>
|
|
3131
|
+
<X className="h-5 w-5" />
|
|
3132
|
+
</button>
|
|
3133
|
+
</div>
|
|
3134
|
+
|
|
3135
|
+
<div className="space-y-4">
|
|
3136
|
+
{/* Contacts importés */}
|
|
3137
|
+
<div className="flex items-start gap-3 rounded-lg border border-green-200 bg-green-50 p-4">
|
|
3138
|
+
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-green-100">
|
|
3139
|
+
<svg
|
|
3140
|
+
className="h-5 w-5 text-green-600"
|
|
3141
|
+
fill="none"
|
|
3142
|
+
stroke="currentColor"
|
|
3143
|
+
viewBox="0 0 24 24"
|
|
3144
|
+
>
|
|
3145
|
+
<path
|
|
3146
|
+
strokeLinecap="round"
|
|
3147
|
+
strokeLinejoin="round"
|
|
3148
|
+
strokeWidth={2}
|
|
3149
|
+
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
3150
|
+
/>
|
|
3151
|
+
</svg>
|
|
3152
|
+
</div>
|
|
3153
|
+
<div className="flex-1">
|
|
3154
|
+
<p className="text-sm font-semibold text-green-900">
|
|
3155
|
+
{importResultData.imported} contact(s) importé(s)
|
|
3156
|
+
</p>
|
|
3157
|
+
<p className="mt-1 text-xs text-green-700">
|
|
3158
|
+
Les contacts ont été ajoutés avec succès à votre base de données.
|
|
3159
|
+
</p>
|
|
3160
|
+
</div>
|
|
3161
|
+
</div>
|
|
3162
|
+
|
|
3163
|
+
{/* Doublons fusionnés */}
|
|
3164
|
+
{importResultData.duplicates > 0 && (
|
|
3165
|
+
<div className="flex items-start gap-3 rounded-lg border border-orange-200 bg-orange-50 p-4">
|
|
3166
|
+
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-orange-100">
|
|
3167
|
+
<svg
|
|
3168
|
+
className="h-5 w-5 text-orange-600"
|
|
3169
|
+
fill="none"
|
|
3170
|
+
stroke="currentColor"
|
|
3171
|
+
viewBox="0 0 24 24"
|
|
3172
|
+
>
|
|
3173
|
+
<path
|
|
3174
|
+
strokeLinecap="round"
|
|
3175
|
+
strokeLinejoin="round"
|
|
3176
|
+
strokeWidth={2}
|
|
3177
|
+
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
|
3178
|
+
/>
|
|
3179
|
+
</svg>
|
|
3180
|
+
</div>
|
|
3181
|
+
<div className="flex-1">
|
|
3182
|
+
<p className="text-sm font-semibold text-orange-900">
|
|
3183
|
+
{importResultData.duplicates} doublon(s) fusionné(s)
|
|
3184
|
+
</p>
|
|
3185
|
+
<p className="mt-1 text-xs text-orange-700">
|
|
3186
|
+
Ces contacts existaient déjà et ont été fusionnés avec les données existantes.
|
|
3187
|
+
</p>
|
|
3188
|
+
</div>
|
|
3189
|
+
</div>
|
|
3190
|
+
)}
|
|
3191
|
+
|
|
3192
|
+
{/* Lignes ignorées */}
|
|
3193
|
+
{importResultData.skipped > 0 && (
|
|
3194
|
+
<div className="flex items-start gap-3 rounded-lg border border-yellow-200 bg-yellow-50 p-4">
|
|
3195
|
+
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-yellow-100">
|
|
3196
|
+
<svg
|
|
3197
|
+
className="h-5 w-5 text-yellow-600"
|
|
3198
|
+
fill="none"
|
|
3199
|
+
stroke="currentColor"
|
|
3200
|
+
viewBox="0 0 24 24"
|
|
3201
|
+
>
|
|
3202
|
+
<path
|
|
3203
|
+
strokeLinecap="round"
|
|
3204
|
+
strokeLinejoin="round"
|
|
3205
|
+
strokeWidth={2}
|
|
3206
|
+
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
3207
|
+
/>
|
|
3208
|
+
</svg>
|
|
3209
|
+
</div>
|
|
3210
|
+
<div className="flex-1">
|
|
3211
|
+
<p className="text-sm font-semibold text-yellow-900">
|
|
3212
|
+
{importResultData.skipped} ligne(s) ignorée(s)
|
|
3213
|
+
</p>
|
|
3214
|
+
<p className="mt-1 text-xs text-yellow-700">
|
|
3215
|
+
Ces lignes n'ont pas pu être importées (téléphone manquant ou invalide).
|
|
3216
|
+
</p>
|
|
3217
|
+
</div>
|
|
3218
|
+
</div>
|
|
3219
|
+
)}
|
|
3220
|
+
|
|
3221
|
+
{/* Erreurs */}
|
|
3222
|
+
{importResultData.errors > 0 && (
|
|
3223
|
+
<div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4">
|
|
3224
|
+
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-red-100">
|
|
3225
|
+
<svg
|
|
3226
|
+
className="h-5 w-5 text-red-600"
|
|
3227
|
+
fill="none"
|
|
3228
|
+
stroke="currentColor"
|
|
3229
|
+
viewBox="0 0 24 24"
|
|
3230
|
+
>
|
|
3231
|
+
<path
|
|
3232
|
+
strokeLinecap="round"
|
|
3233
|
+
strokeLinejoin="round"
|
|
3234
|
+
strokeWidth={2}
|
|
3235
|
+
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
3236
|
+
/>
|
|
3237
|
+
</svg>
|
|
3238
|
+
</div>
|
|
3239
|
+
<div className="flex-1">
|
|
3240
|
+
<p className="text-sm font-semibold text-red-900">
|
|
3241
|
+
{importResultData.errors} erreur(s)
|
|
3242
|
+
</p>
|
|
3243
|
+
<p className="mt-1 text-xs text-red-700">
|
|
3244
|
+
Certaines lignes ont rencontré des erreurs lors de l'import.
|
|
3245
|
+
</p>
|
|
3246
|
+
</div>
|
|
3247
|
+
</div>
|
|
3248
|
+
)}
|
|
3249
|
+
</div>
|
|
3250
|
+
|
|
3251
|
+
<div className="mt-6 flex justify-end">
|
|
3252
|
+
<button
|
|
3253
|
+
onClick={() => {
|
|
3254
|
+
setShowImportResultModal(false);
|
|
3255
|
+
setImportResultData(null);
|
|
3256
|
+
}}
|
|
3257
|
+
className="cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
|
3258
|
+
>
|
|
3259
|
+
Fermer
|
|
3260
|
+
</button>
|
|
3261
|
+
</div>
|
|
3262
|
+
</div>
|
|
3263
|
+
</div>
|
|
3264
|
+
)}
|
|
3265
|
+
|
|
3266
|
+
{/* Modal - Changer commercial */}
|
|
3267
|
+
{showBulkCommercialModal && (
|
|
3268
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-500/20 p-4 backdrop-blur-sm">
|
|
3269
|
+
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
|
3270
|
+
<div className="mb-4 flex items-center justify-between">
|
|
3271
|
+
<h3 className="text-lg font-semibold text-gray-900">Changer le commercial</h3>
|
|
3272
|
+
<button
|
|
3273
|
+
onClick={() => {
|
|
3274
|
+
setShowBulkCommercialModal(false);
|
|
3275
|
+
setBulkCommercialId('');
|
|
3276
|
+
}}
|
|
3277
|
+
className="cursor-pointer rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-100"
|
|
3278
|
+
>
|
|
3279
|
+
<X className="h-5 w-5" />
|
|
3280
|
+
</button>
|
|
3281
|
+
</div>
|
|
3282
|
+
|
|
3283
|
+
<p className="mb-4 text-sm text-gray-600">
|
|
3284
|
+
{selectedContactIds.size} contact(s) sélectionné(s)
|
|
3285
|
+
</p>
|
|
3286
|
+
|
|
3287
|
+
<div className="mb-4">
|
|
3288
|
+
<label className="mb-2 block text-sm font-medium text-gray-700">
|
|
3289
|
+
Nouveau commercial
|
|
3290
|
+
</label>
|
|
3291
|
+
<select
|
|
3292
|
+
value={bulkCommercialId}
|
|
3293
|
+
onChange={(e) => setBulkCommercialId(e.target.value)}
|
|
3294
|
+
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
3295
|
+
>
|
|
3296
|
+
<option value="">Sélectionner un commercial</option>
|
|
3297
|
+
{users
|
|
3298
|
+
.filter((u) => u.role !== 'USER')
|
|
3299
|
+
.map((user) => (
|
|
3300
|
+
<option key={user.id} value={user.id}>
|
|
3301
|
+
{user.name}
|
|
3302
|
+
</option>
|
|
3303
|
+
))}
|
|
3304
|
+
</select>
|
|
3305
|
+
</div>
|
|
3306
|
+
|
|
3307
|
+
<div className="flex justify-end gap-3">
|
|
3308
|
+
<button
|
|
3309
|
+
onClick={() => {
|
|
3310
|
+
setShowBulkCommercialModal(false);
|
|
3311
|
+
setBulkCommercialId('');
|
|
3312
|
+
}}
|
|
3313
|
+
className="cursor-pointer rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
|
3314
|
+
>
|
|
3315
|
+
Annuler
|
|
3316
|
+
</button>
|
|
3317
|
+
<button
|
|
3318
|
+
onClick={handleBulkChangeCommercial}
|
|
3319
|
+
disabled={bulkActionLoading || !bulkCommercialId}
|
|
3320
|
+
className="cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
3321
|
+
>
|
|
3322
|
+
{bulkActionLoading ? 'En cours...' : 'Appliquer'}
|
|
3323
|
+
</button>
|
|
3324
|
+
</div>
|
|
3325
|
+
</div>
|
|
3326
|
+
</div>
|
|
3327
|
+
)}
|
|
3328
|
+
|
|
3329
|
+
{/* Modal - Changer statut */}
|
|
3330
|
+
{showBulkStatusModal && (
|
|
3331
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-500/20 p-4 backdrop-blur-sm">
|
|
3332
|
+
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
|
3333
|
+
<div className="mb-4 flex items-center justify-between">
|
|
3334
|
+
<h3 className="text-lg font-semibold text-gray-900">Changer le statut</h3>
|
|
3335
|
+
<button
|
|
3336
|
+
onClick={() => {
|
|
3337
|
+
setShowBulkStatusModal(false);
|
|
3338
|
+
setBulkStatusId('');
|
|
3339
|
+
}}
|
|
3340
|
+
className="cursor-pointer rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-100"
|
|
3341
|
+
>
|
|
3342
|
+
<X className="h-5 w-5" />
|
|
3343
|
+
</button>
|
|
3344
|
+
</div>
|
|
3345
|
+
|
|
3346
|
+
<p className="mb-4 text-sm text-gray-600">
|
|
3347
|
+
{selectedContactIds.size} contact(s) sélectionné(s)
|
|
3348
|
+
</p>
|
|
3349
|
+
|
|
3350
|
+
<div className="mb-4">
|
|
3351
|
+
<label className="mb-2 block text-sm font-medium text-gray-700">Nouveau statut</label>
|
|
3352
|
+
<select
|
|
3353
|
+
value={bulkStatusId}
|
|
3354
|
+
onChange={(e) => setBulkStatusId(e.target.value)}
|
|
3355
|
+
className="w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
3356
|
+
>
|
|
3357
|
+
<option value="">Sélectionner un statut</option>
|
|
3358
|
+
{statuses.map((status) => (
|
|
3359
|
+
<option key={status.id} value={status.id}>
|
|
3360
|
+
{status.name}
|
|
3361
|
+
</option>
|
|
3362
|
+
))}
|
|
3363
|
+
</select>
|
|
3364
|
+
</div>
|
|
3365
|
+
|
|
3366
|
+
<div className="flex justify-end gap-3">
|
|
3367
|
+
<button
|
|
3368
|
+
onClick={() => {
|
|
3369
|
+
setShowBulkStatusModal(false);
|
|
3370
|
+
setBulkStatusId('');
|
|
3371
|
+
}}
|
|
3372
|
+
className="cursor-pointer rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
|
3373
|
+
>
|
|
3374
|
+
Annuler
|
|
3375
|
+
</button>
|
|
3376
|
+
<button
|
|
3377
|
+
onClick={handleBulkChangeStatus}
|
|
3378
|
+
disabled={bulkActionLoading || !bulkStatusId}
|
|
3379
|
+
className="cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
3380
|
+
>
|
|
3381
|
+
{bulkActionLoading ? 'En cours...' : 'Appliquer'}
|
|
3382
|
+
</button>
|
|
3383
|
+
</div>
|
|
3384
|
+
</div>
|
|
3385
|
+
</div>
|
|
3386
|
+
)}
|
|
3387
|
+
|
|
3388
|
+
{/* Popover de filtre de date (sous l'icône) */}
|
|
3389
|
+
{dateFilterModal && dateFilterPosition && (
|
|
3390
|
+
<div
|
|
3391
|
+
ref={dateFilterModalRef}
|
|
3392
|
+
className="fixed z-50 w-72 rounded-lg border border-gray-200 bg-white p-4 shadow-xl"
|
|
3393
|
+
style={{ top: dateFilterPosition.top, left: dateFilterPosition.left }}
|
|
3394
|
+
>
|
|
3395
|
+
<div className="relative">
|
|
3396
|
+
<button
|
|
3397
|
+
onClick={() => {
|
|
3398
|
+
setDateFilterModal(null);
|
|
3399
|
+
setDateFilterPosition(null);
|
|
3400
|
+
}}
|
|
3401
|
+
className="absolute top-0 right-0 cursor-pointer rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100"
|
|
3402
|
+
>
|
|
3403
|
+
<X className="h-4 w-4" />
|
|
3404
|
+
</button>
|
|
3405
|
+
|
|
3406
|
+
<h3 className="mb-3 pr-6 text-sm font-semibold text-gray-900">
|
|
3407
|
+
{dateFilterModal === 'createdAt' ? 'Date de création' : 'Date de modification'}
|
|
3408
|
+
</h3>
|
|
3409
|
+
|
|
3410
|
+
<div className="space-y-3">
|
|
3411
|
+
<div>
|
|
3412
|
+
<label className="mb-1 block text-xs font-medium text-gray-700">Du</label>
|
|
3413
|
+
<input
|
|
3414
|
+
type="date"
|
|
3415
|
+
value={dateFilterModal === 'createdAt' ? createdAtStart : updatedAtStart}
|
|
3416
|
+
onChange={(e) => {
|
|
3417
|
+
if (dateFilterModal === 'createdAt') {
|
|
3418
|
+
setCreatedAtStart(e.target.value);
|
|
3419
|
+
} else {
|
|
3420
|
+
setUpdatedAtStart(e.target.value);
|
|
3421
|
+
}
|
|
3422
|
+
}}
|
|
3423
|
+
className="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
3424
|
+
/>
|
|
3425
|
+
</div>
|
|
3426
|
+
|
|
3427
|
+
<div>
|
|
3428
|
+
<label className="mb-1 block text-xs font-medium text-gray-700">Au</label>
|
|
3429
|
+
<input
|
|
3430
|
+
type="date"
|
|
3431
|
+
value={dateFilterModal === 'createdAt' ? createdAtEnd : updatedAtEnd}
|
|
3432
|
+
onChange={(e) => {
|
|
3433
|
+
if (dateFilterModal === 'createdAt') {
|
|
3434
|
+
setCreatedAtEnd(e.target.value);
|
|
3435
|
+
} else {
|
|
3436
|
+
setUpdatedAtEnd(e.target.value);
|
|
3437
|
+
}
|
|
3438
|
+
}}
|
|
3439
|
+
className="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
3440
|
+
/>
|
|
3441
|
+
</div>
|
|
3442
|
+
</div>
|
|
3443
|
+
|
|
3444
|
+
<div className="mt-4 flex justify-end gap-2">
|
|
3445
|
+
<button
|
|
3446
|
+
onClick={() => handleClearDateFilter(dateFilterModal)}
|
|
3447
|
+
className="cursor-pointer rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
|
3448
|
+
>
|
|
3449
|
+
Effacer
|
|
3450
|
+
</button>
|
|
3451
|
+
<button
|
|
3452
|
+
onClick={handleApplyDateFilter}
|
|
3453
|
+
className="cursor-pointer rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-indigo-700"
|
|
3454
|
+
>
|
|
3455
|
+
Filtrer
|
|
3456
|
+
</button>
|
|
3457
|
+
</div>
|
|
3458
|
+
</div>
|
|
3459
|
+
</div>
|
|
3460
|
+
)}
|
|
3461
|
+
|
|
3462
|
+
{/* Popover de filtre de liste (Statut, Origine, Commercial, Télépro) */}
|
|
3463
|
+
{listFilterModal && listFilterPosition && (
|
|
3464
|
+
<div
|
|
3465
|
+
ref={listFilterModalRef}
|
|
3466
|
+
className="fixed z-50 w-80 rounded-lg border border-gray-200 bg-white p-4 shadow-xl"
|
|
3467
|
+
style={{ top: listFilterPosition.top, left: listFilterPosition.left }}
|
|
3468
|
+
>
|
|
3469
|
+
<div className="relative">
|
|
3470
|
+
<button
|
|
3471
|
+
onClick={() => {
|
|
3472
|
+
setListFilterModal(null);
|
|
3473
|
+
setListFilterPosition(null);
|
|
3474
|
+
}}
|
|
3475
|
+
className="absolute top-0 right-0 cursor-pointer rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100"
|
|
3476
|
+
>
|
|
3477
|
+
<X className="h-4 w-4" />
|
|
3478
|
+
</button>
|
|
3479
|
+
|
|
3480
|
+
{/* Titre */}
|
|
3481
|
+
<h3 className="mb-3 pr-6 text-sm font-semibold text-gray-900">
|
|
3482
|
+
{listFilterModal === 'status'
|
|
3483
|
+
? 'Status'
|
|
3484
|
+
: listFilterModal === 'origin'
|
|
3485
|
+
? 'Origine'
|
|
3486
|
+
: listFilterModal === 'commercial'
|
|
3487
|
+
? 'Commercial'
|
|
3488
|
+
: 'Télépro'}
|
|
3489
|
+
</h3>
|
|
3490
|
+
|
|
3491
|
+
{/* Boutons tout cocher / tout décocher */}
|
|
3492
|
+
<div className="mb-3 flex gap-2">
|
|
3493
|
+
<button
|
|
3494
|
+
type="button"
|
|
3495
|
+
onClick={() => {
|
|
3496
|
+
if (listFilterModal === 'status') {
|
|
3497
|
+
setStatusFilter('');
|
|
3498
|
+
} else if (listFilterModal === 'origin') {
|
|
3499
|
+
setOriginFilter('');
|
|
3500
|
+
} else if (listFilterModal === 'commercial') {
|
|
3501
|
+
setAssignedCommercialFilter('');
|
|
3502
|
+
} else if (listFilterModal === 'telepro') {
|
|
3503
|
+
setAssignedTeleproFilter('');
|
|
3504
|
+
}
|
|
3505
|
+
setCurrentPage(1);
|
|
3506
|
+
}}
|
|
3507
|
+
className="flex-1 cursor-pointer rounded-md bg-indigo-50 px-3 py-1.5 text-center text-xs font-medium text-indigo-700"
|
|
3508
|
+
>
|
|
3509
|
+
Tout cocher
|
|
3510
|
+
</button>
|
|
3511
|
+
<button
|
|
3512
|
+
type="button"
|
|
3513
|
+
onClick={() => {
|
|
3514
|
+
if (listFilterModal === 'status') {
|
|
3515
|
+
setStatusFilter('');
|
|
3516
|
+
} else if (listFilterModal === 'origin') {
|
|
3517
|
+
setOriginFilter('');
|
|
3518
|
+
} else if (listFilterModal === 'commercial') {
|
|
3519
|
+
setAssignedCommercialFilter('');
|
|
3520
|
+
} else if (listFilterModal === 'telepro') {
|
|
3521
|
+
setAssignedTeleproFilter('');
|
|
3522
|
+
}
|
|
3523
|
+
setCurrentPage(1);
|
|
3524
|
+
}}
|
|
3525
|
+
className="flex-1 cursor-pointer rounded-md bg-gray-50 px-3 py-1.5 text-center text-xs font-medium text-gray-700"
|
|
3526
|
+
>
|
|
3527
|
+
Tout décocher
|
|
3528
|
+
</button>
|
|
3529
|
+
</div>
|
|
3530
|
+
|
|
3531
|
+
{/* Liste des options */}
|
|
3532
|
+
<div className="max-h-72 space-y-2 overflow-y-auto pt-1">
|
|
3533
|
+
{listFilterModal === 'status' &&
|
|
3534
|
+
statuses.map((status) => {
|
|
3535
|
+
const checked = statusFilter === status.id;
|
|
3536
|
+
return (
|
|
3537
|
+
<button
|
|
3538
|
+
key={status.id}
|
|
3539
|
+
type="button"
|
|
3540
|
+
onClick={() => {
|
|
3541
|
+
setStatusFilter(checked ? '' : status.id);
|
|
3542
|
+
setCurrentPage(1);
|
|
3543
|
+
}}
|
|
3544
|
+
className="flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-1.5 text-left text-sm hover:bg-gray-50"
|
|
3545
|
+
>
|
|
3546
|
+
<div className="flex items-center gap-2">
|
|
3547
|
+
<span
|
|
3548
|
+
className="inline-block h-3 w-3 rounded-full"
|
|
3549
|
+
style={{ backgroundColor: status.color || '#E5E7EB' }}
|
|
3550
|
+
/>
|
|
3551
|
+
<span className="text-sm text-gray-800">{status.name}</span>
|
|
3552
|
+
</div>
|
|
3553
|
+
{checked && <span className="text-indigo-600">✓</span>}
|
|
3554
|
+
</button>
|
|
3555
|
+
);
|
|
3556
|
+
})}
|
|
3557
|
+
|
|
3558
|
+
{listFilterModal === 'origin' &&
|
|
3559
|
+
Array.from(
|
|
3560
|
+
new Set(
|
|
3561
|
+
contacts
|
|
3562
|
+
.map((c) => c.origin)
|
|
3563
|
+
.filter((o): o is string => Boolean(o && o.trim())),
|
|
3564
|
+
),
|
|
3565
|
+
).map((origin) => {
|
|
3566
|
+
const checked = originFilter === origin;
|
|
3567
|
+
return (
|
|
3568
|
+
<button
|
|
3569
|
+
key={origin}
|
|
3570
|
+
type="button"
|
|
3571
|
+
onClick={() => {
|
|
3572
|
+
setOriginFilter(checked ? '' : origin);
|
|
3573
|
+
setCurrentPage(1);
|
|
3574
|
+
}}
|
|
3575
|
+
className="flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-1.5 text-left text-sm hover:bg-gray-50"
|
|
3576
|
+
>
|
|
3577
|
+
<span className="text-sm text-gray-800">{origin}</span>
|
|
3578
|
+
{checked && <span className="text-indigo-600">✓</span>}
|
|
3579
|
+
</button>
|
|
3580
|
+
);
|
|
3581
|
+
})}
|
|
3582
|
+
|
|
3583
|
+
{listFilterModal === 'commercial' &&
|
|
3584
|
+
[
|
|
3585
|
+
{ id: 'unassigned', name: 'NON ATTRIBUÉ' },
|
|
3586
|
+
...users
|
|
3587
|
+
.filter((u) => u.role !== 'USER')
|
|
3588
|
+
.map((u) => ({ id: u.id, name: u.name || u.email || 'Utilisateur' })),
|
|
3589
|
+
].map((item) => {
|
|
3590
|
+
const value = item.id === 'unassigned' ? 'UNASSIGNED' : item.id;
|
|
3591
|
+
const checked = assignedCommercialFilter === value;
|
|
3592
|
+
return (
|
|
3593
|
+
<button
|
|
3594
|
+
key={item.id}
|
|
3595
|
+
type="button"
|
|
3596
|
+
onClick={() => {
|
|
3597
|
+
setAssignedCommercialFilter(checked ? '' : value);
|
|
3598
|
+
setCurrentPage(1);
|
|
3599
|
+
}}
|
|
3600
|
+
className="flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-1.5 text-left text-sm hover:bg-gray-50"
|
|
3601
|
+
>
|
|
3602
|
+
<span className="text-sm text-gray-800">{item.name}</span>
|
|
3603
|
+
{checked && <span className="text-indigo-600">✓</span>}
|
|
3604
|
+
</button>
|
|
3605
|
+
);
|
|
3606
|
+
})}
|
|
3607
|
+
|
|
3608
|
+
{listFilterModal === 'telepro' &&
|
|
3609
|
+
[
|
|
3610
|
+
{ id: 'unassigned', name: 'NON ATTRIBUÉ' },
|
|
3611
|
+
...users
|
|
3612
|
+
.filter((u) => u.role !== 'USER')
|
|
3613
|
+
.map((u) => ({ id: u.id, name: u.name || u.email || 'Utilisateur' })),
|
|
3614
|
+
].map((item) => {
|
|
3615
|
+
const value = item.id === 'unassigned' ? 'UNASSIGNED' : item.id;
|
|
3616
|
+
const checked = assignedTeleproFilter === value;
|
|
3617
|
+
return (
|
|
3618
|
+
<button
|
|
3619
|
+
key={item.id}
|
|
3620
|
+
type="button"
|
|
3621
|
+
onClick={() => {
|
|
3622
|
+
setAssignedTeleproFilter(checked ? '' : value);
|
|
3623
|
+
setCurrentPage(1);
|
|
3624
|
+
}}
|
|
3625
|
+
className="flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-1.5 text-left text-sm hover:bg-gray-50"
|
|
3626
|
+
>
|
|
3627
|
+
<span className="text-sm text-gray-800">{item.name}</span>
|
|
3628
|
+
{checked && <span className="text-indigo-600">✓</span>}
|
|
3629
|
+
</button>
|
|
3630
|
+
);
|
|
3631
|
+
})}
|
|
3632
|
+
</div>
|
|
3633
|
+
</div>
|
|
3634
|
+
</div>
|
|
3635
|
+
)}
|
|
3636
|
+
|
|
3637
|
+
{/* Modal d'export */}
|
|
3638
|
+
{showExportModal && (
|
|
3639
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-500/20 p-4 backdrop-blur-sm">
|
|
3640
|
+
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
|
3641
|
+
<div className="mb-4 flex items-center justify-between">
|
|
3642
|
+
<h3 className="text-lg font-semibold text-gray-900">
|
|
3643
|
+
{exportAll
|
|
3644
|
+
? 'Exporter tous les contacts'
|
|
3645
|
+
: `Exporter ${selectedContactIds.size} contact(s)`}
|
|
3646
|
+
</h3>
|
|
3647
|
+
<button
|
|
3648
|
+
onClick={() => {
|
|
3649
|
+
setShowExportModal(false);
|
|
3650
|
+
setExportAll(false);
|
|
3651
|
+
}}
|
|
3652
|
+
className="cursor-pointer rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-100"
|
|
3653
|
+
>
|
|
3654
|
+
<X className="h-5 w-5" />
|
|
3655
|
+
</button>
|
|
3656
|
+
</div>
|
|
3657
|
+
|
|
3658
|
+
<p className="mb-4 text-sm text-gray-600">Choisissez le format d'export :</p>
|
|
3659
|
+
|
|
3660
|
+
<div className="mb-4 space-y-3">
|
|
3661
|
+
<button
|
|
3662
|
+
onClick={() => handleExport('csv')}
|
|
3663
|
+
disabled={exporting}
|
|
3664
|
+
className="flex w-full cursor-pointer items-center justify-between rounded-lg border-2 border-gray-200 bg-white p-4 transition-colors hover:border-indigo-500 hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
3665
|
+
>
|
|
3666
|
+
<div className="flex items-center gap-3">
|
|
3667
|
+
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
|
|
3668
|
+
<Download className="h-5 w-5 text-green-600" />
|
|
3669
|
+
</div>
|
|
3670
|
+
<div className="text-left">
|
|
3671
|
+
<div className="font-medium text-gray-900">CSV</div>
|
|
3672
|
+
<div className="text-xs text-gray-500">Format texte séparé par virgules</div>
|
|
3673
|
+
</div>
|
|
3674
|
+
</div>
|
|
3675
|
+
{exporting && <div className="text-sm text-gray-500">Export en cours...</div>}
|
|
3676
|
+
</button>
|
|
3677
|
+
|
|
3678
|
+
<button
|
|
3679
|
+
onClick={() => handleExport('excel')}
|
|
3680
|
+
disabled={exporting}
|
|
3681
|
+
className="flex w-full cursor-pointer items-center justify-between rounded-lg border-2 border-gray-200 bg-white p-4 transition-colors hover:border-indigo-500 hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
3682
|
+
>
|
|
3683
|
+
<div className="flex items-center gap-3">
|
|
3684
|
+
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
|
3685
|
+
<Download className="h-5 w-5 text-blue-600" />
|
|
3686
|
+
</div>
|
|
3687
|
+
<div className="text-left">
|
|
3688
|
+
<div className="font-medium text-gray-900">Excel</div>
|
|
3689
|
+
<div className="text-xs text-gray-500">Format Microsoft Excel (.xlsx)</div>
|
|
3690
|
+
</div>
|
|
3691
|
+
</div>
|
|
3692
|
+
{exporting && <div className="text-sm text-gray-500">Export en cours...</div>}
|
|
3693
|
+
</button>
|
|
3694
|
+
</div>
|
|
3695
|
+
|
|
3696
|
+
<div className="flex justify-end gap-3">
|
|
3697
|
+
<button
|
|
3698
|
+
onClick={() => {
|
|
3699
|
+
setShowExportModal(false);
|
|
3700
|
+
setExportAll(false);
|
|
3701
|
+
}}
|
|
3702
|
+
disabled={exporting}
|
|
3703
|
+
className="cursor-pointer rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
3704
|
+
>
|
|
3705
|
+
Annuler
|
|
3706
|
+
</button>
|
|
3707
|
+
</div>
|
|
3708
|
+
</div>
|
|
3709
|
+
</div>
|
|
3710
|
+
)}
|
|
3711
|
+
</div>
|
|
3712
|
+
);
|
|
3713
|
+
}
|