ptechcore_ui 0.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/eslint.config.js +28 -0
- package/index.html +78 -0
- package/package.json +42 -0
- package/postcss.config.js +6 -0
- package/src/App.tsx +156 -0
- package/src/assets/imgs/login_illustration.png +0 -0
- package/src/components/common/Buttons.tsx +39 -0
- package/src/components/common/Cards.tsx +18 -0
- package/src/components/common/FDrawer.tsx +2448 -0
- package/src/components/common/FDrawer.types.ts +191 -0
- package/src/components/common/Inputs.tsx +409 -0
- package/src/components/common/Modals.tsx +41 -0
- package/src/components/common/Navigations.tsx +0 -0
- package/src/components/common/Toast.tsx +0 -0
- package/src/components/demo/ToastDemo.tsx +73 -0
- package/src/components/layout/Header.tsx +202 -0
- package/src/components/layout/ModernDoubleSidebarLayout.tsx +727 -0
- package/src/components/layout/PrivateLayout.tsx +52 -0
- package/src/components/layout/Sidebar.tsx +182 -0
- package/src/components/ui/Toast.tsx +93 -0
- package/src/contexts/SessionContext.tsx +77 -0
- package/src/contexts/ThemeContext.tsx +58 -0
- package/src/contexts/ToastContext.tsx +94 -0
- package/src/index.css +3 -0
- package/src/main.tsx +10 -0
- package/src/models/Organization.ts +47 -0
- package/src/models/Plan.ts +42 -0
- package/src/models/User.ts +23 -0
- package/src/pages/Analytics.tsx +101 -0
- package/src/pages/CreateOrganization.tsx +215 -0
- package/src/pages/Dashboard.tsx +15 -0
- package/src/pages/Home.tsx +12 -0
- package/src/pages/Profile.tsx +313 -0
- package/src/pages/Settings.tsx +382 -0
- package/src/pages/Team.tsx +180 -0
- package/src/pages/auth/Login.tsx +140 -0
- package/src/pages/auth/Register.tsx +302 -0
- package/src/pages/organizations/DetailEntity.tsx +1002 -0
- package/src/pages/organizations/DetailOrganizations.tsx +1629 -0
- package/src/pages/organizations/ListOrganizations.tsx +270 -0
- package/src/pages/pricings/CartPlan.tsx +486 -0
- package/src/pages/pricings/ListPricing.tsx +321 -0
- package/src/pages/users/CreateUser.tsx +450 -0
- package/src/pages/users/ListUsers.tsx +0 -0
- package/src/services/AuthServices.ts +94 -0
- package/src/services/OrganizationServices.ts +61 -0
- package/src/services/PlanSubscriptionServices.tsx +137 -0
- package/src/services/UserServices.ts +36 -0
- package/src/services/api.ts +64 -0
- package/src/styles/theme.ts +383 -0
- package/src/utils/utils.ts +48 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +158 -0
- package/tsconfig.app.json +24 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +22 -0
- package/vite.config.ts +10 -0
|
@@ -0,0 +1,1629 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { useParams, useNavigate } from 'react-router-dom';
|
|
3
|
+
import {
|
|
4
|
+
ArrowLeft,
|
|
5
|
+
Building2,
|
|
6
|
+
Edit,
|
|
7
|
+
Trash2,
|
|
8
|
+
Plus,
|
|
9
|
+
MapPin,
|
|
10
|
+
User as UserIcon,
|
|
11
|
+
Eye,
|
|
12
|
+
CreditCard,
|
|
13
|
+
FileText,
|
|
14
|
+
MoreVertical,
|
|
15
|
+
X,
|
|
16
|
+
} from 'lucide-react';
|
|
17
|
+
import { useToast } from '../../contexts/ToastContext';
|
|
18
|
+
import { useSession } from '../../contexts/SessionContext';
|
|
19
|
+
import { OrganizationServices, EntityServices } from '../../services/OrganizationServices';
|
|
20
|
+
import { RewiseBasicCard } from '../../components/common/Cards';
|
|
21
|
+
import { InputField, TextInput, SelectInput } from '../../components/common/Inputs';
|
|
22
|
+
import ListPricing from '../pricings/ListPricing';
|
|
23
|
+
import { Entity, Organization } from '../../models/Organization';
|
|
24
|
+
import { Module } from '../../models/Plan';
|
|
25
|
+
import { ModuleServices } from '../../services/PlanSubscriptionServices';
|
|
26
|
+
import PrimaryButton, { SecondaryButton } from '../../components/common/Buttons';
|
|
27
|
+
import Modal from '../../components/common/Modals';
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
const DetailOrganizations: React.FC = () => {
|
|
31
|
+
const { id } = useParams<{ id: string }>();
|
|
32
|
+
const [organization, setOrganization] = useState<Organization | null>(null);
|
|
33
|
+
const [formData, setFormData] = useState<Partial<Organization | null>>(null);
|
|
34
|
+
const [entities, setEntities] = useState<Entity[]>([]);
|
|
35
|
+
const [modules, setModules] = useState<Module[]>([]);
|
|
36
|
+
const [modulesLoading, setModulesLoading] = useState(true);
|
|
37
|
+
const [selectedEntity, setSelectedEntity] = useState<Entity | null>(null);
|
|
38
|
+
const [loading, setLoading] = useState(true);
|
|
39
|
+
const [entitiesLoading, setEntitiesLoading] = useState(true);
|
|
40
|
+
const [dropdownOpen, setDropdownOpen] = useState<number | null>(null);
|
|
41
|
+
const [showEntityModal, setShowEntityModal] = useState(false);
|
|
42
|
+
const [showModulePurchaseModal, setShowModulePurchaseModal] = useState(false);
|
|
43
|
+
const [selectedModuleForPurchase, setSelectedModuleForPurchase] = useState<Module | null>(null);
|
|
44
|
+
const [processingModulePurchase, setProcessingModulePurchase] = useState(false);
|
|
45
|
+
const [showPlanChangeModal, setShowPlanChangeModal] = useState(false);
|
|
46
|
+
const [availablePlans, setAvailablePlans] = useState<any[]>([]);
|
|
47
|
+
const [plansLoading, setPlansLoading] = useState(false);
|
|
48
|
+
const [processingPlanChange, setProcessingPlanChange] = useState(false);
|
|
49
|
+
|
|
50
|
+
const [errors, setErrors] = useState<Partial<Record<keyof Organization, string>>>({});
|
|
51
|
+
|
|
52
|
+
const navigate = useNavigate();
|
|
53
|
+
const { success, error: showError } = useToast();
|
|
54
|
+
const { token } = useSession();
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
const [activeTab, setActiveTab] = useState<'entities' | 'modules' | 'billing'>('entities');
|
|
59
|
+
const tabs = [
|
|
60
|
+
{ id: 'entities', label: 'Entités', icon: Building2 },
|
|
61
|
+
{ id: 'modules', label: 'Plan & Modules', icon: FileText },
|
|
62
|
+
{ id: 'billing', label: 'Paiements', icon: CreditCard }
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (id) {
|
|
68
|
+
loadOrganizationDetails();
|
|
69
|
+
loadOrganizationEntities();
|
|
70
|
+
loadModules();
|
|
71
|
+
}
|
|
72
|
+
}, [id]);
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
76
|
+
const { name, value } = e.target;
|
|
77
|
+
setFormData(prev => ({ ...prev, [name]: value }));
|
|
78
|
+
if (errors[name as keyof Organization]) {
|
|
79
|
+
setErrors(prev => ({ ...prev, [name]: undefined }));
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
84
|
+
const { name, value } = e.target;
|
|
85
|
+
setFormData(prev => ({ ...prev, [name]: value }));
|
|
86
|
+
if (errors[name as keyof Organization]) {
|
|
87
|
+
setErrors(prev => ({ ...prev, [name]: undefined }));
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
const loadOrganizationDetails = async () => {
|
|
93
|
+
if (!token || !id) return;
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
setLoading(true);
|
|
97
|
+
const result = await OrganizationServices.getOrganization(parseInt(id), token) as { success: boolean, message: string, data: Organization };
|
|
98
|
+
setOrganization(result.data);
|
|
99
|
+
setFormData(result.data);
|
|
100
|
+
console.log(result.data);
|
|
101
|
+
|
|
102
|
+
} catch (error: any) {
|
|
103
|
+
showError('Erreur lors du chargement des détails de l\'organisation');
|
|
104
|
+
console.error(error);
|
|
105
|
+
} finally {
|
|
106
|
+
setLoading(false);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const loadOrganizationEntities = async () => {
|
|
111
|
+
if (!token || !id) return;
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
setEntitiesLoading(true);
|
|
115
|
+
const result = await EntityServices.getOrganizationEntities(parseInt(id), token) as { success: boolean, message: string, data: Entity[] };
|
|
116
|
+
setEntities(result.data);
|
|
117
|
+
} catch (error: any) {
|
|
118
|
+
showError('Erreur lors du chargement des entités');
|
|
119
|
+
console.error(error);
|
|
120
|
+
} finally {
|
|
121
|
+
setEntitiesLoading(false);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const loadModules = async () => {
|
|
125
|
+
if (!token || !id) return;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
setModulesLoading(true);
|
|
129
|
+
const result = await ModuleServices.getAllModules(token) as { success: boolean, message: string, data: Module[] };
|
|
130
|
+
setModules(result.data);
|
|
131
|
+
} catch (error: any) {
|
|
132
|
+
showError('Erreur lors du chargement des modules');
|
|
133
|
+
console.error(error);
|
|
134
|
+
} finally {
|
|
135
|
+
setModulesLoading(false);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const handleDeleteOrganization = async () => {
|
|
140
|
+
if (!token || !organization) return;
|
|
141
|
+
|
|
142
|
+
if (!window.confirm(`Êtes-vous sûr de vouloir supprimer l'organisation "${organization.legal_name}" ?`)) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
await OrganizationServices.deleteOrganization(organization.id, token);
|
|
148
|
+
success('Organisation supprimée avec succès');
|
|
149
|
+
navigate('/organizations');
|
|
150
|
+
} catch (error: any) {
|
|
151
|
+
showError('Erreur lors de la suppression de l\'organisation');
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const handleDeleteEntity = async (entityId: number, entityName: string) => {
|
|
156
|
+
if (!token) return;
|
|
157
|
+
|
|
158
|
+
if (!window.confirm(`Êtes-vous sûr de vouloir supprimer l'entité "${entityName}" ?`)) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
await EntityServices.deleteEntity(entityId, token);
|
|
164
|
+
success('Entité supprimée avec succès');
|
|
165
|
+
loadOrganizationEntities();
|
|
166
|
+
} catch (error: any) {
|
|
167
|
+
showError('Erreur lors de la suppression de l\'entit�');
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
const formatCurrency = (currency?: string) => {
|
|
173
|
+
const currencyMap: Record<string, string> = {
|
|
174
|
+
'EUR': '€',
|
|
175
|
+
'USD': '$',
|
|
176
|
+
'GBP': '£',
|
|
177
|
+
'CAD': 'CAD',
|
|
178
|
+
'CHF': 'CHF',
|
|
179
|
+
'XOF': 'XOF',
|
|
180
|
+
'XAF': 'XAF'
|
|
181
|
+
};
|
|
182
|
+
return currencyMap[currency || ''] || currency || 'Non spécifié';
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const handlePurchaseModule = (module: Module) => {
|
|
186
|
+
if (!organization?.active_subscription) {
|
|
187
|
+
showError('Aucun abonnement actif trouvé');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
setSelectedModuleForPurchase(module);
|
|
192
|
+
setShowModulePurchaseModal(true);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const confirmModulePurchase = async () => {
|
|
196
|
+
if (!selectedModuleForPurchase || !organization || !token) return;
|
|
197
|
+
|
|
198
|
+
setProcessingModulePurchase(true);
|
|
199
|
+
try {
|
|
200
|
+
// Simuler l'achat du module via l'API
|
|
201
|
+
await OrganizationServices.subscribe(organization.id, {
|
|
202
|
+
plan: organization.active_subscription!.plan.id,
|
|
203
|
+
billing_cycle: 'monthly',
|
|
204
|
+
extra_modules: [
|
|
205
|
+
...(
|
|
206
|
+
Array.isArray(organization.active_subscription!.extra_modules)
|
|
207
|
+
? organization.active_subscription!.extra_modules.map(m =>
|
|
208
|
+
typeof m === 'object' && m !== null && 'id' in m ? m.id : m
|
|
209
|
+
)
|
|
210
|
+
: []
|
|
211
|
+
),
|
|
212
|
+
selectedModuleForPurchase.id
|
|
213
|
+
]
|
|
214
|
+
}, token);
|
|
215
|
+
|
|
216
|
+
success(`Module "${selectedModuleForPurchase.name}" ajouté avec succès !`);
|
|
217
|
+
setShowModulePurchaseModal(false);
|
|
218
|
+
setSelectedModuleForPurchase(null);
|
|
219
|
+
|
|
220
|
+
// Recharger les détails de l'organisation pour mettre à jour la liste des modules
|
|
221
|
+
loadOrganizationDetails();
|
|
222
|
+
} catch (error) {
|
|
223
|
+
showError('Erreur lors de l\'achat du module');
|
|
224
|
+
console.error(error);
|
|
225
|
+
} finally {
|
|
226
|
+
setProcessingModulePurchase(false);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const calculateModulePrice = (module: Module, withTax: boolean = true): number => {
|
|
231
|
+
const basePrice = module.price_monthly;
|
|
232
|
+
return withTax ? basePrice * 1.18 : basePrice; // TVA 18%
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const loadAvailablePlans = async () => {
|
|
236
|
+
if (!token) return;
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
setPlansLoading(true);
|
|
240
|
+
// Utiliser les plans depuis ListPricing
|
|
241
|
+
const pricingCategories = [
|
|
242
|
+
{
|
|
243
|
+
id: 'personnel',
|
|
244
|
+
name: 'Personnel',
|
|
245
|
+
plans: [
|
|
246
|
+
{
|
|
247
|
+
id: 1,
|
|
248
|
+
code: 'rewise_pro',
|
|
249
|
+
name: 'Pro',
|
|
250
|
+
description: 'Améliorez la productivité et l\'apprentissage avec un accès supplémentaire.',
|
|
251
|
+
price: 100000,
|
|
252
|
+
originalPrice: 83333,
|
|
253
|
+
currency: 'FCFA',
|
|
254
|
+
period: '/ mois',
|
|
255
|
+
features: [
|
|
256
|
+
{ name: '1 entité', included: true },
|
|
257
|
+
{ name: 'jusqu\'à 5 utilisateurs', included: true },
|
|
258
|
+
{ name: 'CRM, Comptabilité et Facturation', included: true },
|
|
259
|
+
{ name: '+1 module au choix', included: false },
|
|
260
|
+
]
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
id: 2,
|
|
264
|
+
code: 'rewise_max',
|
|
265
|
+
name: 'Max',
|
|
266
|
+
description: 'Débloquez toutes les capacités avec un accès anticipé aux nouveaux produits.',
|
|
267
|
+
price: 200000,
|
|
268
|
+
originalPrice: 166667,
|
|
269
|
+
currency: 'FCFA',
|
|
270
|
+
period: '/ mois',
|
|
271
|
+
popular: true,
|
|
272
|
+
features: [
|
|
273
|
+
{ name: 'jusqu\'à 3 entités', included: true },
|
|
274
|
+
{ name: 'jusqu\'à 15 utilisateurs', included: true },
|
|
275
|
+
{ name: 'CRM, Comptabilité et Facturation', included: true },
|
|
276
|
+
{ name: '+1 module au choix', included: true },
|
|
277
|
+
]
|
|
278
|
+
}
|
|
279
|
+
]
|
|
280
|
+
}
|
|
281
|
+
];
|
|
282
|
+
|
|
283
|
+
setAvailablePlans(pricingCategories[0].plans);
|
|
284
|
+
} catch (error: any) {
|
|
285
|
+
showError('Erreur lors du chargement des plans');
|
|
286
|
+
console.error(error);
|
|
287
|
+
} finally {
|
|
288
|
+
setPlansLoading(false);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const handleChangePlan = () => {
|
|
293
|
+
if (!organization?.active_subscription) {
|
|
294
|
+
showError('Aucun abonnement actif trouvé');
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
loadAvailablePlans();
|
|
299
|
+
setShowPlanChangeModal(true);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const confirmPlanChange = async (newPlan: any) => {
|
|
303
|
+
if (!organization || !token) return;
|
|
304
|
+
|
|
305
|
+
setProcessingPlanChange(true);
|
|
306
|
+
try {
|
|
307
|
+
await OrganizationServices.subscribe(organization.id, {
|
|
308
|
+
plan: newPlan.id,
|
|
309
|
+
billing_cycle: 'monthly',
|
|
310
|
+
extra_modules: Array.isArray(organization.active_subscription!.extra_modules)
|
|
311
|
+
? organization.active_subscription!.extra_modules.map(m =>
|
|
312
|
+
typeof m === 'object' && m !== null && 'id' in m ? m.id : m
|
|
313
|
+
)
|
|
314
|
+
: []
|
|
315
|
+
}, token);
|
|
316
|
+
|
|
317
|
+
success(`Plan changé vers "${newPlan.name}" avec succès !`);
|
|
318
|
+
setShowPlanChangeModal(false);
|
|
319
|
+
|
|
320
|
+
// Recharger les détails de l'organisation
|
|
321
|
+
loadOrganizationDetails();
|
|
322
|
+
} catch (error) {
|
|
323
|
+
showError('Erreur lors du changement de plan');
|
|
324
|
+
console.error(error);
|
|
325
|
+
} finally {
|
|
326
|
+
setProcessingPlanChange(false);
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
const renderTabContent = () => {
|
|
332
|
+
switch (activeTab) {
|
|
333
|
+
case 'entities':
|
|
334
|
+
return (
|
|
335
|
+
<RewiseBasicCard title={
|
|
336
|
+
<div className="w-full flex items-center justify-between">
|
|
337
|
+
<div>
|
|
338
|
+
<h6 className="">Entités</h6>
|
|
339
|
+
|
|
340
|
+
</div>
|
|
341
|
+
<PrimaryButton
|
|
342
|
+
type="button"
|
|
343
|
+
onClick={() => setShowEntityModal(true)}
|
|
344
|
+
>
|
|
345
|
+
<Plus className="w-4 h-4" /><span>Nouvelle Entité</span>
|
|
346
|
+
</PrimaryButton>
|
|
347
|
+
</div>
|
|
348
|
+
} >
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
<div className="">
|
|
352
|
+
{entitiesLoading ? (
|
|
353
|
+
<div className="text-center py-8">
|
|
354
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#8290A9] mx-auto mb-4"></div>
|
|
355
|
+
<p className="text-gray-600">Chargement des entités...</p>
|
|
356
|
+
</div>
|
|
357
|
+
) : entities.length === 0 ? (
|
|
358
|
+
<div className="text-center py-12">
|
|
359
|
+
<Building2 className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
|
360
|
+
<h3 className="text-lg font-medium text-gray-900 mb-2">Aucune entité</h3>
|
|
361
|
+
<p className="text-gray-600 mb-6">
|
|
362
|
+
Cette organisation n'a pas encore d'entités associées.
|
|
363
|
+
</p>
|
|
364
|
+
<button
|
|
365
|
+
onClick={() => navigate(`/organizations/${organization!.id}/entities/create`)}
|
|
366
|
+
className="bg-[#8290A9] text-white px-6 py-2 rounded-lg hover:bg-[#6B7C92] transition-colors"
|
|
367
|
+
>
|
|
368
|
+
Créer une entité
|
|
369
|
+
</button>
|
|
370
|
+
</div>
|
|
371
|
+
) : (
|
|
372
|
+
<div className="overflow-x-auto">
|
|
373
|
+
<table className="min-w-full divide-y divide-gray-200">
|
|
374
|
+
<thead className="bg-gray-50">
|
|
375
|
+
<tr>
|
|
376
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Raison sociale</th>
|
|
377
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Nom commercial</th>
|
|
378
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
|
|
379
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Téléphone</th>
|
|
380
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Pays / Ville</th>
|
|
381
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Devise</th>
|
|
382
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
|
383
|
+
</tr>
|
|
384
|
+
</thead>
|
|
385
|
+
<tbody className="bg-white divide-y divide-gray-100">
|
|
386
|
+
{entities.map((entity) => (
|
|
387
|
+
<tr key={entity.id} className="hover:bg-gray-50">
|
|
388
|
+
<td className="px-4 py-2 font-medium text-gray-900">{entity.legal_name}</td>
|
|
389
|
+
<td className="px-4 py-2 text-gray-600">{entity.trading_name || '-'}</td>
|
|
390
|
+
<td className="px-4 py-2 text-gray-600">{entity.email || '-'}</td>
|
|
391
|
+
<td className="px-4 py-2 text-gray-600">{entity.phone || '-'}</td>
|
|
392
|
+
<td className="px-4 py-2 text-gray-600">
|
|
393
|
+
{entity.country || '-'}
|
|
394
|
+
{entity.city ? `, ${entity.city}` : ''}
|
|
395
|
+
</td>
|
|
396
|
+
<td className="px-4 py-2 text-gray-600">{formatCurrency(entity.currency)}</td>
|
|
397
|
+
<td className="px-4 py-2">
|
|
398
|
+
<div className="relative">
|
|
399
|
+
<button
|
|
400
|
+
onClick={() => setDropdownOpen(dropdownOpen === entity.id ? null : entity.id)}
|
|
401
|
+
className="p-1 rounded-full hover:bg-gray-100 transition-colors"
|
|
402
|
+
>
|
|
403
|
+
<MoreVertical className="w-4 h-4 text-gray-400" />
|
|
404
|
+
</button>
|
|
405
|
+
|
|
406
|
+
{dropdownOpen === entity.id && (
|
|
407
|
+
<div className="fixed right-3 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-10">
|
|
408
|
+
<button
|
|
409
|
+
onClick={() => { navigate(`/entities/${entity.id}`); setDropdownOpen(null); }}
|
|
410
|
+
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center space-x-2"
|
|
411
|
+
>
|
|
412
|
+
<Eye className="w-4 h-4" />
|
|
413
|
+
<span>Voir les détails</span>
|
|
414
|
+
</button>
|
|
415
|
+
<button
|
|
416
|
+
onClick={() => { setSelectedEntity(entity); setShowEntityModal(true); }}
|
|
417
|
+
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center space-x-2"
|
|
418
|
+
>
|
|
419
|
+
<Edit className="w-4 h-4" />
|
|
420
|
+
<span>Modifier</span>
|
|
421
|
+
</button>
|
|
422
|
+
<button
|
|
423
|
+
onClick={() => handleDeleteEntity(entity.id, entity.legal_name)}
|
|
424
|
+
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center space-x-2"
|
|
425
|
+
>
|
|
426
|
+
<Trash2 className="w-4 h-4" />
|
|
427
|
+
<span>Supprimer</span>
|
|
428
|
+
</button>
|
|
429
|
+
</div>
|
|
430
|
+
)}
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
</td>
|
|
434
|
+
</tr>
|
|
435
|
+
))}
|
|
436
|
+
</tbody>
|
|
437
|
+
</table>
|
|
438
|
+
</div>
|
|
439
|
+
)}
|
|
440
|
+
</div>
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
</RewiseBasicCard>
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
case 'modules':
|
|
449
|
+
return (
|
|
450
|
+
<RewiseBasicCard title={
|
|
451
|
+
<div className="w-full flex items-center justify-between">
|
|
452
|
+
<div>
|
|
453
|
+
<h6 className="">Plan & Modules</h6>
|
|
454
|
+
</div>
|
|
455
|
+
|
|
456
|
+
</div>
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
}>
|
|
460
|
+
{/* <p>Gestion des modules et fonctionnalités activées pour cette organisation.</p> */}
|
|
461
|
+
{organization?.active_subscription && (
|
|
462
|
+
<div className="mb-6">
|
|
463
|
+
<div className="bg-[#F5F7FA] rounded-lg p-4 flex items-center justify-between shadow-sm">
|
|
464
|
+
<div>actif
|
|
465
|
+
<div className="font-semibold text-lg text-[#8290A9]">{organization.active_subscription.plan.name}</div>
|
|
466
|
+
<div className="text-sm text-gray-500">{organization.active_subscription.plan.description}</div>
|
|
467
|
+
<div className="text-sm text-gray-700">
|
|
468
|
+
{organization.active_subscription.plan.price_monthly} / mois
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
<button
|
|
472
|
+
className="bg-[#8290A9] text-white px-4 py-2 rounded-lg hover:bg-[#6B7C92] transition-colors"
|
|
473
|
+
onClick={handleChangePlan}
|
|
474
|
+
>
|
|
475
|
+
Changer de plan
|
|
476
|
+
</button>
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
)}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
<div className=" gap-4">
|
|
483
|
+
<div className="">
|
|
484
|
+
{modulesLoading ? (
|
|
485
|
+
<div className="text-center py-8">
|
|
486
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#8290A9] mx-auto mb-4"></div>
|
|
487
|
+
<p className="text-gray-600">Chargement des modules...</p>
|
|
488
|
+
</div>
|
|
489
|
+
) : modules.length === 0 ? (
|
|
490
|
+
<div className="text-center py-12">
|
|
491
|
+
<Building2 className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
|
492
|
+
<h3 className="text-lg font-medium text-gray-900 mb-2">Aucun module</h3>
|
|
493
|
+
<p className="text-gray-600 mb-6">
|
|
494
|
+
Aucun module n'est disponible pour le moment.
|
|
495
|
+
</p>
|
|
496
|
+
<button
|
|
497
|
+
onClick={() => navigate(`/organizations/${organization!.id}/modules/create`)}
|
|
498
|
+
className="bg-[#8290A9] text-white px-6 py-2 rounded-lg hover:bg-[#6B7C92] transition-colors"
|
|
499
|
+
>
|
|
500
|
+
Créer une entité
|
|
501
|
+
</button>
|
|
502
|
+
</div>
|
|
503
|
+
) : (
|
|
504
|
+
<div className="overflow-x-auto">
|
|
505
|
+
<table className="min-w-full divide-y divide-gray-200">
|
|
506
|
+
<thead className="bg-gray-50">
|
|
507
|
+
<tr>
|
|
508
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">nom</th>
|
|
509
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">description</th>
|
|
510
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Montant</th>
|
|
511
|
+
|
|
512
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Action</th>
|
|
513
|
+
|
|
514
|
+
</tr>
|
|
515
|
+
</thead>
|
|
516
|
+
<tbody className="bg-white divide-y divide-gray-100">
|
|
517
|
+
{modules.map((module) => (
|
|
518
|
+
<tr key={module.id} className="hover:bg-gray-50">
|
|
519
|
+
<td className="px-4 py-2 font-medium text-gray-900">{module.name}</td>
|
|
520
|
+
<td className="px-4 py-2 text-gray-600">{module.description || '-'}</td>
|
|
521
|
+
<td className="px-4 py-2 text-gray-600">{module.price_monthly}</td>
|
|
522
|
+
|
|
523
|
+
<td className="px-4 py-2 text-gray-600">
|
|
524
|
+
{organization?.active_subscription && !organization!.modules!.some(m => m.id === module.id) ? (
|
|
525
|
+
<PrimaryButton onClick={() => handlePurchaseModule(module)} >Obtenir</PrimaryButton>
|
|
526
|
+
) : organization?.active_subscription && organization!.modules!.some(m => m.id === module.id) ? (
|
|
527
|
+
<span className="text-green-600 text-sm font-medium">✓ Activé</span>
|
|
528
|
+
) : (
|
|
529
|
+
<span className="text-gray-400 text-sm">Non disponible</span>
|
|
530
|
+
)}
|
|
531
|
+
</td>
|
|
532
|
+
</tr>
|
|
533
|
+
))}
|
|
534
|
+
</tbody>
|
|
535
|
+
</table>
|
|
536
|
+
</div>
|
|
537
|
+
)}
|
|
538
|
+
</div>
|
|
539
|
+
|
|
540
|
+
</div>
|
|
541
|
+
</RewiseBasicCard>
|
|
542
|
+
);
|
|
543
|
+
case 'billing':
|
|
544
|
+
return (
|
|
545
|
+
<RewiseBasicCard title={<h6>Paiements</h6>}>
|
|
546
|
+
<div className="">
|
|
547
|
+
{entitiesLoading ? (
|
|
548
|
+
<div className="text-center py-8">
|
|
549
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#8290A9] mx-auto mb-4"></div>
|
|
550
|
+
<p className="text-gray-600">Chargement des entités...</p>
|
|
551
|
+
</div>
|
|
552
|
+
) : entities.length === 0 ? (
|
|
553
|
+
<div className="text-center py-12">
|
|
554
|
+
<Building2 className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
|
555
|
+
<h3 className="text-lg font-medium text-gray-900 mb-2">Aucune entité</h3>
|
|
556
|
+
<p className="text-gray-600 mb-6">
|
|
557
|
+
Cette organisation n'a pas encore d'entités associées.
|
|
558
|
+
</p>
|
|
559
|
+
<button
|
|
560
|
+
onClick={() => navigate(`/organizations/${organization!.id}/entities/create`)}
|
|
561
|
+
className="bg-[#8290A9] text-white px-6 py-2 rounded-lg hover:bg-[#6B7C92] transition-colors"
|
|
562
|
+
>
|
|
563
|
+
Créer une entité
|
|
564
|
+
</button>
|
|
565
|
+
</div>
|
|
566
|
+
) : (
|
|
567
|
+
<div className="overflow-x-auto">
|
|
568
|
+
<table className="min-w-full divide-y divide-gray-200">
|
|
569
|
+
<thead className="bg-gray-50">
|
|
570
|
+
<tr>
|
|
571
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
|
|
572
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Methode de paiement</th>
|
|
573
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Montant</th>
|
|
574
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">statut</th>
|
|
575
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Factures</th>
|
|
576
|
+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Recu de paiement</th>
|
|
577
|
+
|
|
578
|
+
</tr>
|
|
579
|
+
</thead>
|
|
580
|
+
<tbody className="bg-white divide-y divide-gray-100">
|
|
581
|
+
{[].map((payment) => (
|
|
582
|
+
<tr key={payment.id} className="hover:bg-gray-50">
|
|
583
|
+
<td className="px-4 py-2 font-medium text-gray-900">{payment.date}</td>
|
|
584
|
+
<td className="px-4 py-2 text-gray-600">{payment.method || '-'}</td>
|
|
585
|
+
<td className="px-4 py-2 text-gray-600">{formatCurrency(payment.amount)}</td>
|
|
586
|
+
<td className="px-4 py-2 text-gray-600">{payment.status || '-'}</td>
|
|
587
|
+
<td className="px-4 py-2 text-gray-600">
|
|
588
|
+
{payment.invoice || '-'}
|
|
589
|
+
</td>
|
|
590
|
+
<td className="px-4 py-2 text-gray-600">{formatCurrency(payment.receipt)}</td>
|
|
591
|
+
|
|
592
|
+
</tr>
|
|
593
|
+
))}
|
|
594
|
+
</tbody>
|
|
595
|
+
</table>
|
|
596
|
+
</div>
|
|
597
|
+
)}
|
|
598
|
+
</div>
|
|
599
|
+
</RewiseBasicCard>
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
default:
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
if (loading) {
|
|
612
|
+
return (
|
|
613
|
+
<div className="space-y-6">
|
|
614
|
+
<div className="flex items-center justify-center min-h-[400px]">
|
|
615
|
+
<div className="text-center">
|
|
616
|
+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8290A9] mx-auto mb-4"></div>
|
|
617
|
+
<p className="text-gray-600">Chargement des détails de l'organisation...</p>
|
|
618
|
+
</div>
|
|
619
|
+
</div>
|
|
620
|
+
</div>
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (organization && organization.active_subscription === null) {
|
|
625
|
+
return (
|
|
626
|
+
<div id='' className="relative space-y-6">
|
|
627
|
+
<X onClick={() => navigate('/organizations')} className="absolute right-4 top-4 w-8 h-8 text-gray-300 mx-auto mb-4" />
|
|
628
|
+
<ListPricing organizationId={organization.id} />
|
|
629
|
+
</div>
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (!organization) {
|
|
634
|
+
return (
|
|
635
|
+
<div className="space-y-6">
|
|
636
|
+
<div className="flex items-center justify-center min-h-[400px]">
|
|
637
|
+
<div className="text-center">
|
|
638
|
+
<Building2 className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
|
639
|
+
<h3 className="text-lg font-medium text-gray-900 mb-2">Organisation non trouvée</h3>
|
|
640
|
+
<p className="text-gray-600 mb-6">L'organisation demandée n'existe pas ou vous n'y avez pas accès.</p>
|
|
641
|
+
<button
|
|
642
|
+
onClick={() => navigate('/organizations')}
|
|
643
|
+
className="bg-[#8290A9] text-white px-6 py-2 rounded-lg hover:bg-[#6B7C92] transition-colors"
|
|
644
|
+
>
|
|
645
|
+
Retour aux organisations
|
|
646
|
+
</button>
|
|
647
|
+
</div>
|
|
648
|
+
</div>
|
|
649
|
+
</div>
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return (
|
|
654
|
+
<div className="space-y-6">
|
|
655
|
+
{/* Header */}
|
|
656
|
+
<div className="flex items-center justify-between">
|
|
657
|
+
<div className="flex items-center space-x-4">
|
|
658
|
+
<button
|
|
659
|
+
onClick={() => navigate('/organizations')}
|
|
660
|
+
className="flex items-center text-gray-600 hover:text-gray-900 transition-colors"
|
|
661
|
+
>
|
|
662
|
+
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
663
|
+
Retour aux organisations
|
|
664
|
+
</button>
|
|
665
|
+
</div>
|
|
666
|
+
|
|
667
|
+
<div className="flex items-center space-x-3">
|
|
668
|
+
<button
|
|
669
|
+
onClick={() => navigate(`/organizations/${organization.id}/edit`)}
|
|
670
|
+
className="bg-[#8290A9] text-white px-4 py-2 rounded-lg hover:bg-[#6B7C92] transition-colors flex items-center space-x-2"
|
|
671
|
+
>
|
|
672
|
+
<Edit className="w-4 h-4" />
|
|
673
|
+
<span>Modifier</span>
|
|
674
|
+
</button>
|
|
675
|
+
<button
|
|
676
|
+
onClick={handleDeleteOrganization}
|
|
677
|
+
className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors flex items-center space-x-2"
|
|
678
|
+
>
|
|
679
|
+
<Trash2 className="w-4 h-4" />
|
|
680
|
+
<span>Supprimer</span>
|
|
681
|
+
</button>
|
|
682
|
+
</div>
|
|
683
|
+
</div>
|
|
684
|
+
|
|
685
|
+
{/* Organization Details */}
|
|
686
|
+
<RewiseBasicCard title={<h6>Détails de l'organisation</h6>}>
|
|
687
|
+
|
|
688
|
+
<div className="flex items-center space-x-3 mb-6">
|
|
689
|
+
|
|
690
|
+
<div className='w-[60%]'>
|
|
691
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-3">
|
|
692
|
+
|
|
693
|
+
{/* Raison sociale */}
|
|
694
|
+
<TextInput
|
|
695
|
+
label="Raison sociale"
|
|
696
|
+
name="legal_name"
|
|
697
|
+
value={formData?.legal_name ?? ''}
|
|
698
|
+
placeholder="Nom légal de l'organisation"
|
|
699
|
+
required
|
|
700
|
+
error={errors.legal_name}
|
|
701
|
+
onChange={handleInputChange}
|
|
702
|
+
/>
|
|
703
|
+
|
|
704
|
+
{/* Nom commercial */}
|
|
705
|
+
<TextInput
|
|
706
|
+
label="Nom commercial"
|
|
707
|
+
name="trading_name"
|
|
708
|
+
value={formData?.trading_name ?? ''}
|
|
709
|
+
placeholder="Nom commercial (optionnel)"
|
|
710
|
+
onChange={handleInputChange}
|
|
711
|
+
/>
|
|
712
|
+
|
|
713
|
+
</div>
|
|
714
|
+
|
|
715
|
+
{/* Contact */}
|
|
716
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-3">
|
|
717
|
+
<InputField
|
|
718
|
+
label="Téléphone"
|
|
719
|
+
name="phone"
|
|
720
|
+
type="tel"
|
|
721
|
+
value={formData?.phone ?? ''}
|
|
722
|
+
placeholder="+225 XX XX XXX XXX"
|
|
723
|
+
onChange={handleInputChange}
|
|
724
|
+
/>
|
|
725
|
+
|
|
726
|
+
<InputField
|
|
727
|
+
label="Email"
|
|
728
|
+
name="email"
|
|
729
|
+
type="email"
|
|
730
|
+
value={formData?.email ?? ''}
|
|
731
|
+
placeholder="contact@organisation.com"
|
|
732
|
+
error={errors.email}
|
|
733
|
+
onChange={handleInputChange}
|
|
734
|
+
/>
|
|
735
|
+
</div>
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
</div>
|
|
740
|
+
|
|
741
|
+
<div className="w-[40%]">
|
|
742
|
+
{/* Adresse */}
|
|
743
|
+
<div>
|
|
744
|
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
745
|
+
Adresse
|
|
746
|
+
</label>
|
|
747
|
+
<textarea
|
|
748
|
+
name="address"
|
|
749
|
+
value={formData?.address ?? ''}
|
|
750
|
+
onChange={handleTextareaChange}
|
|
751
|
+
rows={4}
|
|
752
|
+
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#6B7C92] focus:border-transparent placeholder-gray-400"
|
|
753
|
+
placeholder="Adresse complète de l'organisation"
|
|
754
|
+
/>
|
|
755
|
+
</div>
|
|
756
|
+
</div>
|
|
757
|
+
|
|
758
|
+
</div>
|
|
759
|
+
</RewiseBasicCard>
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
<div className="border-b border-gray-200">
|
|
765
|
+
<nav className="flex space-x-8 px-6">
|
|
766
|
+
{tabs.map((tab) => {
|
|
767
|
+
const Icon = tab.icon;
|
|
768
|
+
return (
|
|
769
|
+
<button
|
|
770
|
+
key={tab.id}
|
|
771
|
+
onClick={() => setActiveTab(tab.id as any)}
|
|
772
|
+
className={`
|
|
773
|
+
flex items-center space-x-2 py-4 px-2 text-sm font-medium border-b-2 transition-colors
|
|
774
|
+
${activeTab === tab.id
|
|
775
|
+
? 'border-[#6A8A82] text-[#6A8A82]'
|
|
776
|
+
: 'border-transparent text-[#767676] hover:text-[#6A8A82] hover:border-[#6A8A82]/30'
|
|
777
|
+
}
|
|
778
|
+
`}
|
|
779
|
+
>
|
|
780
|
+
<Icon className="w-4 h-4 inline mr-2" />
|
|
781
|
+
{tab.label}
|
|
782
|
+
</button>
|
|
783
|
+
);
|
|
784
|
+
})}
|
|
785
|
+
</nav>
|
|
786
|
+
</div>
|
|
787
|
+
|
|
788
|
+
{renderTabContent()}
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
{/* Click outside to close dropdown */}
|
|
796
|
+
{dropdownOpen && (
|
|
797
|
+
<div
|
|
798
|
+
className="fixed inset-0 z-5"
|
|
799
|
+
onClick={() => setDropdownOpen(null)}
|
|
800
|
+
/>
|
|
801
|
+
)}
|
|
802
|
+
|
|
803
|
+
{/* Entity Modal */}
|
|
804
|
+
{showEntityModal && (
|
|
805
|
+
<EntityModal
|
|
806
|
+
key={`entity-modal-${selectedEntity ? selectedEntity.id : 'new'}-${organization?.id}`}
|
|
807
|
+
object={selectedEntity}
|
|
808
|
+
isOpen={showEntityModal}
|
|
809
|
+
onClose={() => setShowEntityModal(false)}
|
|
810
|
+
organization={organization}
|
|
811
|
+
/>
|
|
812
|
+
)}
|
|
813
|
+
|
|
814
|
+
{/* Module Purchase Modal */}
|
|
815
|
+
{showModulePurchaseModal && selectedModuleForPurchase && (
|
|
816
|
+
<ModulePurchaseModal
|
|
817
|
+
module={selectedModuleForPurchase}
|
|
818
|
+
organization={organization}
|
|
819
|
+
isOpen={showModulePurchaseModal}
|
|
820
|
+
onClose={() => {
|
|
821
|
+
setShowModulePurchaseModal(false);
|
|
822
|
+
setSelectedModuleForPurchase(null);
|
|
823
|
+
}}
|
|
824
|
+
onConfirm={confirmModulePurchase}
|
|
825
|
+
processing={processingModulePurchase}
|
|
826
|
+
/>
|
|
827
|
+
)}
|
|
828
|
+
|
|
829
|
+
{/* Plan Change Modal */}
|
|
830
|
+
{showPlanChangeModal && (
|
|
831
|
+
<PlanChangeModal
|
|
832
|
+
plans={availablePlans}
|
|
833
|
+
currentPlan={organization?.active_subscription?.plan}
|
|
834
|
+
organization={organization}
|
|
835
|
+
isOpen={showPlanChangeModal}
|
|
836
|
+
onClose={() => setShowPlanChangeModal(false)}
|
|
837
|
+
onConfirm={confirmPlanChange}
|
|
838
|
+
processing={processingPlanChange}
|
|
839
|
+
plansLoading={plansLoading}
|
|
840
|
+
/>
|
|
841
|
+
)}
|
|
842
|
+
</div>
|
|
843
|
+
);
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
export default DetailOrganizations;
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
// EntityModal Component
|
|
851
|
+
const EntityModal: React.FC<{ isOpen: boolean; onClose: () => void; object: Entity | null; organization: Organization | null }> = ({
|
|
852
|
+
isOpen,
|
|
853
|
+
onClose,
|
|
854
|
+
object,
|
|
855
|
+
organization
|
|
856
|
+
}) => {
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
const [formData, setFormData] = useState<Partial<Entity>>(object || {
|
|
860
|
+
organization: organization?.id,
|
|
861
|
+
legal_name: '',
|
|
862
|
+
trading_name: '',
|
|
863
|
+
phone: '',
|
|
864
|
+
email: '',
|
|
865
|
+
country: '',
|
|
866
|
+
city: '',
|
|
867
|
+
address: '',
|
|
868
|
+
rib: '',
|
|
869
|
+
iban: '',
|
|
870
|
+
bank_name: '',
|
|
871
|
+
bank_address: '',
|
|
872
|
+
tax_account: '',
|
|
873
|
+
rccm: '',
|
|
874
|
+
currency: 'EUR',
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
const [errors, setErrors] = useState<Partial<Record<keyof Entity, string>>>({});
|
|
878
|
+
const [activeTab, setActiveTab] = useState<'general' | 'location' | 'banking' | 'fne'>('general');
|
|
879
|
+
const [loading, setLoading] = useState(false);
|
|
880
|
+
const { token } = useSession();
|
|
881
|
+
const { success, error: showError } = useToast();
|
|
882
|
+
|
|
883
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
884
|
+
const { name, value } = e.target;
|
|
885
|
+
setFormData(prev => ({ ...prev, [name]: value }));
|
|
886
|
+
if (errors[name as keyof Entity]) {
|
|
887
|
+
setErrors(prev => ({ ...prev, [name]: undefined }));
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
892
|
+
const { name, value } = e.target;
|
|
893
|
+
setFormData(prev => ({ ...prev, [name]: value }));
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
897
|
+
const { name, value } = e.target;
|
|
898
|
+
setFormData(prev => ({ ...prev, [name]: value }));
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
const validateForm = (): boolean => {
|
|
902
|
+
const newErrors: Partial<Record<keyof Entity, string>> = {};
|
|
903
|
+
|
|
904
|
+
if (!formData.legal_name?.trim()) {
|
|
905
|
+
newErrors.legal_name = 'La raison sociale est obligatoire';
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
|
909
|
+
newErrors.email = 'Format d\'email invalide';
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (formData.iban && formData.iban.length > 0 && formData.iban.length < 15) {
|
|
913
|
+
newErrors.iban = 'Format IBAN invalide';
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
setErrors(newErrors);
|
|
917
|
+
return Object.keys(newErrors).length === 0;
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
const handleSaveEntity = async (entityData: Partial<Entity>) => {
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
try {
|
|
924
|
+
if (object && object.id) {
|
|
925
|
+
// Modification
|
|
926
|
+
await EntityServices.updateEntity(object.id, entityData as any, token);
|
|
927
|
+
success('Entité modifiée avec succès !');
|
|
928
|
+
} else {
|
|
929
|
+
// Création
|
|
930
|
+
await EntityServices.createEntity(organization!.id, entityData as any, token);
|
|
931
|
+
success('Entité créée avec succès !');
|
|
932
|
+
}
|
|
933
|
+
onClose();
|
|
934
|
+
} catch (error: any) {
|
|
935
|
+
console.error(error);
|
|
936
|
+
showError('Erreur lors de l\'enregistrement de l\'entité');
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
941
|
+
e.preventDefault();
|
|
942
|
+
if (!validateForm()) return;
|
|
943
|
+
|
|
944
|
+
setLoading(true);
|
|
945
|
+
try {
|
|
946
|
+
await handleSaveEntity(formData);
|
|
947
|
+
} finally {
|
|
948
|
+
setLoading(false);
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
const tabs = [
|
|
953
|
+
{ id: 'general', label: 'Général', icon: Building2 },
|
|
954
|
+
{ id: 'location', label: 'Localisation', icon: MapPin },
|
|
955
|
+
{ id: 'banking', label: 'Bancaire', icon: CreditCard },
|
|
956
|
+
{ id: 'fne', label: 'FNE', icon: FileText }
|
|
957
|
+
];
|
|
958
|
+
|
|
959
|
+
const currencies = [
|
|
960
|
+
{ value: 'EUR', label: 'Euro (€)' },
|
|
961
|
+
{ value: 'USD', label: 'Dollar US ($)' },
|
|
962
|
+
{ value: 'GBP', label: 'Livre Sterling (£)' },
|
|
963
|
+
{ value: 'CAD', label: 'Dollar Canadien (CAD)' },
|
|
964
|
+
{ value: 'CHF', label: 'Franc Suisse (CHF)' },
|
|
965
|
+
{ value: 'XOF', label: 'Franc CFA (XOF)' },
|
|
966
|
+
{ value: 'XAF', label: 'Franc CFA Central (XAF)' }
|
|
967
|
+
];
|
|
968
|
+
|
|
969
|
+
const renderTabContent = () => {
|
|
970
|
+
switch (activeTab) {
|
|
971
|
+
case 'general':
|
|
972
|
+
return (
|
|
973
|
+
<div className="space-y-4">
|
|
974
|
+
<TextInput
|
|
975
|
+
label="Raison sociale"
|
|
976
|
+
name="legal_name"
|
|
977
|
+
value={formData.legal_name || ''}
|
|
978
|
+
placeholder="Nom légal de l'entité"
|
|
979
|
+
required
|
|
980
|
+
error={errors.legal_name}
|
|
981
|
+
onChange={handleInputChange}
|
|
982
|
+
/>
|
|
983
|
+
|
|
984
|
+
<TextInput
|
|
985
|
+
label="Nom commercial"
|
|
986
|
+
name="trading_name"
|
|
987
|
+
value={formData.trading_name || ''}
|
|
988
|
+
placeholder="Nom commercial (optionnel)"
|
|
989
|
+
onChange={handleInputChange}
|
|
990
|
+
/>
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
995
|
+
<TextInput
|
|
996
|
+
label="Numéro d'identification fiscale (NCC)"
|
|
997
|
+
name="tax_account"
|
|
998
|
+
value={formData.tax_account || ''}
|
|
999
|
+
placeholder="Numéro d'identification fiscale"
|
|
1000
|
+
onChange={handleInputChange}
|
|
1001
|
+
/>
|
|
1002
|
+
|
|
1003
|
+
<TextInput
|
|
1004
|
+
label="RCCM"
|
|
1005
|
+
name="rccm"
|
|
1006
|
+
value={formData.rccm || ''}
|
|
1007
|
+
placeholder="Registre du Commerce et du Crédit Mobilier"
|
|
1008
|
+
onChange={handleInputChange}
|
|
1009
|
+
/>
|
|
1010
|
+
|
|
1011
|
+
<InputField
|
|
1012
|
+
label="Téléphone"
|
|
1013
|
+
name="phone"
|
|
1014
|
+
type="tel"
|
|
1015
|
+
value={formData.phone || ''}
|
|
1016
|
+
placeholder="+33 1 23 45 67 89"
|
|
1017
|
+
onChange={handleInputChange}
|
|
1018
|
+
/>
|
|
1019
|
+
|
|
1020
|
+
<InputField
|
|
1021
|
+
label="Email"
|
|
1022
|
+
name="email"
|
|
1023
|
+
type="email"
|
|
1024
|
+
value={formData.email || ''}
|
|
1025
|
+
placeholder="contact@entite.com"
|
|
1026
|
+
error={errors.email}
|
|
1027
|
+
onChange={handleInputChange}
|
|
1028
|
+
/>
|
|
1029
|
+
</div>
|
|
1030
|
+
</div>
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
case 'location':
|
|
1034
|
+
return (
|
|
1035
|
+
<div className="space-y-4">
|
|
1036
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
1037
|
+
<TextInput
|
|
1038
|
+
label="Pays"
|
|
1039
|
+
name="country"
|
|
1040
|
+
value={formData.country || ''}
|
|
1041
|
+
placeholder="Cote d'Ivoire"
|
|
1042
|
+
onChange={handleInputChange}
|
|
1043
|
+
/>
|
|
1044
|
+
|
|
1045
|
+
<TextInput
|
|
1046
|
+
label="Ville"
|
|
1047
|
+
name="city"
|
|
1048
|
+
value={formData.city || ''}
|
|
1049
|
+
placeholder="Abidjan"
|
|
1050
|
+
onChange={handleInputChange}
|
|
1051
|
+
/>
|
|
1052
|
+
</div>
|
|
1053
|
+
|
|
1054
|
+
<div>
|
|
1055
|
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
1056
|
+
Adresse complète
|
|
1057
|
+
</label>
|
|
1058
|
+
<textarea
|
|
1059
|
+
name="address"
|
|
1060
|
+
value={formData.address || ''}
|
|
1061
|
+
onChange={handleTextareaChange}
|
|
1062
|
+
rows={3}
|
|
1063
|
+
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#6B7C92] focus:border-transparent placeholder-gray-400"
|
|
1064
|
+
placeholder="Adresse complète de l'entité"
|
|
1065
|
+
/>
|
|
1066
|
+
</div>
|
|
1067
|
+
</div>
|
|
1068
|
+
);
|
|
1069
|
+
|
|
1070
|
+
case 'banking':
|
|
1071
|
+
return (
|
|
1072
|
+
<div className="space-y-4">
|
|
1073
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
1074
|
+
<TextInput
|
|
1075
|
+
label="RIB"
|
|
1076
|
+
name="rib"
|
|
1077
|
+
value={formData.rib || ''}
|
|
1078
|
+
placeholder="Relevé d'Identité Bancaire"
|
|
1079
|
+
onChange={handleInputChange}
|
|
1080
|
+
/>
|
|
1081
|
+
|
|
1082
|
+
<TextInput
|
|
1083
|
+
label="IBAN"
|
|
1084
|
+
name="iban"
|
|
1085
|
+
value={formData.iban || ''}
|
|
1086
|
+
placeholder="FR76 1234 5678 9012 3456 789"
|
|
1087
|
+
error={errors.iban}
|
|
1088
|
+
onChange={handleInputChange}
|
|
1089
|
+
/>
|
|
1090
|
+
</div>
|
|
1091
|
+
|
|
1092
|
+
<TextInput
|
|
1093
|
+
label="Nom de la banque"
|
|
1094
|
+
name="bank_name"
|
|
1095
|
+
value={formData.bank_name || ''}
|
|
1096
|
+
placeholder="Nom de la banque"
|
|
1097
|
+
onChange={handleInputChange}
|
|
1098
|
+
/>
|
|
1099
|
+
|
|
1100
|
+
<div>
|
|
1101
|
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
1102
|
+
Adresse de la banque
|
|
1103
|
+
</label>
|
|
1104
|
+
<textarea
|
|
1105
|
+
name="bank_address"
|
|
1106
|
+
value={formData.bank_address || ''}
|
|
1107
|
+
onChange={handleTextareaChange}
|
|
1108
|
+
rows={2}
|
|
1109
|
+
className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#6B7C92] focus:border-transparent placeholder-gray-400"
|
|
1110
|
+
placeholder="Adresse de la banque"
|
|
1111
|
+
/>
|
|
1112
|
+
</div>
|
|
1113
|
+
|
|
1114
|
+
<SelectInput
|
|
1115
|
+
label="Devise"
|
|
1116
|
+
name="currency"
|
|
1117
|
+
value={formData.currency || 'EUR'}
|
|
1118
|
+
options={currencies}
|
|
1119
|
+
onChange={handleSelectChange}
|
|
1120
|
+
/>
|
|
1121
|
+
</div>
|
|
1122
|
+
);
|
|
1123
|
+
|
|
1124
|
+
case 'fne':
|
|
1125
|
+
return (
|
|
1126
|
+
<div className="space-y-4">
|
|
1127
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
1128
|
+
<TextInput
|
|
1129
|
+
label="Regime Fiscal"
|
|
1130
|
+
name="regime_taxes"
|
|
1131
|
+
value={formData.regime_taxes || ''}
|
|
1132
|
+
placeholder=""
|
|
1133
|
+
onChange={handleInputChange}
|
|
1134
|
+
/>
|
|
1135
|
+
<TextInput
|
|
1136
|
+
label="Centre de taxe"
|
|
1137
|
+
name="centers_taxes"
|
|
1138
|
+
value={formData.centers_taxes || ''}
|
|
1139
|
+
placeholder=""
|
|
1140
|
+
onChange={handleInputChange}
|
|
1141
|
+
/>
|
|
1142
|
+
<TextInput
|
|
1143
|
+
label="Centre de taxe"
|
|
1144
|
+
name="centers_taxes"
|
|
1145
|
+
value={formData.centers_taxes || ''}
|
|
1146
|
+
placeholder=""
|
|
1147
|
+
onChange={handleInputChange}
|
|
1148
|
+
/>
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
<TextInput
|
|
1152
|
+
label={`Etablissement`}
|
|
1153
|
+
name="establishment"
|
|
1154
|
+
value={formData.establishment ?? ''}
|
|
1155
|
+
onChange={handleInputChange}
|
|
1156
|
+
/>
|
|
1157
|
+
|
|
1158
|
+
<TextInput
|
|
1159
|
+
label={`Point de vente`}
|
|
1160
|
+
name="point_of_sale"
|
|
1161
|
+
value={formData.point_of_sale ?? ''}
|
|
1162
|
+
onChange={handleInputChange}
|
|
1163
|
+
/>
|
|
1164
|
+
|
|
1165
|
+
<TextInput
|
|
1166
|
+
label={`Clé d'authentification FNE`}
|
|
1167
|
+
name="fne_auth_key"
|
|
1168
|
+
value={formData.fne_auth_key ?? ''}
|
|
1169
|
+
onChange={handleInputChange}
|
|
1170
|
+
/>
|
|
1171
|
+
|
|
1172
|
+
<SelectInput
|
|
1173
|
+
label={`STATUT FNE`}
|
|
1174
|
+
name="fne_url"
|
|
1175
|
+
value={formData.fne_url ?? ''}
|
|
1176
|
+
options={[
|
|
1177
|
+
{ value: 'test', label: 'test' },
|
|
1178
|
+
{ value: 'prod', label: 'production' },
|
|
1179
|
+
]}
|
|
1180
|
+
onChange={handleInputChange}
|
|
1181
|
+
/>
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
</div>
|
|
1185
|
+
</div>
|
|
1186
|
+
);
|
|
1187
|
+
|
|
1188
|
+
default:
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1191
|
+
};
|
|
1192
|
+
|
|
1193
|
+
if (!isOpen) return null;
|
|
1194
|
+
|
|
1195
|
+
return (
|
|
1196
|
+
<Modal
|
|
1197
|
+
title="Créer une entité"
|
|
1198
|
+
description={`la création de l'entité pour l'organisation ${organization?.legal_name}.`}
|
|
1199
|
+
open={isOpen}
|
|
1200
|
+
onClose={onClose}
|
|
1201
|
+
>
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
{/* Tabs */}
|
|
1206
|
+
<div className="border-b border-gray-200">
|
|
1207
|
+
<nav className="flex space-x-8 px-6">
|
|
1208
|
+
{tabs.map((tab) => {
|
|
1209
|
+
const Icon = tab.icon;
|
|
1210
|
+
return (
|
|
1211
|
+
<button
|
|
1212
|
+
type='button'
|
|
1213
|
+
key={tab.id}
|
|
1214
|
+
onClick={() => setActiveTab(tab.id as any)}
|
|
1215
|
+
className={`
|
|
1216
|
+
flex items-center space-x-2 py-4 px-2 text-sm font-medium border-b-2 transition-colors
|
|
1217
|
+
${activeTab === tab.id
|
|
1218
|
+
? 'border-[#6A8A82] text-[#6A8A82]'
|
|
1219
|
+
: 'border-transparent text-[#767676] hover:text-[#6A8A82] hover:border-[#6A8A82]/30'
|
|
1220
|
+
}
|
|
1221
|
+
`}
|
|
1222
|
+
>
|
|
1223
|
+
<Icon className="w-4 h-4 inline mr-2" />
|
|
1224
|
+
<span>{tab.label}</span>
|
|
1225
|
+
{tab.badge && (
|
|
1226
|
+
<span className="ml-2 px-2 py-1 text-xs bg-red-100 text-red-600 rounded-full">
|
|
1227
|
+
{tab.badge}
|
|
1228
|
+
</span>
|
|
1229
|
+
)}
|
|
1230
|
+
</button>
|
|
1231
|
+
);
|
|
1232
|
+
})}
|
|
1233
|
+
</nav>
|
|
1234
|
+
</div>
|
|
1235
|
+
|
|
1236
|
+
{/* Content */}
|
|
1237
|
+
<form onSubmit={handleSubmit} className="p-6">
|
|
1238
|
+
{renderTabContent()}
|
|
1239
|
+
|
|
1240
|
+
{/* Actions */}
|
|
1241
|
+
<div className="flex justify-between pt-6 border-t border-gray-200 mt-8">
|
|
1242
|
+
<button
|
|
1243
|
+
type="button"
|
|
1244
|
+
onClick={onClose}
|
|
1245
|
+
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
|
1246
|
+
>
|
|
1247
|
+
Annuler
|
|
1248
|
+
</button>
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
<PrimaryButton
|
|
1253
|
+
type="submit"
|
|
1254
|
+
disabled={loading}
|
|
1255
|
+
>
|
|
1256
|
+
|
|
1257
|
+
{loading ? 'chargement...' : 'Enregistrer l\'entité'}
|
|
1258
|
+
</PrimaryButton>
|
|
1259
|
+
</div>
|
|
1260
|
+
</form>
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
</Modal>
|
|
1267
|
+
|
|
1268
|
+
);
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
// ModulePurchaseModal Component
|
|
1272
|
+
interface ModulePurchaseModalProps {
|
|
1273
|
+
module: Module;
|
|
1274
|
+
organization: Organization | null;
|
|
1275
|
+
isOpen: boolean;
|
|
1276
|
+
onClose: () => void;
|
|
1277
|
+
onConfirm: () => void;
|
|
1278
|
+
processing: boolean;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
const ModulePurchaseModal: React.FC<ModulePurchaseModalProps> = ({
|
|
1282
|
+
module,
|
|
1283
|
+
organization,
|
|
1284
|
+
isOpen,
|
|
1285
|
+
onClose,
|
|
1286
|
+
onConfirm,
|
|
1287
|
+
processing
|
|
1288
|
+
}) => {
|
|
1289
|
+
const calculateModulePrice = (module: Module, withTax: boolean = true): number => {
|
|
1290
|
+
const basePrice = module.price_monthly;
|
|
1291
|
+
return withTax ? basePrice * 1.18 : basePrice; // TVA 18%
|
|
1292
|
+
};
|
|
1293
|
+
|
|
1294
|
+
const formatPrice = (price: number): string => {
|
|
1295
|
+
return new Intl.NumberFormat('fr-FR', {
|
|
1296
|
+
minimumFractionDigits: 0,
|
|
1297
|
+
maximumFractionDigits: 0,
|
|
1298
|
+
}).format(price);
|
|
1299
|
+
};
|
|
1300
|
+
|
|
1301
|
+
if (!isOpen) return null;
|
|
1302
|
+
|
|
1303
|
+
const basePrice = module.price_monthly;
|
|
1304
|
+
const tax = basePrice * 0.18;
|
|
1305
|
+
const totalPrice = calculateModulePrice(module, true);
|
|
1306
|
+
|
|
1307
|
+
return (
|
|
1308
|
+
<Modal
|
|
1309
|
+
title="Achat de Module"
|
|
1310
|
+
description={`Confirmez l'achat du module ${module.name} pour l'organisation ${organization?.legal_name}.`}
|
|
1311
|
+
open={isOpen}
|
|
1312
|
+
onClose={onClose}
|
|
1313
|
+
>
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
{/* Content */}
|
|
1317
|
+
<div className="p-6">
|
|
1318
|
+
{/* Module Info */}
|
|
1319
|
+
<div className="mb-6">
|
|
1320
|
+
<h3 className="text-lg font-semibold text-gray-900 mb-2">{module.name}</h3>
|
|
1321
|
+
<p className="text-sm text-gray-600 mb-4">{module.description}</p>
|
|
1322
|
+
|
|
1323
|
+
{/* Module Features */}
|
|
1324
|
+
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
|
1325
|
+
<h4 className="text-sm font-medium text-gray-700 mb-2">Fonctionnalités incluses:</h4>
|
|
1326
|
+
<ul className="text-sm text-gray-600 space-y-1">
|
|
1327
|
+
<li>• Accès complet au module {module.name}</li>
|
|
1328
|
+
<li>• Intégration avec les modules existants</li>
|
|
1329
|
+
<li>• Support technique inclus</li>
|
|
1330
|
+
<li>• Mises à jour automatiques</li>
|
|
1331
|
+
</ul>
|
|
1332
|
+
</div>
|
|
1333
|
+
</div>
|
|
1334
|
+
|
|
1335
|
+
{/* Price Breakdown */}
|
|
1336
|
+
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
|
1337
|
+
<h4 className="text-sm font-semibold text-gray-700 mb-3">Détail du prix:</h4>
|
|
1338
|
+
<div className="space-y-2">
|
|
1339
|
+
<div className="flex justify-between text-sm">
|
|
1340
|
+
<span className="text-gray-600">Prix du module:</span>
|
|
1341
|
+
<span className="font-medium">{formatPrice(basePrice)} FCFA</span>
|
|
1342
|
+
</div>
|
|
1343
|
+
<div className="flex justify-between text-sm">
|
|
1344
|
+
<span className="text-gray-600">TVA (18%):</span>
|
|
1345
|
+
<span className="font-medium">{formatPrice(tax)} FCFA</span>
|
|
1346
|
+
</div>
|
|
1347
|
+
<div className="border-t border-gray-200 pt-2 mt-2">
|
|
1348
|
+
<div className="flex justify-between text-base font-semibold">
|
|
1349
|
+
<span className="text-gray-900">Total:</span>
|
|
1350
|
+
<span className="text-[#8290A9]">{formatPrice(totalPrice)} FCFA/mois</span>
|
|
1351
|
+
</div>
|
|
1352
|
+
</div>
|
|
1353
|
+
</div>
|
|
1354
|
+
</div>
|
|
1355
|
+
|
|
1356
|
+
{/* Warning */}
|
|
1357
|
+
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
|
1358
|
+
<div className="flex">
|
|
1359
|
+
<div className="ml-3">
|
|
1360
|
+
<h5 className="text-sm font-medium text-blue-800">Information</h5>
|
|
1361
|
+
<p className="text-sm text-blue-700 mt-1">
|
|
1362
|
+
Ce module sera ajouté à votre abonnement actuel et facturé mensuellement.
|
|
1363
|
+
</p>
|
|
1364
|
+
</div>
|
|
1365
|
+
</div>
|
|
1366
|
+
</div>
|
|
1367
|
+
</div>
|
|
1368
|
+
|
|
1369
|
+
{/* Actions */}
|
|
1370
|
+
<div className="flex justify-between p-6 border-t border-gray-200">
|
|
1371
|
+
<button
|
|
1372
|
+
type="button"
|
|
1373
|
+
onClick={onClose}
|
|
1374
|
+
disabled={processing}
|
|
1375
|
+
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
1376
|
+
>
|
|
1377
|
+
Annuler
|
|
1378
|
+
</button>
|
|
1379
|
+
|
|
1380
|
+
<button
|
|
1381
|
+
type="button"
|
|
1382
|
+
onClick={onConfirm}
|
|
1383
|
+
disabled={processing}
|
|
1384
|
+
className="px-6 py-2 bg-[#8290A9] text-white rounded-lg hover:bg-[#6B7C92] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
|
|
1385
|
+
>
|
|
1386
|
+
{processing ? (
|
|
1387
|
+
<>
|
|
1388
|
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
1389
|
+
<span>Traitement...</span>
|
|
1390
|
+
</>
|
|
1391
|
+
) : (
|
|
1392
|
+
<>
|
|
1393
|
+
<CreditCard className="w-4 h-4" />
|
|
1394
|
+
<span>Confirmer l'achat</span>
|
|
1395
|
+
</>
|
|
1396
|
+
)}
|
|
1397
|
+
</button>
|
|
1398
|
+
</div>
|
|
1399
|
+
</Modal>
|
|
1400
|
+
);
|
|
1401
|
+
};
|
|
1402
|
+
|
|
1403
|
+
// PlanChangeModal Component
|
|
1404
|
+
interface PlanChangeModalProps {
|
|
1405
|
+
plans: any[];
|
|
1406
|
+
currentPlan: any;
|
|
1407
|
+
organization: Organization | null;
|
|
1408
|
+
isOpen: boolean;
|
|
1409
|
+
onClose: () => void;
|
|
1410
|
+
onConfirm: (plan: any) => void;
|
|
1411
|
+
processing: boolean;
|
|
1412
|
+
plansLoading: boolean;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const PlanChangeModal: React.FC<PlanChangeModalProps> = ({
|
|
1416
|
+
plans,
|
|
1417
|
+
currentPlan,
|
|
1418
|
+
organization,
|
|
1419
|
+
isOpen,
|
|
1420
|
+
onClose,
|
|
1421
|
+
onConfirm,
|
|
1422
|
+
processing,
|
|
1423
|
+
plansLoading
|
|
1424
|
+
}) => {
|
|
1425
|
+
const [selectedPlan, setSelectedPlan] = useState<any>(null);
|
|
1426
|
+
|
|
1427
|
+
const formatPrice = (price: number): string => {
|
|
1428
|
+
return new Intl.NumberFormat('fr-FR', {
|
|
1429
|
+
minimumFractionDigits: 0,
|
|
1430
|
+
maximumFractionDigits: 0,
|
|
1431
|
+
}).format(price);
|
|
1432
|
+
};
|
|
1433
|
+
|
|
1434
|
+
const handlePlanSelect = (plan: any) => {
|
|
1435
|
+
if (plan.id === currentPlan?.id) return; // Ne pas permettre de sélectionner le plan actuel
|
|
1436
|
+
setSelectedPlan(plan);
|
|
1437
|
+
};
|
|
1438
|
+
|
|
1439
|
+
const handleConfirm = () => {
|
|
1440
|
+
if (selectedPlan) {
|
|
1441
|
+
onConfirm(selectedPlan);
|
|
1442
|
+
}
|
|
1443
|
+
};
|
|
1444
|
+
|
|
1445
|
+
if (!isOpen) return null;
|
|
1446
|
+
|
|
1447
|
+
return (
|
|
1448
|
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
1449
|
+
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
|
1450
|
+
{/* Header */}
|
|
1451
|
+
<div className="p-6 border-b border-gray-200">
|
|
1452
|
+
<div className="flex items-center justify-between">
|
|
1453
|
+
<div className="flex items-center space-x-3">
|
|
1454
|
+
<div className="w-10 h-10 bg-[#8290A9] rounded-full flex items-center justify-center">
|
|
1455
|
+
<CreditCard className="w-5 h-5 text-white" />
|
|
1456
|
+
</div>
|
|
1457
|
+
<div>
|
|
1458
|
+
<h2 className="text-xl font-semibold text-gray-900">Changer de Plan</h2>
|
|
1459
|
+
<p className="text-sm text-gray-600">
|
|
1460
|
+
Organisation: <span className="font-medium">{organization?.legal_name}</span>
|
|
1461
|
+
</p>
|
|
1462
|
+
</div>
|
|
1463
|
+
</div>
|
|
1464
|
+
<button
|
|
1465
|
+
onClick={onClose}
|
|
1466
|
+
disabled={processing}
|
|
1467
|
+
className="text-gray-400 hover:text-gray-600 transition-colors disabled:opacity-50"
|
|
1468
|
+
>
|
|
1469
|
+
<X className="w-6 h-6" />
|
|
1470
|
+
</button>
|
|
1471
|
+
</div>
|
|
1472
|
+
</div>
|
|
1473
|
+
|
|
1474
|
+
{/* Content */}
|
|
1475
|
+
<div className="p-6">
|
|
1476
|
+
{plansLoading ? (
|
|
1477
|
+
<div className="text-center py-8">
|
|
1478
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#8290A9] mx-auto mb-4"></div>
|
|
1479
|
+
<p className="text-gray-600">Chargement des plans...</p>
|
|
1480
|
+
</div>
|
|
1481
|
+
) : (
|
|
1482
|
+
<>
|
|
1483
|
+
{/* Current Plan Info */}
|
|
1484
|
+
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
|
1485
|
+
<h3 className="text-sm font-medium text-blue-800 mb-1">Plan actuel</h3>
|
|
1486
|
+
<p className="text-sm text-blue-700">
|
|
1487
|
+
<span className="font-semibold">{currentPlan?.name}</span> - {formatPrice(currentPlan?.price_monthly || 0)} FCFA/mois
|
|
1488
|
+
</p>
|
|
1489
|
+
</div>
|
|
1490
|
+
|
|
1491
|
+
{/* Plans Grid */}
|
|
1492
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
|
1493
|
+
{plans.map((plan) => {
|
|
1494
|
+
const isCurrentPlan = plan.id === currentPlan?.id;
|
|
1495
|
+
const isSelected = selectedPlan?.id === plan.id;
|
|
1496
|
+
|
|
1497
|
+
return (
|
|
1498
|
+
<div
|
|
1499
|
+
key={plan.id}
|
|
1500
|
+
onClick={() => !isCurrentPlan && handlePlanSelect(plan)}
|
|
1501
|
+
className={`relative p-6 rounded-lg border-2 transition-all cursor-pointer ${isCurrentPlan
|
|
1502
|
+
? 'border-gray-300 bg-gray-50 cursor-not-allowed opacity-75'
|
|
1503
|
+
: isSelected
|
|
1504
|
+
? 'border-[#8290A9] bg-[#8290A9]/5'
|
|
1505
|
+
: 'border-gray-200 hover:border-[#8290A9]/50'
|
|
1506
|
+
}`}
|
|
1507
|
+
>
|
|
1508
|
+
{/* Popular Badge */}
|
|
1509
|
+
{plan.popular && (
|
|
1510
|
+
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2">
|
|
1511
|
+
<span className="bg-teal-100 text-teal-700 px-3 py-1 rounded-full text-xs font-medium">
|
|
1512
|
+
Populaire
|
|
1513
|
+
</span>
|
|
1514
|
+
</div>
|
|
1515
|
+
)}
|
|
1516
|
+
|
|
1517
|
+
{/* Current Plan Badge */}
|
|
1518
|
+
{isCurrentPlan && (
|
|
1519
|
+
<div className="absolute -top-3 right-4">
|
|
1520
|
+
<span className="bg-blue-100 text-blue-700 px-3 py-1 rounded-full text-xs font-medium">
|
|
1521
|
+
Plan actuel
|
|
1522
|
+
</span>
|
|
1523
|
+
</div>
|
|
1524
|
+
)}
|
|
1525
|
+
|
|
1526
|
+
{/* Selected indicator */}
|
|
1527
|
+
{isSelected && (
|
|
1528
|
+
<div className="absolute top-4 right-4">
|
|
1529
|
+
<div className="w-6 h-6 bg-[#8290A9] rounded-full flex items-center justify-center">
|
|
1530
|
+
<div className="w-2 h-2 bg-white rounded-full"></div>
|
|
1531
|
+
</div>
|
|
1532
|
+
</div>
|
|
1533
|
+
)}
|
|
1534
|
+
|
|
1535
|
+
{/* Plan Content */}
|
|
1536
|
+
<div className="mb-4">
|
|
1537
|
+
<h3 className="text-xl font-bold text-gray-900 mb-2">{plan.name}</h3>
|
|
1538
|
+
<div className="mb-3">
|
|
1539
|
+
<span className="text-2xl font-bold text-gray-900">
|
|
1540
|
+
{formatPrice(plan.price)} FCFA
|
|
1541
|
+
</span>
|
|
1542
|
+
<span className="text-gray-600 ml-1">{plan.period}</span>
|
|
1543
|
+
{plan.originalPrice && (
|
|
1544
|
+
<div className="text-sm text-gray-500">
|
|
1545
|
+
{formatPrice(plan.originalPrice)} FCFA lorsque facturé annuellement
|
|
1546
|
+
</div>
|
|
1547
|
+
)}
|
|
1548
|
+
</div>
|
|
1549
|
+
<p className="text-gray-600 text-sm">{plan.description}</p>
|
|
1550
|
+
</div>
|
|
1551
|
+
|
|
1552
|
+
{/* Features */}
|
|
1553
|
+
<ul className="space-y-2">
|
|
1554
|
+
{plan.features.slice(0, 3).map((feature: any, index: number) => (
|
|
1555
|
+
<li key={index} className="flex items-center space-x-2 text-sm">
|
|
1556
|
+
<div className="w-4 h-4 rounded-full bg-green-100 flex items-center justify-center">
|
|
1557
|
+
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
|
1558
|
+
</div>
|
|
1559
|
+
<span className="text-gray-700">{feature.name}</span>
|
|
1560
|
+
</li>
|
|
1561
|
+
))}
|
|
1562
|
+
{plan.features.length > 3 && (
|
|
1563
|
+
<li className="text-sm text-gray-500 ml-6">
|
|
1564
|
+
+{plan.features.length - 3} autres fonctionnalités
|
|
1565
|
+
</li>
|
|
1566
|
+
)}
|
|
1567
|
+
</ul>
|
|
1568
|
+
</div>
|
|
1569
|
+
);
|
|
1570
|
+
})}
|
|
1571
|
+
</div>
|
|
1572
|
+
|
|
1573
|
+
{/* Warning */}
|
|
1574
|
+
{selectedPlan && (
|
|
1575
|
+
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
|
1576
|
+
<div className="flex">
|
|
1577
|
+
<div className="ml-3">
|
|
1578
|
+
<h5 className="text-sm font-medium text-yellow-800">Changement de plan</h5>
|
|
1579
|
+
<p className="text-sm text-yellow-700 mt-1">
|
|
1580
|
+
Le changement de plan prendra effet immédiatement.
|
|
1581
|
+
{selectedPlan.price > currentPlan?.price_monthly && (
|
|
1582
|
+
" La différence de prix sera facturée au prorata."
|
|
1583
|
+
)}
|
|
1584
|
+
{selectedPlan.price < currentPlan?.price_monthly && (
|
|
1585
|
+
" Le crédit restant sera appliqué à votre prochaine facture."
|
|
1586
|
+
)}
|
|
1587
|
+
</p>
|
|
1588
|
+
</div>
|
|
1589
|
+
</div>
|
|
1590
|
+
</div>
|
|
1591
|
+
)}
|
|
1592
|
+
</>
|
|
1593
|
+
)}
|
|
1594
|
+
</div>
|
|
1595
|
+
|
|
1596
|
+
{/* Actions */}
|
|
1597
|
+
<div className="flex justify-between p-6 border-t border-gray-200">
|
|
1598
|
+
<button
|
|
1599
|
+
type="button"
|
|
1600
|
+
onClick={onClose}
|
|
1601
|
+
disabled={processing}
|
|
1602
|
+
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
1603
|
+
>
|
|
1604
|
+
Annuler
|
|
1605
|
+
</button>
|
|
1606
|
+
|
|
1607
|
+
<button
|
|
1608
|
+
type="button"
|
|
1609
|
+
onClick={handleConfirm}
|
|
1610
|
+
disabled={processing || !selectedPlan || plansLoading}
|
|
1611
|
+
className="px-6 py-2 bg-[#8290A9] text-white rounded-lg hover:bg-[#6B7C92] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
|
|
1612
|
+
>
|
|
1613
|
+
{processing ? (
|
|
1614
|
+
<>
|
|
1615
|
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
1616
|
+
<span>Traitement...</span>
|
|
1617
|
+
</>
|
|
1618
|
+
) : (
|
|
1619
|
+
<>
|
|
1620
|
+
<CreditCard className="w-4 h-4" />
|
|
1621
|
+
<span>Confirmer le changement</span>
|
|
1622
|
+
</>
|
|
1623
|
+
)}
|
|
1624
|
+
</button>
|
|
1625
|
+
</div>
|
|
1626
|
+
</div>
|
|
1627
|
+
</div>
|
|
1628
|
+
);
|
|
1629
|
+
};
|