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,381 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { auth } from '@/lib/auth';
|
|
3
|
+
import { prisma } from '@/lib/prisma';
|
|
4
|
+
import { handleContactDuplicate } from '@/lib/contact-duplicate';
|
|
5
|
+
import { normalizePhoneNumber } from '@/lib/utils';
|
|
6
|
+
|
|
7
|
+
// POST /api/contacts/import - Importer des contacts depuis un fichier CSV/Excel
|
|
8
|
+
export async function POST(request: NextRequest) {
|
|
9
|
+
try {
|
|
10
|
+
const session = await auth.api.getSession({
|
|
11
|
+
headers: request.headers,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
if (!session) {
|
|
15
|
+
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const formData = await request.formData();
|
|
19
|
+
const file = formData.get('file') as File;
|
|
20
|
+
const fieldMappingsJson = formData.get('fieldMappings') as string;
|
|
21
|
+
const skipFirstRow = formData.get('skipFirstRow') === 'true';
|
|
22
|
+
|
|
23
|
+
// Récupérer les valeurs par défaut
|
|
24
|
+
const defaultStatusId = formData.get('defaultStatusId') as string | null;
|
|
25
|
+
const defaultCommercialId = formData.get('defaultCommercialId') as string | null;
|
|
26
|
+
const defaultOrigin = formData.get('defaultOrigin') as string | null;
|
|
27
|
+
|
|
28
|
+
if (!file) {
|
|
29
|
+
return NextResponse.json({ error: 'Aucun fichier fourni' }, { status: 400 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!fieldMappingsJson) {
|
|
33
|
+
return NextResponse.json({ error: 'Mapping des colonnes requis' }, { status: 400 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const fieldMappings: Record<
|
|
37
|
+
string,
|
|
38
|
+
{
|
|
39
|
+
action: 'map' | 'note' | 'ignore';
|
|
40
|
+
crmField?: string;
|
|
41
|
+
}
|
|
42
|
+
> = JSON.parse(fieldMappingsJson);
|
|
43
|
+
|
|
44
|
+
// Construire le mapping inversé (crmField -> colonne du fichier) pour les champs mappés
|
|
45
|
+
const mapping: Record<string, string> = {};
|
|
46
|
+
const noteFields: string[] = [];
|
|
47
|
+
|
|
48
|
+
for (const [fileColumn, config] of Object.entries(fieldMappings)) {
|
|
49
|
+
if (config.action === 'map' && config.crmField) {
|
|
50
|
+
mapping[config.crmField] = fileColumn;
|
|
51
|
+
} else if (config.action === 'note') {
|
|
52
|
+
noteFields.push(fileColumn);
|
|
53
|
+
}
|
|
54
|
+
// 'ignore' : on ne fait rien
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Parser le fichier selon son type
|
|
58
|
+
let rows: any[] = [];
|
|
59
|
+
const fileName = file.name.toLowerCase();
|
|
60
|
+
const fileExtension = fileName.split('.').pop();
|
|
61
|
+
|
|
62
|
+
if (fileExtension === 'csv') {
|
|
63
|
+
// Parser CSV
|
|
64
|
+
const text = await file.text();
|
|
65
|
+
rows = parseCSV(text);
|
|
66
|
+
} else if (fileExtension === 'xlsx' || fileExtension === 'xls') {
|
|
67
|
+
// Parser Excel
|
|
68
|
+
try {
|
|
69
|
+
const XLSX = require('xlsx');
|
|
70
|
+
const buffer = await file.arrayBuffer();
|
|
71
|
+
const workbook = XLSX.read(buffer, { type: 'array' });
|
|
72
|
+
const sheetName = workbook.SheetNames[0];
|
|
73
|
+
const worksheet = workbook.Sheets[sheetName];
|
|
74
|
+
rows = XLSX.utils.sheet_to_json(worksheet, { raw: false });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return NextResponse.json(
|
|
77
|
+
{ error: 'Erreur lors du parsing Excel. Assurez-vous que xlsx est installé.' },
|
|
78
|
+
{ status: 400 },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
return NextResponse.json(
|
|
83
|
+
{ error: 'Format de fichier non supporté. Utilisez CSV ou Excel (.xlsx, .xls)' },
|
|
84
|
+
{ status: 400 },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (rows.length === 0) {
|
|
89
|
+
return NextResponse.json({ error: 'Le fichier est vide' }, { status: 400 });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Ignorer la première ligne si c'est un en-tête
|
|
93
|
+
const dataRows = skipFirstRow ? rows.slice(1) : rows;
|
|
94
|
+
|
|
95
|
+
// Valider et mapper les données
|
|
96
|
+
const contactsToCreate: any[] = [];
|
|
97
|
+
const errors: string[] = [];
|
|
98
|
+
const skipped: number[] = [];
|
|
99
|
+
|
|
100
|
+
for (let i = 0; i < dataRows.length; i++) {
|
|
101
|
+
const row = dataRows[i];
|
|
102
|
+
const rowNumber = skipFirstRow ? i + 2 : i + 1;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
// Mapper les colonnes selon le mapping fourni
|
|
106
|
+
const phone = getValueFromRow(row, mapping.phone);
|
|
107
|
+
if (!phone) {
|
|
108
|
+
skipped.push(rowNumber);
|
|
109
|
+
continue; // Le téléphone est obligatoire
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Normaliser le numéro de téléphone au format : 0X XX XX XX XX
|
|
113
|
+
const normalizedPhone = normalizePhoneNumber(phone.toString());
|
|
114
|
+
|
|
115
|
+
// Déterminer le statusId : utiliser le mapping si fourni, sinon le statut par défaut fourni
|
|
116
|
+
let statusId = null;
|
|
117
|
+
if (mapping.statusId) {
|
|
118
|
+
const mappedStatus = getValueFromRow(row, mapping.statusId);
|
|
119
|
+
if (mappedStatus) {
|
|
120
|
+
// Si une valeur est mappée, chercher le statut par nom
|
|
121
|
+
const status = await prisma.status.findUnique({
|
|
122
|
+
where: { name: mappedStatus },
|
|
123
|
+
});
|
|
124
|
+
statusId = status?.id || null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Si aucun statut n'a été trouvé via le mapping, utiliser le statut par défaut fourni
|
|
128
|
+
if (!statusId && defaultStatusId) {
|
|
129
|
+
statusId = defaultStatusId;
|
|
130
|
+
}
|
|
131
|
+
// Si toujours aucun statut, utiliser "Nouveau" par défaut
|
|
132
|
+
if (!statusId) {
|
|
133
|
+
const nouveauStatus = await prisma.status.findUnique({
|
|
134
|
+
where: { name: 'Nouveau' },
|
|
135
|
+
});
|
|
136
|
+
statusId = nouveauStatus?.id || null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Déterminer l'origine : utiliser le mapping si fourni, sinon la valeur par défaut fournie
|
|
140
|
+
const origin = getValueFromRow(row, mapping.origin) || defaultOrigin || null;
|
|
141
|
+
|
|
142
|
+
// Déterminer le commercial : utiliser le mapping si fourni, sinon le commercial par défaut fourni
|
|
143
|
+
let assignedCommercialId = null;
|
|
144
|
+
if (mapping.assignedCommercialId) {
|
|
145
|
+
assignedCommercialId = getValueFromRow(row, mapping.assignedCommercialId) || null;
|
|
146
|
+
}
|
|
147
|
+
if (!assignedCommercialId && defaultCommercialId) {
|
|
148
|
+
assignedCommercialId = defaultCommercialId;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const contactData: any = {
|
|
152
|
+
phone: normalizedPhone,
|
|
153
|
+
civility: getValueFromRow(row, mapping.civility) || null,
|
|
154
|
+
firstName: getValueFromRow(row, mapping.firstName) || null,
|
|
155
|
+
lastName: getValueFromRow(row, mapping.lastName) || null,
|
|
156
|
+
secondaryPhone: mapping.secondaryPhone
|
|
157
|
+
? normalizePhoneNumber(getValueFromRow(row, mapping.secondaryPhone) || '') || null
|
|
158
|
+
: null,
|
|
159
|
+
email: getValueFromRow(row, mapping.email) || null,
|
|
160
|
+
address: getValueFromRow(row, mapping.address) || null,
|
|
161
|
+
city: getValueFromRow(row, mapping.city) || null,
|
|
162
|
+
postalCode: getValueFromRow(row, mapping.postalCode) || null,
|
|
163
|
+
origin: origin,
|
|
164
|
+
statusId: statusId,
|
|
165
|
+
assignedCommercialId: assignedCommercialId,
|
|
166
|
+
assignedTeleproId: mapping.assignedTeleproId
|
|
167
|
+
? getValueFromRow(row, mapping.assignedTeleproId) || null
|
|
168
|
+
: null,
|
|
169
|
+
createdById: session.user.id,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Collecter les notes à ajouter
|
|
173
|
+
const noteContents: string[] = [];
|
|
174
|
+
for (const noteField of noteFields) {
|
|
175
|
+
const value = getValueFromRow(row, noteField);
|
|
176
|
+
if (value) {
|
|
177
|
+
noteContents.push(`${noteField}: ${value}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Valider les données
|
|
182
|
+
if (!contactData.phone || contactData.phone.trim() === '') {
|
|
183
|
+
skipped.push(rowNumber);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Stocker les notes avec le contact
|
|
188
|
+
contactsToCreate.push({
|
|
189
|
+
...contactData,
|
|
190
|
+
_noteContents: noteContents, // Préfixe _ pour indiquer que c'est une propriété temporaire
|
|
191
|
+
});
|
|
192
|
+
} catch (error: any) {
|
|
193
|
+
errors.push(`Ligne ${rowNumber}: ${error.message}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Créer les contacts en lot
|
|
198
|
+
const createdContacts = [];
|
|
199
|
+
const duplicateErrors = [];
|
|
200
|
+
|
|
201
|
+
for (const contactDataWithNotes of contactsToCreate) {
|
|
202
|
+
// Extraire les notes et les données du contact
|
|
203
|
+
const { _noteContents, ...contactData } = contactDataWithNotes as any;
|
|
204
|
+
const noteContents = _noteContents || [];
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
// Vérifier si c'est un doublon (nom, prénom ET email)
|
|
208
|
+
const duplicateContactId = await handleContactDuplicate(
|
|
209
|
+
contactData.firstName,
|
|
210
|
+
contactData.lastName,
|
|
211
|
+
contactData.email,
|
|
212
|
+
contactData.origin || 'Import CSV/Excel',
|
|
213
|
+
session.user.id,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
if (duplicateContactId) {
|
|
217
|
+
// C'est un doublon, récupérer le contact existant
|
|
218
|
+
const existingContact = await prisma.contact.findUnique({
|
|
219
|
+
where: { id: duplicateContactId },
|
|
220
|
+
include: {
|
|
221
|
+
status: true,
|
|
222
|
+
assignedCommercial: {
|
|
223
|
+
select: { id: true, name: true, email: true },
|
|
224
|
+
},
|
|
225
|
+
assignedTelepro: {
|
|
226
|
+
select: { id: true, name: true, email: true },
|
|
227
|
+
},
|
|
228
|
+
createdBy: {
|
|
229
|
+
select: { id: true, name: true, email: true },
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
if (existingContact) {
|
|
234
|
+
createdContacts.push(existingContact);
|
|
235
|
+
duplicateErrors.push(
|
|
236
|
+
`Contact ${contactData.firstName} ${contactData.lastName} (${contactData.email}) - doublon détecté`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Vérifier aussi par téléphone pour éviter les doublons par téléphone uniquement
|
|
243
|
+
const existingByPhone = await prisma.contact.findFirst({
|
|
244
|
+
where: { phone: contactData.phone },
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
if (existingByPhone) {
|
|
248
|
+
duplicateErrors.push(`Téléphone ${contactData.phone} déjà existant`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Préparer les interactions à créer
|
|
253
|
+
const interactionsToCreate: any[] = [
|
|
254
|
+
{
|
|
255
|
+
type: 'NOTE',
|
|
256
|
+
title: 'Contact importé',
|
|
257
|
+
content: `Contact importé depuis un fichier le ${new Date().toLocaleDateString(
|
|
258
|
+
'fr-FR',
|
|
259
|
+
{
|
|
260
|
+
day: 'numeric',
|
|
261
|
+
month: 'long',
|
|
262
|
+
year: 'numeric',
|
|
263
|
+
hour: '2-digit',
|
|
264
|
+
minute: '2-digit',
|
|
265
|
+
},
|
|
266
|
+
)}`,
|
|
267
|
+
userId: session.user.id,
|
|
268
|
+
date: new Date(),
|
|
269
|
+
},
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
// Ajouter les notes pour les colonnes configurées comme "note"
|
|
273
|
+
if (noteContents.length > 0) {
|
|
274
|
+
interactionsToCreate.push({
|
|
275
|
+
type: 'NOTE',
|
|
276
|
+
title: 'Informations supplémentaires',
|
|
277
|
+
content: noteContents.join('\n'),
|
|
278
|
+
userId: session.user.id,
|
|
279
|
+
date: new Date(),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const contact = await prisma.contact.create({
|
|
284
|
+
data: {
|
|
285
|
+
...contactData,
|
|
286
|
+
interactions: {
|
|
287
|
+
create: interactionsToCreate,
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
include: {
|
|
291
|
+
status: true,
|
|
292
|
+
assignedCommercial: {
|
|
293
|
+
select: { id: true, name: true, email: true },
|
|
294
|
+
},
|
|
295
|
+
assignedTelepro: {
|
|
296
|
+
select: { id: true, name: true, email: true },
|
|
297
|
+
},
|
|
298
|
+
createdBy: {
|
|
299
|
+
select: { id: true, name: true, email: true },
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
createdContacts.push(contact);
|
|
305
|
+
} catch (error: any) {
|
|
306
|
+
errors.push(`Erreur lors de la création: ${error.message}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return NextResponse.json({
|
|
311
|
+
success: true,
|
|
312
|
+
imported: createdContacts.length,
|
|
313
|
+
skipped: skipped.length,
|
|
314
|
+
duplicates: duplicateErrors.length,
|
|
315
|
+
errors: errors.length,
|
|
316
|
+
details: {
|
|
317
|
+
created: createdContacts.length,
|
|
318
|
+
skippedRows: skipped,
|
|
319
|
+
duplicatePhones: duplicateErrors,
|
|
320
|
+
errors: errors.slice(0, 10), // Limiter à 10 erreurs
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
} catch (error: any) {
|
|
324
|
+
console.error("Erreur lors de l'import:", error);
|
|
325
|
+
return NextResponse.json(
|
|
326
|
+
{ error: error.message || "Erreur serveur lors de l'import" },
|
|
327
|
+
{ status: 500 },
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Fonction pour parser CSV
|
|
333
|
+
function parseCSV(text: string): any[] {
|
|
334
|
+
const lines = text.split('\n').filter((line) => line.trim() !== '');
|
|
335
|
+
if (lines.length === 0) return [];
|
|
336
|
+
|
|
337
|
+
// Détecter le délimiteur (virgule ou point-virgule)
|
|
338
|
+
const firstLine = lines[0];
|
|
339
|
+
const delimiter = firstLine.includes(';') ? ';' : ',';
|
|
340
|
+
|
|
341
|
+
// Parser les en-têtes
|
|
342
|
+
const headers = lines[0].split(delimiter).map((h) => h.trim().replace(/^"|"$/g, ''));
|
|
343
|
+
|
|
344
|
+
// Parser les lignes de données
|
|
345
|
+
const rows: any[] = [];
|
|
346
|
+
for (let i = 1; i < lines.length; i++) {
|
|
347
|
+
const values = lines[i].split(delimiter).map((v) => v.trim().replace(/^"|"$/g, ''));
|
|
348
|
+
const row: any = {};
|
|
349
|
+
headers.forEach((header, index) => {
|
|
350
|
+
row[header] = values[index] || '';
|
|
351
|
+
});
|
|
352
|
+
rows.push(row);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return rows;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Fonction pour extraire une valeur d'une ligne selon le mapping
|
|
359
|
+
function getValueFromRow(row: any, columnName: string | null | undefined): string | null {
|
|
360
|
+
if (!columnName || columnName === '') return null;
|
|
361
|
+
|
|
362
|
+
// Essayer d'abord le nom exact (pour les colonnes du fichier)
|
|
363
|
+
if (row[columnName] !== undefined && row[columnName] !== null && row[columnName] !== '') {
|
|
364
|
+
return String(row[columnName]).trim();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Essayer différentes variantes du nom de colonne
|
|
368
|
+
const variants = [
|
|
369
|
+
columnName.trim(),
|
|
370
|
+
columnName.toLowerCase(),
|
|
371
|
+
columnName.toUpperCase(),
|
|
372
|
+
];
|
|
373
|
+
|
|
374
|
+
for (const variant of variants) {
|
|
375
|
+
if (row[variant] !== undefined && row[variant] !== null && row[variant] !== '') {
|
|
376
|
+
return String(row[variant]).trim();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { auth } from '@/lib/auth';
|
|
3
|
+
import { prisma } from '@/lib/prisma';
|
|
4
|
+
import { handleContactDuplicate } from '@/lib/contact-duplicate';
|
|
5
|
+
import { executeWorkflowsOnContactCreated } from '@/lib/workflow-executor';
|
|
6
|
+
import { normalizePhoneNumber } from '@/lib/utils';
|
|
7
|
+
|
|
8
|
+
// GET /api/contacts - Récupérer tous les contacts avec filtres
|
|
9
|
+
export async function GET(request: NextRequest) {
|
|
10
|
+
try {
|
|
11
|
+
const session = await auth.api.getSession({
|
|
12
|
+
headers: request.headers,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
if (!session) {
|
|
16
|
+
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { searchParams } = new URL(request.url);
|
|
20
|
+
const search = searchParams.get('search') || '';
|
|
21
|
+
const statusId = searchParams.get('statusId');
|
|
22
|
+
const assignedCommercialId = searchParams.get('assignedCommercialId');
|
|
23
|
+
const assignedTeleproId = searchParams.get('assignedTeleproId');
|
|
24
|
+
const origin = searchParams.get('origin');
|
|
25
|
+
const createdAtStart = searchParams.get('createdAtStart');
|
|
26
|
+
const createdAtEnd = searchParams.get('createdAtEnd');
|
|
27
|
+
const updatedAtStart = searchParams.get('updatedAtStart');
|
|
28
|
+
const updatedAtEnd = searchParams.get('updatedAtEnd');
|
|
29
|
+
// const isCompany = searchParams.get('isCompany');
|
|
30
|
+
const page = parseInt(searchParams.get('page') || '1');
|
|
31
|
+
const limit = parseInt(searchParams.get('limit') || '50');
|
|
32
|
+
const skip = (page - 1) * limit;
|
|
33
|
+
|
|
34
|
+
// Construire les filtres
|
|
35
|
+
const where: any = {};
|
|
36
|
+
|
|
37
|
+
if (search) {
|
|
38
|
+
where.OR = [
|
|
39
|
+
{ firstName: { contains: search, mode: 'insensitive' } },
|
|
40
|
+
{ lastName: { contains: search, mode: 'insensitive' } },
|
|
41
|
+
{ email: { contains: search, mode: 'insensitive' } },
|
|
42
|
+
{ phone: { contains: search, mode: 'insensitive' } },
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (statusId) {
|
|
47
|
+
where.statusId = statusId;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (assignedCommercialId) {
|
|
51
|
+
where.assignedCommercialId = assignedCommercialId;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (assignedTeleproId) {
|
|
55
|
+
where.assignedTeleproId = assignedTeleproId;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (origin) {
|
|
59
|
+
where.origin = origin;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Filtres de date pour createdAt
|
|
63
|
+
if (createdAtStart || createdAtEnd) {
|
|
64
|
+
where.createdAt = {};
|
|
65
|
+
if (createdAtStart) {
|
|
66
|
+
const startDate = new Date(createdAtStart);
|
|
67
|
+
if (!isNaN(startDate.getTime())) {
|
|
68
|
+
where.createdAt.gte = startDate;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (createdAtEnd) {
|
|
72
|
+
// Ajouter 23h59m59s pour inclure toute la journée
|
|
73
|
+
const endDate = new Date(createdAtEnd);
|
|
74
|
+
if (!isNaN(endDate.getTime())) {
|
|
75
|
+
endDate.setHours(23, 59, 59, 999);
|
|
76
|
+
where.createdAt.lte = endDate;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Filtres de date pour updatedAt
|
|
82
|
+
if (updatedAtStart || updatedAtEnd) {
|
|
83
|
+
where.updatedAt = {};
|
|
84
|
+
if (updatedAtStart) {
|
|
85
|
+
const startDate = new Date(updatedAtStart);
|
|
86
|
+
if (!isNaN(startDate.getTime())) {
|
|
87
|
+
where.updatedAt.gte = startDate;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (updatedAtEnd) {
|
|
91
|
+
// Ajouter 23h59m59s pour inclure toute la journée
|
|
92
|
+
const endDate = new Date(updatedAtEnd);
|
|
93
|
+
if (!isNaN(endDate.getTime())) {
|
|
94
|
+
endDate.setHours(23, 59, 59, 999);
|
|
95
|
+
where.updatedAt.lte = endDate;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// if (isCompany === 'true') {
|
|
101
|
+
// where = { ...where, isCompany: true };
|
|
102
|
+
// }
|
|
103
|
+
|
|
104
|
+
const [contacts, total] = await Promise.all([
|
|
105
|
+
prisma.contact.findMany({
|
|
106
|
+
where: {
|
|
107
|
+
...where,
|
|
108
|
+
isCompany: false,
|
|
109
|
+
},
|
|
110
|
+
include: {
|
|
111
|
+
status: true,
|
|
112
|
+
companyRelation: {
|
|
113
|
+
select: { id: true, firstName: true, lastName: true, isCompany: true },
|
|
114
|
+
},
|
|
115
|
+
assignedCommercial: {
|
|
116
|
+
select: { id: true, name: true, email: true },
|
|
117
|
+
},
|
|
118
|
+
assignedTelepro: {
|
|
119
|
+
select: { id: true, name: true, email: true },
|
|
120
|
+
},
|
|
121
|
+
createdBy: {
|
|
122
|
+
select: { id: true, name: true, email: true },
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
orderBy: { createdAt: 'desc' },
|
|
126
|
+
skip,
|
|
127
|
+
take: limit,
|
|
128
|
+
}),
|
|
129
|
+
prisma.contact.count({ where: { ...where, isCompany: false } }),
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
return NextResponse.json({
|
|
133
|
+
contacts,
|
|
134
|
+
pagination: {
|
|
135
|
+
page,
|
|
136
|
+
limit,
|
|
137
|
+
total,
|
|
138
|
+
totalPages: Math.ceil(total / limit),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
} catch (error: any) {
|
|
142
|
+
console.error('Erreur lors de la récupération des contacts:', error);
|
|
143
|
+
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// POST /api/contacts - Créer un nouveau contact
|
|
148
|
+
export async function POST(request: NextRequest) {
|
|
149
|
+
try {
|
|
150
|
+
const session = await auth.api.getSession({
|
|
151
|
+
headers: request.headers,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (!session) {
|
|
155
|
+
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const body = await request.json();
|
|
159
|
+
const {
|
|
160
|
+
civility,
|
|
161
|
+
firstName,
|
|
162
|
+
lastName,
|
|
163
|
+
phone,
|
|
164
|
+
secondaryPhone,
|
|
165
|
+
email,
|
|
166
|
+
address,
|
|
167
|
+
city,
|
|
168
|
+
postalCode,
|
|
169
|
+
origin,
|
|
170
|
+
companyName,
|
|
171
|
+
isCompany,
|
|
172
|
+
companyId,
|
|
173
|
+
statusId,
|
|
174
|
+
closingReason,
|
|
175
|
+
assignedCommercialId,
|
|
176
|
+
assignedTeleproId,
|
|
177
|
+
} = body;
|
|
178
|
+
|
|
179
|
+
// Validation
|
|
180
|
+
if (!phone) {
|
|
181
|
+
return NextResponse.json({ error: 'Le téléphone est obligatoire' }, { status: 400 });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Vérifier si c'est un doublon (nom, prénom ET email)
|
|
185
|
+
const duplicateContactId = await handleContactDuplicate(
|
|
186
|
+
firstName,
|
|
187
|
+
lastName,
|
|
188
|
+
email,
|
|
189
|
+
origin || 'Création manuelle',
|
|
190
|
+
session.user.id,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// Si c'est un doublon, retourner le contact existant
|
|
194
|
+
if (duplicateContactId) {
|
|
195
|
+
const existingContact = await prisma.contact.findUnique({
|
|
196
|
+
where: { id: duplicateContactId },
|
|
197
|
+
include: {
|
|
198
|
+
status: true,
|
|
199
|
+
companyRelation: {
|
|
200
|
+
select: { id: true, firstName: true, lastName: true, isCompany: true },
|
|
201
|
+
},
|
|
202
|
+
assignedCommercial: {
|
|
203
|
+
select: { id: true, name: true, email: true },
|
|
204
|
+
},
|
|
205
|
+
assignedTelepro: {
|
|
206
|
+
select: { id: true, name: true, email: true },
|
|
207
|
+
},
|
|
208
|
+
createdBy: {
|
|
209
|
+
select: { id: true, name: true, email: true },
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
return NextResponse.json(existingContact, { status: 200 });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Sinon, créer un nouveau contact
|
|
217
|
+
const contact = await prisma.contact.create({
|
|
218
|
+
data: {
|
|
219
|
+
civility: civility || null,
|
|
220
|
+
firstName: firstName || null,
|
|
221
|
+
lastName: lastName || null,
|
|
222
|
+
phone: normalizePhoneNumber(phone),
|
|
223
|
+
secondaryPhone: secondaryPhone ? normalizePhoneNumber(secondaryPhone) : null,
|
|
224
|
+
email: email || null,
|
|
225
|
+
address: address || null,
|
|
226
|
+
city: city || null,
|
|
227
|
+
postalCode: postalCode || null,
|
|
228
|
+
origin: origin || null,
|
|
229
|
+
companyName: companyName || null,
|
|
230
|
+
isCompany: isCompany === true,
|
|
231
|
+
companyId: companyId || null,
|
|
232
|
+
statusId: statusId || null,
|
|
233
|
+
closingReason: closingReason || null,
|
|
234
|
+
assignedCommercialId: assignedCommercialId || null,
|
|
235
|
+
assignedTeleproId: assignedTeleproId || null,
|
|
236
|
+
createdById: session.user.id,
|
|
237
|
+
interactions: {
|
|
238
|
+
create: {
|
|
239
|
+
type: 'NOTE',
|
|
240
|
+
title: 'Contact créé',
|
|
241
|
+
content: `Contact créé le ${new Date().toLocaleDateString('fr-FR', {
|
|
242
|
+
day: 'numeric',
|
|
243
|
+
month: 'long',
|
|
244
|
+
year: 'numeric',
|
|
245
|
+
hour: '2-digit',
|
|
246
|
+
minute: '2-digit',
|
|
247
|
+
})}`,
|
|
248
|
+
userId: session.user.id,
|
|
249
|
+
date: new Date(),
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
include: {
|
|
254
|
+
status: true,
|
|
255
|
+
companyRelation: {
|
|
256
|
+
select: { id: true, firstName: true, lastName: true, isCompany: true },
|
|
257
|
+
},
|
|
258
|
+
assignedCommercial: {
|
|
259
|
+
select: { id: true, name: true, email: true },
|
|
260
|
+
},
|
|
261
|
+
assignedTelepro: {
|
|
262
|
+
select: { id: true, name: true, email: true },
|
|
263
|
+
},
|
|
264
|
+
createdBy: {
|
|
265
|
+
select: { id: true, name: true, email: true },
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Exécuter les workflows déclenchés par la création d'un contact
|
|
271
|
+
try {
|
|
272
|
+
await executeWorkflowsOnContactCreated(contact.id);
|
|
273
|
+
} catch (workflowError) {
|
|
274
|
+
// Ne pas faire échouer la création du contact si les workflows échouent
|
|
275
|
+
console.error('Erreur lors de l\'exécution des workflows:', workflowError);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return NextResponse.json(contact, { status: 201 });
|
|
279
|
+
} catch (error: any) {
|
|
280
|
+
console.error('Erreur lors de la création du contact:', error);
|
|
281
|
+
return NextResponse.json({ error: error.message || 'Erreur serveur' }, { status: 500 });
|
|
282
|
+
}
|
|
283
|
+
}
|