create-crm-tmp 1.1.1 → 1.1.3

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.
@@ -72,6 +72,7 @@ APP_NAME="${projectName
72
72
 
73
73
  ENCRYPTION_KEY="" # openssl rand -base64 32
74
74
 
75
+ # API Google à ajouter dans console.cloud.google : Google Drive API, Google Calendar API, Google Sheet API
75
76
  GOOGLE_CLIENT_ID=""
76
77
  GOOGLE_CLIENT_SECRET=""
77
78
  GOOGLE_REDIRECT_URI="http://localhost:3000/api/auth/google/callback"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-crm-tmp",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "Créer un nouveau projet CRM basé sur le template",
5
5
  "bin": {
6
6
  "create-crm-tmp": "./bin/create-crm-tmp.js"
@@ -53,102 +53,88 @@ pro.
53
53
  - **Langage**: TypeScript
54
54
  - **Gestionnaire de paquets**: pnpm
55
55
 
56
- ## 🚀 Initialiser un projet via le package `create-crm-tmp`
56
+ ## 🚀 Installation
57
57
 
58
- Ce template est distribué sous forme de package `create-crm-tmp`.
59
- Pour créer un nouveau projet basé sur ce CRM :
58
+ 1. **Cloner le projet**
60
59
 
61
60
  ```bash
62
- pnpm create crm-tmp@latest mon-crm
61
+ git clone <votre-repo>
62
+ cd crm-template
63
63
  ```
64
64
 
65
- Puis :
65
+ 2. **Installer les dépendances**
66
66
 
67
67
  ```bash
68
- cd mon-crm
69
68
  pnpm install
70
69
  ```
71
70
 
72
- À partir de là, vous pouvez suivre les étapes de configuration ci‑dessous (variables denvironnement, base de données et migrations).
71
+ 3. **Configurer les variables d'environnement**
73
72
 
74
- ### 🔌 Utiliser Supabase comme base de données PostgreSQL
73
+ Créez un fichier `.env` à la racine du projet :
75
74
 
76
- Vous pouvez utiliser **Supabase** uniquement comme **hébergeur PostgreSQL** (l’auth du projet reste gérée par Better Auth).
75
+ ```env
76
+ # Database
77
+ DATABASE_URL="postgresql://postgres:password@localhost:5432/crm_db"
77
78
 
78
- 1. **Créer un projet Supabase**
79
- - Allez sur le dashboard Supabase.
80
- - Créez un nouveau projet.
81
- - Attendez que la base de données soit provisionnée.
82
- 2. **Récupérer la connexion Postgres**
83
- - Dans Supabase, cliquez sur `🔌 Connect`.
84
- - Sélectionnez `ORMs` et choisissez le Tool `Prisma`
85
- - Copiez les 2 premières variables d'environnement du contenu dans `.env.local` (`DATABASE_URL & DIRECT_URL`).
86
- 3. **Configurer `DATABASE_URL`**
87
- - Dans votre projet généré (`mon-crm`), créez ou mettez à jour le fichier `.env` :
79
+ # Better Auth (générer avec: openssl rand -base64 32)
80
+ BETTER_AUTH_SECRET="votre-clé-secrète-minimum-32-caractères"
81
+ BETTER_AUTH_URL="http://localhost:3000"
88
82
 
89
- ```env
90
- # Database (Supabase)
91
- DATABASE_URL="postgresql://postgres.project:[YOUR-PASSWORD]@aws.pooler.supabase.com:port/postgres?pgbouncer=true"
92
- DIRECT_URL="postgresql://postgres.project:[YOUR-PASSWORD]@aws.pooler.supabase.com:port/postgres"
83
+ # Application
84
+ NEXT_PUBLIC_APP_URL="http://localhost:3000"
85
+ NODE_ENV="development"
86
+ ```
93
87
 
94
- # Better Auth
95
- BETTER_AUTH_SECRET="votre-clé-secrète-minimum-32-caractères"
96
- BETTER_AUTH_URL="http://localhost:3000"
88
+ 4. **Créer la base de données**
97
89
 
98
- # Application
99
- NEXT_PUBLIC_APP_URL="http://localhost:3000"
100
- NODE_ENV="development"
101
- ```
102
-
103
- 4. **Appliquer les migrations Prisma sur Supabase**
90
+ ```bash
91
+ createdb crm_db
92
+ # Ou: psql -U postgres -c "CREATE DATABASE crm_db;"
93
+ ```
104
94
 
105
- ```bash
106
- pnpm prisma migrate deploy
107
- ```
95
+ 5. **Appliquer les migrations**
108
96
 
109
- 5. **Lancer l’application**
97
+ ```bash
98
+ pnpm prisma migrate deploy
99
+ ```
110
100
 
111
- ```bash
112
- pnpm dev
113
- ```
101
+ 6. **Lancer le serveur de développement**
102
+
103
+ ```bash
104
+ pnpm dev
105
+ ```
114
106
 
115
107
  Ouvrez [http://localhost:3000](http://localhost:3000) pour voir l'application.
116
108
 
117
- 6. **Lancez le script permettant de créer un compte Admin**
109
+ 7. **Créer votre premier admin**
118
110
 
119
111
  ```bash
120
- # Lancez ceci
121
- npm tsx scripts/create-admin.ts [email] [password] [name]
112
+ # Ouvrir Prisma Studio
113
+ pnpm prisma studio
122
114
 
123
- # Modifier les champ "[email] [password] [name] pour configurer votre compte Admin
124
- ```
125
-
126
- 7. **Lancez le script permettant d'ajouter les permissions dans la base de données**
127
- ```bash
128
- # Lancez ceci
129
- npm tsx scripts/seed-roles.ts
115
+ # Modifier le champ "role" de votre utilisateur en "ADMIN"
130
116
  ```
131
117
 
132
118
  ## 📁 Structure du projet
133
119
 
134
120
  ```
135
- .
136
- ├── prisma/
137
- └── schema.prisma # Schéma Prisma (migrations via CLI)
138
- ├── src/
139
- │ ├── app/
140
- │ │ ├── (auth)/ # Pages d'authentification
141
- │ │ ├── (dashboard)/ # Espace connecté (contacts, agenda, etc.)
142
- │ │ ├── api/ # Routes API (contacts, workflows, users, intégrations…)
143
- │ │ ├── layout.tsx # Layout racine (HTML, <body>, providers…)
144
- │ │ └── page.tsx # Page d'accueil / redirection
145
- │ ├── components/ # Composants UI (sidebar, headers, formulaires…)
146
- ├── lib/ # Auth, Prisma, rôles, intégrations externes, utils…
147
- └── proxy.ts # Protection des routes (proxy Next.js)
148
- ├── scripts/
149
- ├── create-admin.ts # Script pour créer un compte admin
150
- │ └── seed-roles.ts # Script pour insérer rôles & permissions
151
- └── ...
121
+ src/
122
+ ├── app/
123
+ ├── (auth)/ # Groupe de routes d'authentification
124
+ ├── (dashboard)/ # Espace connecté (protégé)
125
+ ├── dashboard/ # Tableau de bord
126
+ │ │ ├── contacts/ # Gestion des contacts
127
+ │ │ ├── agenda/ # Agenda (mois / semaine / jour)
128
+ │ │ ├── automatisation/ # Workflows / automatisations
129
+ │ │ ├── templates/ # Templates d’emails
130
+ │ │ ├── settings/ # Paramètres (profil, entreprise, intégrations…)
131
+ ├── users/ # Gestion des utilisateurs & rôles (admin)
132
+ │ └── layout.tsx # Layout avec sidebar
133
+ ├── api/ # API (contacts, workflows, intégrations, users, etc.)
134
+ │ └── page.tsx # Page d'accueil (redirection)
135
+ ├── components/ # Composants UI (sidebar, headers, skeletons…)
136
+ ├── lib/ # Auth, Prisma, rôles, intégrations Google, workflows…
137
+ └── proxy.ts # Protection des routes (proxy)
152
138
  ```
153
139
 
154
140
  ## 🔒 Protection & rôles
@@ -12,7 +12,6 @@
12
12
  "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,css,md}\""
13
13
  },
14
14
  "dependencies": {
15
- "@hookform/resolvers": "^5.2.2",
16
15
  "@lexical/html": "^0.38.2",
17
16
  "@lexical/list": "^0.38.2",
18
17
  "@lexical/markdown": "^0.38.2",
@@ -21,46 +20,49 @@
21
20
  "@lexical/selection": "^0.38.2",
22
21
  "@lexical/utils": "^0.38.2",
23
22
  "@lexkit/editor": "^0.0.38",
24
- "@prisma/adapter-pg": "^7.3.0",
25
- "@prisma/client": "^7.3.0",
23
+ "@prisma/adapter-pg": "^7.4.1",
24
+ "@prisma/client": "^7.4.1",
26
25
  "@react-email/render": "^2.0.4",
27
26
  "bcryptjs": "^3.0.3",
28
- "better-auth": "^1.4.18",
27
+ "better-auth": "^1.4.19",
29
28
  "clsx": "^2.1.1",
30
- "dotenv": "^17.2.4",
31
- "iron-session": "^8.0.4",
29
+ "dotenv": "^17.3.1",
32
30
  "lexical": "^0.38.2",
33
31
  "lucide-react": "^0.555.0",
34
- "next": "16.0.10",
32
+ "next": "16.1.6",
35
33
  "nodemailer": "^7.0.13",
36
34
  "papaparse": "^5.5.3",
37
- "pg": "^8.18.0",
38
- "react": "19.2.3",
39
- "react-dom": "19.2.3",
35
+ "pg": "^8.19.0",
36
+ "react": "19.2.4",
37
+ "react-dom": "19.2.4",
40
38
  "react-grid-layout": "^2.2.2",
41
- "react-hook-form": "^7.71.1",
42
39
  "recharts": "^2.15.4",
43
- "tailwind-merge": "^3.4.0",
44
- "xlsx": "^0.18.5",
45
- "zod": "^4.3.6"
40
+ "tailwind-merge": "^3.5.0",
41
+ "xlsx": "https://cdn.sheetjs.com/xlsx-latest/xlsx-latest.tgz"
46
42
  },
47
43
  "devDependencies": {
48
- "@tailwindcss/postcss": "^4.1.18",
49
- "@types/node": "^20.19.33",
50
- "@types/nodemailer": "^7.0.9",
44
+ "@tailwindcss/postcss": "^4.2.1",
45
+ "@types/node": "^20.19.34",
46
+ "@types/nodemailer": "^7.0.11",
51
47
  "@types/papaparse": "^5.5.2",
52
48
  "@types/pg": "^8.16.0",
53
- "@types/react": "^19.2.13",
54
- "@types/react-dom": "^19.2.3",
49
+ "@types/react": "19.2.14",
50
+ "@types/react-dom": "19.2.3",
55
51
  "@types/react-grid-layout": "^2.1.0",
56
52
  "babel-plugin-react-compiler": "1.0.0",
57
- "eslint": "^9.39.2",
58
- "eslint-config-next": "16.0.7",
53
+ "eslint": "^9.39.3",
54
+ "eslint-config-next": "16.1.6",
59
55
  "prettier": "^3.8.1",
60
56
  "prettier-plugin-tailwindcss": "^0.7.2",
61
- "prisma": "^7.3.0",
62
- "tailwindcss": "^4.1.18",
57
+ "prisma": "^7.4.1",
58
+ "tailwindcss": "^4.2.1",
63
59
  "tsx": "^4.21.0",
64
60
  "typescript": "^5.9.3"
61
+ },
62
+ "pnpm": {
63
+ "overrides": {
64
+ "@types/react": "19.2.14",
65
+ "@types/react-dom": "19.2.3"
66
+ }
65
67
  }
66
68
  }
@@ -0,0 +1,69 @@
1
+ -- DropForeignKey
2
+ ALTER TABLE "contact" DROP CONSTRAINT "contact_createdById_fkey";
3
+
4
+ -- DropForeignKey
5
+ ALTER TABLE "contact_file" DROP CONSTRAINT "contact_file_uploadedById_fkey";
6
+
7
+ -- DropForeignKey
8
+ ALTER TABLE "google_sheet_sync_config" DROP CONSTRAINT "google_sheet_sync_config_ownerUserId_fkey";
9
+
10
+ -- DropForeignKey
11
+ ALTER TABLE "interaction" DROP CONSTRAINT "interaction_userId_fkey";
12
+
13
+ -- DropForeignKey
14
+ ALTER TABLE "task" DROP CONSTRAINT "task_assignedUserId_fkey";
15
+
16
+ -- DropForeignKey
17
+ ALTER TABLE "task" DROP CONSTRAINT "task_createdById_fkey";
18
+
19
+ -- DropForeignKey
20
+ ALTER TABLE "template" DROP CONSTRAINT "template_userId_fkey";
21
+
22
+ -- DropForeignKey
23
+ ALTER TABLE "workflow" DROP CONSTRAINT "workflow_userId_fkey";
24
+
25
+ -- AlterTable
26
+ ALTER TABLE "contact" ALTER COLUMN "createdById" DROP NOT NULL;
27
+
28
+ -- AlterTable
29
+ ALTER TABLE "contact_file" ALTER COLUMN "uploadedById" DROP NOT NULL;
30
+
31
+ -- AlterTable
32
+ ALTER TABLE "google_sheet_sync_config" ALTER COLUMN "ownerUserId" DROP NOT NULL;
33
+
34
+ -- AlterTable
35
+ ALTER TABLE "interaction" ALTER COLUMN "userId" DROP NOT NULL;
36
+
37
+ -- AlterTable
38
+ ALTER TABLE "task" ALTER COLUMN "assignedUserId" DROP NOT NULL,
39
+ ALTER COLUMN "createdById" DROP NOT NULL;
40
+
41
+ -- AlterTable
42
+ ALTER TABLE "template" ALTER COLUMN "userId" DROP NOT NULL;
43
+
44
+ -- AlterTable
45
+ ALTER TABLE "workflow" ALTER COLUMN "userId" DROP NOT NULL;
46
+
47
+ -- AddForeignKey
48
+ ALTER TABLE "contact" ADD CONSTRAINT "contact_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
49
+
50
+ -- AddForeignKey
51
+ ALTER TABLE "interaction" ADD CONSTRAINT "interaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
52
+
53
+ -- AddForeignKey
54
+ ALTER TABLE "task" ADD CONSTRAINT "task_assignedUserId_fkey" FOREIGN KEY ("assignedUserId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
55
+
56
+ -- AddForeignKey
57
+ ALTER TABLE "task" ADD CONSTRAINT "task_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
58
+
59
+ -- AddForeignKey
60
+ ALTER TABLE "contact_file" ADD CONSTRAINT "contact_file_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
61
+
62
+ -- AddForeignKey
63
+ ALTER TABLE "google_sheet_sync_config" ADD CONSTRAINT "google_sheet_sync_config_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
64
+
65
+ -- AddForeignKey
66
+ ALTER TABLE "template" ADD CONSTRAINT "template_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
67
+
68
+ -- AddForeignKey
69
+ ALTER TABLE "workflow" ADD CONSTRAINT "workflow_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -260,8 +260,8 @@ model Contact {
260
260
  assignedCommercial User? @relation("ContactAssignedCommercial", fields: [assignedCommercialId], references: [id], onDelete: SetNull)
261
261
  assignedTeleproId String? // Télépro assigné
262
262
  assignedTelepro User? @relation("ContactAssignedTelepro", fields: [assignedTeleproId], references: [id], onDelete: SetNull)
263
- createdById String // Utilisateur qui a créé le contact
264
- createdBy User @relation("ContactCreatedBy", fields: [createdById], references: [id], onDelete: Cascade)
263
+ createdById String? // Utilisateur qui a créé le contact
264
+ createdBy User? @relation("ContactCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
265
265
  createdAt DateTime @default(now())
266
266
  updatedAt DateTime @updatedAt
267
267
  interactions Interaction[]
@@ -286,8 +286,8 @@ model Interaction {
286
286
  title String?
287
287
  content String // Contenu de l'interaction
288
288
  metadata Json? // Métadonnées pour stocker les détails des changements (ancienne valeur, nouvelle valeur, etc.)
289
- userId String // Utilisateur qui a créé l'interaction
290
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
289
+ userId String? // Utilisateur qui a créé l'interaction
290
+ user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
291
291
  date DateTime? // Date de l'interaction (pour RDV, appels, etc.)
292
292
  createdAt DateTime @default(now())
293
293
  updatedAt DateTime @updatedAt
@@ -322,10 +322,10 @@ model Task {
322
322
  priority TaskPriority @default(MEDIUM)
323
323
  scheduledAt DateTime // Date et heure de la tâche
324
324
  reminderMinutesBefore Int? // Rappel en minutes avant l'heure prévue (ex: 15, 30, 60)
325
- assignedUserId String // Utilisateur assigné
326
- assignedUser User @relation("TaskAssignedTo", fields: [assignedUserId], references: [id], onDelete: Cascade)
327
- createdById String // Utilisateur qui a créé la tâche
328
- createdBy User @relation("TaskCreatedBy", fields: [createdById], references: [id], onDelete: Cascade)
325
+ assignedUserId String? // Utilisateur assigné
326
+ assignedUser User? @relation("TaskAssignedTo", fields: [assignedUserId], references: [id], onDelete: SetNull)
327
+ createdById String? // Utilisateur qui a créé la tâche
328
+ createdBy User? @relation("TaskCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
329
329
  completed Boolean @default(false) // Tâche terminée ou non
330
330
  completedAt DateTime? // Date de complétion
331
331
  googleEventId String? // ID de l'évènement Google Calendar
@@ -366,8 +366,8 @@ model ContactFile {
366
366
  fileSize Int // Taille en octets
367
367
  mimeType String // Type MIME du fichier
368
368
  googleDriveFileId String // ID du fichier dans Google Drive
369
- uploadedById String // Utilisateur qui a uploadé le fichier
370
- uploadedBy User @relation("ContactFileUploadedBy", fields: [uploadedById], references: [id], onDelete: Cascade)
369
+ uploadedById String? // Utilisateur qui a uploadé le fichier
370
+ uploadedBy User? @relation("ContactFileUploadedBy", fields: [uploadedById], references: [id], onDelete: SetNull)
371
371
  createdAt DateTime @default(now())
372
372
  updatedAt DateTime @updatedAt
373
373
 
@@ -411,8 +411,8 @@ model GoogleAdsLeadConfig {
411
411
  model GoogleSheetSyncConfig {
412
412
  id String @id @default(cuid())
413
413
  name String // Nom de la configuration (ex: "Contacts Ventes")
414
- ownerUserId String
415
- ownerUser User @relation("GoogleSheetOwner", fields: [ownerUserId], references: [id], onDelete: Cascade)
414
+ ownerUserId String?
415
+ ownerUser User? @relation("GoogleSheetOwner", fields: [ownerUserId], references: [id], onDelete: SetNull)
416
416
  spreadsheetId String
417
417
  sheetName String
418
418
  headerRow Int
@@ -457,8 +457,8 @@ model Template {
457
457
  type TemplateType // Type de template (EMAIL, SMS, NOTE)
458
458
  subject String? // Sujet (pour EMAIL uniquement)
459
459
  content String // Contenu du template (HTML pour EMAIL, texte pour SMS et NOTE)
460
- userId String // Utilisateur qui a créé le template
461
- user User @relation("TemplateCreatedBy", fields: [userId], references: [id], onDelete: Cascade)
460
+ userId String? // Utilisateur qui a créé le template
461
+ user User? @relation("TemplateCreatedBy", fields: [userId], references: [id], onDelete: SetNull)
462
462
  workflowActions WorkflowAction[] @relation("WorkflowActionEmailTemplate")
463
463
  createdAt DateTime @default(now())
464
464
  updatedAt DateTime @updatedAt
@@ -493,8 +493,8 @@ model Workflow {
493
493
  name String
494
494
  description String?
495
495
  active Boolean @default(true)
496
- userId String // Utilisateur propriétaire du workflow
497
- user User @relation("WorkflowOwner", fields: [userId], references: [id], onDelete: Cascade)
496
+ userId String? // Utilisateur propriétaire du workflow
497
+ user User? @relation("WorkflowOwner", fields: [userId], references: [id], onDelete: SetNull)
498
498
 
499
499
  // Configuration du déclencheur
500
500
  triggerType WorkflowTriggerType
@@ -3,7 +3,7 @@
3
3
  import { useState, useEffect, useRef, useMemo } from 'react';
4
4
  import { useUserRole } from '@/hooks/use-user-role';
5
5
  import { useViewAs } from '@/contexts/view-as-context';
6
- import { cn } from '@/lib/utils';
6
+ import { cn, getUserDisplayName } from '@/lib/utils';
7
7
  import {
8
8
  Calendar,
9
9
  Clock,
@@ -43,7 +43,7 @@ interface Task {
43
43
  id: string;
44
44
  name: string;
45
45
  eventColor: string | null;
46
- };
46
+ } | null;
47
47
  }
48
48
 
49
49
  // Couleurs par type d'événement (fallback si pas de couleur utilisateur)
@@ -69,7 +69,7 @@ const HOURS = Array.from({ length: 17 }, (_, i) => i + 6); // 6h à 22h
69
69
 
70
70
  // Fonction pour obtenir la couleur d'un événement basée sur l'utilisateur
71
71
  const getEventColor = (task: Task): string => {
72
- return task.assignedUser.eventColor || TYPE_COLORS[task.type] || '#6366F1';
72
+ return task.assignedUser?.eventColor || TYPE_COLORS[task.type] || '#6366F1';
73
73
  };
74
74
 
75
75
  const formatHourLabel = (hour: number) => {
@@ -498,7 +498,7 @@ export default function AgendaPage() {
498
498
  // Filtre par utilisateur (si "Voir les autres utilisateurs" est actif)
499
499
  let userMatch = true;
500
500
  if (showOtherUsersEvents && selectedUserIds.size > 0) {
501
- userMatch = selectedUserIds.has(task.assignedUser.id);
501
+ userMatch = task.assignedUser ? selectedUserIds.has(task.assignedUser.id) : false;
502
502
  }
503
503
 
504
504
  return typeMatch && userMatch;
@@ -1821,7 +1821,7 @@ export default function AgendaPage() {
1821
1821
  {isAdmin && (
1822
1822
  <div className="flex items-center gap-1">
1823
1823
  <User className="h-4 w-4" />
1824
- {task.assignedUser.name}
1824
+ {getUserDisplayName(task.assignedUser)}
1825
1825
  </div>
1826
1826
  )}
1827
1827
  </div>
@@ -1943,7 +1943,7 @@ export default function AgendaPage() {
1943
1943
  {isAdmin && (
1944
1944
  <div className="flex items-center gap-1">
1945
1945
  <User className="h-4 w-4" />
1946
- {task.assignedUser.name}
1946
+ {getUserDisplayName(task.assignedUser)}
1947
1947
  </div>
1948
1948
  )}
1949
1949
  </div>
@@ -2072,7 +2072,7 @@ export default function AgendaPage() {
2072
2072
  {isAdmin && (
2073
2073
  <div className="flex items-center gap-1">
2074
2074
  <User className="h-4 w-4" />
2075
- {task.assignedUser.name}
2075
+ {getUserDisplayName(task.assignedUser)}
2076
2076
  </div>
2077
2077
  )}
2078
2078
  </div>
@@ -22,13 +22,12 @@ import {
22
22
  Upload,
23
23
  File,
24
24
  Target,
25
- ChevronDown,
26
25
  } from 'lucide-react';
27
26
  import Link from 'next/link';
28
27
  import { Editor, type DefaultTemplateRef } from '@/components/editor';
29
28
  import { useUserRole } from '@/hooks/use-user-role';
30
29
  import { replaceTemplateVariables } from '@/lib/template-variables';
31
- import { cn, normalizePhoneNumber } from '@/lib/utils';
30
+ import { cn, normalizePhoneNumber, getUserDisplayName } from '@/lib/utils';
32
31
 
33
32
  interface Status {
34
33
  id: string;
@@ -43,25 +42,6 @@ interface User {
43
42
  role?: string;
44
43
  }
45
44
 
46
- function getNewsFeedCardColor(type: string): string {
47
- switch (type) {
48
- case 'STATUS_CHANGE':
49
- return 'bg-purple-50 border-purple-200';
50
- case 'CONTACT_UPDATE':
51
- return 'bg-indigo-50 border-indigo-200';
52
- case 'ASSIGNMENT_CHANGE':
53
- return 'bg-teal-50 border-teal-200';
54
- case 'FILE_UPLOADED':
55
- return 'bg-green-50 border-green-200';
56
- case 'FILE_REPLACED':
57
- return 'bg-orange-50 border-orange-200';
58
- case 'FILE_DELETED':
59
- return 'bg-red-50 border-red-200';
60
- default:
61
- return 'bg-gray-50 border-gray-200';
62
- }
63
- }
64
-
65
45
  interface Interaction {
66
46
  id: string;
67
47
  type:
@@ -118,8 +98,8 @@ interface Contact {
118
98
  assignedCommercial: User | null;
119
99
  assignedTeleproId: string | null;
120
100
  assignedTelepro: User | null;
121
- createdById: string;
122
- createdBy: User;
101
+ createdById: string | null;
102
+ createdBy: User | null;
123
103
  createdAt: string;
124
104
  updatedAt: string;
125
105
  interactions: Interaction[];
@@ -152,7 +132,6 @@ export default function ContactDetailPage() {
152
132
  const [tasks, setTasks] = useState<any[]>([]);
153
133
  const [sendingEmail, setSendingEmail] = useState(false);
154
134
  const [creatingTask, setCreatingTask] = useState(false);
155
- const [showNewsFeed, setShowNewsFeed] = useState(false);
156
135
  const [emailTemplates, setEmailTemplates] = useState<any[]>([]);
157
136
  const [noteTemplates, setNoteTemplates] = useState<any[]>([]);
158
137
  const emailEditorRef = useRef<DefaultTemplateRef | null>(null);
@@ -2594,12 +2573,9 @@ export default function ContactDetailPage() {
2594
2573
  },
2595
2574
  )}
2596
2575
  </p>
2597
- {interaction.user && (
2598
- <p className="text-xs text-gray-500">
2599
- Par :{' '}
2600
- {interaction.user.name || interaction.user.email}
2601
- </p>
2602
- )}
2576
+ <p className="text-xs text-gray-500">
2577
+ Par : {getUserDisplayName(interaction.user)}
2578
+ </p>
2603
2579
  </div>
2604
2580
  </div>
2605
2581
  </div>
@@ -2615,36 +2591,44 @@ export default function ContactDetailPage() {
2615
2591
 
2616
2592
  {/* Section Fil d'actualités */}
2617
2593
  <div>
2618
- <button
2619
- type="button"
2620
- onClick={() => setShowNewsFeed((prev) => !prev)}
2621
- className="mb-4 flex w-full cursor-pointer items-center justify-between rounded-xl border border-transparent px-3 py-2 text-left transition-colors hover:border-gray-200 hover:bg-gray-50"
2622
- >
2623
- <span className="text-lg font-semibold text-gray-900">Fil d'actualités</span>
2624
- <ChevronDown
2625
- className={cn(
2626
- 'h-4 w-4 text-gray-500 transition-transform',
2627
- showNewsFeed ? 'rotate-180' : 'rotate-0',
2628
- )}
2629
- />
2630
- </button>
2631
- {showNewsFeed && (
2632
- <div className="space-y-4">
2633
- {Object.keys(groupedNewsFeed).length === 0 ? (
2634
- <p className="py-6 text-center text-sm text-gray-500">
2635
- Aucune modification
2636
- </p>
2637
- ) : (
2638
- Object.entries(groupedNewsFeed).map(([date, interactions]) => (
2639
- <div key={date}>
2640
- <h3 className="mb-3 text-sm font-semibold text-gray-700">{date}</h3>
2641
- <div className="space-y-2">
2642
- {interactions.map((interaction) => (
2594
+ <div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
2595
+ <h2 className="text-lg font-semibold text-gray-900">Fil d'actualités</h2>
2596
+ </div>
2597
+ <div className="space-y-4">
2598
+ {Object.keys(groupedNewsFeed).length === 0 ? (
2599
+ <p className="py-6 text-center text-sm text-gray-500">
2600
+ Aucune modification
2601
+ </p>
2602
+ ) : (
2603
+ Object.entries(groupedNewsFeed).map(([date, interactions]) => (
2604
+ <div key={date}>
2605
+ <h3 className="mb-3 text-sm font-semibold text-gray-700">{date}</h3>
2606
+ <div className="space-y-2">
2607
+ {interactions.map((interaction) => {
2608
+ const getCardColor = (type: string) => {
2609
+ switch (type) {
2610
+ case 'STATUS_CHANGE':
2611
+ return 'bg-purple-50 border-purple-200';
2612
+ case 'CONTACT_UPDATE':
2613
+ return 'bg-indigo-50 border-indigo-200';
2614
+ case 'ASSIGNMENT_CHANGE':
2615
+ return 'bg-teal-50 border-teal-200';
2616
+ case 'FILE_UPLOADED':
2617
+ return 'bg-green-50 border-green-200';
2618
+ case 'FILE_REPLACED':
2619
+ return 'bg-orange-50 border-orange-200';
2620
+ case 'FILE_DELETED':
2621
+ return 'bg-red-50 border-red-200';
2622
+ default:
2623
+ return 'bg-gray-50 border-gray-200';
2624
+ }
2625
+ };
2626
+ return (
2643
2627
  <div
2644
2628
  key={interaction.id}
2645
2629
  className={cn(
2646
2630
  'relative rounded-lg border p-3 sm:p-4',
2647
- getNewsFeedCardColor(interaction.type),
2631
+ getCardColor(interaction.type),
2648
2632
  )}
2649
2633
  >
2650
2634
  <div className="flex items-start gap-2 sm:gap-3">
@@ -2670,23 +2654,20 @@ export default function ContactDetailPage() {
2670
2654
  },
2671
2655
  )}
2672
2656
  </p>
2673
- {interaction.user && (
2674
- <p className="text-xs text-gray-500">
2675
- Par :{' '}
2676
- {interaction.user.name || interaction.user.email}
2677
- </p>
2678
- )}
2657
+ <p className="text-xs text-gray-500">
2658
+ Par : {getUserDisplayName(interaction.user)}
2659
+ </p>
2679
2660
  </div>
2680
2661
  </div>
2681
2662
  </div>
2682
2663
  </div>
2683
- ))}
2684
- </div>
2664
+ );
2665
+ })}
2685
2666
  </div>
2686
- ))
2687
- )}
2688
- </div>
2689
- )}
2667
+ </div>
2668
+ ))
2669
+ )}
2670
+ </div>
2690
2671
  </div>
2691
2672
  </div>
2692
2673
  )}
@@ -2860,11 +2841,9 @@ export default function ContactDetailPage() {
2860
2841
  },
2861
2842
  )}
2862
2843
  </p>
2863
- {interaction.user && (
2864
- <p className="text-xs text-gray-500">
2865
- Par : {interaction.user.name || interaction.user.email}
2866
- </p>
2867
- )}
2844
+ <p className="text-xs text-gray-500">
2845
+ Par : {getUserDisplayName(interaction.user)}
2846
+ </p>
2868
2847
  </div>
2869
2848
  </div>
2870
2849
  </div>
@@ -2935,11 +2914,9 @@ export default function ContactDetailPage() {
2935
2914
  },
2936
2915
  )}
2937
2916
  </p>
2938
- {interaction.user && (
2939
- <p className="text-xs text-gray-500">
2940
- Par : {interaction.user.name || interaction.user.email}
2941
- </p>
2942
- )}
2917
+ <p className="text-xs text-gray-500">
2918
+ Par : {getUserDisplayName(interaction.user)}
2919
+ </p>
2943
2920
  </div>
2944
2921
  </div>
2945
2922
  </div>
@@ -3011,7 +2988,7 @@ export default function ContactDetailPage() {
3011
2988
  <span>{formatFileSize(file.fileSize)}</span>
3012
2989
  <span>•</span>
3013
2990
  <span>
3014
- Ajouté par {file.uploadedBy?.name || 'Utilisateur inconnu'}
2991
+ Ajouté par {getUserDisplayName(file.uploadedBy)}
3015
2992
  </span>
3016
2993
  <span>•</span>
3017
2994
  <span>
@@ -3142,11 +3119,9 @@ export default function ContactDetailPage() {
3142
3119
  },
3143
3120
  )}
3144
3121
  </p>
3145
- {interaction.user && (
3146
- <p className="text-xs text-gray-500">
3147
- Par : {interaction.user.name || interaction.user.email}
3148
- </p>
3149
- )}
3122
+ <p className="text-xs text-gray-500">
3123
+ Par : {getUserDisplayName(interaction.user)}
3124
+ </p>
3150
3125
  </div>
3151
3126
  </div>
3152
3127
  </div>
@@ -5580,9 +5555,7 @@ email2@example.com`}
5580
5555
  minute: '2-digit',
5581
5556
  })}
5582
5557
  </span>
5583
- {viewingActivity.user && (
5584
- <span>Par : {viewingActivity.user.name || viewingActivity.user.email}</span>
5585
- )}
5558
+ <span>Par : {getUserDisplayName(viewingActivity.user)}</span>
5586
5559
  </div>
5587
5560
  </div>
5588
5561
  </div>
@@ -5989,15 +5962,12 @@ email2@example.com`}
5989
5962
  </span>
5990
5963
  </div>
5991
5964
  )}
5992
- {viewingAppointment.assignedUser && (
5993
- <div className="flex items-center gap-2">
5994
- <span className="text-sm font-medium text-gray-700">Assigné à :</span>
5995
- <span className="text-sm text-gray-600">
5996
- {viewingAppointment.assignedUser.name ||
5997
- viewingAppointment.assignedUser.email}
5998
- </span>
5999
- </div>
6000
- )}
5965
+ <div className="flex items-center gap-2">
5966
+ <span className="text-sm font-medium text-gray-700">Assigné à :</span>
5967
+ <span className="text-sm text-gray-600">
5968
+ {getUserDisplayName(viewingAppointment.assignedUser)}
5969
+ </span>
5970
+ </div>
6001
5971
  </div>
6002
5972
  )}
6003
5973
  </div>
@@ -68,8 +68,8 @@ interface Contact {
68
68
  assignedCommercial: User | null;
69
69
  assignedTeleproId: string | null;
70
70
  assignedTelepro: User | null;
71
- createdById: string;
72
- createdBy: User;
71
+ createdById: string | null;
72
+ createdBy: User | null;
73
73
  createdAt: string;
74
74
  updatedAt: string;
75
75
  }
@@ -129,7 +129,7 @@ export async function POST(request: NextRequest) {
129
129
  minute: '2-digit',
130
130
  })
131
131
  : '';
132
- const author = interaction.user?.name || 'Inconnu';
132
+ const author = interaction.user?.name || 'Ancien utilisateur';
133
133
  const title = interaction.title ? `${interaction.title}: ` : '';
134
134
  const content = interaction.content || '';
135
135
 
@@ -8,7 +8,7 @@ import nodemailer from 'nodemailer';
8
8
  import { render } from '@react-email/render';
9
9
  import React from 'react';
10
10
 
11
- const isDevelopment = process.env.NODE_ENV === 'development';
11
+
12
12
 
13
13
  function htmlToText(html: string): string {
14
14
  if (!html) return '';
@@ -44,7 +44,6 @@ async function getAdminSmtpConfig(userId: string) {
44
44
  };
45
45
  }
46
46
 
47
- console.log('✅ Configuration SMTP trouvée pour:', user.email);
48
47
  return { config: user.smtpConfig, error: null };
49
48
  } catch (error) {
50
49
  console.error('Erreur lors de la récupération de la configuration SMTP:', error);
@@ -94,7 +93,6 @@ async function getAnyAdminSmtpConfig() {
94
93
  };
95
94
  }
96
95
 
97
- console.log("✅ Configuration SMTP trouvée pour l'admin:", smtpConfig.user.email);
98
96
  return { config: smtpConfig, error: null };
99
97
  } catch (error) {
100
98
  console.error('Erreur lors de la récupération de la configuration SMTP admin:', error);
@@ -123,14 +121,6 @@ export async function POST(request: Request) {
123
121
  return Response.json({ error: 'Non authentifié' }, { status: 401 });
124
122
  }
125
123
 
126
- console.log("📧 Tentative d'envoi d'email:", {
127
- to,
128
- subject,
129
- template,
130
- isDevelopment,
131
- userId: session?.user?.id || 'none (reset-password)',
132
- });
133
-
134
124
  // Récupérer la configuration SMTP
135
125
  let smtpConfig, smtpError;
136
126
  if (isResetPassword) {
@@ -177,30 +167,6 @@ export async function POST(request: Request) {
177
167
  const emailHtml = await render(emailComponent);
178
168
  const emailText = htmlToText(emailHtml);
179
169
 
180
- // Logger les informations de l'email (même en production pour le debug)
181
- if (isDevelopment) {
182
- console.log("📧 [DEV MODE] Envoi de l'email:");
183
- console.log({
184
- from: smtpConfig.fromName
185
- ? `"${smtpConfig.fromName}" <${smtpConfig.fromEmail}>`
186
- : smtpConfig.fromEmail,
187
- to,
188
- subject,
189
- template,
190
- data: { ...emailData },
191
- });
192
-
193
- // Afficher le lien d'invitation dans la console si c'est une invitation
194
- if (template === 'invitation' && emailData.invitationUrl) {
195
- console.log("🔗 Lien d'invitation:", emailData.invitationUrl);
196
- }
197
-
198
- // Afficher le code de réinitialisation dans la console
199
- if (template === 'reset-password' && emailData.code) {
200
- console.log('🔑 Code de réinitialisation:', emailData.code);
201
- }
202
- }
203
-
204
170
  // Déchiffrer le mot de passe SMTP
205
171
  let password: string;
206
172
  try {
@@ -236,16 +202,8 @@ export async function POST(request: Request) {
236
202
  html: emailHtml,
237
203
  };
238
204
 
239
- console.log("📤 Envoi de l'email via SMTP...", {
240
- from: mailOptions.from,
241
- to: recipients,
242
- subject,
243
- });
244
-
245
205
  const info = await transporter.sendMail(mailOptions);
246
206
 
247
- console.log('✅ Email envoyé avec succès:', info.messageId);
248
-
249
207
  return Response.json({
250
208
  id: info.messageId,
251
209
  message: 'Email envoyé avec succès',
@@ -80,6 +80,19 @@ export async function DELETE(
80
80
  return NextResponse.json({ error: 'Statut non trouvé' }, { status: 404 });
81
81
  }
82
82
 
83
+ const contactsCount = await prisma.contact.count({
84
+ where: { statusId: id },
85
+ });
86
+
87
+ if (contactsCount > 0) {
88
+ return NextResponse.json(
89
+ {
90
+ error: `Ce statut ne peut pas être supprimé car ${contactsCount} contact(s) l'utilisent`,
91
+ },
92
+ { status: 400 },
93
+ );
94
+ }
95
+
83
96
  await prisma.status.delete({
84
97
  where: { id },
85
98
  });
@@ -590,12 +590,6 @@ export async function DELETE(
590
590
  // Créer une interaction pour l'annulation si c'est un Google Meet ou un RDV
591
591
  if (task.contactId && (task.type === 'VIDEO_CONFERENCE' || task.type === 'MEETING')) {
592
592
  try {
593
- console.log('Création interaction annulation pour task:', {
594
- contactId: task.contactId,
595
- taskId: task.id,
596
- type: task.type,
597
- title: task.title,
598
- });
599
593
  await logAppointmentCancelled(
600
594
  task.contactId,
601
595
  task.id,
@@ -604,7 +598,6 @@ export async function DELETE(
604
598
  session.user.id,
605
599
  task.type === 'VIDEO_CONFERENCE',
606
600
  );
607
- console.log("Interaction d'annulation créée avec succès");
608
601
  } catch (interactionError: any) {
609
602
  console.error(
610
603
  "Erreur lors de la création de l'interaction d'annulation:",
@@ -612,11 +605,6 @@ export async function DELETE(
612
605
  );
613
606
  // On continue même si l'interaction échoue
614
607
  }
615
- } else {
616
- console.log("Pas de création d'interaction - conditions non remplies:", {
617
- contactId: task.contactId,
618
- type: task.type,
619
- });
620
608
  }
621
609
 
622
610
  // Supprimer la tâche
@@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma';
3
3
  import { checkPermission } from '@/lib/check-permission';
4
4
  import { auth } from '@/lib/auth';
5
5
  import { logAudit } from '@/lib/audit-log';
6
+ import { resolveRoleFromCustomRoleName } from '@/lib/roles';
6
7
 
7
8
  // GET /api/users/[id] - Récupérer un utilisateur spécifique
8
9
  export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
@@ -86,12 +87,26 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
86
87
  return NextResponse.json({ error: 'Utilisateur non trouvé' }, { status: 404 });
87
88
  }
88
89
 
89
- // Mettre à jour l'utilisateur
90
+ // Résoudre le rôle enum si le profil change
91
+ let resolvedRole: string | undefined;
92
+ if (customRoleId !== undefined) {
93
+ if (customRoleId) {
94
+ const customRole = await prisma.customRole.findUnique({
95
+ where: { id: customRoleId },
96
+ select: { name: true },
97
+ });
98
+ resolvedRole = resolveRoleFromCustomRoleName(customRole?.name ?? null);
99
+ } else {
100
+ resolvedRole = resolveRoleFromCustomRoleName(null);
101
+ }
102
+ }
103
+
90
104
  const updatedUser = await prisma.user.update({
91
105
  where: { id },
92
106
  data: {
93
107
  ...(name && { name }),
94
108
  ...(customRoleId !== undefined && { customRoleId: customRoleId || null }),
109
+ ...(resolvedRole && { role: resolvedRole as any }),
95
110
  ...(typeof active === 'boolean' && { active }),
96
111
  },
97
112
  include: {
@@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma';
3
3
  import { checkPermission } from '@/lib/check-permission';
4
4
  import { auth } from '@/lib/auth';
5
5
  import { logAudit } from '@/lib/audit-log';
6
+ import { resolveRoleFromCustomRoleName } from '@/lib/roles';
6
7
 
7
8
  // GET /api/users - Liste tous les utilisateurs (admin seulement)
8
9
  export async function GET(request: NextRequest) {
@@ -105,60 +106,46 @@ export async function POST(request: NextRequest) {
105
106
  // Si l'utilisateur existe mais sans compte, on peut régénérer un token
106
107
  }
107
108
 
109
+ // Résoudre le rôle enum à partir du profil attribué
110
+ const customRole = await prisma.customRole.findUnique({
111
+ where: { id: customRoleId },
112
+ select: { name: true },
113
+ });
114
+ const customRoleName = customRole?.name ?? null;
115
+ const resolvedRole = resolveRoleFromCustomRoleName(customRoleName);
116
+
108
117
  let user;
109
118
  if (existingUser && existingUser.accounts.length === 0) {
110
- // Utilisateur existe déjà sans compte, on met à jour et régénère le token
111
119
  user = await prisma.user.update({
112
120
  where: { id: existingUser.id },
113
121
  data: {
114
122
  name,
115
123
  customRoleId,
124
+ role: resolvedRole,
116
125
  active: true,
117
126
  },
118
127
  });
119
128
  } else {
120
- // Générer une couleur aléatoire pour les événements
121
129
  const colors = [
122
- '#EF4444', // Rouge
123
- '#3B82F6', // Bleu
124
- '#10B981', // Vert
125
- '#F59E0B', // Orange
126
- '#8B5CF6', // Violet
127
- '#EC4899', // Rose
128
- '#06B6D4', // Cyan
129
- '#84CC16', // Lime
130
- '#F97316', // Orange foncé
131
- '#6366F1', // Indigo
132
- '#14B8A6', // Teal
133
- '#A855F7', // Purple
130
+ '#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899',
131
+ '#06B6D4', '#84CC16', '#F97316', '#6366F1', '#14B8A6', '#A855F7',
134
132
  ];
135
133
  const randomColor = colors[Math.floor(Math.random() * colors.length)];
136
134
 
137
- // Créer l'utilisateur SANS mot de passe (sans Account)
138
135
  user = await prisma.user.create({
139
136
  data: {
140
137
  id: crypto.randomUUID(),
141
138
  name,
142
139
  email,
143
- role: 'USER', // Rôle par défaut, mais les permissions viendront du customRole
140
+ role: resolvedRole,
144
141
  customRoleId,
145
- emailVerified: false, // Pas encore vérifié
142
+ emailVerified: false,
146
143
  active: true,
147
144
  eventColor: randomColor,
148
145
  },
149
146
  });
150
147
  }
151
148
 
152
- // Récupérer le nom du profil pour les métadonnées
153
- let customRoleName: string | null = null;
154
- if (customRoleId) {
155
- const customRole = await prisma.customRole.findUnique({
156
- where: { id: customRoleId },
157
- select: { name: true },
158
- });
159
- customRoleName = customRole?.name || null;
160
- }
161
-
162
149
  // Log d'audit : création ou réactivation d'utilisateur
163
150
  await logAudit({
164
151
  actorId: session.user.id,
@@ -226,9 +213,6 @@ export async function POST(request: NextRequest) {
226
213
  statusText: emailResponse.statusText,
227
214
  error: errorData,
228
215
  });
229
- } else {
230
- const successData = await emailResponse.json().catch(() => ({}));
231
- console.log("✅ Email d'invitation envoyé avec succès:", successData);
232
216
  }
233
217
  } catch (emailError: any) {
234
218
  console.error("❌ Erreur lors de l'envoi de l'email:", emailError);
@@ -277,7 +277,6 @@ async function executeScheduledSMS(
277
277
  }
278
278
 
279
279
  // TODO: Implémenter l'envoi de SMS (nécessite une API SMS)
280
- console.log(`SMS à envoyer à ${contact.phone}: ${message}`);
281
280
 
282
281
  // Créer une interaction pour tracer le SMS
283
282
  await prisma.interaction.create({
@@ -980,39 +980,6 @@ body {
980
980
  }
981
981
  }
982
982
 
983
- @keyframes drag-handle-bounce {
984
- 0% {
985
- transform: scale(0.8) rotate(-10deg);
986
- opacity: 0;
987
- }
988
- 50% {
989
- transform: scale(1.05) rotate(2deg);
990
- opacity: 0.8;
991
- }
992
- 100% {
993
- transform: scale(1) rotate(0deg);
994
- opacity: 1;
995
- }
996
- }
997
-
998
- @keyframes button-hover-lift {
999
- 0% {
1000
- transform: translateY(0) scale(1);
1001
- }
1002
- 100% {
1003
- transform: translateY(-2px) scale(1.05);
1004
- }
1005
- }
1006
-
1007
- @keyframes button-press {
1008
- 0% {
1009
- transform: translateY(-2px) scale(1.05);
1010
- }
1011
- 100% {
1012
- transform: translateY(0) scale(0.98);
1013
- }
1014
- }
1015
-
1016
983
  /* ===== DARK MODE SUPPORT ===== */
1017
984
 
1018
985
  [data-theme='dark'] .lexkit-drag-handle,
@@ -34,7 +34,7 @@ function getRoleLevel(role: string): number {
34
34
  * Vérifie si l'utilisateur a le rôle requis ou un rôle supérieur dans la hiérarchie
35
35
  * Un rôle supérieur a automatiquement les permissions des rôles inférieurs
36
36
  */
37
- export function hasRole(userRole: string | undefined, requiredRole: Role): boolean {
37
+ function hasRole(userRole: string | undefined, requiredRole: Role): boolean {
38
38
  if (!userRole) return false;
39
39
 
40
40
  const userLevel = getRoleLevel(userRole);
@@ -44,43 +44,21 @@ export function hasRole(userRole: string | undefined, requiredRole: Role): boole
44
44
  return userLevel <= requiredLevel;
45
45
  }
46
46
 
47
- /**
48
- * Vérifie si l'utilisateur est admin
49
- */
50
- export function isAdmin(userRole: string | undefined): boolean {
51
- return userRole === Role.ADMIN;
52
- }
53
-
54
- /**
55
- * Vérifie si l'utilisateur est manager ou supérieur
56
- */
57
- export function isManagerOrAbove(userRole: string | undefined): boolean {
58
- if (!userRole) return false;
59
- return getRoleLevel(userRole) <= 2; // ADMIN ou MANAGER
60
- }
61
-
62
- /**
63
- * Vérifie si l'utilisateur est commercial ou supérieur
64
- */
65
- export function isCommercialOrAbove(userRole: string | undefined): boolean {
66
- if (!userRole) return false;
67
- return getRoleLevel(userRole) <= 3; // ADMIN, MANAGER ou COMMERCIAL
68
- }
69
-
70
- /**
71
- * Vérifie si l'utilisateur est télépro ou supérieur
72
- */
73
- export function isTeleproOrAbove(userRole: string | undefined): boolean {
74
- if (!userRole) return false;
75
- return getRoleLevel(userRole) <= 4; // ADMIN, MANAGER, COMMERCIAL ou TELEPRO
76
- }
47
+ const CUSTOM_ROLE_NAME_TO_ENUM: Record<string, Role> = {
48
+ 'Administrateur': Role.ADMIN,
49
+ 'Manager': Role.MANAGER,
50
+ 'Commercial': Role.COMMERCIAL,
51
+ 'Télépro': Role.TELEPRO,
52
+ 'Comptable': Role.COMPTABLE,
53
+ };
77
54
 
78
55
  /**
79
- * Vérifie si l'utilisateur est comptable ou supérieur
56
+ * Résout la valeur de l'enum Role à partir du nom d'un CustomRole.
57
+ * Retourne Role.USER pour les profils personnalisés non reconnus.
80
58
  */
81
- export function isComptableOrAbove(userRole: string | undefined): boolean {
82
- if (!userRole) return false;
83
- return getRoleLevel(userRole) <= 5; // ADMIN, MANAGER, COMMERCIAL, TELEPRO ou COMPTABLE
59
+ export function resolveRoleFromCustomRoleName(customRoleName: string | null | undefined): Role {
60
+ if (!customRoleName) return Role.USER;
61
+ return CUSTOM_ROLE_NAME_TO_ENUM[customRoleName] ?? Role.USER;
84
62
  }
85
63
 
86
64
  /**
@@ -5,6 +5,15 @@ export function cn(...inputs: Array<string | false | null | undefined>) {
5
5
  return twMerge(clsx(inputs));
6
6
  }
7
7
 
8
+ export const DELETED_USER_LABEL = 'Ancien utilisateur';
9
+
10
+ export function getUserDisplayName(
11
+ user: { name?: string | null; email?: string | null } | null | undefined,
12
+ ): string {
13
+ if (!user) return DELETED_USER_LABEL;
14
+ return user.name || user.email || DELETED_USER_LABEL;
15
+ }
16
+
8
17
  /**
9
18
  * Normalise un numéro de téléphone au format français : 0X XX XX XX XX
10
19
  * - Supprime tous les caractères non numériques
@@ -413,7 +413,6 @@ export async function sendEmailImmediate(
413
413
  export async function sendSMSImmediate(workflow: any, contact: any, message: string) {
414
414
  try {
415
415
  // TODO: Implémenter l'envoi de SMS (nécessite une API SMS)
416
- console.log(`SMS à envoyer à ${contact.phone}: ${message}`);
417
416
 
418
417
  // Créer une interaction pour tracer le SMS
419
418
  await prisma.interaction.create({
@@ -1,7 +1,7 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import type { NextRequest } from 'next/server';
3
3
  import { auth } from './lib/auth';
4
- import { isAdmin } from './lib/roles';
4
+ import { Role } from './lib/roles';
5
5
  import { prisma } from './lib/prisma';
6
6
 
7
7
  // Routes qui nécessitent une authentification
@@ -65,7 +65,7 @@ export async function proxy(request: NextRequest) {
65
65
  }
66
66
 
67
67
  // Si l'utilisateur est connecté mais n'est pas admin et tente d'accéder à une route admin
68
- if (isAuthenticated && isAdminRoute && !isAdmin(userRole || undefined)) {
68
+ if (isAuthenticated && isAdminRoute && userRole !== Role.ADMIN) {
69
69
  return NextResponse.redirect(new URL('/dashboard', request.url));
70
70
  }
71
71
 
@@ -1,77 +0,0 @@
1
- 'use client';
2
-
3
- import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
4
-
5
- interface SalesAnalyticsChartProps {
6
- data: Array<{ month: string; count: number }>;
7
- }
8
-
9
- export function SalesAnalyticsChart({ data }: SalesAnalyticsChartProps) {
10
- // Calculer le total et la croissance
11
- const total = data.reduce((sum, item) => sum + item.count, 0);
12
- const previousTotal =
13
- data.length > 1 ? data.slice(0, -1).reduce((sum, item) => sum + item.count, 0) : 0;
14
- const growth = previousTotal > 0 ? ((total - previousTotal) / previousTotal) * 100 : 0;
15
-
16
- return (
17
- <div className="rounded-xl border border-gray-200/50 bg-white p-6 shadow-lg transition-shadow duration-300 hover:shadow-xl">
18
- <div className="mb-4 flex items-center justify-between">
19
- <div>
20
- <h3 className="text-lg font-bold text-gray-900">Analytiques des Ventes</h3>
21
- </div>
22
- <select className="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none">
23
- <option>Mensuel</option>
24
- <option>Hebdomadaire</option>
25
- <option>Annuel</option>
26
- </select>
27
- </div>
28
- <div className="mb-4 flex items-baseline gap-4">
29
- <div>
30
- <p className="text-2xl font-bold text-gray-900">
31
- {total.toLocaleString('fr-FR')} contacts ce mois
32
- </p>
33
- <p className="mt-1 text-sm font-semibold text-emerald-600">
34
- Augmentation ce mois : +{growth.toFixed(1)}%
35
- </p>
36
- </div>
37
- </div>
38
- <div className="h-[280px]">
39
- <ResponsiveContainer width="100%" height="100%">
40
- <BarChart data={data} barCategoryGap="20%">
41
- <defs>
42
- <linearGradient id="barGradient" x1="0" y1="0" x2="0" y2="1">
43
- <stop offset="0%" stopColor="#8b5cf6" stopOpacity={1} />
44
- <stop offset="100%" stopColor="#6366f1" stopOpacity={0.8} />
45
- </linearGradient>
46
- </defs>
47
- <XAxis
48
- dataKey="month"
49
- stroke="#9ca3af"
50
- fontSize={12}
51
- tickLine={false}
52
- axisLine={false}
53
- />
54
- <YAxis
55
- stroke="#9ca3af"
56
- fontSize={12}
57
- tickLine={false}
58
- axisLine={false}
59
- tickFormatter={(value) => `${value}`}
60
- />
61
- <Tooltip
62
- contentStyle={{
63
- backgroundColor: 'white',
64
- border: '1px solid #e5e7eb',
65
- borderRadius: '12px',
66
- boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
67
- }}
68
- labelStyle={{ color: '#374151', fontWeight: 600 }}
69
- formatter={(value: number) => [`${value.toLocaleString('fr-FR')}`, 'Contacts']}
70
- />
71
- <Bar dataKey="count" fill="url(#barGradient)" radius={[8, 8, 0, 0]} />
72
- </BarChart>
73
- </ResponsiveContainer>
74
- </div>
75
- </div>
76
- );
77
- }