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.
Files changed (190) hide show
  1. package/bin/create-crm-tmp.js +56 -35
  2. package/package.json +1 -1
  3. package/template/README.md +230 -115
  4. package/template/eslint.config.mjs +13 -0
  5. package/template/next.config.ts +14 -0
  6. package/template/package.json +15 -2
  7. package/template/prisma/migrations/20260318095700_init_db/migration.sql +978 -0
  8. package/template/prisma/migrations/migration_lock.toml +3 -0
  9. package/template/prisma/schema.prisma +132 -637
  10. package/template/src/app/(auth)/invite/[token]/page.tsx +10 -8
  11. package/template/src/app/(auth)/layout.tsx +1 -1
  12. package/template/src/app/(auth)/reset-password/complete/page.tsx +11 -8
  13. package/template/src/app/(auth)/reset-password/page.tsx +4 -4
  14. package/template/src/app/(auth)/reset-password/verify/page.tsx +4 -4
  15. package/template/src/app/(auth)/signin/page.tsx +14 -6
  16. package/template/src/app/(dashboard)/agenda/page.tsx +2243 -988
  17. package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +18 -104
  18. package/template/src/app/(dashboard)/automatisation/page.tsx +10 -26
  19. package/template/src/app/(dashboard)/closing/page.tsx +78 -62
  20. package/template/src/app/(dashboard)/contacts/[id]/page.tsx +2082 -1080
  21. package/template/src/app/(dashboard)/contacts/companies/[id]/page.tsx +46 -47
  22. package/template/src/app/(dashboard)/contacts/page.tsx +1062 -780
  23. package/template/src/app/(dashboard)/dashboard/page.tsx +533 -37
  24. package/template/src/app/(dashboard)/dev/page.tsx +1291 -0
  25. package/template/src/app/(dashboard)/layout.tsx +6 -2
  26. package/template/src/app/(dashboard)/settings/page.tsx +797 -2582
  27. package/template/src/app/(dashboard)/templates/page.tsx +55 -54
  28. package/template/src/app/(dashboard)/users/list/page.tsx +51 -48
  29. package/template/src/app/(dashboard)/users/page.tsx +1 -1
  30. package/template/src/app/(dashboard)/users/permissions/page.tsx +2 -2
  31. package/template/src/app/(dashboard)/users/roles/page.tsx +7 -5
  32. package/template/src/app/api/agenda/google-events/route.ts +92 -0
  33. package/template/src/app/api/auth/check-active/route.ts +3 -2
  34. package/template/src/app/api/auth/google/route.ts +2 -1
  35. package/template/src/app/api/auth/google/status/route.ts +7 -31
  36. package/template/src/app/api/companies/[id]/activities/route.ts +1 -3
  37. package/template/src/app/api/companies/[id]/route.ts +1 -2
  38. package/template/src/app/api/companies/route.ts +42 -12
  39. package/template/src/app/api/contacts/[id]/files/[fileId]/preview/route.ts +9 -31
  40. package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +14 -32
  41. package/template/src/app/api/contacts/[id]/files/route.ts +112 -212
  42. package/template/src/app/api/contacts/[id]/interactions/[interactionId]/route.ts +27 -1
  43. package/template/src/app/api/contacts/[id]/interactions/route.ts +16 -16
  44. package/template/src/app/api/contacts/[id]/kyc/route.ts +21 -11
  45. package/template/src/app/api/contacts/[id]/meet/route.ts +19 -2
  46. package/template/src/app/api/contacts/[id]/route.ts +106 -34
  47. package/template/src/app/api/contacts/[id]/send-email/route.ts +27 -11
  48. package/template/src/app/api/contacts/[id]/workflows/run/route.ts +6 -0
  49. package/template/src/app/api/contacts/export/route.ts +9 -13
  50. package/template/src/app/api/contacts/import/route.ts +55 -25
  51. package/template/src/app/api/contacts/import-preview/route.ts +1 -1
  52. package/template/src/app/api/contacts/origins/route.ts +63 -0
  53. package/template/src/app/api/contacts/route.ts +153 -41
  54. package/template/src/app/api/cron/cleanup-editor-images/route.ts +166 -0
  55. package/template/src/app/api/dashboard/widgets/[id]/route.ts +44 -0
  56. package/template/src/app/api/dashboard/widgets/route.ts +181 -0
  57. package/template/src/app/api/dev/reminders/test/route.ts +114 -0
  58. package/template/src/app/api/editor/upload-image/route.ts +61 -0
  59. package/template/src/app/api/integrations/google-sheet/jobs/[jobId]/route.ts +47 -0
  60. package/template/src/app/api/integrations/google-sheet/jobs/usage/route.ts +50 -0
  61. package/template/src/app/api/integrations/google-sheet/sync/route.ts +24 -556
  62. package/template/src/app/api/jobs/google-sheet/process/route.ts +84 -0
  63. package/template/src/app/api/jobs/google-sheet/schedule/route.ts +50 -0
  64. package/template/src/app/api/reminders/clear/route.ts +120 -0
  65. package/template/src/app/api/reminders/clear/undo/route.ts +112 -0
  66. package/template/src/app/api/reminders/route.ts +164 -39
  67. package/template/src/app/api/reminders/state/route.ts +164 -0
  68. package/template/src/app/api/reset-password/request/route.ts +1 -1
  69. package/template/src/app/api/reset-password/verify/route.ts +1 -1
  70. package/template/src/app/api/send/route.ts +16 -4
  71. package/template/src/app/api/settings/google-ads/route.ts +14 -0
  72. package/template/src/app/api/settings/google-calendar/calendars/route.ts +97 -0
  73. package/template/src/app/api/settings/google-calendar/route.ts +124 -0
  74. package/template/src/app/api/settings/google-sheet/[id]/route.ts +28 -0
  75. package/template/src/app/api/settings/google-sheet/auto-map/route.ts +37 -4
  76. package/template/src/app/api/settings/google-sheet/preview/route.ts +9 -3
  77. package/template/src/app/api/settings/google-sheet/route.ts +14 -0
  78. package/template/src/app/api/settings/integrations/logs/route.ts +93 -0
  79. package/template/src/app/api/settings/integrations/notifications/route.ts +67 -0
  80. package/template/src/app/api/settings/meta-leads/[id]/route.ts +0 -1
  81. package/template/src/app/api/settings/meta-leads/route.ts +14 -2
  82. package/template/src/app/api/settings/smtp/route.ts +53 -6
  83. package/template/src/app/api/tasks/[id]/attendees/route.ts +24 -8
  84. package/template/src/app/api/tasks/[id]/route.ts +234 -58
  85. package/template/src/app/api/tasks/meet/route.ts +27 -19
  86. package/template/src/app/api/tasks/route.ts +62 -17
  87. package/template/src/app/api/users/[id]/route.ts +20 -14
  88. package/template/src/app/api/users/list/route.ts +57 -19
  89. package/template/src/app/api/webhooks/google-ads/route.ts +34 -14
  90. package/template/src/app/api/webhooks/meta-leads/route.ts +32 -12
  91. package/template/src/app/api/workflows/[id]/route.ts +0 -4
  92. package/template/src/app/api/workflows/process/route.ts +22 -51
  93. package/template/src/app/api/workflows/route.ts +0 -4
  94. package/template/src/app/globals.css +342 -4
  95. package/template/src/app/layout.tsx +11 -3
  96. package/template/src/app/page.tsx +1 -1
  97. package/template/src/components/address-autocomplete.tsx +7 -6
  98. package/template/src/components/config-error-alert.tsx +46 -0
  99. package/template/src/components/contacts/filter-bar.tsx +12 -3
  100. package/template/src/components/contacts/filter-builder.tsx +28 -43
  101. package/template/src/components/contacts/save-view-dialog.tsx +1 -1
  102. package/template/src/components/contacts/views-tab-bar.tsx +15 -6
  103. package/template/src/components/dashboard/activity-chart.tsx +41 -28
  104. package/template/src/components/dashboard/add-widget-dialog.tsx +157 -0
  105. package/template/src/components/dashboard/color-picker.tsx +64 -0
  106. package/template/src/components/dashboard/contacts-chart.tsx +69 -0
  107. package/template/src/components/dashboard/interactions-by-type-chart.tsx +121 -0
  108. package/template/src/components/dashboard/recent-activity.tsx +154 -0
  109. package/template/src/components/dashboard/stat-card.tsx +40 -40
  110. package/template/src/components/dashboard/status-distribution-chart.tsx +81 -0
  111. package/template/src/components/dashboard/tasks-pie-chart.tsx +37 -34
  112. package/template/src/components/dashboard/top-contacts-list.tsx +113 -0
  113. package/template/src/components/dashboard/upcoming-tasks-list.tsx +72 -81
  114. package/template/src/components/dashboard/widget-wrapper.tsx +36 -0
  115. package/template/src/components/date-picker.tsx +9 -6
  116. package/template/src/components/editor/upload-editor-image.ts +42 -0
  117. package/template/src/components/editor.tsx +161 -22
  118. package/template/src/components/email-template.tsx +2 -2
  119. package/template/src/components/global-search.tsx +30 -28
  120. package/template/src/components/header.tsx +178 -80
  121. package/template/src/components/inactive-account-guard.tsx +58 -0
  122. package/template/src/components/integration-notifications-listener.tsx +12 -0
  123. package/template/src/components/invitation-email-template.tsx +2 -2
  124. package/template/src/components/meet-cancellation-email-template.tsx +3 -3
  125. package/template/src/components/meet-confirmation-email-template.tsx +3 -3
  126. package/template/src/components/meet-update-email-template.tsx +3 -3
  127. package/template/src/components/page-header.tsx +5 -5
  128. package/template/src/components/protected-page.tsx +1 -1
  129. package/template/src/components/reset-password-email-template.tsx +2 -2
  130. package/template/src/components/settings/integrations/GoogleAdsIntegration.tsx +428 -0
  131. package/template/src/components/settings/integrations/GoogleSheetConfigMonitoringModal.tsx +680 -0
  132. package/template/src/components/settings/integrations/GoogleSheetIntegration.tsx +809 -0
  133. package/template/src/components/settings/integrations/ImportResultDialog.tsx +124 -0
  134. package/template/src/components/settings/integrations/IntegrationLogPanel.tsx +57 -0
  135. package/template/src/components/settings/integrations/IntegrationLogsTable.tsx +186 -0
  136. package/template/src/components/settings/integrations/MetaLeadIntegration.tsx +451 -0
  137. package/template/src/components/sidebar.tsx +45 -26
  138. package/template/src/components/skeleton.tsx +40 -43
  139. package/template/src/components/ui/accordion.tsx +2 -2
  140. package/template/src/components/ui/alert-dialog.tsx +1 -1
  141. package/template/src/components/ui/button.tsx +20 -9
  142. package/template/src/components/ui/components.tsx +1 -1
  143. package/template/src/components/ui/date-picker.tsx +422 -0
  144. package/template/src/components/ui/datetime-picker.tsx +338 -0
  145. package/template/src/components/ui/status-select.tsx +271 -0
  146. package/template/src/components/ui/tooltip.tsx +37 -0
  147. package/template/src/components/view-as-modal.tsx +13 -7
  148. package/template/src/contexts/app-toast-context.tsx +245 -57
  149. package/template/src/contexts/dashboard-theme-context.tsx +53 -0
  150. package/template/src/contexts/sidebar-context.tsx +22 -17
  151. package/template/src/contexts/task-reminder-context.tsx +134 -160
  152. package/template/src/contexts/view-as-context.tsx +33 -6
  153. package/template/src/hooks/use-focus-trap.ts +2 -2
  154. package/template/src/hooks/useIntegrationNotifications.ts +49 -0
  155. package/template/src/lib/auth.ts +8 -1
  156. package/template/src/lib/config-links.ts +14 -0
  157. package/template/src/lib/contact-duplicate.ts +79 -61
  158. package/template/src/lib/contact-interactions.ts +21 -21
  159. package/template/src/lib/contact-view-filters.ts +24 -64
  160. package/template/src/lib/contacts-list-url.ts +190 -0
  161. package/template/src/lib/dashboard-stats.ts +65 -7
  162. package/template/src/lib/dashboard-themes.ts +135 -0
  163. package/template/src/lib/date-utils.ts +127 -0
  164. package/template/src/lib/default-widgets.ts +12 -0
  165. package/template/src/lib/editor-html-image-dimensions.ts +172 -0
  166. package/template/src/lib/editor-image-limits.ts +19 -0
  167. package/template/src/lib/email-html-sanitize.ts +19 -0
  168. package/template/src/lib/encryption.ts +9 -6
  169. package/template/src/lib/fr-geography.ts +192 -0
  170. package/template/src/lib/google-calendar-agenda.ts +201 -0
  171. package/template/src/lib/google-calendar.ts +255 -5
  172. package/template/src/lib/google-sheet-sync-jobs.ts +96 -0
  173. package/template/src/lib/google-sheet-sync-runner.ts +514 -0
  174. package/template/src/lib/integration-import-log.ts +21 -0
  175. package/template/src/lib/permissions.ts +40 -10
  176. package/template/src/lib/prisma.ts +4 -1
  177. package/template/src/lib/qstash.ts +65 -0
  178. package/template/src/lib/reminder-state-server.ts +80 -0
  179. package/template/src/lib/reminder-state.ts +29 -0
  180. package/template/src/lib/supabase-storage.ts +113 -0
  181. package/template/src/lib/template-variables.ts +164 -23
  182. package/template/src/lib/utils.ts +45 -0
  183. package/template/src/lib/widget-registry.ts +173 -0
  184. package/template/src/lib/workflow-executor.ts +16 -70
  185. package/template/src/proxy.ts +1 -0
  186. package/template/vercel.json +3 -10
  187. package/template/skills-lock.json +0 -25
  188. package/template/src/components/dashboard/dashboard-content.tsx +0 -79
  189. package/template/src/lib/google-drive.ts +0 -1101
  190. package/template/src/types/yousign.ts +0 -52
@@ -0,0 +1,65 @@
1
+ import { Client, Receiver } from '@upstash/qstash';
2
+
3
+ type SyncJobPayload = {
4
+ jobId: string;
5
+ };
6
+
7
+ export class QstashAuthError extends Error {
8
+ constructor(message: string) {
9
+ super(message);
10
+ this.name = 'QstashAuthError';
11
+ }
12
+ }
13
+
14
+ function getQstashClient() {
15
+ const token = process.env.QSTASH_TOKEN;
16
+ if (!token) {
17
+ throw new Error('QSTASH_TOKEN manquant');
18
+ }
19
+ return new Client({ token });
20
+ }
21
+
22
+ function getWorkerUrl() {
23
+ const explicit = process.env.GOOGLE_SHEET_SYNC_WORKER_URL;
24
+ if (explicit) return explicit;
25
+
26
+ const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? process.env.VERCEL_PROJECT_PRODUCTION_URL;
27
+ if (!appUrl) {
28
+ throw new Error('NEXT_PUBLIC_APP_URL manquant pour publier le job QStash');
29
+ }
30
+ const normalized = appUrl.startsWith('http') ? appUrl : `https://${appUrl}`;
31
+ return `${normalized}/api/jobs/google-sheet/process`;
32
+ }
33
+
34
+ export async function publishGoogleSheetSyncJob(payload: SyncJobPayload) {
35
+ const client = getQstashClient();
36
+ const workerUrl = getWorkerUrl();
37
+
38
+ await client.publishJSON({
39
+ url: workerUrl,
40
+ body: payload,
41
+ retries: 2,
42
+ });
43
+ }
44
+
45
+ export async function verifyQstashRequest(signature: string | null, body: string): Promise<void> {
46
+ const currentKey = process.env.QSTASH_CURRENT_SIGNING_KEY;
47
+ const nextKey = process.env.QSTASH_NEXT_SIGNING_KEY;
48
+
49
+ if (!currentKey || !nextKey) {
50
+ throw new Error('QSTASH_CURRENT_SIGNING_KEY / QSTASH_NEXT_SIGNING_KEY manquants');
51
+ }
52
+ if (!signature) {
53
+ throw new QstashAuthError('Signature QStash manquante');
54
+ }
55
+
56
+ const receiver = new Receiver({
57
+ currentSigningKey: currentKey,
58
+ nextSigningKey: nextKey,
59
+ });
60
+
61
+ await receiver.verify({
62
+ signature,
63
+ body,
64
+ });
65
+ }
@@ -0,0 +1,80 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { Prisma } from '@/lib/prisma';
3
+ import {
4
+ REMINDERS_CLEAR_ALL_ID,
5
+ REMINDER_STATE_TTL_CLEARED_MS,
6
+ REMINDER_STATE_TTL_READ_MS,
7
+ type ReminderClearMetadata,
8
+ } from '@/lib/reminder-state';
9
+
10
+ export type ReminderLogEvent =
11
+ | 'reminders_fetch'
12
+ | 'reminder_state_read'
13
+ | 'reminder_state_upsert'
14
+ | 'reminders_clear_all'
15
+ | 'reminders_clear_undo'
16
+ | 'reminders_fallback_missing_table';
17
+
18
+ export function getRequestId(request: Request): string {
19
+ const fromHeader = request.headers.get('x-request-id')?.trim();
20
+ return fromHeader && fromHeader.length > 0 ? fromHeader : randomUUID();
21
+ }
22
+
23
+ export function logReminderEvent(payload: {
24
+ event: ReminderLogEvent;
25
+ requestId: string;
26
+ userId?: string;
27
+ countCandidates?: number;
28
+ countReturned?: number;
29
+ degraded?: boolean;
30
+ durationMs?: number;
31
+ extra?: Record<string, unknown>;
32
+ }) {
33
+ console.log(
34
+ JSON.stringify({
35
+ ...payload,
36
+ ts: new Date().toISOString(),
37
+ }),
38
+ );
39
+ }
40
+
41
+ export function isMissingReminderStateTableError(error: unknown): boolean {
42
+ if (error instanceof Prisma.PrismaClientKnownRequestError) {
43
+ if (error.code === 'P2021') return true;
44
+ }
45
+ const msg = error instanceof Error ? error.message : String(error);
46
+ return /user_reminder_state|relation .* does not exist|table .* does not exist/i.test(msg);
47
+ }
48
+
49
+ /** Filtre Prisma : lignes non expirées (ou sans TTL pour rétrocompat). */
50
+ export function reminderStateNotExpired(now: Date): Prisma.UserReminderStateWhereInput {
51
+ return {
52
+ OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
53
+ };
54
+ }
55
+
56
+ export function computeExpiresAtForUpsert(
57
+ reminderId: string,
58
+ status: 'READ' | 'DISMISSED' | 'CLEARED',
59
+ now: Date,
60
+ ): Date | null {
61
+ if (reminderId === REMINDERS_CLEAR_ALL_ID && status === 'CLEARED') {
62
+ return new Date(now.getTime() + REMINDER_STATE_TTL_CLEARED_MS);
63
+ }
64
+ if (status === 'READ' || status === 'DISMISSED') {
65
+ return new Date(now.getTime() + REMINDER_STATE_TTL_READ_MS);
66
+ }
67
+ return null;
68
+ }
69
+
70
+ export function parseClearMetadata(raw: Prisma.JsonValue | null | undefined): ReminderClearMetadata | null {
71
+ if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) return null;
72
+ const o = raw as Record<string, unknown>;
73
+ if (typeof o.undoUntil !== 'string') return null;
74
+ const prev = o.previousClearedAt;
75
+ if (prev != null && typeof prev !== 'string') return null;
76
+ return {
77
+ undoUntil: o.undoUntil,
78
+ previousClearedAt: prev == null ? null : prev,
79
+ };
80
+ }
@@ -0,0 +1,29 @@
1
+ export const REMINDERS_CLEAR_ALL_ID = '__all__';
2
+
3
+ export const REMINDER_READ_STATUSES = new Set(['READ', 'DISMISSED']);
4
+
5
+ /** TTL états READ / DISMISSED par rappel */
6
+ export const REMINDER_STATE_TTL_READ_MS = 30 * 24 * 60 * 60 * 1000;
7
+
8
+ /** TTL état global CLEARED (__all__) */
9
+ export const REMINDER_STATE_TTL_CLEARED_MS = 7 * 24 * 60 * 60 * 1000;
10
+
11
+ /** Fenêtre d’annulation après « Vider les rappels » */
12
+ export const REMINDERS_CLEAR_UNDO_WINDOW_MS = 15_000;
13
+
14
+ /** Métadonnées JSON sur la ligne __all__ pour l’undo */
15
+ export type ReminderClearMetadata = {
16
+ undoUntil: string;
17
+ previousClearedAt: string | null;
18
+ };
19
+
20
+ /** Polling des rappels (cloche + toasts). Plus court qu’1 min pour éviter d’attendre un refresh manuel. */
21
+ export const REMINDERS_POLL_INTERVAL_MS = 15_000;
22
+
23
+ /** À émettre après création / MAJ de tâche avec rappel pour rafraîchir immédiatement. */
24
+ export const REMINDERS_REFRESH_EVENT = 'reminders:refresh';
25
+
26
+ export function requestRemindersRefresh() {
27
+ globalThis.dispatchEvent(new Event(REMINDERS_REFRESH_EVENT));
28
+ }
29
+
@@ -0,0 +1,113 @@
1
+ import { createClient, type SupabaseClient } from '@supabase/supabase-js';
2
+
3
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
4
+ const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
5
+
6
+ let _adminClient: SupabaseClient | null = null;
7
+
8
+ function getAdminClient(): SupabaseClient {
9
+ if (!_adminClient) {
10
+ if (!supabaseUrl || !supabaseServiceRoleKey) {
11
+ throw new Error(
12
+ 'NEXT_PUBLIC_SUPABASE_URL et SUPABASE_SERVICE_ROLE_KEY doivent être configurés.',
13
+ );
14
+ }
15
+ _adminClient = createClient(supabaseUrl, supabaseServiceRoleKey, {
16
+ auth: { autoRefreshToken: false, persistSession: false },
17
+ });
18
+ }
19
+ return _adminClient;
20
+ }
21
+
22
+ export const BUCKETS = {
23
+ CONTACTS: 'contacts',
24
+ EDITOR_IMAGES: 'editor-images',
25
+ } as const;
26
+
27
+ export type BucketName = (typeof BUCKETS)[keyof typeof BUCKETS];
28
+
29
+ function buildStoragePath(prefix: string, entityId: string, fileName: string): string {
30
+ const uuid = crypto.randomUUID();
31
+ const sanitized = fileName.replaceAll(/[^a-zA-Z0-9._-]/g, '_');
32
+ return `${prefix}/${entityId}/${uuid}-${sanitized}`;
33
+ }
34
+
35
+ export function buildContactFilePath(contactId: string, fileName: string): string {
36
+ return buildStoragePath('files', contactId, fileName);
37
+ }
38
+
39
+ export function buildEditorImagePath(fileName: string): string {
40
+ const uuid = crypto.randomUUID();
41
+ const sanitized = fileName.replaceAll(/[^a-zA-Z0-9._-]/g, '_');
42
+ return `images/${uuid}-${sanitized}`;
43
+ }
44
+
45
+ export async function uploadFile(
46
+ bucket: BucketName,
47
+ path: string,
48
+ data: Buffer | Blob,
49
+ contentType: string,
50
+ ): Promise<{ storagePath: string }> {
51
+ const client = getAdminClient();
52
+ const { error } = await client.storage.from(bucket).upload(path, data, {
53
+ contentType,
54
+ upsert: true,
55
+ });
56
+ if (error) throw new Error(`Erreur upload Supabase: ${error.message}`);
57
+ return { storagePath: path };
58
+ }
59
+
60
+ export async function createSignedUploadUrl(
61
+ bucket: BucketName,
62
+ path: string,
63
+ ): Promise<{ signedUrl: string; token: string; storagePath: string }> {
64
+ const client = getAdminClient();
65
+ const { data, error } = await client.storage.from(bucket).createSignedUploadUrl(path);
66
+ if (error || !data) {
67
+ throw new Error(`Erreur création signed upload URL: ${error?.message ?? 'unknown'}`);
68
+ }
69
+ return { signedUrl: data.signedUrl, token: data.token, storagePath: path };
70
+ }
71
+
72
+ export async function createSignedDownloadUrl(
73
+ bucket: BucketName,
74
+ path: string,
75
+ expiresIn = 3600,
76
+ ): Promise<string> {
77
+ const client = getAdminClient();
78
+ const { data, error } = await client.storage.from(bucket).createSignedUrl(path, expiresIn);
79
+ if (error || !data?.signedUrl) {
80
+ throw new Error(`Erreur création signed download URL: ${error?.message ?? 'unknown'}`);
81
+ }
82
+ return data.signedUrl;
83
+ }
84
+
85
+ export async function downloadFile(
86
+ bucket: BucketName,
87
+ path: string,
88
+ ): Promise<{ buffer: Buffer; mimeType: string }> {
89
+ const client = getAdminClient();
90
+ const { data, error } = await client.storage.from(bucket).download(path);
91
+ if (error || !data) {
92
+ throw new Error(`Erreur téléchargement Supabase: ${error?.message ?? 'unknown'}`);
93
+ }
94
+ const buffer = Buffer.from(await data.arrayBuffer());
95
+ return { buffer, mimeType: data.type };
96
+ }
97
+
98
+ export async function deleteFile(bucket: BucketName, path: string): Promise<void> {
99
+ const client = getAdminClient();
100
+ const { error } = await client.storage.from(bucket).remove([path]);
101
+ if (error) {
102
+ throw new Error(`Erreur suppression Supabase: ${error.message}`);
103
+ }
104
+ }
105
+
106
+ export async function deleteFiles(bucket: BucketName, paths: string[]): Promise<void> {
107
+ if (paths.length === 0) return;
108
+ const client = getAdminClient();
109
+ const { error } = await client.storage.from(bucket).remove(paths);
110
+ if (error) {
111
+ throw new Error(`Erreur suppression batch Supabase: ${error.message}`);
112
+ }
113
+ }
@@ -14,19 +14,31 @@ export interface ContactVariables {
14
14
  city?: string | null;
15
15
  postalCode?: string | null;
16
16
  companyName?: string | null;
17
- company?: { name?: string | null } | null;
17
+ company?: {
18
+ name?: string | null;
19
+ address?: string | null;
20
+ city?: string | null;
21
+ postalCode?: string | null;
22
+ } | null;
23
+ jobTitle?: string | null;
24
+ website?: string | null;
25
+ origin?: string | null;
26
+ statusName?: string | null;
27
+ assignedCommercialName?: string | null;
28
+ assignedTeleproName?: string | null;
29
+ closingReason?: string | null;
30
+ createdAt?: Date | string | null;
18
31
  }
19
32
 
20
33
  export interface TemplateVariable {
21
34
  key: string;
22
35
  description: string;
23
- section: 'contact' | 'lieu' | 'tournee';
36
+ section: 'contact' | 'crm';
24
37
  }
25
38
 
26
39
  export const VARIABLE_SECTIONS = {
27
40
  contact: { label: 'Contact', color: 'indigo' },
28
- lieu: { label: 'Lieu & contacts sur place', color: 'blue' },
29
- tournee: { label: 'Tournée', color: 'emerald' },
41
+ crm: { label: 'CRM', color: 'emerald' },
30
42
  } as const;
31
43
 
32
44
  export const AVAILABLE_VARIABLES: TemplateVariable[] = [
@@ -52,33 +64,46 @@ export const AVAILABLE_VARIABLES: TemplateVariable[] = [
52
64
  description: 'Nom de l’entreprise ou structure du contact.',
53
65
  section: 'contact',
54
66
  },
55
- // Lieu
56
- { key: '{{placeName}}', description: 'Nom du lieu.', section: 'lieu' },
57
- { key: '{{placeAddress}}', description: 'Adresse complète du lieu.', section: 'lieu' },
67
+ // CRM
68
+ { key: '{{jobTitle}}', description: 'Poste / fonction du contact.', section: 'crm' },
69
+ { key: '{{website}}', description: 'Site web du contact ou de son entreprise.', section: 'crm' },
70
+ { key: '{{origin}}', description: 'Origine ou source du lead.', section: 'crm' },
71
+ { key: '{{statusName}}', description: 'Nom du statut actuel du contact.', section: 'crm' },
58
72
  {
59
- key: '{{placeContactName}}',
60
- description: 'Nom de la personne à contacter sur place.',
61
- section: 'lieu',
73
+ key: '{{assignedCommercialName}}',
74
+ description: 'Nom du commercial assigné au contact.',
75
+ section: 'crm',
62
76
  },
63
- // Tournée
64
77
  {
65
- key: '{{tourName}}',
66
- description: 'Libellé auto de tournée (région + semaine ISO).',
67
- section: 'tournee',
78
+ key: '{{closingReason}}',
79
+ description: 'Motif de fermeture (si le contact a le statut Fermé).',
80
+ section: 'crm',
68
81
  },
69
- // { key: '{{tourNumber}}', description: 'Numéro unique de la tournée.', section: 'tournee' },
70
82
  {
71
- key: '{{presenceStart}}',
72
- description: 'Date et heure de début de la présence (ex. lundi 10 mars 2026 à 09:00).',
73
- section: 'tournee',
83
+ key: '{{assignedTeleproName}}',
84
+ description: 'Nom du télépro assigné au contact.',
85
+ section: 'crm',
74
86
  },
75
87
  {
76
- key: '{{presenceEnd}}',
77
- description: 'Date et heure de fin de la présence (ex. lundi 10 mars 2026 à 12:00).',
78
- section: 'tournee',
88
+ key: '{{createdAt}}',
89
+ description: 'Date de création du contact (format dd/mm/yyyy).',
90
+ section: 'crm',
91
+ },
92
+ {
93
+ key: '{{companyAddress}}',
94
+ description: 'Adresse de l\'entreprise liée au contact.',
95
+ section: 'crm',
96
+ },
97
+ {
98
+ key: '{{companyCity}}',
99
+ description: 'Ville du siège de l\'entreprise.',
100
+ section: 'crm',
101
+ },
102
+ {
103
+ key: '{{companyPostalCode}}',
104
+ description: 'Code postal de l\'entreprise.',
105
+ section: 'crm',
79
106
  },
80
- { key: '{{city}}', description: 'Ville où se déroule la tournée.', section: 'tournee' },
81
- { key: '{{postalCode}}', description: 'Code postal de la tournée.', section: 'tournee' },
82
107
  ];
83
108
 
84
109
  /**
@@ -101,6 +126,48 @@ export function replaceTemplateVariables(template: string, variables: ContactVar
101
126
  /\{\{companyName\}\}/g,
102
127
  variables.company?.name ?? variables.companyName ?? '',
103
128
  );
129
+ result = result.replace(/\{\{jobTitle\}\}/g, variables.jobTitle || '');
130
+ result = result.replace(/\{\{website\}\}/g, variables.website || '');
131
+ result = result.replace(/\{\{origin\}\}/g, variables.origin || '');
132
+ result = result.replace(/\{\{statusName\}\}/g, variables.statusName || '');
133
+ result = result.replace(
134
+ /\{\{assignedCommercialName\}\}/g,
135
+ variables.assignedCommercialName || '',
136
+ );
137
+ result = result.replace(
138
+ /\{\{assignedTeleproName\}\}/g,
139
+ variables.assignedTeleproName || '',
140
+ );
141
+ result = result.replace(/\{\{closingReason\}\}/g, variables.closingReason || '');
142
+ result = result.replace(
143
+ /\{\{companyAddress\}\}/g,
144
+ variables.company?.address ?? '',
145
+ );
146
+ result = result.replace(
147
+ /\{\{companyCity\}\}/g,
148
+ variables.company?.city ?? '',
149
+ );
150
+ result = result.replace(
151
+ /\{\{companyPostalCode\}\}/g,
152
+ variables.company?.postalCode ?? '',
153
+ );
154
+
155
+ // createdAt formaté (dd/mm/yyyy)
156
+ let createdAtStr = '';
157
+ if (variables.createdAt) {
158
+ const d =
159
+ typeof variables.createdAt === 'string'
160
+ ? new Date(variables.createdAt)
161
+ : variables.createdAt;
162
+ if (!isNaN(d.getTime())) {
163
+ createdAtStr = d.toLocaleDateString('fr-FR', {
164
+ day: '2-digit',
165
+ month: '2-digit',
166
+ year: 'numeric',
167
+ });
168
+ }
169
+ }
170
+ result = result.replace(/\{\{createdAt\}\}/g, createdAtStr);
104
171
 
105
172
  // Variable composée : fullName
106
173
  const fullName = [variables.firstName, variables.lastName].filter(Boolean).join(' ') || '';
@@ -108,3 +175,77 @@ export function replaceTemplateVariables(template: string, variables: ContactVar
108
175
 
109
176
  return result;
110
177
  }
178
+
179
+ /**
180
+ * Construit l'objet ContactVariables à partir d'un contact (shape flexible).
181
+ */
182
+ export function buildContactVariables(contact: {
183
+ firstName?: string | null;
184
+ lastName?: string | null;
185
+ civility?: string | null;
186
+ email?: string | null;
187
+ phone?: string | null;
188
+ secondaryPhone?: string | null;
189
+ address?: string | null;
190
+ city?: string | null;
191
+ postalCode?: string | null;
192
+ company?: {
193
+ name?: string | null;
194
+ address?: string | null;
195
+ city?: string | null;
196
+ postalCode?: string | null;
197
+ } | null;
198
+ companyName?: string | null;
199
+ jobTitle?: string | null;
200
+ website?: string | null;
201
+ origin?: string | null;
202
+ status?: { name?: string | null } | null;
203
+ statusName?: string | null;
204
+ assignedCommercial?: {
205
+ name?: string | null;
206
+ firstName?: string | null;
207
+ lastName?: string | null;
208
+ } | null;
209
+ assignedTelepro?: {
210
+ name?: string | null;
211
+ firstName?: string | null;
212
+ lastName?: string | null;
213
+ } | null;
214
+ closingReason?: string | null;
215
+ createdAt?: Date | string | null;
216
+ }): ContactVariables {
217
+ const commercialName = contact.assignedCommercial
218
+ ? (contact.assignedCommercial.name ??
219
+ [contact.assignedCommercial.firstName, contact.assignedCommercial.lastName]
220
+ .filter(Boolean)
221
+ .join(' ')) || ''
222
+ : '';
223
+ const teleproName = contact.assignedTelepro
224
+ ? (contact.assignedTelepro.name ??
225
+ [contact.assignedTelepro.firstName, contact.assignedTelepro.lastName]
226
+ .filter(Boolean)
227
+ .join(' ')) || ''
228
+ : '';
229
+ return {
230
+ firstName: contact.firstName || '',
231
+ lastName: contact.lastName || '',
232
+ civility: contact.civility || '',
233
+ email: contact.email || '',
234
+ phone: contact.phone || '',
235
+ secondaryPhone: contact.secondaryPhone || '',
236
+ address: contact.address || '',
237
+ city: contact.city || '',
238
+ postalCode: contact.postalCode || '',
239
+ companyName: contact.company?.name ?? contact.companyName ?? '',
240
+ company: contact.company || null,
241
+ jobTitle: contact.jobTitle || '',
242
+ website: contact.website || '',
243
+ origin: contact.origin || '',
244
+ statusName: contact.status?.name ?? contact.statusName ?? '',
245
+ assignedCommercialName: commercialName,
246
+ assignedTeleproName: teleproName,
247
+ closingReason: contact.closingReason || '',
248
+ createdAt: contact.createdAt || null,
249
+ };
250
+ }
251
+
@@ -58,6 +58,51 @@ export function formatDateTime(dateString: string | null): string {
58
58
  return DATE_TIME_FORMATTER.format(new Date(dateString));
59
59
  }
60
60
 
61
+ /**
62
+ * Parse une chaîne en Date pour les imports (création de contact).
63
+ * Accepte ISO (yyyy-mm-dd), dd/mm/yyyy, dd-mm-yyyy.
64
+ * Retourne null si invalide ou vide.
65
+ */
66
+ export function parseImportDate(value: string | null | undefined): Date | null {
67
+ if (value == null || String(value).trim() === '') return null;
68
+ const s = String(value).trim();
69
+ if (/^\d{4}-\d{2}-\d{2}(T|\s|$)/.test(s)) {
70
+ const d = new Date(s);
71
+ return Number.isNaN(d.getTime()) ? null : d;
72
+ }
73
+ const parts = s.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2,4})/);
74
+ if (parts) {
75
+ const [, a, b, year] = parts;
76
+ const y = year.length === 2 ? 2000 + parseInt(year, 10) : parseInt(year, 10);
77
+ const n1 = parseInt(a, 10);
78
+ const n2 = parseInt(b, 10);
79
+ const day = n1 > 31 ? n2 : n1;
80
+ const month = n1 > 31 ? n1 : n2;
81
+ const d = new Date(y, month - 1, day);
82
+ return Number.isNaN(d.getTime()) ? null : d;
83
+ }
84
+ const d = new Date(s);
85
+ return Number.isNaN(d.getTime()) ? null : d;
86
+ }
87
+
88
+ /**
89
+ * Retourne un message toast adapte a l'environnement :
90
+ * - En développement : affiche le message utilisateur + le détail technique
91
+ * - En production : affiche uniquement le message utilisateur
92
+ */
93
+ export function devToast(userMessage: string, devDetail?: unknown): string {
94
+ if (process.env.NODE_ENV === 'development' && devDetail) {
95
+ const detail =
96
+ devDetail instanceof Error
97
+ ? devDetail.message
98
+ : typeof devDetail === 'string'
99
+ ? devDetail
100
+ : JSON.stringify(devDetail);
101
+ return `${userMessage}\n\n🔧 ${detail}`;
102
+ }
103
+ return userMessage;
104
+ }
105
+
61
106
  export function indexToColumn(index: number): string {
62
107
  let col = '';
63
108
  let n = index + 1;