create-crm-tmp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-crm-tmp.js +93 -0
- package/package.json +25 -0
- package/template/.prettierignore +33 -0
- package/template/.prettierrc.json +25 -0
- package/template/README.md +173 -0
- package/template/eslint.config.mjs +18 -0
- package/template/exemple-contacts.csv +11 -0
- package/template/next.config.ts +8 -0
- package/template/package.json +64 -0
- package/template/postcss.config.mjs +7 -0
- package/template/prisma/migrations/20251126144728_init/migration.sql +78 -0
- package/template/prisma/migrations/20251126155204_add_user_roles/migration.sql +5 -0
- package/template/prisma/migrations/20251128095126_add_company_info/migration.sql +19 -0
- package/template/prisma/migrations/20251128123321_add_smtp_config/migration.sql +22 -0
- package/template/prisma/migrations/20251128132303_add_status/migration.sql +23 -0
- package/template/prisma/migrations/20251201102207_add_user_active/migration.sql +75 -0
- package/template/prisma/migrations/20251201105507_add_email_signature/migration.sql +2 -0
- package/template/prisma/migrations/20251201151122_add_tasks/migration.sql +45 -0
- package/template/prisma/migrations/20251202111854_add_task_reminder/migration.sql +2 -0
- package/template/prisma/migrations/20251202135859_add_google_meet_integration/migration.sql +27 -0
- package/template/prisma/migrations/20251203103317_add_meta_lead_integration/migration.sql +20 -0
- package/template/prisma/migrations/20251203104002_add_google_ads_integration/migration.sql +18 -0
- package/template/prisma/migrations/20251203112122_add_google_sheet_integration/migration.sql +32 -0
- package/template/prisma/migrations/20251203153853_allow_multiple_integration_configs/migration.sql +20 -0
- package/template/prisma/migrations/20251205141705_update_user_roles/migration.sql +12 -0
- package/template/prisma/migrations/20251205150000_add_commercial_and_telepro_assignment/migration.sql +21 -0
- package/template/prisma/migrations/20251205160000_add_interaction_logging/migration.sql +11 -0
- package/template/prisma/migrations/20251208090314_add_automatic_interaction_types/migration.sql +12 -0
- package/template/prisma/migrations/20251208094843_mg/migration.sql +14 -0
- package/template/prisma/migrations/20251208100000_add_company_support/migration.sql +14 -0
- package/template/prisma/migrations/20251208110000_add_templates/migration.sql +26 -0
- package/template/prisma/migrations/20251208141304_add_video_conference_task_type/migration.sql +2 -0
- package/template/prisma/migrations/20251209104759_add_internal_note_to_task/migration.sql +2 -0
- package/template/prisma/migrations/20251209134803_add_company_field/migration.sql +2 -0
- package/template/prisma/migrations/20251209150000_rename_company_to_company_name/migration.sql +3 -0
- package/template/prisma/migrations/20251209150016_add_email_tracking/migration.sql +21 -0
- package/template/prisma/migrations/20251209155908_add_notify_contact_to_task/migration.sql +2 -0
- package/template/prisma/migrations/20251210110019_add_appointment_types/migration.sql +10 -0
- package/template/prisma/migrations/20251210113928_add_contact_files/migration.sql +26 -0
- package/template/prisma/migrations/20251212132339_add_custom_roles/migration.sql +24 -0
- package/template/prisma/migrations/20251215104448_add_file_interaction_types/migration.sql +11 -0
- package/template/prisma/migrations/20251215145616_add_closing_reasons/migration.sql +12 -0
- package/template/prisma/migrations/20251216140850_add_log_users/migration.sql +25 -0
- package/template/prisma/migrations/20251216151000_rename_perdu_to_ferme/migration.sql +8 -0
- package/template/prisma/migrations/20251216162318_add_column_mappings_to_google_sheet/migration.sql +2 -0
- package/template/prisma/migrations/20251216185127_add_workflows/migration.sql +80 -0
- package/template/prisma/migrations/20251216192237_add_scheduled_workflow_actions/migration.sql +32 -0
- package/template/prisma/migrations/migration_lock.toml +3 -0
- package/template/prisma/schema.prisma +582 -0
- package/template/prisma.config.ts +14 -0
- package/template/src/app/(auth)/invite/[token]/page.tsx +200 -0
- package/template/src/app/(auth)/layout.tsx +3 -0
- package/template/src/app/(auth)/reset-password/complete/page.tsx +213 -0
- package/template/src/app/(auth)/reset-password/page.tsx +146 -0
- package/template/src/app/(auth)/reset-password/verify/page.tsx +183 -0
- package/template/src/app/(auth)/signin/page.tsx +166 -0
- package/template/src/app/(dashboard)/agenda/page.tsx +3051 -0
- package/template/src/app/(dashboard)/automatisation/[id]/page.tsx +24 -0
- package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +905 -0
- package/template/src/app/(dashboard)/automatisation/new/page.tsx +20 -0
- package/template/src/app/(dashboard)/automatisation/page.tsx +337 -0
- package/template/src/app/(dashboard)/closing/page.tsx +1052 -0
- package/template/src/app/(dashboard)/contacts/[id]/page.tsx +6028 -0
- package/template/src/app/(dashboard)/contacts/page.tsx +3713 -0
- package/template/src/app/(dashboard)/dashboard/page.tsx +186 -0
- package/template/src/app/(dashboard)/layout.tsx +30 -0
- package/template/src/app/(dashboard)/settings/page.tsx +4070 -0
- package/template/src/app/(dashboard)/templates/page.tsx +567 -0
- package/template/src/app/(dashboard)/users/list/page.tsx +507 -0
- package/template/src/app/(dashboard)/users/page.tsx +457 -0
- package/template/src/app/(dashboard)/users/permissions/page.tsx +181 -0
- package/template/src/app/(dashboard)/users/roles/page.tsx +434 -0
- package/template/src/app/api/audit-logs/route.ts +57 -0
- package/template/src/app/api/auth/[...all]/route.ts +4 -0
- package/template/src/app/api/auth/check-active/route.ts +31 -0
- package/template/src/app/api/auth/google/callback/route.ts +94 -0
- package/template/src/app/api/auth/google/disconnect/route.ts +32 -0
- package/template/src/app/api/auth/google/route.ts +34 -0
- package/template/src/app/api/auth/google/status/route.ts +32 -0
- package/template/src/app/api/closing-reasons/route.ts +27 -0
- package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +94 -0
- package/template/src/app/api/contacts/[id]/files/route.ts +269 -0
- package/template/src/app/api/contacts/[id]/interactions/[interactionId]/route.ts +91 -0
- package/template/src/app/api/contacts/[id]/interactions/route.ts +103 -0
- package/template/src/app/api/contacts/[id]/meet/route.ts +296 -0
- package/template/src/app/api/contacts/[id]/route.ts +322 -0
- package/template/src/app/api/contacts/[id]/send-email/route.ts +254 -0
- package/template/src/app/api/contacts/export/route.ts +270 -0
- package/template/src/app/api/contacts/import/route.ts +381 -0
- package/template/src/app/api/contacts/route.ts +283 -0
- package/template/src/app/api/dashboard/stats/route.ts +299 -0
- package/template/src/app/api/email/track/[id]/route.ts +68 -0
- package/template/src/app/api/integrations/google-sheet/sync/route.ts +526 -0
- package/template/src/app/api/invite/complete/route.ts +88 -0
- package/template/src/app/api/invite/validate/route.ts +55 -0
- package/template/src/app/api/reminders/route.ts +95 -0
- package/template/src/app/api/reset-password/complete/route.ts +73 -0
- package/template/src/app/api/reset-password/request/route.ts +84 -0
- package/template/src/app/api/reset-password/validate/route.ts +49 -0
- package/template/src/app/api/reset-password/verify/route.ts +74 -0
- package/template/src/app/api/roles/[id]/route.ts +183 -0
- package/template/src/app/api/roles/route.ts +140 -0
- package/template/src/app/api/send/route.ts +282 -0
- package/template/src/app/api/settings/change-password/route.ts +95 -0
- package/template/src/app/api/settings/closing-reasons/[id]/route.ts +84 -0
- package/template/src/app/api/settings/closing-reasons/route.ts +74 -0
- package/template/src/app/api/settings/company/route.ts +121 -0
- package/template/src/app/api/settings/google-ads/[id]/route.ts +117 -0
- package/template/src/app/api/settings/google-ads/route.ts +122 -0
- package/template/src/app/api/settings/google-sheet/[id]/route.ts +230 -0
- package/template/src/app/api/settings/google-sheet/auto-map/route.ts +196 -0
- package/template/src/app/api/settings/google-sheet/route.ts +254 -0
- package/template/src/app/api/settings/meta-leads/[id]/route.ts +123 -0
- package/template/src/app/api/settings/meta-leads/route.ts +132 -0
- package/template/src/app/api/settings/profile/route.ts +42 -0
- package/template/src/app/api/settings/smtp/route.ts +130 -0
- package/template/src/app/api/settings/smtp/test/route.ts +121 -0
- package/template/src/app/api/settings/statuses/[id]/route.ts +101 -0
- package/template/src/app/api/settings/statuses/route.ts +83 -0
- package/template/src/app/api/statuses/route.ts +25 -0
- package/template/src/app/api/tasks/[id]/attendees/route.ts +76 -0
- package/template/src/app/api/tasks/[id]/route.ts +728 -0
- package/template/src/app/api/tasks/meet/route.ts +240 -0
- package/template/src/app/api/tasks/route.ts +417 -0
- package/template/src/app/api/templates/[id]/route.ts +140 -0
- package/template/src/app/api/templates/route.ts +91 -0
- package/template/src/app/api/users/[id]/route.ts +168 -0
- package/template/src/app/api/users/list/route.ts +45 -0
- package/template/src/app/api/users/me/route.ts +48 -0
- package/template/src/app/api/users/route.ts +250 -0
- package/template/src/app/api/webhooks/google-ads/route.ts +208 -0
- package/template/src/app/api/webhooks/meta-leads/route.ts +258 -0
- package/template/src/app/api/workflows/[id]/route.ts +192 -0
- package/template/src/app/api/workflows/process/route.ts +293 -0
- package/template/src/app/api/workflows/route.ts +124 -0
- package/template/src/app/favicon.ico +0 -0
- package/template/src/app/globals.css +1416 -0
- package/template/src/app/layout.tsx +31 -0
- package/template/src/app/page.tsx +32 -0
- package/template/src/components/dashboard/activity-chart.tsx +67 -0
- package/template/src/components/dashboard/contacts-chart.tsx +63 -0
- package/template/src/components/dashboard/recent-activity.tsx +164 -0
- package/template/src/components/dashboard/sales-analytics-chart.tsx +81 -0
- package/template/src/components/dashboard/stat-card.tsx +61 -0
- package/template/src/components/dashboard/status-distribution-chart.tsx +45 -0
- package/template/src/components/dashboard/tasks-pie-chart.tsx +88 -0
- package/template/src/components/dashboard/top-contacts-list.tsx +129 -0
- package/template/src/components/dashboard/upcoming-tasks-list.tsx +126 -0
- package/template/src/components/editor.tsx +856 -0
- package/template/src/components/email-template.tsx +35 -0
- package/template/src/components/header.tsx +320 -0
- package/template/src/components/invitation-email-template.tsx +79 -0
- package/template/src/components/meet-cancellation-email-template.tsx +120 -0
- package/template/src/components/meet-confirmation-email-template.tsx +156 -0
- package/template/src/components/meet-update-email-template.tsx +209 -0
- package/template/src/components/page-header.tsx +61 -0
- package/template/src/components/reset-password-email-template.tsx +79 -0
- package/template/src/components/sidebar.tsx +294 -0
- package/template/src/components/skeleton.tsx +380 -0
- package/template/src/components/ui/commands.tsx +396 -0
- package/template/src/components/ui/components.tsx +150 -0
- package/template/src/components/ui/theme.tsx +5 -0
- package/template/src/components/view-as-banner.tsx +45 -0
- package/template/src/components/view-as-modal.tsx +186 -0
- package/template/src/contexts/mobile-menu-context.tsx +31 -0
- package/template/src/contexts/sidebar-context.tsx +107 -0
- package/template/src/contexts/task-reminder-context.tsx +239 -0
- package/template/src/contexts/view-as-context.tsx +84 -0
- package/template/src/hooks/use-user-role.ts +82 -0
- package/template/src/lib/audit-log.ts +45 -0
- package/template/src/lib/auth-client.ts +16 -0
- package/template/src/lib/auth.ts +35 -0
- package/template/src/lib/check-permission.ts +193 -0
- package/template/src/lib/contact-duplicate.ts +112 -0
- package/template/src/lib/contact-interactions.ts +371 -0
- package/template/src/lib/encryption.ts +99 -0
- package/template/src/lib/google-calendar.ts +300 -0
- package/template/src/lib/google-drive.ts +372 -0
- package/template/src/lib/permissions.ts +412 -0
- package/template/src/lib/prisma.ts +32 -0
- package/template/src/lib/roles.ts +120 -0
- package/template/src/lib/template-variables.ts +76 -0
- package/template/src/lib/utils.ts +46 -0
- package/template/src/lib/workflow-executor.ts +482 -0
- package/template/src/proxy.ts +91 -0
- package/template/tsconfig.json +34 -0
- package/template/vercel.json +8 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { useSession } from '@/lib/auth-client';
|
|
5
|
+
import { useViewAs } from '@/contexts/view-as-context';
|
|
6
|
+
import { X, Check, User as UserIcon } from 'lucide-react';
|
|
7
|
+
import { useRouter } from 'next/navigation';
|
|
8
|
+
import { cn } from '@/lib/utils';
|
|
9
|
+
|
|
10
|
+
interface User {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
email: string;
|
|
14
|
+
customRole: {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
permissions: string[];
|
|
18
|
+
} | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ViewAsModalProps {
|
|
22
|
+
isOpen: boolean;
|
|
23
|
+
onClose: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ViewAsModal({ isOpen, onClose }: ViewAsModalProps) {
|
|
27
|
+
const { data: session } = useSession();
|
|
28
|
+
const { viewAsUser, setViewAsUser, clearViewAsUser } = useViewAs();
|
|
29
|
+
const [users, setUsers] = useState<User[]>([]);
|
|
30
|
+
const [loading, setLoading] = useState(true);
|
|
31
|
+
const router = useRouter();
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (isOpen) {
|
|
35
|
+
fetchUsers();
|
|
36
|
+
}
|
|
37
|
+
}, [isOpen]);
|
|
38
|
+
|
|
39
|
+
const fetchUsers = async () => {
|
|
40
|
+
try {
|
|
41
|
+
setLoading(true);
|
|
42
|
+
const response = await fetch('/api/users/list');
|
|
43
|
+
if (response.ok) {
|
|
44
|
+
const data = await response.json();
|
|
45
|
+
setUsers(data);
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error('Erreur lors du chargement des utilisateurs:', error);
|
|
49
|
+
} finally {
|
|
50
|
+
setLoading(false);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const handleSelectUser = (user: User) => {
|
|
55
|
+
setViewAsUser(user);
|
|
56
|
+
onClose();
|
|
57
|
+
// Rafraîchir la page pour appliquer les changements
|
|
58
|
+
router.refresh();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const getInitials = (name: string) => {
|
|
62
|
+
return name
|
|
63
|
+
.split(' ')
|
|
64
|
+
.map((n) => n[0])
|
|
65
|
+
.join('')
|
|
66
|
+
.toUpperCase()
|
|
67
|
+
.slice(0, 2);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (!isOpen) return null;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center rounded-lg bg-gray-500/20 p-4 shadow-xl backdrop-blur-sm">
|
|
74
|
+
<div className="w-full max-w-2xl rounded-lg bg-white shadow-xl">
|
|
75
|
+
{/* En-tête */}
|
|
76
|
+
<div className="flex items-center justify-between rounded-t-lg border-b border-gray-200 bg-indigo-600 px-6 py-4">
|
|
77
|
+
<div className="flex items-center gap-3 text-white">
|
|
78
|
+
<UserIcon className="h-6 w-6" />
|
|
79
|
+
<div>
|
|
80
|
+
<h2 className="text-xl font-bold">Changer de vue</h2>
|
|
81
|
+
<p className="text-sm text-white/90">
|
|
82
|
+
Voir l'application avec les permissions d'un profil
|
|
83
|
+
</p>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
<button
|
|
87
|
+
onClick={onClose}
|
|
88
|
+
className="cursor-pointer rounded-lg p-2 text-white transition-colors hover:bg-white/20"
|
|
89
|
+
aria-label="Fermer"
|
|
90
|
+
>
|
|
91
|
+
<X className="h-5 w-5" />
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{/* Contenu */}
|
|
96
|
+
<div className="max-h-[60vh] overflow-y-auto p-6">
|
|
97
|
+
{loading ? (
|
|
98
|
+
<div className="py-12 text-center">
|
|
99
|
+
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-indigo-600 border-t-transparent"></div>
|
|
100
|
+
</div>
|
|
101
|
+
) : (
|
|
102
|
+
<div className="space-y-2">
|
|
103
|
+
{/* Ma vue */}
|
|
104
|
+
{session?.user && (
|
|
105
|
+
<button
|
|
106
|
+
onClick={() => {
|
|
107
|
+
clearViewAsUser();
|
|
108
|
+
onClose();
|
|
109
|
+
router.refresh();
|
|
110
|
+
}}
|
|
111
|
+
className={cn(
|
|
112
|
+
'w-full cursor-pointer rounded-lg border-2 p-4 text-left transition-all',
|
|
113
|
+
!viewAsUser
|
|
114
|
+
? 'border-indigo-500 bg-indigo-50'
|
|
115
|
+
: 'border-gray-200 bg-white hover:border-indigo-300 hover:bg-indigo-50/50',
|
|
116
|
+
)}
|
|
117
|
+
>
|
|
118
|
+
<div className="flex items-center justify-between">
|
|
119
|
+
<div className="flex items-center gap-4">
|
|
120
|
+
<div
|
|
121
|
+
className={cn(
|
|
122
|
+
'flex h-12 w-12 items-center justify-center rounded-full text-lg font-bold',
|
|
123
|
+
!viewAsUser
|
|
124
|
+
? 'bg-indigo-600 text-white'
|
|
125
|
+
: 'bg-indigo-100 text-indigo-600',
|
|
126
|
+
)}
|
|
127
|
+
>
|
|
128
|
+
{getInitials(session.user.name || session.user.email)}
|
|
129
|
+
</div>
|
|
130
|
+
<div>
|
|
131
|
+
<div className="flex items-center gap-2">
|
|
132
|
+
<span className="font-semibold text-gray-900">Ma vue</span>
|
|
133
|
+
{!viewAsUser && <span className="text-sm text-indigo-600">← Retour</span>}
|
|
134
|
+
</div>
|
|
135
|
+
<span className="text-sm text-gray-600">{session.user.name}</span>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
{!viewAsUser && <Check className="h-6 w-6 text-indigo-600" />}
|
|
139
|
+
</div>
|
|
140
|
+
</button>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{/* Autres utilisateurs */}
|
|
144
|
+
{users
|
|
145
|
+
.filter((user) => user.id !== session?.user?.id)
|
|
146
|
+
.map((user) => (
|
|
147
|
+
<button
|
|
148
|
+
key={user.id}
|
|
149
|
+
onClick={() => handleSelectUser(user)}
|
|
150
|
+
className={cn(
|
|
151
|
+
'w-full cursor-pointer rounded-lg border-2 p-4 text-left transition-all',
|
|
152
|
+
viewAsUser?.id === user.id
|
|
153
|
+
? 'border-indigo-500 bg-indigo-50'
|
|
154
|
+
: 'border-gray-200 bg-white hover:border-indigo-300 hover:bg-indigo-50/50',
|
|
155
|
+
)}
|
|
156
|
+
>
|
|
157
|
+
<div className="flex items-center justify-between">
|
|
158
|
+
<div className="flex items-center gap-4">
|
|
159
|
+
<div
|
|
160
|
+
className={cn(
|
|
161
|
+
'flex h-12 w-12 items-center justify-center rounded-full text-sm font-bold',
|
|
162
|
+
viewAsUser?.id === user.id
|
|
163
|
+
? 'bg-indigo-600 text-white'
|
|
164
|
+
: 'bg-gray-200 text-gray-600',
|
|
165
|
+
)}
|
|
166
|
+
>
|
|
167
|
+
{getInitials(user.name || user.email)}
|
|
168
|
+
</div>
|
|
169
|
+
<div>
|
|
170
|
+
<div className="font-semibold text-gray-900">{user.name}</div>
|
|
171
|
+
<div className="text-sm text-gray-600">
|
|
172
|
+
{user.customRole?.name || 'Sans profil'} · {user.email.split('@')[0]}
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
{viewAsUser?.id === user.id && <Check className="h-6 w-6 text-indigo-600" />}
|
|
177
|
+
</div>
|
|
178
|
+
</button>
|
|
179
|
+
))}
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
interface MobileMenuContextType {
|
|
6
|
+
isOpen: boolean;
|
|
7
|
+
setIsOpen: (open: boolean) => void;
|
|
8
|
+
toggle: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const MobileMenuContext = createContext<MobileMenuContextType | undefined>(undefined);
|
|
12
|
+
|
|
13
|
+
export function MobileMenuProvider({ children }: { children: ReactNode }) {
|
|
14
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
15
|
+
|
|
16
|
+
const toggle = () => setIsOpen(!isOpen);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<MobileMenuContext.Provider value={{ isOpen, setIsOpen, toggle }}>
|
|
20
|
+
{children}
|
|
21
|
+
</MobileMenuContext.Provider>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useMobileMenuContext() {
|
|
26
|
+
const context = useContext(MobileMenuContext);
|
|
27
|
+
if (context === undefined) {
|
|
28
|
+
throw new Error('useMobileMenuContext must be used within a MobileMenuProvider');
|
|
29
|
+
}
|
|
30
|
+
return context;
|
|
31
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
interface SidebarContextType {
|
|
6
|
+
isCollapsed: boolean;
|
|
7
|
+
isPinned: boolean;
|
|
8
|
+
setIsCollapsed: (collapsed: boolean) => void;
|
|
9
|
+
setIsPinned: (pinned: boolean) => void;
|
|
10
|
+
togglePin: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
|
14
|
+
|
|
15
|
+
export function SidebarProvider({ children }: { children: ReactNode }) {
|
|
16
|
+
// Éviter tout accès à window/localStorage pendant le rendu SSR
|
|
17
|
+
// Valeurs par défaut stables côté serveur et lors de la toute première
|
|
18
|
+
// hydratation côté client. Les vraies valeurs sont appliquées ensuite via useEffect.
|
|
19
|
+
const [isPinned, setIsPinnedState] = useState(false);
|
|
20
|
+
// Par défaut on considère la sidebar comme réduite (desktop),
|
|
21
|
+
// puis on ajuste après le montage en fonction de la taille d'écran et de la préférence.
|
|
22
|
+
const [isCollapsed, setIsCollapsedState] = useState(true);
|
|
23
|
+
|
|
24
|
+
// Lecture initiale de la préférence et de la taille d'écran après montage
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (typeof window === 'undefined') return;
|
|
27
|
+
|
|
28
|
+
const saved = window.localStorage.getItem('sidebar-pinned');
|
|
29
|
+
const initialPinned = saved === 'true';
|
|
30
|
+
setIsPinnedState(initialPinned);
|
|
31
|
+
|
|
32
|
+
if (window.innerWidth < 1024) {
|
|
33
|
+
// En mobile, jamais de collapse
|
|
34
|
+
setIsCollapsedState(false);
|
|
35
|
+
} else {
|
|
36
|
+
// En desktop, utiliser la préférence
|
|
37
|
+
setIsCollapsedState(!initialPinned);
|
|
38
|
+
}
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
// Sauvegarder la préférence dans localStorage et adapter le collapse
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (typeof window === 'undefined') return;
|
|
44
|
+
|
|
45
|
+
window.localStorage.setItem('sidebar-pinned', String(isPinned));
|
|
46
|
+
|
|
47
|
+
// Ne réduire la sidebar qu'en desktop (>= 1024px)
|
|
48
|
+
if (window.innerWidth >= 1024) {
|
|
49
|
+
setIsCollapsedState(!isPinned);
|
|
50
|
+
} else {
|
|
51
|
+
// En mobile, toujours false (pas de collapse)
|
|
52
|
+
setIsCollapsedState(false);
|
|
53
|
+
}
|
|
54
|
+
}, [isPinned]);
|
|
55
|
+
|
|
56
|
+
// Écouter les changements de taille d'écran
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (typeof window === 'undefined') return;
|
|
59
|
+
|
|
60
|
+
const handleResize = () => {
|
|
61
|
+
// En mobile, toujours false (pas de collapse)
|
|
62
|
+
if (window.innerWidth < 1024) {
|
|
63
|
+
setIsCollapsedState(false);
|
|
64
|
+
} else {
|
|
65
|
+
// En desktop, utiliser la préférence isPinned
|
|
66
|
+
setIsCollapsedState(!isPinned);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
window.addEventListener('resize', handleResize);
|
|
71
|
+
return () => window.removeEventListener('resize', handleResize);
|
|
72
|
+
}, [isPinned]);
|
|
73
|
+
|
|
74
|
+
const setIsPinned = (pinned: boolean) => {
|
|
75
|
+
setIsPinnedState(pinned);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const setIsCollapsed = (collapsed: boolean) => {
|
|
79
|
+
setIsCollapsedState(collapsed);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const togglePin = () => {
|
|
83
|
+
setIsPinnedState(!isPinned);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<SidebarContext.Provider
|
|
88
|
+
value={{
|
|
89
|
+
isCollapsed,
|
|
90
|
+
isPinned,
|
|
91
|
+
setIsCollapsed,
|
|
92
|
+
setIsPinned,
|
|
93
|
+
togglePin,
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
{children}
|
|
97
|
+
</SidebarContext.Provider>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function useSidebarContext() {
|
|
102
|
+
const context = useContext(SidebarContext);
|
|
103
|
+
if (context === undefined) {
|
|
104
|
+
throw new Error('useSidebarContext must be used within a SidebarProvider');
|
|
105
|
+
}
|
|
106
|
+
return context;
|
|
107
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useSession } from '@/lib/auth-client';
|
|
5
|
+
import { useUserRole } from '@/hooks/use-user-role';
|
|
6
|
+
import Link from 'next/link';
|
|
7
|
+
import { Bell, X } from 'lucide-react';
|
|
8
|
+
|
|
9
|
+
type Task = {
|
|
10
|
+
id: string;
|
|
11
|
+
type: 'CALL' | 'MEETING' | 'EMAIL' | 'OTHER';
|
|
12
|
+
title: string | null;
|
|
13
|
+
description: string;
|
|
14
|
+
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
|
|
15
|
+
scheduledAt: string;
|
|
16
|
+
completed: boolean;
|
|
17
|
+
reminderMinutesBefore?: number | null;
|
|
18
|
+
contact: {
|
|
19
|
+
id: string;
|
|
20
|
+
firstName: string | null;
|
|
21
|
+
lastName: string | null;
|
|
22
|
+
} | null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type Notification = { id: string; message: string; link?: string };
|
|
26
|
+
|
|
27
|
+
type TaskReminderContextValue = {
|
|
28
|
+
notifications: Notification[];
|
|
29
|
+
dismissNotification: (id: string) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const TaskReminderContext = createContext<TaskReminderContextValue | undefined>(undefined);
|
|
33
|
+
|
|
34
|
+
const TASK_TYPE_LABELS: Record<Task['type'], string> = {
|
|
35
|
+
CALL: 'Appel téléphonique',
|
|
36
|
+
MEETING: 'RDV',
|
|
37
|
+
EMAIL: 'Email',
|
|
38
|
+
OTHER: 'Autre',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function formatTime(dateString: string) {
|
|
42
|
+
return new Date(dateString).toLocaleTimeString('fr-FR', {
|
|
43
|
+
hour: '2-digit',
|
|
44
|
+
minute: '2-digit',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function TaskReminderProvider({ children }: { children: React.ReactNode }) {
|
|
49
|
+
const { data: session } = useSession();
|
|
50
|
+
const { isAdmin } = useUserRole();
|
|
51
|
+
const [tasks, setTasks] = useState<Task[]>([]);
|
|
52
|
+
const [notifications, setNotifications] = useState<Notification[]>([]);
|
|
53
|
+
const notifiedKeysRef = useRef<Set<string>>(new Set());
|
|
54
|
+
|
|
55
|
+
// Charger les tâches pertinentes pour les rappels
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!session) return;
|
|
58
|
+
|
|
59
|
+
const fetchTasks = async () => {
|
|
60
|
+
try {
|
|
61
|
+
const now = new Date();
|
|
62
|
+
const start = new Date(now);
|
|
63
|
+
start.setDate(start.getDate() - 1); // hier
|
|
64
|
+
const end = new Date(now);
|
|
65
|
+
end.setDate(end.getDate() + 1); // demain
|
|
66
|
+
|
|
67
|
+
const params = new URLSearchParams({
|
|
68
|
+
startDate: start.toISOString(),
|
|
69
|
+
endDate: end.toISOString(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const response = await fetch(`/api/tasks?${params.toString()}`);
|
|
73
|
+
if (response.ok) {
|
|
74
|
+
const data = await response.json();
|
|
75
|
+
setTasks(data);
|
|
76
|
+
}
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error('Erreur lors du chargement des tâches pour les rappels:', error);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
fetchTasks();
|
|
83
|
+
const interval = setInterval(fetchTasks, 5 * 60 * 1000); // rafraîchir toutes les 5 min
|
|
84
|
+
return () => clearInterval(interval);
|
|
85
|
+
}, [session, isAdmin]);
|
|
86
|
+
|
|
87
|
+
// Supprimer les notifications des tâches qui n'existent plus
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!session) return;
|
|
90
|
+
|
|
91
|
+
const taskIds = new Set(tasks.map((task) => task.id));
|
|
92
|
+
setNotifications((prev) =>
|
|
93
|
+
prev.filter((notif) => {
|
|
94
|
+
// Extraire l'ID de la tâche depuis l'ID de la notification
|
|
95
|
+
// Format: `${task.id}-due` ou `${task.id}-reminder`
|
|
96
|
+
let taskId: string;
|
|
97
|
+
if (notif.id.endsWith('-due')) {
|
|
98
|
+
taskId = notif.id.slice(0, -4); // Retirer '-due'
|
|
99
|
+
} else if (notif.id.endsWith('-reminder')) {
|
|
100
|
+
taskId = notif.id.slice(0, -9); // Retirer '-reminder'
|
|
101
|
+
} else {
|
|
102
|
+
// Format inattendu, on garde la notification pour éviter de la supprimer par erreur
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
return taskIds.has(taskId);
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Nettoyer aussi les clés de notification des tâches supprimées
|
|
110
|
+
const validKeys = new Set<string>();
|
|
111
|
+
tasks.forEach((task) => {
|
|
112
|
+
validKeys.add(`${task.id}-due`);
|
|
113
|
+
validKeys.add(`${task.id}-reminder`);
|
|
114
|
+
});
|
|
115
|
+
notifiedKeysRef.current = new Set(
|
|
116
|
+
Array.from(notifiedKeysRef.current).filter((key) => validKeys.has(key)),
|
|
117
|
+
);
|
|
118
|
+
}, [tasks, session]);
|
|
119
|
+
|
|
120
|
+
// Génération des notifications
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!session) return;
|
|
123
|
+
|
|
124
|
+
const interval = setInterval(() => {
|
|
125
|
+
const now = new Date();
|
|
126
|
+
const newNotifications: Notification[] = [];
|
|
127
|
+
const notified = new Set(notifiedKeysRef.current);
|
|
128
|
+
|
|
129
|
+
tasks.forEach((task) => {
|
|
130
|
+
if (task.completed) return;
|
|
131
|
+
const scheduled = new Date(task.scheduledAt);
|
|
132
|
+
|
|
133
|
+
// Notification à l'heure exacte de la tâche (fenêtre de 5 minutes)
|
|
134
|
+
const dueKey = `${task.id}-due`;
|
|
135
|
+
const diffMs = now.getTime() - scheduled.getTime();
|
|
136
|
+
if (diffMs >= 0 && diffMs < 5 * 60 * 1000 && !notified.has(dueKey)) {
|
|
137
|
+
notified.add(dueKey);
|
|
138
|
+
newNotifications.push({
|
|
139
|
+
id: dueKey,
|
|
140
|
+
message: `Vous avez une tâche maintenant : ${
|
|
141
|
+
task.title || TASK_TYPE_LABELS[task.type]
|
|
142
|
+
} (${formatTime(task.scheduledAt)})`,
|
|
143
|
+
link: task.contact ? `/contacts/${task.contact.id}` : undefined,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Notification de rappel avant l'heure
|
|
148
|
+
if (task.reminderMinutesBefore != null && task.reminderMinutesBefore > 0) {
|
|
149
|
+
const reminderMs = task.reminderMinutesBefore * 60 * 1000;
|
|
150
|
+
const reminderTime = new Date(scheduled.getTime() - reminderMs);
|
|
151
|
+
const reminderKey = `${task.id}-reminder`;
|
|
152
|
+
const diffReminderMs = now.getTime() - reminderTime.getTime();
|
|
153
|
+
|
|
154
|
+
if (
|
|
155
|
+
diffReminderMs >= 0 &&
|
|
156
|
+
diffReminderMs < 5 * 60 * 1000 &&
|
|
157
|
+
now < scheduled &&
|
|
158
|
+
!notified.has(reminderKey)
|
|
159
|
+
) {
|
|
160
|
+
notified.add(reminderKey);
|
|
161
|
+
newNotifications.push({
|
|
162
|
+
id: reminderKey,
|
|
163
|
+
message: `Rappel dans ${task.reminderMinutesBefore} min : ${
|
|
164
|
+
task.title || TASK_TYPE_LABELS[task.type]
|
|
165
|
+
} (${formatTime(task.scheduledAt)})`,
|
|
166
|
+
link: task.contact ? `/contacts/${task.contact.id}` : undefined,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (newNotifications.length > 0) {
|
|
173
|
+
setNotifications((prev) => [...prev, ...newNotifications]);
|
|
174
|
+
notifiedKeysRef.current = notified;
|
|
175
|
+
|
|
176
|
+
// Faire disparaître automatiquement les nouvelles notifications après 5 secondes
|
|
177
|
+
newNotifications.forEach((notif) => {
|
|
178
|
+
setTimeout(() => {
|
|
179
|
+
setNotifications((prev) => prev.filter((n) => n.id !== notif.id));
|
|
180
|
+
}, 5000);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}, 60 * 1000); // vérif toutes les minutes
|
|
184
|
+
|
|
185
|
+
return () => clearInterval(interval);
|
|
186
|
+
}, [tasks, session]);
|
|
187
|
+
|
|
188
|
+
const dismissNotification = (id: string) => {
|
|
189
|
+
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<TaskReminderContext.Provider value={{ notifications, dismissNotification }}>
|
|
194
|
+
{children}
|
|
195
|
+
{notifications.length > 0 && (
|
|
196
|
+
<div className="pointer-events-none fixed right-4 bottom-4 z-50 space-y-3">
|
|
197
|
+
{notifications.map((notif) => (
|
|
198
|
+
<div
|
|
199
|
+
key={notif.id}
|
|
200
|
+
className="pointer-events-auto flex max-w-sm items-start gap-3 rounded-xl border border-indigo-100 bg-white p-4 shadow-xl"
|
|
201
|
+
>
|
|
202
|
+
<div className="mt-0.5 rounded-full bg-indigo-50 p-2 text-indigo-600">
|
|
203
|
+
<Bell className="h-4 w-4" />
|
|
204
|
+
</div>
|
|
205
|
+
<div className="flex-1">
|
|
206
|
+
<p className="text-sm font-medium text-gray-900">Rappel de tâche</p>
|
|
207
|
+
<p className="mt-1 text-sm text-gray-700">{notif.message}</p>
|
|
208
|
+
{notif.link && (
|
|
209
|
+
<Link
|
|
210
|
+
href={notif.link}
|
|
211
|
+
className="mt-2 inline-flex text-xs font-medium text-indigo-600 hover:text-indigo-700"
|
|
212
|
+
>
|
|
213
|
+
Ouvrir le contact
|
|
214
|
+
</Link>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
onClick={() => dismissNotification(notif.id)}
|
|
220
|
+
className="ml-2 inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
|
221
|
+
>
|
|
222
|
+
<span className="sr-only">Fermer</span>
|
|
223
|
+
<X />
|
|
224
|
+
</button>
|
|
225
|
+
</div>
|
|
226
|
+
))}
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
</TaskReminderContext.Provider>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function useTaskReminders() {
|
|
234
|
+
const ctx = useContext(TaskReminderContext);
|
|
235
|
+
if (!ctx) {
|
|
236
|
+
throw new Error('useTaskReminders doit être utilisé dans un TaskReminderProvider');
|
|
237
|
+
}
|
|
238
|
+
return ctx;
|
|
239
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, useState, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
interface User {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
email: string;
|
|
9
|
+
customRole: {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
permissions: string[];
|
|
13
|
+
} | null;
|
|
14
|
+
permissions?: string[]; // Alias pour faciliter l'accès
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ViewAsContextType {
|
|
18
|
+
viewAsUser: User | null;
|
|
19
|
+
setViewAsUser: (user: User | null) => void;
|
|
20
|
+
isViewingAsOther: boolean;
|
|
21
|
+
clearViewAsUser: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ViewAsContext = createContext<ViewAsContextType | undefined>(undefined);
|
|
25
|
+
|
|
26
|
+
export function ViewAsProvider({ children }: { children: React.ReactNode }) {
|
|
27
|
+
const [viewAsUser, setViewAsUserState] = useState<User | null>(null);
|
|
28
|
+
|
|
29
|
+
// Charger depuis le localStorage au démarrage
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const stored = localStorage.getItem('viewAsUser');
|
|
32
|
+
if (stored) {
|
|
33
|
+
try {
|
|
34
|
+
setViewAsUserState(JSON.parse(stored));
|
|
35
|
+
} catch (e) {
|
|
36
|
+
localStorage.removeItem('viewAsUser');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
const setViewAsUser = (user: User | null) => {
|
|
42
|
+
// Si l'utilisateur a un customRole, copier les permissions dans un champ direct pour faciliter l'accès
|
|
43
|
+
const userWithPermissions = user
|
|
44
|
+
? {
|
|
45
|
+
...user,
|
|
46
|
+
permissions: user.customRole?.permissions || [],
|
|
47
|
+
}
|
|
48
|
+
: null;
|
|
49
|
+
|
|
50
|
+
setViewAsUserState(userWithPermissions);
|
|
51
|
+
if (userWithPermissions) {
|
|
52
|
+
localStorage.setItem('viewAsUser', JSON.stringify(userWithPermissions));
|
|
53
|
+
} else {
|
|
54
|
+
localStorage.removeItem('viewAsUser');
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const clearViewAsUser = () => {
|
|
59
|
+
setViewAsUser(null);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const isViewingAsOther = viewAsUser !== null;
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<ViewAsContext.Provider
|
|
66
|
+
value={{
|
|
67
|
+
viewAsUser,
|
|
68
|
+
setViewAsUser,
|
|
69
|
+
isViewingAsOther,
|
|
70
|
+
clearViewAsUser,
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
{children}
|
|
74
|
+
</ViewAsContext.Provider>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function useViewAs() {
|
|
79
|
+
const context = useContext(ViewAsContext);
|
|
80
|
+
if (context === undefined) {
|
|
81
|
+
throw new Error('useViewAs must be used within a ViewAsProvider');
|
|
82
|
+
}
|
|
83
|
+
return context;
|
|
84
|
+
}
|