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,1052 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import {
|
|
6
|
+
Filter,
|
|
7
|
+
Settings as SettingsIcon,
|
|
8
|
+
Phone,
|
|
9
|
+
Mail,
|
|
10
|
+
MapPin,
|
|
11
|
+
Users,
|
|
12
|
+
X,
|
|
13
|
+
Plus,
|
|
14
|
+
Calendar,
|
|
15
|
+
Eye,
|
|
16
|
+
} from 'lucide-react';
|
|
17
|
+
import { cn } from '@/lib/utils';
|
|
18
|
+
import { useMobileMenuContext } from '@/contexts/mobile-menu-context';
|
|
19
|
+
|
|
20
|
+
interface Status {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
color: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface User {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
email: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Contact {
|
|
33
|
+
id: string;
|
|
34
|
+
civility: string | null;
|
|
35
|
+
firstName: string | null;
|
|
36
|
+
lastName: string | null;
|
|
37
|
+
phone: string;
|
|
38
|
+
secondaryPhone: string | null;
|
|
39
|
+
email: string | null;
|
|
40
|
+
address: string | null;
|
|
41
|
+
city: string | null;
|
|
42
|
+
postalCode: string | null;
|
|
43
|
+
origin: string | null;
|
|
44
|
+
companyName: string | null;
|
|
45
|
+
isCompany: boolean;
|
|
46
|
+
companyId: string | null;
|
|
47
|
+
companyRelation: Contact | null;
|
|
48
|
+
statusId: string | null;
|
|
49
|
+
status: Status | null;
|
|
50
|
+
assignedCommercialId: string | null;
|
|
51
|
+
assignedCommercial: User | null;
|
|
52
|
+
assignedTeleproId: string | null;
|
|
53
|
+
assignedTelepro: User | null;
|
|
54
|
+
updatedAt?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ClosingColumn {
|
|
58
|
+
id: string;
|
|
59
|
+
statusId: string | null;
|
|
60
|
+
title: string;
|
|
61
|
+
color: string;
|
|
62
|
+
width: number;
|
|
63
|
+
order: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const MIN_WIDTH = 280;
|
|
67
|
+
const MAX_WIDTH = 500;
|
|
68
|
+
const LOCAL_STORAGE_KEY = 'closing_pipeline_columns_v1';
|
|
69
|
+
|
|
70
|
+
function formatRelativeDate(dateString: string): string {
|
|
71
|
+
const date = new Date(dateString);
|
|
72
|
+
const now = new Date();
|
|
73
|
+
const diffMs = now.getTime() - date.getTime();
|
|
74
|
+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
75
|
+
|
|
76
|
+
// Réinitialiser les heures pour comparer uniquement les dates
|
|
77
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
78
|
+
const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
79
|
+
const daysDiff = Math.floor((today.getTime() - dateOnly.getTime()) / (1000 * 60 * 60 * 24));
|
|
80
|
+
|
|
81
|
+
if (daysDiff === 0) {
|
|
82
|
+
return "Aujourd'hui";
|
|
83
|
+
}
|
|
84
|
+
if (daysDiff === 1) {
|
|
85
|
+
return 'Hier';
|
|
86
|
+
}
|
|
87
|
+
if (daysDiff < 7) {
|
|
88
|
+
return `Il y a ${daysDiff} jour${daysDiff > 1 ? 's' : ''}`;
|
|
89
|
+
}
|
|
90
|
+
const weeks = Math.floor(daysDiff / 7);
|
|
91
|
+
if (weeks < 4) {
|
|
92
|
+
return `Il y a ${weeks} semaine${weeks > 1 ? 's' : ''}`;
|
|
93
|
+
}
|
|
94
|
+
const months = Math.floor(daysDiff / 30);
|
|
95
|
+
if (months < 12) {
|
|
96
|
+
return `Il y a ${months} mois`;
|
|
97
|
+
}
|
|
98
|
+
const years = Math.floor(daysDiff / 365);
|
|
99
|
+
return `Il y a ${years} an${years > 1 ? 's' : ''}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function KanbanContactCard({
|
|
103
|
+
contact,
|
|
104
|
+
onDragStart,
|
|
105
|
+
onDragEnd,
|
|
106
|
+
isDragging,
|
|
107
|
+
}: {
|
|
108
|
+
contact: Contact;
|
|
109
|
+
onDragStart: () => void;
|
|
110
|
+
onDragEnd: () => void;
|
|
111
|
+
isDragging: boolean;
|
|
112
|
+
}) {
|
|
113
|
+
const fullName = `${contact.civility ? `${contact.civility}. ` : ''}${
|
|
114
|
+
contact.firstName || ''
|
|
115
|
+
} ${contact.lastName || ''}`.trim();
|
|
116
|
+
|
|
117
|
+
const formattedUpdatedAt = contact.updatedAt && formatRelativeDate(contact.updatedAt);
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
draggable
|
|
122
|
+
onDragStart={onDragStart}
|
|
123
|
+
onDragEnd={onDragEnd}
|
|
124
|
+
className={cn(
|
|
125
|
+
'relative rounded-lg border border-gray-200 bg-white p-4 text-[13px] shadow-sm transition-transform hover:-translate-y-0.5 hover:shadow',
|
|
126
|
+
'cursor-grab active:cursor-grabbing',
|
|
127
|
+
isDragging && 'opacity-80 ring-2 ring-indigo-400',
|
|
128
|
+
)}
|
|
129
|
+
>
|
|
130
|
+
{/* Icône Eye en haut à droite */}
|
|
131
|
+
<Link
|
|
132
|
+
href={`/contacts/${contact.id}`}
|
|
133
|
+
className="absolute top-2 right-2 z-10 rounded p-1 transition-colors hover:bg-gray-100"
|
|
134
|
+
onClick={(e) => e.stopPropagation()}
|
|
135
|
+
>
|
|
136
|
+
<Eye className="h-4 w-4 stroke-gray-400" />
|
|
137
|
+
</Link>
|
|
138
|
+
|
|
139
|
+
<div className="block">
|
|
140
|
+
{contact.companyName && (
|
|
141
|
+
<p className="mb-1 truncate text-[10px] font-semibold text-gray-400 uppercase">
|
|
142
|
+
{contact.companyName}
|
|
143
|
+
</p>
|
|
144
|
+
)}
|
|
145
|
+
<div className="mb-3 flex items-center gap-3">
|
|
146
|
+
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-xs font-semibold text-indigo-600">
|
|
147
|
+
{contact.isCompany ? (
|
|
148
|
+
<span>🏢</span>
|
|
149
|
+
) : (
|
|
150
|
+
(contact.firstName?.[0] || contact.lastName?.[0] || '?').toUpperCase()
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
<div className="min-w-0 flex-1">
|
|
154
|
+
<p className="truncate text-sm font-semibold text-gray-900">{fullName || 'Sans nom'}</p>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{contact.email && (
|
|
159
|
+
<div className="mt-1 flex items-center text-[11px] text-gray-700">
|
|
160
|
+
<Mail className="mr-1 h-3 w-3 text-gray-400" />
|
|
161
|
+
<span className="truncate">{contact.email}</span>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
|
|
165
|
+
{contact.phone && (
|
|
166
|
+
<div className="mt-1 flex items-center text-[11px] text-gray-700">
|
|
167
|
+
<Phone className="mr-1 h-3 w-3 text-gray-400" />
|
|
168
|
+
{contact.phone}
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
|
|
172
|
+
{contact.secondaryPhone && (
|
|
173
|
+
<div className="mt-1 flex items-center text-[11px] text-gray-500">
|
|
174
|
+
<Phone className="mr-1 h-3 w-3 text-gray-300" />
|
|
175
|
+
{contact.secondaryPhone}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
{contact.city && (
|
|
180
|
+
<div className="mt-1 flex items-center text-[11px] text-gray-500">
|
|
181
|
+
<MapPin className="mr-1 h-3 w-3 text-gray-400" />
|
|
182
|
+
{contact.city}
|
|
183
|
+
{contact.postalCode && ` ${contact.postalCode}`}
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{contact.assignedTelepro && (
|
|
188
|
+
<div className="mt-1 flex items-center text-[11px] text-emerald-700">
|
|
189
|
+
<Users className="mr-1 h-3 w-3 text-emerald-500" />
|
|
190
|
+
<span className="font-medium">Télépro: </span>
|
|
191
|
+
<span className="truncate">{contact.assignedTelepro.name}</span>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{contact.assignedCommercial && (
|
|
196
|
+
<div className="mt-1 flex items-center text-[11px] text-sky-700">
|
|
197
|
+
<Users className="mr-1 h-3 w-3 text-sky-500" />
|
|
198
|
+
<span className="font-medium">Commercial: </span>
|
|
199
|
+
<span className="truncate">{contact.assignedCommercial.name}</span>
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
<div className="mt-2 w-full border-[0.2px] border-gray-300" />
|
|
204
|
+
|
|
205
|
+
{formattedUpdatedAt && (
|
|
206
|
+
<div className="mt-2 flex items-center text-[11px] text-gray-400">
|
|
207
|
+
<Calendar className="mr-1 h-3 w-3 text-gray-400" />
|
|
208
|
+
<span>{formattedUpdatedAt}</span>
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function createDefaultColumns(statuses: Status[]): ClosingColumn[] {
|
|
217
|
+
return statuses.map((status, idx) => ({
|
|
218
|
+
id: status.id,
|
|
219
|
+
statusId: status.id,
|
|
220
|
+
title: status.name,
|
|
221
|
+
color: status.color || '#4f46e5',
|
|
222
|
+
width: 320,
|
|
223
|
+
order: idx,
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export default function ClosingPage() {
|
|
228
|
+
const { toggle: toggleMobileMenu, isOpen: isMobileMenuOpen } = useMobileMenuContext();
|
|
229
|
+
const [statuses, setStatuses] = useState<Status[]>([]);
|
|
230
|
+
const [contacts, setContacts] = useState<Contact[]>([]);
|
|
231
|
+
const [loading, setLoading] = useState(true);
|
|
232
|
+
const [error, setError] = useState('');
|
|
233
|
+
const [columns, setColumns] = useState<ClosingColumn[]>([]);
|
|
234
|
+
const [draggedCard, setDraggedCard] = useState<{
|
|
235
|
+
contactId: string;
|
|
236
|
+
fromStatusId: string | null;
|
|
237
|
+
} | null>(null);
|
|
238
|
+
const [draggingContactId, setDraggingContactId] = useState<string | null>(null);
|
|
239
|
+
const [dragOverStatusId, setDragOverStatusId] = useState<string | null>(null);
|
|
240
|
+
const [isSavingDrag, setIsSavingDrag] = useState(false);
|
|
241
|
+
|
|
242
|
+
const [showConfigModal, setShowConfigModal] = useState(false);
|
|
243
|
+
const [configColumns, setConfigColumns] = useState<ClosingColumn[]>([]);
|
|
244
|
+
const [draggedConfigColumnId, setDraggedConfigColumnId] = useState<string | null>(null);
|
|
245
|
+
const [dragOverConfigColumnId, setDragOverConfigColumnId] = useState<string | null>(null);
|
|
246
|
+
|
|
247
|
+
// États pour le drag & drop des colonnes dans le board
|
|
248
|
+
const [draggedColumnId, setDraggedColumnId] = useState<string | null>(null);
|
|
249
|
+
const [dragOverColumnId, setDragOverColumnId] = useState<string | null>(null);
|
|
250
|
+
|
|
251
|
+
const [selectedCompanyId, setSelectedCompanyId] = useState<string | 'all'>('all');
|
|
252
|
+
const [selectedPeriod, setSelectedPeriod] = useState<string>('all');
|
|
253
|
+
const [selectedCommercialId, setSelectedCommercialId] = useState<string | 'all'>('all');
|
|
254
|
+
const [selectedTeleproId, setSelectedTeleproId] = useState<string | 'all'>('all');
|
|
255
|
+
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
const fetchData = async () => {
|
|
258
|
+
try {
|
|
259
|
+
setLoading(true);
|
|
260
|
+
setError('');
|
|
261
|
+
|
|
262
|
+
const [statusRes, contactsRes] = await Promise.all([
|
|
263
|
+
fetch('/api/statuses'),
|
|
264
|
+
fetch('/api/contacts?limit=500'),
|
|
265
|
+
]);
|
|
266
|
+
|
|
267
|
+
if (!statusRes.ok) {
|
|
268
|
+
throw new Error('Erreur lors du chargement des statuts');
|
|
269
|
+
}
|
|
270
|
+
if (!contactsRes.ok) {
|
|
271
|
+
throw new Error('Erreur lors du chargement des contacts');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const statusesJson: Status[] = await statusRes.json();
|
|
275
|
+
const contactsJson = await contactsRes.json();
|
|
276
|
+
|
|
277
|
+
setStatuses(statusesJson);
|
|
278
|
+
setContacts(contactsJson.contacts || []);
|
|
279
|
+
|
|
280
|
+
if (typeof window !== 'undefined') {
|
|
281
|
+
const stored = window.localStorage.getItem(LOCAL_STORAGE_KEY);
|
|
282
|
+
if (stored) {
|
|
283
|
+
try {
|
|
284
|
+
const parsed: ClosingColumn[] = JSON.parse(stored);
|
|
285
|
+
setColumns(
|
|
286
|
+
parsed
|
|
287
|
+
.filter((c) => !c.statusId || statusesJson.some((s) => s.id === c.statusId))
|
|
288
|
+
.sort((a, b) => a.order - b.order),
|
|
289
|
+
);
|
|
290
|
+
} catch {
|
|
291
|
+
setColumns(createDefaultColumns(statusesJson));
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
setColumns(createDefaultColumns(statusesJson));
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
setColumns(createDefaultColumns(statusesJson));
|
|
298
|
+
}
|
|
299
|
+
} catch (e: any) {
|
|
300
|
+
console.error(e);
|
|
301
|
+
setError(e.message || 'Erreur de chargement');
|
|
302
|
+
} finally {
|
|
303
|
+
setLoading(false);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
fetchData();
|
|
308
|
+
}, []);
|
|
309
|
+
|
|
310
|
+
const statusesById = useMemo(() => {
|
|
311
|
+
const map: Record<string, Status> = {};
|
|
312
|
+
statuses.forEach((s) => {
|
|
313
|
+
map[s.id] = s;
|
|
314
|
+
});
|
|
315
|
+
return map;
|
|
316
|
+
}, [statuses]);
|
|
317
|
+
|
|
318
|
+
const sortedColumns = useMemo(() => [...columns].sort((a, b) => a.order - b.order), [columns]);
|
|
319
|
+
|
|
320
|
+
const filteredContacts = useMemo(() => {
|
|
321
|
+
return contacts.filter((c) => {
|
|
322
|
+
if (selectedCompanyId !== 'all' && c.companyId !== selectedCompanyId) {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
if (selectedCommercialId !== 'all' && c.assignedCommercialId !== selectedCommercialId) {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
if (selectedTeleproId !== 'all' && c.assignedTeleproId !== selectedTeleproId) {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
// Filtre par période (basé sur updatedAt)
|
|
332
|
+
if (selectedPeriod !== 'all') {
|
|
333
|
+
const updatedDate = c.updatedAt ? new Date(c.updatedAt) : null;
|
|
334
|
+
if (!updatedDate) return false;
|
|
335
|
+
const now = new Date();
|
|
336
|
+
const daysDiff = Math.floor(
|
|
337
|
+
(now.getTime() - updatedDate.getTime()) / (1000 * 60 * 60 * 24),
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
switch (selectedPeriod) {
|
|
341
|
+
case 'today':
|
|
342
|
+
if (daysDiff !== 0) return false;
|
|
343
|
+
break;
|
|
344
|
+
case 'week':
|
|
345
|
+
if (daysDiff > 7) return false;
|
|
346
|
+
break;
|
|
347
|
+
case 'month':
|
|
348
|
+
if (daysDiff > 30) return false;
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return true;
|
|
353
|
+
});
|
|
354
|
+
}, [contacts, selectedCompanyId, selectedPeriod, selectedCommercialId, selectedTeleproId]);
|
|
355
|
+
|
|
356
|
+
const contactsByStatusId = useMemo(() => {
|
|
357
|
+
const map = new Map<string | null, Contact[]>();
|
|
358
|
+
sortedColumns.forEach((col) => {
|
|
359
|
+
map.set(col.statusId, []);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
filteredContacts.forEach((contact) => {
|
|
363
|
+
const key = contact.statusId || null;
|
|
364
|
+
if (!map.has(key)) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
map.get(key)!.push(contact);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
return map;
|
|
371
|
+
}, [filteredContacts, sortedColumns]);
|
|
372
|
+
|
|
373
|
+
const handleCardDragStart = (contactId: string, fromStatusId: string | null) => {
|
|
374
|
+
setDraggedCard({ contactId, fromStatusId });
|
|
375
|
+
setDraggingContactId(contactId);
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const handleColumnDragOver = (e: React.DragEvent<HTMLDivElement>, statusId: string | null) => {
|
|
379
|
+
e.preventDefault();
|
|
380
|
+
setDragOverStatusId(statusId);
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const handleColumnDrop = async (targetStatusId: string | null) => {
|
|
384
|
+
if (!draggedCard) return;
|
|
385
|
+
|
|
386
|
+
const { contactId, fromStatusId } = draggedCard;
|
|
387
|
+
if (fromStatusId === targetStatusId) {
|
|
388
|
+
setDraggedCard(null);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
setContacts((prev) =>
|
|
393
|
+
prev.map((c) =>
|
|
394
|
+
c.id === contactId
|
|
395
|
+
? {
|
|
396
|
+
...c,
|
|
397
|
+
statusId: targetStatusId,
|
|
398
|
+
status: targetStatusId ? statusesById[targetStatusId] || c.status : c.status,
|
|
399
|
+
}
|
|
400
|
+
: c,
|
|
401
|
+
),
|
|
402
|
+
);
|
|
403
|
+
setDraggedCard(null);
|
|
404
|
+
setDraggingContactId(null);
|
|
405
|
+
setDragOverStatusId(null);
|
|
406
|
+
|
|
407
|
+
if (targetStatusId) {
|
|
408
|
+
try {
|
|
409
|
+
setIsSavingDrag(true);
|
|
410
|
+
const res = await fetch(`/api/contacts/${contactId}`, {
|
|
411
|
+
method: 'PUT',
|
|
412
|
+
headers: { 'Content-Type': 'application/json' },
|
|
413
|
+
body: JSON.stringify({ statusId: targetStatusId }),
|
|
414
|
+
});
|
|
415
|
+
if (!res.ok) {
|
|
416
|
+
throw new Error('Erreur lors de la mise à jour du statut');
|
|
417
|
+
}
|
|
418
|
+
} catch (e) {
|
|
419
|
+
console.error(e);
|
|
420
|
+
} finally {
|
|
421
|
+
setIsSavingDrag(false);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// Handlers pour le drag & drop des colonnes dans le board
|
|
427
|
+
const handleColumnHeaderDragStart = (columnId: string) => {
|
|
428
|
+
setDraggedColumnId(columnId);
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const handleColumnHeaderDragOver = (e: React.DragEvent<HTMLDivElement>, columnId: string) => {
|
|
432
|
+
e.preventDefault();
|
|
433
|
+
if (draggedColumnId && draggedColumnId !== columnId) {
|
|
434
|
+
setDragOverColumnId(columnId);
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const handleColumnHeaderDrop = (targetColumnId: string) => {
|
|
439
|
+
if (!draggedColumnId || draggedColumnId === targetColumnId) {
|
|
440
|
+
setDraggedColumnId(null);
|
|
441
|
+
setDragOverColumnId(null);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const draggedIndex = sortedColumns.findIndex((c) => c.id === draggedColumnId);
|
|
446
|
+
const targetIndex = sortedColumns.findIndex((c) => c.id === targetColumnId);
|
|
447
|
+
|
|
448
|
+
if (draggedIndex === -1 || targetIndex === -1) {
|
|
449
|
+
setDraggedColumnId(null);
|
|
450
|
+
setDragOverColumnId(null);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Réorganiser les colonnes
|
|
455
|
+
const newColumns = [...sortedColumns];
|
|
456
|
+
const [removed] = newColumns.splice(draggedIndex, 1);
|
|
457
|
+
newColumns.splice(targetIndex, 0, removed);
|
|
458
|
+
|
|
459
|
+
// Mettre à jour l'ordre
|
|
460
|
+
const reorderedColumns = newColumns.map((col, idx) => ({
|
|
461
|
+
...col,
|
|
462
|
+
order: idx,
|
|
463
|
+
}));
|
|
464
|
+
|
|
465
|
+
setColumns(reorderedColumns);
|
|
466
|
+
if (typeof window !== 'undefined') {
|
|
467
|
+
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(reorderedColumns));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
setDraggedColumnId(null);
|
|
471
|
+
setDragOverColumnId(null);
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const openConfigModal = () => {
|
|
475
|
+
setConfigColumns([...columns].sort((a, b) => a.order - b.order));
|
|
476
|
+
setShowConfigModal(true);
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const addConfigColumn = () => {
|
|
480
|
+
const usedStatusIds = new Set(configColumns.map((c) => c.statusId).filter(Boolean) as string[]);
|
|
481
|
+
const availableStatus = statuses.find((s) => !usedStatusIds.has(s.id));
|
|
482
|
+
const statusId = availableStatus?.id || null;
|
|
483
|
+
|
|
484
|
+
const newCol: ClosingColumn = {
|
|
485
|
+
id: `tmp-${Date.now()}`,
|
|
486
|
+
statusId,
|
|
487
|
+
title: availableStatus?.name || 'Nouvelle colonne',
|
|
488
|
+
color: availableStatus?.color || '#e5e7eb',
|
|
489
|
+
width: 320,
|
|
490
|
+
order: configColumns.length,
|
|
491
|
+
};
|
|
492
|
+
setConfigColumns((prev) => [...prev, newCol]);
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const updateConfigColumn = (id: string, patch: Partial<ClosingColumn>) => {
|
|
496
|
+
setConfigColumns((prev) => prev.map((col) => (col.id === id ? { ...col, ...patch } : col)));
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const removeConfigColumn = (id: string) => {
|
|
500
|
+
setConfigColumns((prev) =>
|
|
501
|
+
prev
|
|
502
|
+
.filter((col) => col.id !== id)
|
|
503
|
+
.map((col, idx) => ({
|
|
504
|
+
...col,
|
|
505
|
+
order: idx,
|
|
506
|
+
})),
|
|
507
|
+
);
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const handleSaveConfig = () => {
|
|
511
|
+
const normalized = [...configColumns]
|
|
512
|
+
.filter((col) => col.statusId)
|
|
513
|
+
.map((col, idx) => ({
|
|
514
|
+
...col,
|
|
515
|
+
order: idx,
|
|
516
|
+
id: col.id.startsWith('tmp-') ? `${Date.now()}-${idx}` : col.id,
|
|
517
|
+
}));
|
|
518
|
+
|
|
519
|
+
setColumns(normalized);
|
|
520
|
+
if (typeof window !== 'undefined') {
|
|
521
|
+
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(normalized));
|
|
522
|
+
}
|
|
523
|
+
setShowConfigModal(false);
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const handleConfigColumnDrop = (targetId: string) => {
|
|
527
|
+
if (!draggedConfigColumnId || draggedConfigColumnId === targetId) {
|
|
528
|
+
setDraggedConfigColumnId(null);
|
|
529
|
+
setDragOverConfigColumnId(null);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
setConfigColumns((prev) => {
|
|
534
|
+
const fromIndex = prev.findIndex((c) => c.id === draggedConfigColumnId);
|
|
535
|
+
const toIndex = prev.findIndex((c) => c.id === targetId);
|
|
536
|
+
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) {
|
|
537
|
+
return prev;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const updated = [...prev];
|
|
541
|
+
const [moved] = updated.splice(fromIndex, 1);
|
|
542
|
+
updated.splice(toIndex, 0, moved);
|
|
543
|
+
|
|
544
|
+
return updated.map((col, idx) => ({
|
|
545
|
+
...col,
|
|
546
|
+
order: idx,
|
|
547
|
+
}));
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
setDraggedConfigColumnId(null);
|
|
551
|
+
setDragOverConfigColumnId(null);
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const resetToDefaultColumns = () => {
|
|
555
|
+
const defaults = createDefaultColumns(statuses);
|
|
556
|
+
setConfigColumns(defaults);
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
// Récupérer les entreprises uniques pour le filtre
|
|
560
|
+
const companies = useMemo(() => {
|
|
561
|
+
const companyMap = new Map<string, { id: string; name: string }>();
|
|
562
|
+
contacts.forEach((c) => {
|
|
563
|
+
if (c.companyId && c.companyName) {
|
|
564
|
+
companyMap.set(c.companyId, { id: c.companyId, name: c.companyName });
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
return Array.from(companyMap.values());
|
|
568
|
+
}, [contacts]);
|
|
569
|
+
|
|
570
|
+
if (loading) {
|
|
571
|
+
return (
|
|
572
|
+
<div className="h-full">
|
|
573
|
+
<div className="border-b border-gray-200 bg-white px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
|
|
574
|
+
<div className="flex items-start gap-3">
|
|
575
|
+
{/* Mobile menu button */}
|
|
576
|
+
<button
|
|
577
|
+
onClick={toggleMobileMenu}
|
|
578
|
+
className="mt-1 shrink-0 cursor-pointer rounded-lg p-2 text-gray-700 transition-colors hover:bg-gray-100 lg:hidden"
|
|
579
|
+
aria-label="Toggle menu"
|
|
580
|
+
>
|
|
581
|
+
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
582
|
+
{isMobileMenuOpen ? (
|
|
583
|
+
<path
|
|
584
|
+
strokeLinecap="round"
|
|
585
|
+
strokeLinejoin="round"
|
|
586
|
+
strokeWidth={2}
|
|
587
|
+
d="M6 18L18 6M6 6l12 12"
|
|
588
|
+
/>
|
|
589
|
+
) : (
|
|
590
|
+
<path
|
|
591
|
+
strokeLinecap="round"
|
|
592
|
+
strokeLinejoin="round"
|
|
593
|
+
strokeWidth={2}
|
|
594
|
+
d="M4 6h16M4 12h16M4 18h16"
|
|
595
|
+
/>
|
|
596
|
+
)}
|
|
597
|
+
</svg>
|
|
598
|
+
</button>
|
|
599
|
+
<div className="min-w-0 flex-1">
|
|
600
|
+
<h1 className="text-xl font-bold text-gray-900 sm:text-2xl">Pipeline de Closing</h1>
|
|
601
|
+
<p className="mt-1 text-sm text-gray-600">
|
|
602
|
+
Visualisez et gérez vos opportunités commerciales
|
|
603
|
+
</p>
|
|
604
|
+
</div>
|
|
605
|
+
</div>
|
|
606
|
+
</div>
|
|
607
|
+
<div className="p-4 sm:p-6 lg:p-8">
|
|
608
|
+
<div className="rounded-lg bg-white p-8 shadow">
|
|
609
|
+
<p className="text-gray-500">Chargement du pipeline...</p>
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
</div>
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return (
|
|
617
|
+
<div className="h-full">
|
|
618
|
+
{' '}
|
|
619
|
+
{/* md:overflow-y-hidden */}
|
|
620
|
+
{/* En-tête personnalisé avec filtres intégrés */}
|
|
621
|
+
<div className="border-b border-gray-200 bg-white px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
|
|
622
|
+
<div className="flex items-start gap-3">
|
|
623
|
+
{/* Mobile menu button */}
|
|
624
|
+
<button
|
|
625
|
+
onClick={toggleMobileMenu}
|
|
626
|
+
className="mt-1 shrink-0 cursor-pointer rounded-lg p-2 text-gray-700 transition-colors hover:bg-gray-100 lg:hidden"
|
|
627
|
+
aria-label="Toggle menu"
|
|
628
|
+
>
|
|
629
|
+
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
630
|
+
{isMobileMenuOpen ? (
|
|
631
|
+
<path
|
|
632
|
+
strokeLinecap="round"
|
|
633
|
+
strokeLinejoin="round"
|
|
634
|
+
strokeWidth={2}
|
|
635
|
+
d="M6 18L18 6M6 6l12 12"
|
|
636
|
+
/>
|
|
637
|
+
) : (
|
|
638
|
+
<path
|
|
639
|
+
strokeLinecap="round"
|
|
640
|
+
strokeLinejoin="round"
|
|
641
|
+
strokeWidth={2}
|
|
642
|
+
d="M4 6h16M4 12h16M4 18h16"
|
|
643
|
+
/>
|
|
644
|
+
)}
|
|
645
|
+
</svg>
|
|
646
|
+
</button>
|
|
647
|
+
<div className="flex min-w-0 flex-1 items-start justify-between gap-4">
|
|
648
|
+
<div className="min-w-0 flex-1">
|
|
649
|
+
<h1 className="text-xl font-bold text-gray-900 sm:text-2xl">Pipeline de Closing</h1>
|
|
650
|
+
<p className="mt-1 text-sm text-gray-600">
|
|
651
|
+
Visualisez et gérez vos opportunités commerciales
|
|
652
|
+
</p>
|
|
653
|
+
|
|
654
|
+
{/* Filtres intégrés dans l'en-tête */}
|
|
655
|
+
<div className="mt-4 flex flex-col gap-3 sm:flex-row">
|
|
656
|
+
<div className="flex items-center gap-2">
|
|
657
|
+
<span className="text-xs font-medium text-gray-700">Période:</span>
|
|
658
|
+
<div className="relative">
|
|
659
|
+
<Filter className="absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
|
|
660
|
+
<select
|
|
661
|
+
className="rounded-lg border border-gray-300 py-1.5 pr-2 pl-7 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
662
|
+
value={selectedPeriod}
|
|
663
|
+
onChange={(e) => setSelectedPeriod(e.target.value)}
|
|
664
|
+
>
|
|
665
|
+
<option value="all">Toutes</option>
|
|
666
|
+
<option value="today">Aujourd'hui</option>
|
|
667
|
+
<option value="week">Cette semaine</option>
|
|
668
|
+
<option value="month">Ce mois</option>
|
|
669
|
+
</select>
|
|
670
|
+
</div>
|
|
671
|
+
</div>
|
|
672
|
+
|
|
673
|
+
<div className="flex items-center gap-2">
|
|
674
|
+
<span className="text-xs font-medium text-gray-700">Commercial:</span>
|
|
675
|
+
<select
|
|
676
|
+
className="rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
677
|
+
value={selectedCommercialId}
|
|
678
|
+
onChange={(e) =>
|
|
679
|
+
setSelectedCommercialId(e.target.value === 'all' ? 'all' : e.target.value)
|
|
680
|
+
}
|
|
681
|
+
>
|
|
682
|
+
<option value="all">Tous</option>
|
|
683
|
+
{Array.from(
|
|
684
|
+
new Map(
|
|
685
|
+
contacts
|
|
686
|
+
.filter((c) => c.assignedCommercial)
|
|
687
|
+
.map((c) => [c.assignedCommercialId, c.assignedCommercial!]),
|
|
688
|
+
).values(),
|
|
689
|
+
).map((user) => (
|
|
690
|
+
<option key={user.id} value={user.id}>
|
|
691
|
+
{user.name}
|
|
692
|
+
</option>
|
|
693
|
+
))}
|
|
694
|
+
</select>
|
|
695
|
+
</div>
|
|
696
|
+
|
|
697
|
+
<div className="flex items-center gap-2">
|
|
698
|
+
<span className="text-xs font-medium text-gray-700">Télépro:</span>
|
|
699
|
+
<select
|
|
700
|
+
className="rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
701
|
+
value={selectedTeleproId}
|
|
702
|
+
onChange={(e) =>
|
|
703
|
+
setSelectedTeleproId(e.target.value === 'all' ? 'all' : e.target.value)
|
|
704
|
+
}
|
|
705
|
+
>
|
|
706
|
+
<option value="all">Tous</option>
|
|
707
|
+
{Array.from(
|
|
708
|
+
new Map(
|
|
709
|
+
contacts
|
|
710
|
+
.filter((c) => c.assignedTelepro)
|
|
711
|
+
.map((c) => [c.assignedTeleproId, c.assignedTelepro!]),
|
|
712
|
+
).values(),
|
|
713
|
+
).map((user) => (
|
|
714
|
+
<option key={user.id} value={user.id}>
|
|
715
|
+
{user.name}
|
|
716
|
+
</option>
|
|
717
|
+
))}
|
|
718
|
+
</select>
|
|
719
|
+
</div>
|
|
720
|
+
|
|
721
|
+
{isSavingDrag && (
|
|
722
|
+
<span className="self-end text-xs text-gray-400">
|
|
723
|
+
Sauvegarde des changements…
|
|
724
|
+
</span>
|
|
725
|
+
)}
|
|
726
|
+
</div>
|
|
727
|
+
</div>
|
|
728
|
+
|
|
729
|
+
<div className="shrink-0">
|
|
730
|
+
<button
|
|
731
|
+
type="button"
|
|
732
|
+
onClick={openConfigModal}
|
|
733
|
+
className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-semibold text-gray-700 shadow-sm transition-colors hover:bg-gray-50 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
734
|
+
>
|
|
735
|
+
<SettingsIcon className="h-4 w-4" />
|
|
736
|
+
Configurer
|
|
737
|
+
</button>
|
|
738
|
+
</div>
|
|
739
|
+
</div>
|
|
740
|
+
</div>
|
|
741
|
+
</div>
|
|
742
|
+
{error && (
|
|
743
|
+
<div className="px-4 pt-4 sm:px-6 sm:pt-6">
|
|
744
|
+
<div className="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-600">{error}</div>
|
|
745
|
+
</div>
|
|
746
|
+
)}
|
|
747
|
+
{/* Board kanban : taille adaptée au contenu avec padding haut/bas */}
|
|
748
|
+
<div
|
|
749
|
+
className="flex gap-4 overflow-x-auto px-4 pt-4 pb-4 sm:px-6 sm:pt-6 sm:pb-6"
|
|
750
|
+
style={{ scrollbarWidth: 'none' as 'none' }} // for Firefox
|
|
751
|
+
>
|
|
752
|
+
<style jsx>{`
|
|
753
|
+
div::-webkit-scrollbar {
|
|
754
|
+
display: none;
|
|
755
|
+
}
|
|
756
|
+
`}</style>
|
|
757
|
+
{sortedColumns.map((column) => {
|
|
758
|
+
const columnContacts = contactsByStatusId.get(column.statusId) || [];
|
|
759
|
+
const status = column.statusId ? statusesById[column.statusId] : null;
|
|
760
|
+
|
|
761
|
+
return (
|
|
762
|
+
<div
|
|
763
|
+
key={column.id}
|
|
764
|
+
className={cn(
|
|
765
|
+
'flex shrink-0 flex-col rounded-xl bg-gray-50 shadow-sm transition-colors',
|
|
766
|
+
'h-auto max-h-[calc(100vh-220px)]',
|
|
767
|
+
dragOverStatusId === column.statusId && 'ring-2 ring-indigo-400 ring-offset-2',
|
|
768
|
+
dragOverColumnId === column.id &&
|
|
769
|
+
draggedColumnId &&
|
|
770
|
+
'ring-2 ring-indigo-500 ring-offset-2',
|
|
771
|
+
)}
|
|
772
|
+
style={{ width: column.width }}
|
|
773
|
+
onDragOver={(e) => {
|
|
774
|
+
// Gérer le drag des colonnes ET des contacts
|
|
775
|
+
if (draggedColumnId) {
|
|
776
|
+
handleColumnHeaderDragOver(e, column.id);
|
|
777
|
+
} else {
|
|
778
|
+
handleColumnDragOver(e, column.statusId);
|
|
779
|
+
}
|
|
780
|
+
}}
|
|
781
|
+
onDrop={(e) => {
|
|
782
|
+
e.preventDefault();
|
|
783
|
+
if (draggedColumnId) {
|
|
784
|
+
handleColumnHeaderDrop(column.id);
|
|
785
|
+
} else {
|
|
786
|
+
handleColumnDrop(column.statusId);
|
|
787
|
+
}
|
|
788
|
+
}}
|
|
789
|
+
onDragLeave={() => {
|
|
790
|
+
if (!draggedColumnId) {
|
|
791
|
+
setDragOverStatusId(null);
|
|
792
|
+
}
|
|
793
|
+
if (draggedColumnId && dragOverColumnId === column.id) {
|
|
794
|
+
setDragOverColumnId(null);
|
|
795
|
+
}
|
|
796
|
+
}}
|
|
797
|
+
>
|
|
798
|
+
<div
|
|
799
|
+
draggable
|
|
800
|
+
onDragStart={(e) => {
|
|
801
|
+
// Ne pas drag si on drag déjà un contact
|
|
802
|
+
if (!draggedCard && !draggingContactId) {
|
|
803
|
+
handleColumnHeaderDragStart(column.id);
|
|
804
|
+
} else {
|
|
805
|
+
e.preventDefault();
|
|
806
|
+
}
|
|
807
|
+
}}
|
|
808
|
+
onDragEnd={() => {
|
|
809
|
+
setDraggedColumnId(null);
|
|
810
|
+
setDragOverColumnId(null);
|
|
811
|
+
}}
|
|
812
|
+
className={cn(
|
|
813
|
+
'flex shrink-0 items-center justify-between rounded-t-xl px-4 py-3 text-sm font-semibold text-white',
|
|
814
|
+
'cursor-grab transition-opacity active:cursor-grabbing',
|
|
815
|
+
draggedColumnId === column.id && 'opacity-50',
|
|
816
|
+
)}
|
|
817
|
+
style={{ backgroundColor: column.color || '#4f46e5' }}
|
|
818
|
+
>
|
|
819
|
+
<div className="flex items-center gap-2">
|
|
820
|
+
<span>{column.title || status?.name || 'Colonne'}</span>
|
|
821
|
+
<span className="rounded-full bg-white/20 px-2 py-0.5 text-xs font-medium">
|
|
822
|
+
{columnContacts.length}
|
|
823
|
+
</span>
|
|
824
|
+
</div>
|
|
825
|
+
</div>
|
|
826
|
+
|
|
827
|
+
<div
|
|
828
|
+
className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3"
|
|
829
|
+
onDragStart={(e) => {
|
|
830
|
+
// Empêcher le drag du header si on drag un contact
|
|
831
|
+
if (draggingContactId || draggedCard) {
|
|
832
|
+
e.stopPropagation();
|
|
833
|
+
}
|
|
834
|
+
}}
|
|
835
|
+
>
|
|
836
|
+
{columnContacts.length === 0 ? (
|
|
837
|
+
<div className="mt-4 rounded-lg border border-dashed border-gray-200 bg-white/40 p-4 text-center text-xs text-gray-400">
|
|
838
|
+
Les contacts avec ce statut apparaîtront ici
|
|
839
|
+
</div>
|
|
840
|
+
) : (
|
|
841
|
+
columnContacts.map((contact) => (
|
|
842
|
+
<KanbanContactCard
|
|
843
|
+
key={contact.id}
|
|
844
|
+
contact={contact}
|
|
845
|
+
isDragging={draggingContactId === contact.id}
|
|
846
|
+
onDragStart={() => handleCardDragStart(contact.id, contact.statusId)}
|
|
847
|
+
onDragEnd={() => {
|
|
848
|
+
setDraggingContactId(null);
|
|
849
|
+
setDragOverStatusId(null);
|
|
850
|
+
}}
|
|
851
|
+
/>
|
|
852
|
+
))
|
|
853
|
+
)}
|
|
854
|
+
</div>
|
|
855
|
+
</div>
|
|
856
|
+
);
|
|
857
|
+
})}
|
|
858
|
+
</div>
|
|
859
|
+
{showConfigModal && (
|
|
860
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-500/30 p-4 backdrop-blur-sm">
|
|
861
|
+
<div className="flex w-full max-w-7xl flex-col rounded-2xl bg-white shadow-xl">
|
|
862
|
+
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
|
863
|
+
<div>
|
|
864
|
+
<h2 className="text-lg font-semibold text-gray-900">Configuration du Pipeline</h2>
|
|
865
|
+
<p className="mt-1 text-sm text-gray-500">
|
|
866
|
+
Personnalisez les colonnes de votre pipeline de closing.
|
|
867
|
+
</p>
|
|
868
|
+
</div>
|
|
869
|
+
<button
|
|
870
|
+
type="button"
|
|
871
|
+
onClick={() => setShowConfigModal(false)}
|
|
872
|
+
className="cursor-pointer rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-100"
|
|
873
|
+
>
|
|
874
|
+
<X className="h-5 w-5" />
|
|
875
|
+
</button>
|
|
876
|
+
</div>
|
|
877
|
+
|
|
878
|
+
<div className="flex-1 overflow-y-auto px-6 py-4">
|
|
879
|
+
<div className="mb-3 flex items-center justify-between">
|
|
880
|
+
<div>
|
|
881
|
+
<p className="text-sm text-gray-600">
|
|
882
|
+
{configColumns.length} colonne
|
|
883
|
+
{configColumns.length > 1 ? 's' : ''} configurée
|
|
884
|
+
{configColumns.length > 1 ? 's' : ''}.
|
|
885
|
+
</p>
|
|
886
|
+
<p className="mt-1 text-xs text-gray-400">
|
|
887
|
+
Glissez-déposez les colonnes ci-dessous pour organiser l'ordre de votre
|
|
888
|
+
pipeline.
|
|
889
|
+
</p>
|
|
890
|
+
</div>
|
|
891
|
+
<div className="flex items-center gap-2">
|
|
892
|
+
<button
|
|
893
|
+
type="button"
|
|
894
|
+
onClick={resetToDefaultColumns}
|
|
895
|
+
className="inline-flex cursor-pointer items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
|
896
|
+
>
|
|
897
|
+
Réinitialiser
|
|
898
|
+
</button>
|
|
899
|
+
<button
|
|
900
|
+
type="button"
|
|
901
|
+
onClick={addConfigColumn}
|
|
902
|
+
className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-indigo-600 bg-white px-3 py-1.5 text-sm font-medium text-indigo-600 transition-colors hover:bg-indigo-50"
|
|
903
|
+
>
|
|
904
|
+
<Plus className="h-4 w-4" />
|
|
905
|
+
Ajouter une colonne
|
|
906
|
+
</button>
|
|
907
|
+
</div>
|
|
908
|
+
</div>
|
|
909
|
+
|
|
910
|
+
<div className="flex gap-4 overflow-x-auto p-2">
|
|
911
|
+
{configColumns.map((column) => (
|
|
912
|
+
<div
|
|
913
|
+
key={column.id}
|
|
914
|
+
draggable
|
|
915
|
+
onDragStart={() => setDraggedConfigColumnId(column.id)}
|
|
916
|
+
onDragOver={(e) => {
|
|
917
|
+
e.preventDefault();
|
|
918
|
+
setDragOverConfigColumnId(column.id);
|
|
919
|
+
}}
|
|
920
|
+
onDrop={(e) => {
|
|
921
|
+
e.preventDefault();
|
|
922
|
+
handleConfigColumnDrop(column.id);
|
|
923
|
+
}}
|
|
924
|
+
onDragLeave={() =>
|
|
925
|
+
setDragOverConfigColumnId((current) =>
|
|
926
|
+
current === column.id ? null : current,
|
|
927
|
+
)
|
|
928
|
+
}
|
|
929
|
+
className={cn(
|
|
930
|
+
'flex w-80 shrink-0 flex-col rounded-xl border border-gray-200 bg-gray-50',
|
|
931
|
+
'cursor-grab transition-all duration-150 active:cursor-grabbing',
|
|
932
|
+
'hover:-translate-y-1 hover:shadow-lg',
|
|
933
|
+
dragOverConfigColumnId === column.id &&
|
|
934
|
+
'ring-2 ring-indigo-400 ring-offset-2',
|
|
935
|
+
)}
|
|
936
|
+
>
|
|
937
|
+
<div className="p-4">
|
|
938
|
+
<div className="mb-3 flex items-center justify-between">
|
|
939
|
+
<input
|
|
940
|
+
type="text"
|
|
941
|
+
value={column.title}
|
|
942
|
+
onChange={(e) => updateConfigColumn(column.id, { title: e.target.value })}
|
|
943
|
+
className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
944
|
+
placeholder="Nom de la colonne"
|
|
945
|
+
/>
|
|
946
|
+
<button
|
|
947
|
+
type="button"
|
|
948
|
+
onClick={() => removeConfigColumn(column.id)}
|
|
949
|
+
className="ml-2 cursor-pointer rounded-lg p-1 text-gray-400 transition-colors hover:bg-gray-200 hover:text-red-600"
|
|
950
|
+
>
|
|
951
|
+
<X className="h-4 w-4" />
|
|
952
|
+
</button>
|
|
953
|
+
</div>
|
|
954
|
+
|
|
955
|
+
<label className="mb-2 block text-xs font-medium text-gray-600">
|
|
956
|
+
Statut associé
|
|
957
|
+
</label>
|
|
958
|
+
<select
|
|
959
|
+
value={column.statusId || ''}
|
|
960
|
+
onChange={(e) =>
|
|
961
|
+
updateConfigColumn(column.id, {
|
|
962
|
+
statusId: e.target.value || null,
|
|
963
|
+
title:
|
|
964
|
+
e.target.value && statusesById[e.target.value]
|
|
965
|
+
? statusesById[e.target.value].name
|
|
966
|
+
: column.title,
|
|
967
|
+
color:
|
|
968
|
+
e.target.value && statusesById[e.target.value]
|
|
969
|
+
? statusesById[e.target.value].color
|
|
970
|
+
: column.color,
|
|
971
|
+
})
|
|
972
|
+
}
|
|
973
|
+
className="mb-3 w-full rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
974
|
+
>
|
|
975
|
+
<option value="">Sélectionnez un statut</option>
|
|
976
|
+
{statuses.map((status) => (
|
|
977
|
+
<option key={status.id} value={status.id}>
|
|
978
|
+
{status.name}
|
|
979
|
+
</option>
|
|
980
|
+
))}
|
|
981
|
+
</select>
|
|
982
|
+
|
|
983
|
+
<label className="mb-1 block text-xs font-medium text-gray-600">
|
|
984
|
+
Couleur de la colonne
|
|
985
|
+
</label>
|
|
986
|
+
<div className="mb-3 flex items-center gap-2">
|
|
987
|
+
<input
|
|
988
|
+
type="color"
|
|
989
|
+
value={column.color}
|
|
990
|
+
onChange={(e) => updateConfigColumn(column.id, { color: e.target.value })}
|
|
991
|
+
className="h-8 w-12 cursor-pointer rounded border border-gray-300 bg-white"
|
|
992
|
+
/>
|
|
993
|
+
<input
|
|
994
|
+
type="text"
|
|
995
|
+
value={column.color}
|
|
996
|
+
onChange={(e) => updateConfigColumn(column.id, { color: e.target.value })}
|
|
997
|
+
className="flex-1 rounded-lg border border-gray-300 px-2 py-1.5 text-xs text-gray-900 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
998
|
+
/>
|
|
999
|
+
</div>
|
|
1000
|
+
|
|
1001
|
+
<label className="mb-1 block text-xs font-medium text-gray-600">
|
|
1002
|
+
Largeur de la colonne (px)
|
|
1003
|
+
</label>
|
|
1004
|
+
<input
|
|
1005
|
+
type="range"
|
|
1006
|
+
min={MIN_WIDTH}
|
|
1007
|
+
max={MAX_WIDTH}
|
|
1008
|
+
value={column.width}
|
|
1009
|
+
onChange={(e) =>
|
|
1010
|
+
updateConfigColumn(column.id, {
|
|
1011
|
+
width: Number(e.target.value),
|
|
1012
|
+
})
|
|
1013
|
+
}
|
|
1014
|
+
className="w-full"
|
|
1015
|
+
/>
|
|
1016
|
+
<div className="mt-1 text-right text-xs text-gray-500">{column.width}px</div>
|
|
1017
|
+
</div>
|
|
1018
|
+
<div className="flex h-[250px] flex-col items-center justify-center rounded-b-xl bg-white">
|
|
1019
|
+
<div className="mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-gray-100">
|
|
1020
|
+
<Filter className="h-8 w-8 text-gray-400" />
|
|
1021
|
+
</div>
|
|
1022
|
+
<p className="px-6 text-center text-sm font-medium text-gray-400">
|
|
1023
|
+
Les contacts avec ce statut apparaîtront ici
|
|
1024
|
+
</p>
|
|
1025
|
+
</div>
|
|
1026
|
+
</div>
|
|
1027
|
+
))}
|
|
1028
|
+
</div>
|
|
1029
|
+
</div>
|
|
1030
|
+
|
|
1031
|
+
<div className="flex items-center justify-between border-t border-gray-200 px-6 py-4">
|
|
1032
|
+
<button
|
|
1033
|
+
type="button"
|
|
1034
|
+
onClick={() => setShowConfigModal(false)}
|
|
1035
|
+
className="cursor-pointer rounded-lg px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100"
|
|
1036
|
+
>
|
|
1037
|
+
Annuler
|
|
1038
|
+
</button>
|
|
1039
|
+
<button
|
|
1040
|
+
type="button"
|
|
1041
|
+
onClick={handleSaveConfig}
|
|
1042
|
+
className="cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-700"
|
|
1043
|
+
>
|
|
1044
|
+
Enregistrer la configuration
|
|
1045
|
+
</button>
|
|
1046
|
+
</div>
|
|
1047
|
+
</div>
|
|
1048
|
+
</div>
|
|
1049
|
+
)}
|
|
1050
|
+
</div>
|
|
1051
|
+
);
|
|
1052
|
+
}
|