create-crm-tmp 2.0.0 → 2.1.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 +56 -35
- package/package.json +1 -1
- package/template/README.md +230 -115
- package/template/eslint.config.mjs +13 -0
- package/template/next.config.ts +14 -0
- package/template/package.json +15 -2
- package/template/prisma/migrations/20260318095700_init_db/migration.sql +978 -0
- package/template/prisma/migrations/migration_lock.toml +3 -0
- package/template/prisma/schema.prisma +132 -637
- package/template/src/app/(auth)/invite/[token]/page.tsx +10 -8
- package/template/src/app/(auth)/layout.tsx +1 -1
- package/template/src/app/(auth)/reset-password/complete/page.tsx +11 -8
- package/template/src/app/(auth)/reset-password/page.tsx +4 -4
- package/template/src/app/(auth)/reset-password/verify/page.tsx +4 -4
- package/template/src/app/(auth)/signin/page.tsx +14 -6
- package/template/src/app/(dashboard)/agenda/page.tsx +2243 -988
- package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +18 -104
- package/template/src/app/(dashboard)/automatisation/page.tsx +10 -26
- package/template/src/app/(dashboard)/closing/page.tsx +78 -62
- package/template/src/app/(dashboard)/contacts/[id]/page.tsx +2082 -1080
- package/template/src/app/(dashboard)/contacts/companies/[id]/page.tsx +46 -47
- package/template/src/app/(dashboard)/contacts/page.tsx +1062 -780
- package/template/src/app/(dashboard)/dashboard/page.tsx +533 -37
- package/template/src/app/(dashboard)/dev/page.tsx +1291 -0
- package/template/src/app/(dashboard)/layout.tsx +6 -2
- package/template/src/app/(dashboard)/settings/page.tsx +797 -2582
- package/template/src/app/(dashboard)/templates/page.tsx +55 -54
- package/template/src/app/(dashboard)/users/list/page.tsx +51 -48
- package/template/src/app/(dashboard)/users/page.tsx +1 -1
- package/template/src/app/(dashboard)/users/permissions/page.tsx +2 -2
- package/template/src/app/(dashboard)/users/roles/page.tsx +7 -5
- package/template/src/app/api/agenda/google-events/route.ts +92 -0
- package/template/src/app/api/auth/check-active/route.ts +3 -2
- package/template/src/app/api/auth/google/route.ts +2 -1
- package/template/src/app/api/auth/google/status/route.ts +7 -31
- package/template/src/app/api/companies/[id]/activities/route.ts +1 -3
- package/template/src/app/api/companies/[id]/route.ts +1 -2
- package/template/src/app/api/companies/route.ts +42 -12
- package/template/src/app/api/contacts/[id]/files/[fileId]/preview/route.ts +9 -31
- package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +14 -32
- package/template/src/app/api/contacts/[id]/files/route.ts +112 -212
- package/template/src/app/api/contacts/[id]/interactions/[interactionId]/route.ts +27 -1
- package/template/src/app/api/contacts/[id]/interactions/route.ts +16 -16
- package/template/src/app/api/contacts/[id]/kyc/route.ts +21 -11
- package/template/src/app/api/contacts/[id]/meet/route.ts +19 -2
- package/template/src/app/api/contacts/[id]/route.ts +106 -34
- package/template/src/app/api/contacts/[id]/send-email/route.ts +27 -11
- package/template/src/app/api/contacts/[id]/workflows/run/route.ts +6 -0
- package/template/src/app/api/contacts/export/route.ts +9 -13
- package/template/src/app/api/contacts/import/route.ts +55 -25
- package/template/src/app/api/contacts/import-preview/route.ts +1 -1
- package/template/src/app/api/contacts/origins/route.ts +63 -0
- package/template/src/app/api/contacts/route.ts +153 -41
- package/template/src/app/api/cron/cleanup-editor-images/route.ts +166 -0
- package/template/src/app/api/dashboard/widgets/[id]/route.ts +44 -0
- package/template/src/app/api/dashboard/widgets/route.ts +181 -0
- package/template/src/app/api/dev/reminders/test/route.ts +114 -0
- package/template/src/app/api/editor/upload-image/route.ts +61 -0
- package/template/src/app/api/integrations/google-sheet/jobs/[jobId]/route.ts +47 -0
- package/template/src/app/api/integrations/google-sheet/jobs/usage/route.ts +50 -0
- package/template/src/app/api/integrations/google-sheet/sync/route.ts +24 -556
- package/template/src/app/api/jobs/google-sheet/process/route.ts +84 -0
- package/template/src/app/api/jobs/google-sheet/schedule/route.ts +50 -0
- package/template/src/app/api/reminders/clear/route.ts +120 -0
- package/template/src/app/api/reminders/clear/undo/route.ts +112 -0
- package/template/src/app/api/reminders/route.ts +164 -39
- package/template/src/app/api/reminders/state/route.ts +164 -0
- package/template/src/app/api/reset-password/request/route.ts +1 -1
- package/template/src/app/api/reset-password/verify/route.ts +1 -1
- package/template/src/app/api/send/route.ts +16 -4
- package/template/src/app/api/settings/google-ads/route.ts +14 -0
- package/template/src/app/api/settings/google-calendar/calendars/route.ts +97 -0
- package/template/src/app/api/settings/google-calendar/route.ts +124 -0
- package/template/src/app/api/settings/google-sheet/[id]/route.ts +28 -0
- package/template/src/app/api/settings/google-sheet/auto-map/route.ts +37 -4
- package/template/src/app/api/settings/google-sheet/preview/route.ts +9 -3
- package/template/src/app/api/settings/google-sheet/route.ts +14 -0
- package/template/src/app/api/settings/integrations/logs/route.ts +93 -0
- package/template/src/app/api/settings/integrations/notifications/route.ts +67 -0
- package/template/src/app/api/settings/meta-leads/[id]/route.ts +0 -1
- package/template/src/app/api/settings/meta-leads/route.ts +14 -2
- package/template/src/app/api/settings/smtp/route.ts +53 -6
- package/template/src/app/api/tasks/[id]/attendees/route.ts +24 -8
- package/template/src/app/api/tasks/[id]/route.ts +234 -58
- package/template/src/app/api/tasks/meet/route.ts +27 -19
- package/template/src/app/api/tasks/route.ts +62 -17
- package/template/src/app/api/users/[id]/route.ts +20 -14
- package/template/src/app/api/users/list/route.ts +57 -19
- package/template/src/app/api/webhooks/google-ads/route.ts +34 -14
- package/template/src/app/api/webhooks/meta-leads/route.ts +32 -12
- package/template/src/app/api/workflows/[id]/route.ts +0 -4
- package/template/src/app/api/workflows/process/route.ts +22 -51
- package/template/src/app/api/workflows/route.ts +0 -4
- package/template/src/app/globals.css +342 -4
- package/template/src/app/layout.tsx +11 -3
- package/template/src/app/page.tsx +1 -1
- package/template/src/components/address-autocomplete.tsx +7 -6
- package/template/src/components/config-error-alert.tsx +46 -0
- package/template/src/components/contacts/filter-bar.tsx +12 -3
- package/template/src/components/contacts/filter-builder.tsx +28 -43
- package/template/src/components/contacts/save-view-dialog.tsx +1 -1
- package/template/src/components/contacts/views-tab-bar.tsx +15 -6
- package/template/src/components/dashboard/activity-chart.tsx +41 -28
- package/template/src/components/dashboard/add-widget-dialog.tsx +157 -0
- package/template/src/components/dashboard/color-picker.tsx +64 -0
- package/template/src/components/dashboard/contacts-chart.tsx +69 -0
- package/template/src/components/dashboard/interactions-by-type-chart.tsx +121 -0
- package/template/src/components/dashboard/recent-activity.tsx +154 -0
- package/template/src/components/dashboard/stat-card.tsx +40 -40
- package/template/src/components/dashboard/status-distribution-chart.tsx +81 -0
- package/template/src/components/dashboard/tasks-pie-chart.tsx +37 -34
- package/template/src/components/dashboard/top-contacts-list.tsx +113 -0
- package/template/src/components/dashboard/upcoming-tasks-list.tsx +72 -81
- package/template/src/components/dashboard/widget-wrapper.tsx +36 -0
- package/template/src/components/date-picker.tsx +9 -6
- package/template/src/components/editor/upload-editor-image.ts +42 -0
- package/template/src/components/editor.tsx +161 -22
- package/template/src/components/email-template.tsx +2 -2
- package/template/src/components/global-search.tsx +30 -28
- package/template/src/components/header.tsx +178 -80
- package/template/src/components/inactive-account-guard.tsx +58 -0
- package/template/src/components/integration-notifications-listener.tsx +12 -0
- package/template/src/components/invitation-email-template.tsx +2 -2
- package/template/src/components/meet-cancellation-email-template.tsx +3 -3
- package/template/src/components/meet-confirmation-email-template.tsx +3 -3
- package/template/src/components/meet-update-email-template.tsx +3 -3
- package/template/src/components/page-header.tsx +5 -5
- package/template/src/components/protected-page.tsx +1 -1
- package/template/src/components/reset-password-email-template.tsx +2 -2
- package/template/src/components/settings/integrations/GoogleAdsIntegration.tsx +428 -0
- package/template/src/components/settings/integrations/GoogleSheetConfigMonitoringModal.tsx +680 -0
- package/template/src/components/settings/integrations/GoogleSheetIntegration.tsx +809 -0
- package/template/src/components/settings/integrations/ImportResultDialog.tsx +124 -0
- package/template/src/components/settings/integrations/IntegrationLogPanel.tsx +57 -0
- package/template/src/components/settings/integrations/IntegrationLogsTable.tsx +186 -0
- package/template/src/components/settings/integrations/MetaLeadIntegration.tsx +451 -0
- package/template/src/components/sidebar.tsx +45 -26
- package/template/src/components/skeleton.tsx +40 -43
- package/template/src/components/ui/accordion.tsx +2 -2
- package/template/src/components/ui/alert-dialog.tsx +1 -1
- package/template/src/components/ui/button.tsx +20 -9
- package/template/src/components/ui/components.tsx +1 -1
- package/template/src/components/ui/date-picker.tsx +422 -0
- package/template/src/components/ui/datetime-picker.tsx +338 -0
- package/template/src/components/ui/status-select.tsx +271 -0
- package/template/src/components/ui/tooltip.tsx +37 -0
- package/template/src/components/view-as-modal.tsx +13 -7
- package/template/src/contexts/app-toast-context.tsx +245 -57
- package/template/src/contexts/dashboard-theme-context.tsx +53 -0
- package/template/src/contexts/sidebar-context.tsx +22 -17
- package/template/src/contexts/task-reminder-context.tsx +134 -160
- package/template/src/contexts/view-as-context.tsx +33 -6
- package/template/src/hooks/use-focus-trap.ts +2 -2
- package/template/src/hooks/useIntegrationNotifications.ts +49 -0
- package/template/src/lib/auth.ts +8 -1
- package/template/src/lib/config-links.ts +14 -0
- package/template/src/lib/contact-duplicate.ts +79 -61
- package/template/src/lib/contact-interactions.ts +21 -21
- package/template/src/lib/contact-view-filters.ts +24 -64
- package/template/src/lib/contacts-list-url.ts +190 -0
- package/template/src/lib/dashboard-stats.ts +65 -7
- package/template/src/lib/dashboard-themes.ts +135 -0
- package/template/src/lib/date-utils.ts +127 -0
- package/template/src/lib/default-widgets.ts +12 -0
- package/template/src/lib/editor-html-image-dimensions.ts +172 -0
- package/template/src/lib/editor-image-limits.ts +19 -0
- package/template/src/lib/email-html-sanitize.ts +19 -0
- package/template/src/lib/encryption.ts +9 -6
- package/template/src/lib/fr-geography.ts +192 -0
- package/template/src/lib/google-calendar-agenda.ts +201 -0
- package/template/src/lib/google-calendar.ts +255 -5
- package/template/src/lib/google-sheet-sync-jobs.ts +96 -0
- package/template/src/lib/google-sheet-sync-runner.ts +514 -0
- package/template/src/lib/integration-import-log.ts +21 -0
- package/template/src/lib/permissions.ts +40 -10
- package/template/src/lib/prisma.ts +4 -1
- package/template/src/lib/qstash.ts +65 -0
- package/template/src/lib/reminder-state-server.ts +80 -0
- package/template/src/lib/reminder-state.ts +29 -0
- package/template/src/lib/supabase-storage.ts +113 -0
- package/template/src/lib/template-variables.ts +164 -23
- package/template/src/lib/utils.ts +45 -0
- package/template/src/lib/widget-registry.ts +173 -0
- package/template/src/lib/workflow-executor.ts +16 -70
- package/template/src/proxy.ts +1 -0
- package/template/vercel.json +3 -10
- package/template/skills-lock.json +0 -25
- package/template/src/components/dashboard/dashboard-content.tsx +0 -79
- package/template/src/lib/google-drive.ts +0 -1101
- package/template/src/types/yousign.ts +0 -52
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import { Plus, Trash2, ArrowRight } from 'lucide-react';
|
|
5
|
+
import { cn, indexToColumn, devToast } from '@/lib/utils';
|
|
6
|
+
import { useAppToast } from '@/contexts/app-toast-context';
|
|
7
|
+
import { useConfirm } from '@/hooks/use-confirm';
|
|
8
|
+
import { useFetch } from '@/hooks/use-fetch';
|
|
9
|
+
import { CONFIG_LINKS } from '@/lib/config-links';
|
|
10
|
+
import { ImportResultDialog, type ImportResultItem } from './ImportResultDialog';
|
|
11
|
+
import { StatusSelect } from '@/components/ui/status-select';
|
|
12
|
+
import { GoogleSheetConfigMonitoringModal } from './GoogleSheetConfigMonitoringModal';
|
|
13
|
+
import { ConfigErrorAlert } from '@/components/config-error-alert';
|
|
14
|
+
|
|
15
|
+
interface ColumnMapping {
|
|
16
|
+
id: string;
|
|
17
|
+
columnName: string;
|
|
18
|
+
action: 'map' | 'note' | 'ignore';
|
|
19
|
+
crmField?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface GoogleSheetConfig {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
active: boolean;
|
|
26
|
+
spreadsheetId: string;
|
|
27
|
+
sheetName: string;
|
|
28
|
+
headerRow: number;
|
|
29
|
+
phoneColumn: string;
|
|
30
|
+
firstNameColumn: string | null;
|
|
31
|
+
lastNameColumn: string | null;
|
|
32
|
+
emailColumn: string | null;
|
|
33
|
+
cityColumn: string | null;
|
|
34
|
+
postalCodeColumn: string | null;
|
|
35
|
+
originColumn: string | null;
|
|
36
|
+
columnMappings?: ColumnMapping[];
|
|
37
|
+
defaultStatusId: string | null;
|
|
38
|
+
defaultAssignedUserId: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface GoogleSheetIntegrationProps {
|
|
42
|
+
statuses: Array<{ id: string; name: string; color: string }>;
|
|
43
|
+
users: Array<{ id: string; name: string; email: string }>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type GoogleSheetJobStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED';
|
|
47
|
+
type GoogleSheetJobResult = {
|
|
48
|
+
totalImported?: number;
|
|
49
|
+
totalUpdated?: number;
|
|
50
|
+
totalSkipped?: number;
|
|
51
|
+
results?: ImportResultItem[];
|
|
52
|
+
message?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const INITIAL_FORM_DATA = {
|
|
56
|
+
name: '',
|
|
57
|
+
active: true,
|
|
58
|
+
sheetUrl: '',
|
|
59
|
+
sheetName: '',
|
|
60
|
+
headerRow: '1',
|
|
61
|
+
defaultStatusId: null as string | null,
|
|
62
|
+
defaultAssignedUserId: null as string | null,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function GoogleSheetIntegration({
|
|
66
|
+
statuses,
|
|
67
|
+
users,
|
|
68
|
+
}: Readonly<GoogleSheetIntegrationProps>) {
|
|
69
|
+
const toast = useAppToast();
|
|
70
|
+
const { confirm, ConfirmDialog } = useConfirm();
|
|
71
|
+
const { data: googleStatus } = useFetch<{
|
|
72
|
+
calendar?: { connected?: boolean; email?: string | null };
|
|
73
|
+
}>('/api/auth/google/status');
|
|
74
|
+
/** Aligné sur GET /api/auth/google/status : { calendar: { connected, email } } */
|
|
75
|
+
const googleStatusLoaded = googleStatus !== undefined;
|
|
76
|
+
const googleConnected = Boolean(googleStatus?.calendar?.connected);
|
|
77
|
+
|
|
78
|
+
const [loading, setLoading] = useState(true);
|
|
79
|
+
const [saving, setSaving] = useState(false);
|
|
80
|
+
const [syncing, setSyncing] = useState(false);
|
|
81
|
+
const [configs, setConfigs] = useState<GoogleSheetConfig[]>([]);
|
|
82
|
+
|
|
83
|
+
const [showModal, setShowModal] = useState(false);
|
|
84
|
+
const [editingConfig, setEditingConfig] = useState<string | null>(null);
|
|
85
|
+
const [step, setStep] = useState<1 | 2>(1);
|
|
86
|
+
const [formData, setFormData] = useState(INITIAL_FORM_DATA);
|
|
87
|
+
|
|
88
|
+
const [mappings, setMappings] = useState<ColumnMapping[]>([]);
|
|
89
|
+
const [preview, setPreview] = useState<Array<Record<string, string>>>([]);
|
|
90
|
+
const [headers, setHeaders] = useState<string[]>([]);
|
|
91
|
+
|
|
92
|
+
const [gsPreviewStep, setGsPreviewStep] = useState<'url' | 'sheet' | 'header'>('url');
|
|
93
|
+
const [gsAvailableSheets, setGsAvailableSheets] = useState<string[]>([]);
|
|
94
|
+
const [gsRawRows, setGsRawRows] = useState<string[][]>([]);
|
|
95
|
+
const [gsSelectedHeaderRow, setGsSelectedHeaderRow] = useState(0);
|
|
96
|
+
const [gsLoadingPreview, setGsLoadingPreview] = useState(false);
|
|
97
|
+
const [gsConfigError, setGsConfigError] = useState<string | null>(null);
|
|
98
|
+
const [gsConfigErrorConfigLink, setGsConfigErrorConfigLink] = useState<string | null>(null);
|
|
99
|
+
|
|
100
|
+
const [showImportResult, setShowImportResult] = useState(false);
|
|
101
|
+
const [importResults, setImportResults] = useState<ImportResultItem[]>([]);
|
|
102
|
+
const [importTotals, setImportTotals] = useState({ totalImported: 0, totalUpdated: 0, totalSkipped: 0 });
|
|
103
|
+
const [importConfigName, setImportConfigName] = useState<string | undefined>();
|
|
104
|
+
const [showMonitoringModal, setShowMonitoringModal] = useState(false);
|
|
105
|
+
const [selectedMonitoringConfig, setSelectedMonitoringConfig] = useState<GoogleSheetConfig | null>(null);
|
|
106
|
+
|
|
107
|
+
const pollSyncJob = useCallback(
|
|
108
|
+
async (jobId: string): Promise<GoogleSheetJobResult> => {
|
|
109
|
+
const timeoutMs = 5 * 60 * 1000;
|
|
110
|
+
const intervalMs = 1500;
|
|
111
|
+
const start = Date.now();
|
|
112
|
+
|
|
113
|
+
while (Date.now() - start < timeoutMs) {
|
|
114
|
+
const res = await fetch(`/api/integrations/google-sheet/jobs/${jobId}`);
|
|
115
|
+
const data = await res.json();
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
throw new Error(data.error || 'Impossible de suivre la synchronisation');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const status = data.status as GoogleSheetJobStatus;
|
|
121
|
+
if (status === 'SUCCEEDED') {
|
|
122
|
+
return (data.result ?? {}) as GoogleSheetJobResult;
|
|
123
|
+
}
|
|
124
|
+
if (status === 'FAILED') {
|
|
125
|
+
throw new Error(data.error || 'La synchronisation a échoué');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
throw new Error('La synchronisation est en cours depuis trop longtemps. Réessayez.');
|
|
132
|
+
},
|
|
133
|
+
[],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const loadConfigs = useCallback(async () => {
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch('/api/settings/google-sheet');
|
|
139
|
+
if (res.ok) {
|
|
140
|
+
const data = await res.json();
|
|
141
|
+
setConfigs(Array.isArray(data) ? data : []);
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// silent
|
|
145
|
+
} finally {
|
|
146
|
+
setLoading(false);
|
|
147
|
+
}
|
|
148
|
+
}, []);
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
loadConfigs();
|
|
152
|
+
}, [loadConfigs]);
|
|
153
|
+
|
|
154
|
+
const resetModal = () => {
|
|
155
|
+
setShowModal(false);
|
|
156
|
+
setEditingConfig(null);
|
|
157
|
+
setStep(1);
|
|
158
|
+
setFormData(INITIAL_FORM_DATA);
|
|
159
|
+
setMappings([]);
|
|
160
|
+
setPreview([]);
|
|
161
|
+
setHeaders([]);
|
|
162
|
+
setGsPreviewStep('url');
|
|
163
|
+
setGsAvailableSheets([]);
|
|
164
|
+
setGsRawRows([]);
|
|
165
|
+
setGsSelectedHeaderRow(0);
|
|
166
|
+
setGsConfigError(null);
|
|
167
|
+
setGsConfigErrorConfigLink(null);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const handleAutoMap = async (): Promise<boolean> => {
|
|
171
|
+
try {
|
|
172
|
+
if (!formData.sheetUrl || !formData.sheetName || !formData.headerRow) {
|
|
173
|
+
toast.error('Veuillez renseigner le lien du Google Sheet, le nom de l\u2019onglet et la ligne des en-têtes.');
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
const res = await fetch('/api/settings/google-sheet/auto-map', {
|
|
177
|
+
method: 'POST',
|
|
178
|
+
headers: { 'Content-Type': 'application/json' },
|
|
179
|
+
body: JSON.stringify({
|
|
180
|
+
sheetUrl: formData.sheetUrl,
|
|
181
|
+
sheetName: formData.sheetName,
|
|
182
|
+
headerRow: formData.headerRow || '1',
|
|
183
|
+
}),
|
|
184
|
+
});
|
|
185
|
+
const data = await res.json();
|
|
186
|
+
if (!res.ok) {
|
|
187
|
+
setGsConfigError(data.error || 'Erreur lors du mapping automatique');
|
|
188
|
+
setGsConfigErrorConfigLink(data.configLink || null);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
setGsConfigError(null);
|
|
193
|
+
setGsConfigErrorConfigLink(null);
|
|
194
|
+
const h = data.headers || [];
|
|
195
|
+
const autoMapping = data.mapping || {};
|
|
196
|
+
const p = data.preview || [];
|
|
197
|
+
|
|
198
|
+
setHeaders(h);
|
|
199
|
+
setPreview(p);
|
|
200
|
+
|
|
201
|
+
const crmFieldMap: Record<string, string> = {
|
|
202
|
+
phoneColumn: 'phone',
|
|
203
|
+
firstNameColumn: 'firstName',
|
|
204
|
+
lastNameColumn: 'lastName',
|
|
205
|
+
emailColumn: 'email',
|
|
206
|
+
cityColumn: 'city',
|
|
207
|
+
postalCodeColumn: 'postalCode',
|
|
208
|
+
companyNameColumn: 'companyName',
|
|
209
|
+
originColumn: 'origin',
|
|
210
|
+
websiteColumn: 'website',
|
|
211
|
+
linkedinColumn: 'linkedin',
|
|
212
|
+
facebookColumn: 'facebook',
|
|
213
|
+
twitterColumn: 'twitter',
|
|
214
|
+
instagramColumn: 'instagram',
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const initial: ColumnMapping[] = h.map((header: string, index: number) => {
|
|
218
|
+
if (!header) return null;
|
|
219
|
+
const col = indexToColumn(index);
|
|
220
|
+
const mapped = Object.entries(autoMapping).find(([, v]) => v === col)?.[0];
|
|
221
|
+
return {
|
|
222
|
+
id: `mapping-${Date.now()}-${Math.random()}`,
|
|
223
|
+
columnName: header,
|
|
224
|
+
action: mapped ? 'map' : 'ignore',
|
|
225
|
+
crmField: mapped ? crmFieldMap[mapped] : undefined,
|
|
226
|
+
} as ColumnMapping;
|
|
227
|
+
}).filter(Boolean) as ColumnMapping[];
|
|
228
|
+
|
|
229
|
+
setMappings(initial);
|
|
230
|
+
toast.success('Colonnes détectées. Configurez le mapping.');
|
|
231
|
+
return true;
|
|
232
|
+
} catch (err: unknown) {
|
|
233
|
+
toast.error(devToast('Erreur lors du mapping automatique', err));
|
|
234
|
+
setGsConfigError(null);
|
|
235
|
+
setGsConfigErrorConfigLink(null);
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
setSaving(true);
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
const phoneMapping = mappings.find(
|
|
246
|
+
(m) => m.action === 'map' && m.crmField === 'phone' && m.columnName.trim() !== '',
|
|
247
|
+
);
|
|
248
|
+
if (!phoneMapping) {
|
|
249
|
+
toast.error('Le mapping du téléphone est obligatoire');
|
|
250
|
+
setSaving(false);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const url = editingConfig
|
|
255
|
+
? `/api/settings/google-sheet/${editingConfig}`
|
|
256
|
+
: '/api/settings/google-sheet';
|
|
257
|
+
const method = editingConfig ? 'PUT' : 'POST';
|
|
258
|
+
|
|
259
|
+
const res = await fetch(url, {
|
|
260
|
+
method,
|
|
261
|
+
headers: { 'Content-Type': 'application/json' },
|
|
262
|
+
body: JSON.stringify({ ...formData, columnMappings: mappings }),
|
|
263
|
+
});
|
|
264
|
+
const data = await res.json();
|
|
265
|
+
if (!res.ok) throw new Error(data.error || 'Erreur lors de la sauvegarde');
|
|
266
|
+
|
|
267
|
+
const createdId = data.config?.id ?? data.id;
|
|
268
|
+
const createdName = data.config?.name ?? data.name;
|
|
269
|
+
const wasCreating = !editingConfig;
|
|
270
|
+
|
|
271
|
+
toast.success(
|
|
272
|
+
editingConfig
|
|
273
|
+
? 'Configuration Google Sheets mise à jour !'
|
|
274
|
+
: 'Configuration Google Sheets créée !',
|
|
275
|
+
);
|
|
276
|
+
resetModal();
|
|
277
|
+
await loadConfigs();
|
|
278
|
+
|
|
279
|
+
if (wasCreating && createdId) {
|
|
280
|
+
setSyncing(true);
|
|
281
|
+
try {
|
|
282
|
+
toast.success('Synchronisation lancée en arrière-plan...');
|
|
283
|
+
const enqueueRes = await fetch('/api/integrations/google-sheet/sync', {
|
|
284
|
+
method: 'POST',
|
|
285
|
+
headers: { 'Content-Type': 'application/json' },
|
|
286
|
+
body: JSON.stringify({ configId: createdId }),
|
|
287
|
+
});
|
|
288
|
+
const enqueueData = await enqueueRes.json();
|
|
289
|
+
if (!enqueueRes.ok) {
|
|
290
|
+
throw new Error(enqueueData.error || 'Erreur lors du lancement de la synchronisation');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const syncData = await pollSyncJob(enqueueData.jobId);
|
|
294
|
+
const results = syncData.results ?? [];
|
|
295
|
+
setImportResults(results);
|
|
296
|
+
setImportTotals({
|
|
297
|
+
totalImported: syncData.totalImported ?? 0,
|
|
298
|
+
totalUpdated: syncData.totalUpdated ?? 0,
|
|
299
|
+
totalSkipped: syncData.totalSkipped ?? 0,
|
|
300
|
+
});
|
|
301
|
+
setImportConfigName(createdName);
|
|
302
|
+
setShowImportResult(true);
|
|
303
|
+
} catch (syncErr: unknown) {
|
|
304
|
+
setImportResults([{
|
|
305
|
+
configId: createdId,
|
|
306
|
+
configName: createdName ?? 'Configuration',
|
|
307
|
+
imported: 0,
|
|
308
|
+
error: syncErr instanceof Error ? syncErr.message : 'Erreur lors de l\'import',
|
|
309
|
+
}]);
|
|
310
|
+
setImportConfigName(createdName);
|
|
311
|
+
setShowImportResult(true);
|
|
312
|
+
} finally {
|
|
313
|
+
setSyncing(false);
|
|
314
|
+
await loadConfigs();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
} catch (err: unknown) {
|
|
318
|
+
toast.error(devToast('Impossible de sauvegarder la configuration Google Sheets. Veuillez réessayer.', err));
|
|
319
|
+
} finally {
|
|
320
|
+
setSaving(false);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const handleSync = async (configId?: string) => {
|
|
325
|
+
setSyncing(true);
|
|
326
|
+
try {
|
|
327
|
+
toast.success('Synchronisation lancée en arrière-plan...');
|
|
328
|
+
const enqueueRes = await fetch('/api/integrations/google-sheet/sync', {
|
|
329
|
+
method: 'POST',
|
|
330
|
+
headers: { 'Content-Type': 'application/json' },
|
|
331
|
+
body: configId ? JSON.stringify({ configId }) : '{}',
|
|
332
|
+
});
|
|
333
|
+
const enqueueData = await enqueueRes.json();
|
|
334
|
+
if (!enqueueRes.ok) throw new Error(enqueueData.error || 'Erreur lors de la synchronisation');
|
|
335
|
+
|
|
336
|
+
const data = await pollSyncJob(enqueueData.jobId);
|
|
337
|
+
const results = data.results ?? [];
|
|
338
|
+
setImportResults(results);
|
|
339
|
+
setImportTotals({
|
|
340
|
+
totalImported: data.totalImported ?? 0,
|
|
341
|
+
totalUpdated: data.totalUpdated ?? 0,
|
|
342
|
+
totalSkipped: data.totalSkipped ?? 0,
|
|
343
|
+
});
|
|
344
|
+
setImportConfigName(results.length === 1 ? results[0]?.configName : undefined);
|
|
345
|
+
setShowImportResult(true);
|
|
346
|
+
} catch (err: unknown) {
|
|
347
|
+
toast.error(devToast('Impossible de lancer la synchronisation. Veuillez réessayer.', err));
|
|
348
|
+
} finally {
|
|
349
|
+
setSyncing(false);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const openMonitoringModal = (config: GoogleSheetConfig) => {
|
|
354
|
+
setSelectedMonitoringConfig(config);
|
|
355
|
+
setShowMonitoringModal(true);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const handleDelete = async (id: string) => {
|
|
359
|
+
const ok = await confirm({
|
|
360
|
+
title: 'Supprimer la configuration Google Sheet',
|
|
361
|
+
description: 'Êtes-vous sûr de vouloir supprimer cette configuration ?',
|
|
362
|
+
confirmText: 'Supprimer',
|
|
363
|
+
cancelText: 'Annuler',
|
|
364
|
+
variant: 'destructive',
|
|
365
|
+
});
|
|
366
|
+
if (!ok) return;
|
|
367
|
+
try {
|
|
368
|
+
const res = await fetch(`/api/settings/google-sheet/${id}`, { method: 'DELETE' });
|
|
369
|
+
if (!res.ok) {
|
|
370
|
+
const data = await res.json();
|
|
371
|
+
throw new Error(data.error || 'Erreur lors de la suppression');
|
|
372
|
+
}
|
|
373
|
+
toast.success('Configuration supprimée !');
|
|
374
|
+
await loadConfigs();
|
|
375
|
+
} catch (err: unknown) {
|
|
376
|
+
toast.error(devToast('Impossible de supprimer la configuration. Veuillez réessayer.', err));
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
return (
|
|
381
|
+
<>
|
|
382
|
+
<div className="rounded-lg bg-white p-6 shadow-sm">
|
|
383
|
+
<div className="flex items-center justify-between">
|
|
384
|
+
<div>
|
|
385
|
+
<h2 className="text-lg font-bold text-gray-900">Intégration Google Sheets</h2>
|
|
386
|
+
<p className="mt-1 text-sm text-gray-600">
|
|
387
|
+
Importez automatiquement des contacts à partir d'un Google Sheet.
|
|
388
|
+
</p>
|
|
389
|
+
</div>
|
|
390
|
+
<div className="flex items-center gap-2">
|
|
391
|
+
<button
|
|
392
|
+
type="button"
|
|
393
|
+
onClick={() => {
|
|
394
|
+
if (googleStatusLoaded && !googleConnected) {
|
|
395
|
+
toast.errorConfigRequired(
|
|
396
|
+
'Connectez votre compte Google dans les paramètres avant de synchroniser.',
|
|
397
|
+
CONFIG_LINKS.googleSheet,
|
|
398
|
+
);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
handleSync();
|
|
402
|
+
}}
|
|
403
|
+
disabled={syncing}
|
|
404
|
+
className="cursor-pointer rounded-lg border border-blue-600 bg-white px-4 py-2 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
405
|
+
>
|
|
406
|
+
{syncing ? 'Synchronisation...' : 'Synchroniser'}
|
|
407
|
+
</button>
|
|
408
|
+
<button
|
|
409
|
+
type="button"
|
|
410
|
+
onClick={() => {
|
|
411
|
+
if (googleStatusLoaded && !googleConnected) {
|
|
412
|
+
toast.errorConfigRequired(
|
|
413
|
+
'Connectez votre compte Google dans les paramètres avant d\'ajouter une configuration.',
|
|
414
|
+
CONFIG_LINKS.googleSheet,
|
|
415
|
+
);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
setEditingConfig(null);
|
|
419
|
+
setFormData(INITIAL_FORM_DATA);
|
|
420
|
+
setMappings([]);
|
|
421
|
+
setStep(1);
|
|
422
|
+
setShowModal(true);
|
|
423
|
+
}}
|
|
424
|
+
className="cursor-pointer rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
|
425
|
+
>
|
|
426
|
+
+ Ajouter
|
|
427
|
+
</button>
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
|
|
431
|
+
{loading ? (
|
|
432
|
+
<div className="mt-6 text-center text-gray-500">Chargement...</div>
|
|
433
|
+
) : configs.length === 0 ? (
|
|
434
|
+
<div className="mt-6 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
|
435
|
+
<p className="text-sm text-gray-600">Aucune configuration Google Sheets</p>
|
|
436
|
+
<p className="mt-1 text-xs text-gray-500">
|
|
437
|
+
Cliquez sur "+ Ajouter" pour créer votre première configuration
|
|
438
|
+
</p>
|
|
439
|
+
</div>
|
|
440
|
+
) : (
|
|
441
|
+
<div className="mt-6 space-y-3">
|
|
442
|
+
{configs.map((config) => (
|
|
443
|
+
<div
|
|
444
|
+
key={config.id}
|
|
445
|
+
className="flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 p-4"
|
|
446
|
+
>
|
|
447
|
+
<div className="flex-1">
|
|
448
|
+
<div className="flex items-center gap-2">
|
|
449
|
+
<h3 className="font-medium text-gray-900">{config.name}</h3>
|
|
450
|
+
{config.active ? (
|
|
451
|
+
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">
|
|
452
|
+
Actif
|
|
453
|
+
</span>
|
|
454
|
+
) : (
|
|
455
|
+
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800">
|
|
456
|
+
Inactif
|
|
457
|
+
</span>
|
|
458
|
+
)}
|
|
459
|
+
</div>
|
|
460
|
+
<p className="mt-1 text-xs text-gray-500">
|
|
461
|
+
{config.sheetName} - Ligne {config.headerRow}
|
|
462
|
+
</p>
|
|
463
|
+
<p className="mt-0.5 text-xs text-gray-400">
|
|
464
|
+
Lien : https://docs.google.com/spreadsheets/d/{config.spreadsheetId}
|
|
465
|
+
</p>
|
|
466
|
+
</div>
|
|
467
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
468
|
+
<button
|
|
469
|
+
type="button"
|
|
470
|
+
onClick={() => openMonitoringModal(config)}
|
|
471
|
+
className="cursor-pointer rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-100"
|
|
472
|
+
>
|
|
473
|
+
Gérer
|
|
474
|
+
</button>
|
|
475
|
+
<button
|
|
476
|
+
type="button"
|
|
477
|
+
onClick={() => handleDelete(config.id)}
|
|
478
|
+
className="cursor-pointer rounded-lg border border-rose-200 px-3 py-1.5 text-xs font-medium text-rose-700 transition-colors hover:bg-rose-50"
|
|
479
|
+
>
|
|
480
|
+
Supprimer
|
|
481
|
+
</button>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
))}
|
|
485
|
+
</div>
|
|
486
|
+
)}
|
|
487
|
+
</div>
|
|
488
|
+
|
|
489
|
+
<GoogleSheetConfigMonitoringModal
|
|
490
|
+
open={showMonitoringModal}
|
|
491
|
+
onClose={() => {
|
|
492
|
+
setShowMonitoringModal(false);
|
|
493
|
+
setSelectedMonitoringConfig(null);
|
|
494
|
+
}}
|
|
495
|
+
config={selectedMonitoringConfig}
|
|
496
|
+
statuses={statuses}
|
|
497
|
+
users={users}
|
|
498
|
+
onSaved={loadConfigs}
|
|
499
|
+
/>
|
|
500
|
+
|
|
501
|
+
{/* Google Sheet Modal */}
|
|
502
|
+
{showModal && (
|
|
503
|
+
<div className="ui-fade-in fixed inset-0 z-50 flex min-h-dvh items-center justify-center bg-gray-500/20 p-4 backdrop-blur-sm sm:p-6">
|
|
504
|
+
<div className="ui-scale-in flex max-h-[90vh] w-full max-w-5xl flex-col rounded-lg bg-white p-6 shadow-xl sm:p-8">
|
|
505
|
+
<div className="shrink-0 border-b border-slate-200 pb-4">
|
|
506
|
+
<div className="flex items-center justify-between">
|
|
507
|
+
<h2 className="text-xl font-bold text-slate-900 sm:text-2xl">
|
|
508
|
+
{editingConfig ? 'Modifier' : 'Ajouter'} une configuration Google Sheets
|
|
509
|
+
</h2>
|
|
510
|
+
<button type="button" onClick={resetModal} className="cursor-pointer rounded-xl p-2 text-slate-400 transition-colors hover:bg-slate-100">
|
|
511
|
+
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
512
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
513
|
+
</svg>
|
|
514
|
+
</button>
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
|
|
518
|
+
<form id="google-sheet-form" onSubmit={handleSubmit} className="flex-1 space-y-4 overflow-y-auto pt-4 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
519
|
+
{gsConfigError && (
|
|
520
|
+
<ConfigErrorAlert
|
|
521
|
+
message={gsConfigError}
|
|
522
|
+
configLink={gsConfigErrorConfigLink || undefined}
|
|
523
|
+
linkLabel="Configurer dans les paramètres"
|
|
524
|
+
/>
|
|
525
|
+
)}
|
|
526
|
+
{step === 1 && (
|
|
527
|
+
<>
|
|
528
|
+
<div>
|
|
529
|
+
<label htmlFor="gs-config-name" className="block text-sm font-medium text-gray-700">Nom de la configuration *</label>
|
|
530
|
+
<input id="gs-config-name" type="text" required value={formData.name} onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))} className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" placeholder="Ex: Contacts Ventes" />
|
|
531
|
+
</div>
|
|
532
|
+
|
|
533
|
+
{gsPreviewStep !== 'url' && (
|
|
534
|
+
<div className="flex items-center gap-2 text-xs font-medium text-gray-500">
|
|
535
|
+
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-gray-500">1. Lien</span>
|
|
536
|
+
<span className="text-gray-300">/</span>
|
|
537
|
+
{gsAvailableSheets.length > 1 && (
|
|
538
|
+
<>
|
|
539
|
+
<span className={cn('rounded-full px-2.5 py-0.5', gsPreviewStep === 'sheet' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500')}>2. Feuille</span>
|
|
540
|
+
<span className="text-gray-300">/</span>
|
|
541
|
+
</>
|
|
542
|
+
)}
|
|
543
|
+
<span className={cn('rounded-full px-2.5 py-0.5', gsPreviewStep === 'header' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500')}>
|
|
544
|
+
{gsAvailableSheets.length > 1 ? '3' : '2'}. En-tête
|
|
545
|
+
</span>
|
|
546
|
+
</div>
|
|
547
|
+
)}
|
|
548
|
+
|
|
549
|
+
{gsPreviewStep === 'url' && (
|
|
550
|
+
<div>
|
|
551
|
+
<label htmlFor="gs-sheet-url" className="block text-sm font-medium text-gray-700">Lien du Google Sheet *</label>
|
|
552
|
+
<input id="gs-sheet-url" type="url" required value={formData.sheetUrl} onChange={(e) => { setFormData((p) => ({ ...p, sheetUrl: e.target.value, sheetName: '', headerRow: '1' })); setGsAvailableSheets([]); setGsRawRows([]); setGsSelectedHeaderRow(0); setPreview([]); setHeaders([]); }} className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" placeholder="https://docs.google.com/spreadsheets/d/..." />
|
|
553
|
+
</div>
|
|
554
|
+
)}
|
|
555
|
+
|
|
556
|
+
{gsPreviewStep === 'sheet' && (
|
|
557
|
+
<div className="space-y-6">
|
|
558
|
+
<div className="rounded-lg border border-gray-200 p-4">
|
|
559
|
+
<div className="flex items-center justify-between">
|
|
560
|
+
<p className="text-sm font-medium text-gray-900">{formData.sheetUrl.length > 60 ? formData.sheetUrl.slice(0, 60) + '...' : formData.sheetUrl}</p>
|
|
561
|
+
<button type="button" onClick={() => { setGsPreviewStep('url'); setGsAvailableSheets([]); setGsRawRows([]); }} className="cursor-pointer rounded-lg p-1 text-gray-400 hover:text-gray-600">
|
|
562
|
+
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
|
563
|
+
</button>
|
|
564
|
+
</div>
|
|
565
|
+
</div>
|
|
566
|
+
<div>
|
|
567
|
+
<h3 className="mb-2 text-base font-semibold text-gray-900">Choisir une feuille</h3>
|
|
568
|
+
<p className="mb-4 text-sm text-gray-600">Ce fichier contient {gsAvailableSheets.length} feuilles. Sélectionnez celle qui contient les contacts.</p>
|
|
569
|
+
<div className="space-y-2">
|
|
570
|
+
{gsAvailableSheets.map((name, idx) => (
|
|
571
|
+
<button key={name} type="button" disabled={gsLoadingPreview} onClick={async () => {
|
|
572
|
+
setFormData((p) => ({ ...p, sheetName: name }));
|
|
573
|
+
setGsLoadingPreview(true);
|
|
574
|
+
try {
|
|
575
|
+
const res = await fetch('/api/settings/google-sheet/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sheetUrl: formData.sheetUrl, sheetName: name }) });
|
|
576
|
+
const data = await res.json();
|
|
577
|
+
if (!res.ok) {
|
|
578
|
+
setGsConfigError(data.error || 'Erreur');
|
|
579
|
+
setGsConfigErrorConfigLink(data.configLink || null);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
setGsConfigError(null);
|
|
583
|
+
setGsConfigErrorConfigLink(null);
|
|
584
|
+
setGsRawRows(data.rawRows || []);
|
|
585
|
+
setGsSelectedHeaderRow(0);
|
|
586
|
+
setGsPreviewStep('header');
|
|
587
|
+
} catch (err: unknown) {
|
|
588
|
+
toast.error(devToast('Impossible de charger l\'aperçu des données. Vérifiez le lien du Google Sheet.', err));
|
|
589
|
+
setGsConfigError(null);
|
|
590
|
+
setGsConfigErrorConfigLink(null);
|
|
591
|
+
} finally { setGsLoadingPreview(false); }
|
|
592
|
+
}} className={cn('flex w-full items-center gap-3 rounded-lg border-2 px-4 py-3 text-left text-sm font-medium transition-colors disabled:opacity-50', formData.sheetName === name ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-gray-200 text-gray-700 hover:border-gray-300 hover:bg-gray-50')}>
|
|
593
|
+
<span className={cn('flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold', formData.sheetName === name ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500')}>{idx + 1}</span>
|
|
594
|
+
{name}
|
|
595
|
+
</button>
|
|
596
|
+
))}
|
|
597
|
+
</div>
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
)}
|
|
601
|
+
|
|
602
|
+
{gsPreviewStep === 'header' && (
|
|
603
|
+
<div className="space-y-6">
|
|
604
|
+
<div className="rounded-lg border border-gray-200 p-4">
|
|
605
|
+
<div className="flex items-center justify-between">
|
|
606
|
+
<p className="text-sm font-medium text-gray-900">
|
|
607
|
+
{formData.sheetUrl.length > 50 ? formData.sheetUrl.slice(0, 50) + '...' : formData.sheetUrl}
|
|
608
|
+
{formData.sheetName && <span className="ml-2 text-xs text-gray-500">— {formData.sheetName}</span>}
|
|
609
|
+
</p>
|
|
610
|
+
<button type="button" onClick={() => setGsPreviewStep(gsAvailableSheets.length > 1 ? 'sheet' : 'url')} className="cursor-pointer rounded-lg p-1 text-gray-400 hover:text-gray-600">
|
|
611
|
+
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
|
612
|
+
</button>
|
|
613
|
+
</div>
|
|
614
|
+
</div>
|
|
615
|
+
<div>
|
|
616
|
+
<h3 className="mb-2 text-base font-semibold text-gray-900">Sélectionner la ligne d'en-tête</h3>
|
|
617
|
+
<p className="mb-4 text-sm text-gray-600">Cliquez sur la ligne qui contient les noms de colonnes.</p>
|
|
618
|
+
{gsRawRows.length > 0 ? (
|
|
619
|
+
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
|
620
|
+
<table className="min-w-full divide-y divide-gray-200">
|
|
621
|
+
<thead className="bg-gray-50">
|
|
622
|
+
<tr>
|
|
623
|
+
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">#</th>
|
|
624
|
+
{gsRawRows[0]?.map((_, colIdx) => (
|
|
625
|
+
<th key={`col-${colIdx}`} className="px-3 py-2 text-left text-xs font-medium text-gray-500">Col {colIdx + 1}</th>
|
|
626
|
+
))}
|
|
627
|
+
</tr>
|
|
628
|
+
</thead>
|
|
629
|
+
<tbody className="divide-y divide-gray-100 bg-white">
|
|
630
|
+
{gsRawRows.map((row, rowIdx) => (
|
|
631
|
+
<tr key={`row-${rowIdx}`} onClick={() => { setGsSelectedHeaderRow(rowIdx); setFormData((p) => ({ ...p, headerRow: String(rowIdx + 1) })); }} className={cn('cursor-pointer transition-colors', gsSelectedHeaderRow === rowIdx ? 'bg-blue-50 ring-2 ring-blue-500 ring-inset' : rowIdx < gsSelectedHeaderRow ? 'bg-gray-50 text-gray-400' : 'hover:bg-gray-50')}>
|
|
632
|
+
<td className="px-3 py-2 text-xs font-medium text-gray-400">{rowIdx + 1}</td>
|
|
633
|
+
{row.map((cell, colIdx) => (
|
|
634
|
+
<td key={`cell-${rowIdx}-${colIdx}`} className={cn('max-w-[200px] truncate px-3 py-2 text-xs', gsSelectedHeaderRow === rowIdx ? 'font-semibold text-blue-700' : 'text-gray-900')}>
|
|
635
|
+
{cell || <span className="text-gray-300">—</span>}
|
|
636
|
+
</td>
|
|
637
|
+
))}
|
|
638
|
+
</tr>
|
|
639
|
+
))}
|
|
640
|
+
</tbody>
|
|
641
|
+
</table>
|
|
642
|
+
</div>
|
|
643
|
+
) : (
|
|
644
|
+
<p className="text-sm text-gray-500">Aucune donnée disponible.</p>
|
|
645
|
+
)}
|
|
646
|
+
</div>
|
|
647
|
+
</div>
|
|
648
|
+
)}
|
|
649
|
+
</>
|
|
650
|
+
)}
|
|
651
|
+
|
|
652
|
+
{step === 2 && (
|
|
653
|
+
<>
|
|
654
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
655
|
+
<div>
|
|
656
|
+
<label htmlFor="gs-assigned-user" className="block text-sm font-medium text-gray-700">Utilisateur assigné par défaut</label>
|
|
657
|
+
<select id="gs-assigned-user" value={formData.defaultAssignedUserId || ''} onChange={(e) => setFormData((p) => ({ ...p, defaultAssignedUserId: e.target.value || null }))} className="mt-1 block w-full rounded-lg border border-gray-300 px-4 py-2 text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50">
|
|
658
|
+
<option value="">Aucun utilisateur par défaut</option>
|
|
659
|
+
{users.map((user) => (<option key={user.id} value={user.id}>{user.name} ({user.email})</option>))}
|
|
660
|
+
</select>
|
|
661
|
+
</div>
|
|
662
|
+
<div>
|
|
663
|
+
<label htmlFor="gs-default-status" className="block text-sm font-medium text-gray-700">Statut par défaut</label>
|
|
664
|
+
<StatusSelect
|
|
665
|
+
id="gs-default-status"
|
|
666
|
+
statuses={statuses}
|
|
667
|
+
value={formData.defaultStatusId || ''}
|
|
668
|
+
onChange={(v) => setFormData((p) => ({ ...p, defaultStatusId: v || null }))}
|
|
669
|
+
placeholder="Aucun statut par défaut"
|
|
670
|
+
className="mt-1"
|
|
671
|
+
/>
|
|
672
|
+
</div>
|
|
673
|
+
</div>
|
|
674
|
+
|
|
675
|
+
<div className="flex items-center justify-between">
|
|
676
|
+
<h3 className="text-base font-semibold text-gray-900">Correspondance des champs</h3>
|
|
677
|
+
<button type="button" onClick={() => setMappings((p) => [...p, { id: `mapping-${Date.now()}-${Math.random()}`, columnName: '', action: 'ignore' }])} className="flex cursor-pointer items-center gap-1 rounded-lg border border-blue-600 bg-white px-3 py-1.5 text-sm font-medium text-blue-600 transition-colors hover:bg-blue-50">
|
|
678
|
+
<Plus className="h-4 w-4" /> Ajouter un champ
|
|
679
|
+
</button>
|
|
680
|
+
</div>
|
|
681
|
+
|
|
682
|
+
<div className="mt-4 space-y-3">
|
|
683
|
+
{mappings.map((mapping) => (
|
|
684
|
+
<div key={mapping.id} className="flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
|
685
|
+
<div className="flex-1">
|
|
686
|
+
<input type="text" value={mapping.columnName} onChange={(e) => setMappings((p) => p.map((m) => m.id === mapping.id ? { ...m, columnName: e.target.value } : m))} placeholder="Nom de la colonne Google Sheets" className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:ring-2 focus:ring-gray-400/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" />
|
|
687
|
+
</div>
|
|
688
|
+
<ArrowRight className="h-5 w-5 shrink-0 text-gray-400" />
|
|
689
|
+
<div className="flex-1">
|
|
690
|
+
<select value={mapping.action} onChange={(e) => { const a = e.target.value as 'map' | 'note' | 'ignore'; setMappings((p) => p.map((m) => m.id === mapping.id ? { ...m, action: a, crmField: a === 'map' ? (m.crmField || 'firstName') : undefined } : m)); }} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50">
|
|
691
|
+
<option value="map">Mapper vers un champ</option>
|
|
692
|
+
<option value="note">Ajouter comme note</option>
|
|
693
|
+
<option value="ignore">-- Ne pas importer --</option>
|
|
694
|
+
</select>
|
|
695
|
+
</div>
|
|
696
|
+
{mapping.action === 'map' && (
|
|
697
|
+
<>
|
|
698
|
+
<ArrowRight className="h-5 w-5 shrink-0 text-gray-400" />
|
|
699
|
+
<div className="flex-1">
|
|
700
|
+
<select value={mapping.crmField || ''} onChange={(e) => setMappings((p) => p.map((m) => m.id === mapping.id ? { ...m, crmField: e.target.value } : m))} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:ring-2 focus:ring-gray-400/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50">
|
|
701
|
+
<option value="">Sélectionnez un champ</option>
|
|
702
|
+
<option value="phone">Téléphone *</option>
|
|
703
|
+
<option value="firstName">Prénom</option>
|
|
704
|
+
<option value="lastName">Nom</option>
|
|
705
|
+
<option value="email">Email</option>
|
|
706
|
+
<option value="civility">Civilité</option>
|
|
707
|
+
<option value="secondaryPhone">Téléphone secondaire</option>
|
|
708
|
+
<option value="address">Adresse</option>
|
|
709
|
+
<option value="city">Ville</option>
|
|
710
|
+
<option value="postalCode">Code postal</option>
|
|
711
|
+
<option value="companyName">Société</option>
|
|
712
|
+
<option value="origin">Origine</option>
|
|
713
|
+
<option value="website">Site internet</option>
|
|
714
|
+
<option value="jobTitle">Intitulé du poste</option>
|
|
715
|
+
<option value="createdAt">Date de création</option>
|
|
716
|
+
<option value="linkedin">LinkedIn</option>
|
|
717
|
+
<option value="facebook">Facebook</option>
|
|
718
|
+
<option value="twitter">Twitter</option>
|
|
719
|
+
<option value="instagram">Instagram</option>
|
|
720
|
+
</select>
|
|
721
|
+
</div>
|
|
722
|
+
</>
|
|
723
|
+
)}
|
|
724
|
+
<button type="button" onClick={() => setMappings((p) => p.filter((m) => m.id !== mapping.id))} className="cursor-pointer rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-200 hover:text-blue-600">
|
|
725
|
+
<Trash2 className="h-4 w-4" />
|
|
726
|
+
</button>
|
|
727
|
+
</div>
|
|
728
|
+
))}
|
|
729
|
+
</div>
|
|
730
|
+
|
|
731
|
+
<div className="mt-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
|
732
|
+
<ul className="space-y-2 text-xs text-gray-600">
|
|
733
|
+
<li>• Le champ "Téléphone" est obligatoire pour l'import.</li>
|
|
734
|
+
<li>• Les colonnes "-- Ne pas importer --" seront ignorées</li>
|
|
735
|
+
<li>• Les colonnes "Ajouter comme note" seront regroupées dans une seule note</li>
|
|
736
|
+
</ul>
|
|
737
|
+
</div>
|
|
738
|
+
|
|
739
|
+
{preview.length > 0 && headers.length > 0 && (
|
|
740
|
+
<div>
|
|
741
|
+
<h3 className="mb-3 text-sm font-semibold text-gray-900">Aperçu (5 premières lignes)</h3>
|
|
742
|
+
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
|
743
|
+
<table className="min-w-full divide-y divide-gray-200">
|
|
744
|
+
<thead className="bg-gray-50">
|
|
745
|
+
<tr>{headers.map((h) => (<th key={h} className="px-4 py-2 text-left text-xs font-medium text-gray-700">{h}</th>))}</tr>
|
|
746
|
+
</thead>
|
|
747
|
+
<tbody className="divide-y divide-gray-200 bg-white">
|
|
748
|
+
{preview.map((row, idx) => (<tr key={`preview-${idx}`}>{headers.map((h) => (<td key={h} className="px-4 py-2 text-xs whitespace-nowrap text-gray-900">{row[h] || '-'}</td>))}</tr>))}
|
|
749
|
+
</tbody>
|
|
750
|
+
</table>
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
)}
|
|
754
|
+
</>
|
|
755
|
+
)}
|
|
756
|
+
</form>
|
|
757
|
+
|
|
758
|
+
<div className="shrink-0 border-t border-slate-200 pt-4">
|
|
759
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
|
760
|
+
<button type="button" onClick={resetModal} className="w-full cursor-pointer rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50 sm:w-auto">Annuler</button>
|
|
761
|
+
{step === 1 && gsPreviewStep === 'url' ? (
|
|
762
|
+
<button type="button" disabled={gsLoadingPreview || !formData.sheetUrl} onClick={async () => {
|
|
763
|
+
setGsLoadingPreview(true);
|
|
764
|
+
setGsConfigError(null);
|
|
765
|
+
setGsConfigErrorConfigLink(null);
|
|
766
|
+
try {
|
|
767
|
+
const res = await fetch('/api/settings/google-sheet/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sheetUrl: formData.sheetUrl }) });
|
|
768
|
+
const data = await res.json();
|
|
769
|
+
if (!res.ok) {
|
|
770
|
+
setGsConfigError(data.error || 'Erreur');
|
|
771
|
+
setGsConfigErrorConfigLink(data.configLink || null);
|
|
772
|
+
if (!data.configLink) toast.error('Impossible de charger l\'aperçu des données. Vérifiez le lien du Google Sheet.');
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
setGsAvailableSheets(data.sheetNames || []);
|
|
776
|
+
setGsRawRows(data.rawRows || []);
|
|
777
|
+
if (data.sheetNames?.length > 1) { setGsPreviewStep('sheet'); } else { setFormData((p) => ({ ...p, sheetName: data.sheetNames?.[0] || '' })); setGsPreviewStep('header'); }
|
|
778
|
+
} catch (err: unknown) { toast.error(devToast('Impossible de charger l\'aperçu. Vérifiez votre connexion.', err)); setGsConfigError(null); setGsConfigErrorConfigLink(null); } finally { setGsLoadingPreview(false); }
|
|
779
|
+
}} className="w-full cursor-pointer rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto">
|
|
780
|
+
{gsLoadingPreview ? 'Chargement...' : 'Charger les feuilles'}
|
|
781
|
+
</button>
|
|
782
|
+
) : step === 1 ? (
|
|
783
|
+
<button type="button" disabled={saving || gsPreviewStep !== 'header' || gsRawRows.length === 0} onClick={async () => { const ok = await handleAutoMap(); if (ok) setStep(2); }} className="w-full cursor-pointer rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto">
|
|
784
|
+
Étape suivante
|
|
785
|
+
</button>
|
|
786
|
+
) : (
|
|
787
|
+
<button type="submit" form="google-sheet-form" disabled={saving} className="w-full cursor-pointer rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto">
|
|
788
|
+
{saving ? 'Enregistrement...' : 'Enregistrer'}
|
|
789
|
+
</button>
|
|
790
|
+
)}
|
|
791
|
+
</div>
|
|
792
|
+
</div>
|
|
793
|
+
</div>
|
|
794
|
+
</div>
|
|
795
|
+
)}
|
|
796
|
+
|
|
797
|
+
<ImportResultDialog
|
|
798
|
+
open={showImportResult}
|
|
799
|
+
onClose={() => setShowImportResult(false)}
|
|
800
|
+
configName={importConfigName}
|
|
801
|
+
results={importResults}
|
|
802
|
+
totalImported={importTotals.totalImported}
|
|
803
|
+
totalUpdated={importTotals.totalUpdated}
|
|
804
|
+
totalSkipped={importTotals.totalSkipped}
|
|
805
|
+
/>
|
|
806
|
+
<ConfirmDialog />
|
|
807
|
+
</>
|
|
808
|
+
);
|
|
809
|
+
}
|