create-crm-tmp 1.1.2 → 2.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/package.json +1 -1
- package/template/.prettierignore +2 -0
- package/template/README.md +53 -67
- package/template/components.json +22 -0
- package/template/exemple-contacts.csv +54 -0
- package/template/next.config.ts +27 -1
- package/template/package.json +64 -27
- package/template/prisma/schema.prisma +821 -72
- package/template/skills-lock.json +25 -0
- package/template/src/app/(auth)/invite/[token]/page.tsx +21 -24
- package/template/src/app/(auth)/reset-password/complete/page.tsx +12 -21
- package/template/src/app/(auth)/reset-password/page.tsx +12 -8
- package/template/src/app/(auth)/reset-password/verify/page.tsx +12 -8
- package/template/src/app/(auth)/signin/page.tsx +20 -17
- package/template/src/app/(dashboard)/agenda/page.tsx +2231 -2188
- package/template/src/app/(dashboard)/automatisation/[id]/page.tsx +10 -7
- package/template/src/app/(dashboard)/automatisation/_components/workflow-editor.tsx +680 -323
- package/template/src/app/(dashboard)/automatisation/new/page.tsx +11 -8
- package/template/src/app/(dashboard)/automatisation/page.tsx +473 -180
- package/template/src/app/(dashboard)/closing/page.tsx +500 -468
- package/template/src/app/(dashboard)/contacts/[id]/page.tsx +5035 -4126
- package/template/src/app/(dashboard)/contacts/companies/[id]/page.tsx +1703 -0
- package/template/src/app/(dashboard)/contacts/loading.tsx +13 -0
- package/template/src/app/(dashboard)/contacts/page.tsx +3776 -2064
- package/template/src/app/(dashboard)/dashboard/page.tsx +37 -519
- package/template/src/app/(dashboard)/error.tsx +37 -0
- package/template/src/app/(dashboard)/layout.tsx +1 -1
- package/template/src/app/(dashboard)/loading.tsx +5 -0
- package/template/src/app/(dashboard)/settings/loading.tsx +19 -0
- package/template/src/app/(dashboard)/settings/page.tsx +2685 -2489
- package/template/src/app/(dashboard)/templates/page.tsx +500 -300
- package/template/src/app/(dashboard)/users/list/page.tsx +356 -350
- package/template/src/app/(dashboard)/users/page.tsx +279 -310
- package/template/src/app/(dashboard)/users/permissions/page.tsx +104 -99
- package/template/src/app/(dashboard)/users/roles/page.tsx +164 -137
- package/template/src/app/api/audit-logs/route.ts +1 -1
- package/template/src/app/api/auth/google/callback/route.ts +8 -5
- package/template/src/app/api/auth/google/disconnect/route.ts +2 -2
- package/template/src/app/api/companies/[id]/activities/route.ts +131 -0
- package/template/src/app/api/companies/[id]/route.ts +195 -0
- package/template/src/app/api/companies/export/route.ts +206 -0
- package/template/src/app/api/companies/route.ts +166 -0
- package/template/src/app/api/contact-views/[id]/pin/route.ts +69 -0
- package/template/src/app/api/contact-views/[id]/route.ts +197 -0
- package/template/src/app/api/contact-views/route.ts +146 -0
- package/template/src/app/api/contacts/[id]/files/[fileId]/preview/route.ts +77 -0
- package/template/src/app/api/contacts/[id]/files/[fileId]/route.ts +7 -17
- package/template/src/app/api/contacts/[id]/files/route.ts +83 -44
- package/template/src/app/api/contacts/[id]/interactions/route.ts +37 -0
- package/template/src/app/api/contacts/[id]/kyc/route.ts +71 -0
- package/template/src/app/api/contacts/[id]/meet/route.ts +38 -29
- package/template/src/app/api/contacts/[id]/route.ts +111 -20
- package/template/src/app/api/contacts/[id]/send-email/route.ts +6 -0
- package/template/src/app/api/contacts/[id]/workflows/run/route.ts +61 -0
- package/template/src/app/api/contacts/export/route.ts +12 -17
- package/template/src/app/api/contacts/import/route.ts +22 -19
- package/template/src/app/api/contacts/import-preview/route.ts +139 -0
- package/template/src/app/api/contacts/route.ts +202 -49
- package/template/src/app/api/dashboard/stats/route.ts +9 -292
- package/template/src/app/api/integrations/google-sheet/sync/route.ts +203 -185
- package/template/src/app/api/invite/complete/route.ts +20 -23
- package/template/src/app/api/reminders/route.ts +1 -0
- package/template/src/app/api/reset-password/complete/route.ts +11 -13
- package/template/src/app/api/send/route.ts +9 -85
- package/template/src/app/api/settings/closing-reasons/[id]/route.ts +10 -21
- package/template/src/app/api/settings/closing-reasons/route.ts +10 -21
- package/template/src/app/api/settings/company/route.ts +19 -26
- package/template/src/app/api/settings/google-ads/[id]/route.ts +20 -23
- package/template/src/app/api/settings/google-ads/route.ts +20 -23
- package/template/src/app/api/settings/google-sheet/[id]/route.ts +20 -23
- package/template/src/app/api/settings/google-sheet/auto-map/route.ts +23 -32
- package/template/src/app/api/settings/google-sheet/preview/route.ts +104 -0
- package/template/src/app/api/settings/google-sheet/route.ts +20 -23
- package/template/src/app/api/settings/meta-leads/[id]/route.ts +20 -23
- package/template/src/app/api/settings/meta-leads/route.ts +20 -23
- package/template/src/app/api/settings/statuses/[id]/route.ts +33 -23
- package/template/src/app/api/settings/statuses/route.ts +24 -22
- package/template/src/app/api/statuses/route.ts +2 -5
- package/template/src/app/api/tasks/[id]/attendees/route.ts +14 -7
- package/template/src/app/api/tasks/[id]/route.ts +161 -137
- package/template/src/app/api/tasks/meet/route.ts +11 -8
- package/template/src/app/api/tasks/route.ts +155 -95
- package/template/src/app/api/templates/[id]/route.ts +22 -13
- package/template/src/app/api/templates/route.ts +22 -5
- package/template/src/app/api/users/[id]/resend-invite/route.ts +95 -0
- package/template/src/app/api/users/[id]/route.ts +16 -1
- package/template/src/app/api/users/commercials/route.ts +38 -0
- package/template/src/app/api/users/for-agenda/route.ts +1 -2
- package/template/src/app/api/users/route.ts +94 -55
- package/template/src/app/api/webhooks/google-ads/route.ts +20 -1
- package/template/src/app/api/webhooks/meta-leads/route.ts +18 -1
- package/template/src/app/api/workflows/[id]/route.ts +33 -6
- package/template/src/app/api/workflows/process/route.ts +509 -146
- package/template/src/app/api/workflows/route.ts +46 -4
- package/template/src/app/globals.css +210 -101
- package/template/src/app/layout.tsx +19 -8
- package/template/src/app/page.tsx +37 -7
- package/template/src/components/address-autocomplete.tsx +232 -0
- package/template/src/components/contacts/filter-bar.tsx +181 -0
- package/template/src/components/contacts/filter-builder.tsx +589 -0
- package/template/src/components/contacts/save-view-dialog.tsx +160 -0
- package/template/src/components/contacts/views-tab-bar.tsx +440 -0
- package/template/src/components/dashboard/activity-chart.tsx +31 -39
- package/template/src/components/dashboard/dashboard-content.tsx +79 -0
- package/template/src/components/dashboard/stat-card.tsx +40 -42
- package/template/src/components/dashboard/tasks-pie-chart.tsx +34 -37
- package/template/src/components/dashboard/upcoming-tasks-list.tsx +78 -72
- package/template/src/components/date-picker.tsx +396 -0
- package/template/src/components/editor.tsx +27 -13
- package/template/src/components/email-template.tsx +4 -2
- package/template/src/components/global-search.tsx +358 -0
- package/template/src/components/header.tsx +57 -62
- package/template/src/components/invitation-email-template.tsx +4 -2
- package/template/src/components/lazy-editor.tsx +11 -0
- package/template/src/components/meet-cancellation-email-template.tsx +11 -3
- package/template/src/components/meet-confirmation-email-template.tsx +10 -3
- package/template/src/components/meet-update-email-template.tsx +10 -3
- package/template/src/components/page-header.tsx +19 -15
- package/template/src/components/protected-page.tsx +94 -0
- package/template/src/components/reset-password-email-template.tsx +4 -2
- package/template/src/components/sidebar.tsx +92 -94
- package/template/src/components/skeleton.tsx +128 -42
- package/template/src/components/ui/accordion.tsx +64 -0
- package/template/src/components/ui/alert-dialog.tsx +139 -0
- package/template/src/components/ui/button.tsx +60 -0
- package/template/src/components/view-as-banner.tsx +1 -1
- package/template/src/components/view-as-modal.tsx +21 -16
- package/template/src/config/nav-pages.ts +108 -0
- package/template/src/contexts/app-toast-context.tsx +174 -0
- package/template/src/contexts/sidebar-context.tsx +16 -47
- package/template/src/contexts/task-reminder-context.tsx +6 -6
- package/template/src/contexts/view-as-context.tsx +11 -16
- package/template/src/hooks/use-alert.tsx +65 -0
- package/template/src/hooks/use-confirm.tsx +87 -0
- package/template/src/hooks/use-contact-views.ts +140 -0
- package/template/src/hooks/use-contacts.ts +69 -0
- package/template/src/hooks/use-fetch.ts +17 -0
- package/template/src/hooks/use-focus-trap.ts +73 -0
- package/template/src/hooks/use-statuses.ts +22 -0
- package/template/src/lib/address-api.ts +155 -0
- package/template/src/lib/cache.ts +73 -0
- package/template/src/lib/check-permission.ts +12 -177
- package/template/src/lib/contact-interactions.ts +3 -1
- package/template/src/lib/contact-view-filters.ts +341 -0
- package/template/src/lib/dashboard-stats.ts +224 -0
- package/template/src/lib/date-utils.ts +49 -0
- package/template/src/lib/get-auth-user.ts +25 -0
- package/template/src/lib/google-calendar.ts +54 -12
- package/template/src/lib/google-drive.ts +796 -75
- package/template/src/lib/google-fetch.ts +63 -0
- package/template/src/lib/local-storage.ts +34 -0
- package/template/src/lib/permissions.ts +245 -47
- package/template/src/lib/prisma.ts +11 -11
- package/template/src/lib/roles.ts +14 -39
- package/template/src/lib/template-variables.ts +67 -33
- package/template/src/lib/utils.ts +26 -2
- package/template/src/lib/workflow-executor.ts +445 -229
- package/template/src/proxy.ts +34 -73
- package/template/src/types/contact-views.ts +351 -0
- package/template/src/types/yousign.ts +52 -0
- package/template/vercel.json +12 -0
- package/template/WORKFLOWS_CRON.md +0 -185
- package/template/prisma/migrations/20251126144728_init/migration.sql +0 -78
- package/template/prisma/migrations/20251126155204_add_user_roles/migration.sql +0 -5
- package/template/prisma/migrations/20251128095126_add_company_info/migration.sql +0 -19
- package/template/prisma/migrations/20251128123321_add_smtp_config/migration.sql +0 -22
- package/template/prisma/migrations/20251128132303_add_status/migration.sql +0 -23
- package/template/prisma/migrations/20251201102207_add_user_active/migration.sql +0 -75
- package/template/prisma/migrations/20251201105507_add_email_signature/migration.sql +0 -2
- package/template/prisma/migrations/20251201151122_add_tasks/migration.sql +0 -45
- package/template/prisma/migrations/20251202111854_add_task_reminder/migration.sql +0 -2
- package/template/prisma/migrations/20251202135859_add_google_meet_integration/migration.sql +0 -27
- package/template/prisma/migrations/20251203103317_add_meta_lead_integration/migration.sql +0 -20
- package/template/prisma/migrations/20251203104002_add_google_ads_integration/migration.sql +0 -18
- package/template/prisma/migrations/20251203112122_add_google_sheet_integration/migration.sql +0 -32
- package/template/prisma/migrations/20251203153853_allow_multiple_integration_configs/migration.sql +0 -20
- package/template/prisma/migrations/20251205141705_update_user_roles/migration.sql +0 -12
- package/template/prisma/migrations/20251205150000_add_commercial_and_telepro_assignment/migration.sql +0 -21
- package/template/prisma/migrations/20251205160000_add_interaction_logging/migration.sql +0 -11
- package/template/prisma/migrations/20251208090314_add_automatic_interaction_types/migration.sql +0 -12
- package/template/prisma/migrations/20251208094843_mg/migration.sql +0 -14
- package/template/prisma/migrations/20251208100000_add_company_support/migration.sql +0 -14
- package/template/prisma/migrations/20251208110000_add_templates/migration.sql +0 -26
- package/template/prisma/migrations/20251208141304_add_video_conference_task_type/migration.sql +0 -2
- package/template/prisma/migrations/20251209104759_add_internal_note_to_task/migration.sql +0 -2
- package/template/prisma/migrations/20251209134803_add_company_field/migration.sql +0 -2
- package/template/prisma/migrations/20251209150000_rename_company_to_company_name/migration.sql +0 -3
- package/template/prisma/migrations/20251209150016_add_email_tracking/migration.sql +0 -21
- package/template/prisma/migrations/20251209155908_add_notify_contact_to_task/migration.sql +0 -2
- package/template/prisma/migrations/20251210110019_add_appointment_types/migration.sql +0 -10
- package/template/prisma/migrations/20251210113928_add_contact_files/migration.sql +0 -26
- package/template/prisma/migrations/20251212132339_add_custom_roles/migration.sql +0 -24
- package/template/prisma/migrations/20251215104448_add_file_interaction_types/migration.sql +0 -11
- package/template/prisma/migrations/20251215145616_add_closing_reasons/migration.sql +0 -12
- package/template/prisma/migrations/20251216140850_add_log_users/migration.sql +0 -25
- package/template/prisma/migrations/20251216151000_rename_perdu_to_ferme/migration.sql +0 -8
- package/template/prisma/migrations/20251216162318_add_column_mappings_to_google_sheet/migration.sql +0 -2
- package/template/prisma/migrations/20251216185127_add_workflows/migration.sql +0 -80
- package/template/prisma/migrations/20251216192237_add_scheduled_workflow_actions/migration.sql +0 -32
- package/template/prisma/migrations/20251220000000_add_task_interaction_type/migration.sql +0 -4
- package/template/prisma/migrations/20251221000000_add_task_type/migration.sql +0 -3
- package/template/prisma/migrations/20251221000001_add_event_color/migration.sql +0 -23
- package/template/prisma/migrations/20260210114913_add_dashboard_widget/migration.sql +0 -20
- package/template/prisma/migrations/migration_lock.toml +0 -3
- package/template/src/app/(dashboard)/users/layout.tsx +0 -30
- package/template/src/app/api/dashboard/widgets/[id]/route.ts +0 -47
- package/template/src/app/api/dashboard/widgets/route.ts +0 -181
- package/template/src/components/dashboard/add-widget-dialog.tsx +0 -161
- package/template/src/components/dashboard/color-picker.tsx +0 -65
- package/template/src/components/dashboard/contacts-chart.tsx +0 -69
- package/template/src/components/dashboard/interactions-by-type-chart.tsx +0 -121
- package/template/src/components/dashboard/recent-activity.tsx +0 -157
- package/template/src/components/dashboard/sales-analytics-chart.tsx +0 -77
- package/template/src/components/dashboard/status-distribution-chart.tsx +0 -82
- package/template/src/components/dashboard/top-contacts-list.tsx +0 -119
- package/template/src/components/dashboard/widget-wrapper.tsx +0 -39
- package/template/src/contexts/dashboard-theme-context.tsx +0 -58
- package/template/src/lib/dashboard-themes.ts +0 -140
- package/template/src/lib/default-widgets.ts +0 -14
- package/template/src/lib/widget-registry.ts +0 -177
|
@@ -1,533 +1,51 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
1
|
+
import { Suspense } from 'react';
|
|
2
|
+
import { redirect } from 'next/navigation';
|
|
3
|
+
import { getDashboardStats } from '@/lib/dashboard-stats';
|
|
4
|
+
import { DashboardContent } from '@/components/dashboard/dashboard-content';
|
|
5
|
+
import { Skeleton } from '@/components/skeleton';
|
|
6
6
|
import { PageHeader } from '@/components/page-header';
|
|
7
|
-
import {
|
|
8
|
-
import { AddWidgetDialog } from '@/components/dashboard/add-widget-dialog';
|
|
9
|
-
import { DashboardColorPicker } from '@/components/dashboard/color-picker';
|
|
10
|
-
import { useUserRole } from '@/hooks/use-user-role';
|
|
11
|
-
import { DashboardThemeProvider, useDashboardTheme } from '@/contexts/dashboard-theme-context';
|
|
12
|
-
import { StatCard } from '@/components/dashboard/stat-card';
|
|
13
|
-
import { ContactsChart } from '@/components/dashboard/contacts-chart';
|
|
14
|
-
import { ActivityChart } from '@/components/dashboard/activity-chart';
|
|
15
|
-
import { StatusDistributionChart } from '@/components/dashboard/status-distribution-chart';
|
|
16
|
-
import { TasksPieChart } from '@/components/dashboard/tasks-pie-chart';
|
|
17
|
-
import { UpcomingTasksList } from '@/components/dashboard/upcoming-tasks-list';
|
|
18
|
-
import { RecentActivity } from '@/components/dashboard/recent-activity';
|
|
19
|
-
import { TopContactsList } from '@/components/dashboard/top-contacts-list';
|
|
20
|
-
import { InteractionsByTypeChart } from '@/components/dashboard/interactions-by-type-chart';
|
|
21
|
-
import { getWidgetDefinition } from '@/lib/widget-registry';
|
|
22
|
-
|
|
23
|
-
interface DashboardWidget {
|
|
24
|
-
id: string;
|
|
25
|
-
type: string;
|
|
26
|
-
x: number;
|
|
27
|
-
y: number;
|
|
28
|
-
w: number;
|
|
29
|
-
h: number;
|
|
30
|
-
}
|
|
7
|
+
import { getAuthUser } from '@/lib/get-auth-user';
|
|
31
8
|
|
|
32
|
-
|
|
33
|
-
overview: {
|
|
34
|
-
totalContacts: number;
|
|
35
|
-
contactsThisMonth: number;
|
|
36
|
-
contactsGrowth: number;
|
|
37
|
-
monthsData: Array<{ month: string; count: number }>;
|
|
38
|
-
};
|
|
39
|
-
statusDistribution: Array<{ name: string; value: number }>;
|
|
40
|
-
tasks: {
|
|
41
|
-
total: number;
|
|
42
|
-
completed: number;
|
|
43
|
-
pending: number;
|
|
44
|
-
upcoming: Array<{
|
|
45
|
-
id: string;
|
|
46
|
-
title: string;
|
|
47
|
-
type: string;
|
|
48
|
-
scheduledAt: string;
|
|
49
|
-
contact: { id: string; name: string } | null;
|
|
50
|
-
priority: string;
|
|
51
|
-
}>;
|
|
52
|
-
byType: Array<{ type: string; count: number }>;
|
|
53
|
-
};
|
|
54
|
-
interactions: {
|
|
55
|
-
recent: Array<{
|
|
56
|
-
id: string;
|
|
57
|
-
type: string;
|
|
58
|
-
title: string | null;
|
|
59
|
-
content: string;
|
|
60
|
-
date: string;
|
|
61
|
-
contact: {
|
|
62
|
-
id: string;
|
|
63
|
-
name: string;
|
|
64
|
-
};
|
|
65
|
-
}>;
|
|
66
|
-
byType: Array<{ type: string; count: number }>;
|
|
67
|
-
};
|
|
68
|
-
activity: {
|
|
69
|
-
last7Days: Array<{ date: string; interactions: number; tasks: number }>;
|
|
70
|
-
};
|
|
71
|
-
topContacts: Array<{
|
|
72
|
-
id: string;
|
|
73
|
-
name: string;
|
|
74
|
-
phone: string;
|
|
75
|
-
email: string | null;
|
|
76
|
-
status: string;
|
|
77
|
-
interactionsCount: number;
|
|
78
|
-
assignedCommercial?: string;
|
|
79
|
-
assignedTelepro?: string;
|
|
80
|
-
}>;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export default function DashboardPage() {
|
|
9
|
+
function DashboardSkeleton() {
|
|
84
10
|
return (
|
|
85
|
-
<
|
|
86
|
-
<
|
|
87
|
-
|
|
11
|
+
<div className="h-full">
|
|
12
|
+
<PageHeader title="Tableau de Bord" description="Vue d'ensemble de votre activité" />
|
|
13
|
+
<div className="p-4 sm:p-6">
|
|
14
|
+
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
|
15
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
16
|
+
<Skeleton key={i} className="h-32 rounded-lg" />
|
|
17
|
+
))}
|
|
18
|
+
</div>
|
|
19
|
+
<div className="mt-6 grid gap-6 lg:grid-cols-2">
|
|
20
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
21
|
+
<Skeleton key={i} className="h-96 rounded-lg" />
|
|
22
|
+
))}
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
88
26
|
);
|
|
89
27
|
}
|
|
90
28
|
|
|
91
|
-
function
|
|
92
|
-
const
|
|
93
|
-
const [stats, setStats] = useState<DashboardStats | null>(null);
|
|
94
|
-
const [loading, setLoading] = useState(true);
|
|
95
|
-
const [error, setError] = useState<string | null>(null);
|
|
96
|
-
const [showAddDialog, setShowAddDialog] = useState(false);
|
|
97
|
-
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
98
|
-
const { hasPermission, isLoading: permissionsLoading } = useUserRole();
|
|
99
|
-
const { theme } = useDashboardTheme();
|
|
100
|
-
|
|
101
|
-
// Permissions dashboard
|
|
102
|
-
const canViewDashboard = hasPermission('dashboard.view');
|
|
103
|
-
const canManageWidgets = hasPermission('dashboard.widgets.manage');
|
|
104
|
-
const canResetDashboard = hasPermission('dashboard.widgets.reset');
|
|
105
|
-
const {
|
|
106
|
-
width: containerWidth,
|
|
107
|
-
containerRef,
|
|
108
|
-
mounted,
|
|
109
|
-
} = useContainerWidth({ initialWidth: 1200 });
|
|
110
|
-
|
|
111
|
-
// Nombre de colonnes dynamique basé sur la largeur réelle
|
|
112
|
-
const cols = useMemo(() => {
|
|
113
|
-
if (containerWidth < 768) return 2;
|
|
114
|
-
return 12;
|
|
115
|
-
}, [containerWidth]);
|
|
116
|
-
|
|
117
|
-
// Charger widgets et stats
|
|
118
|
-
useEffect(() => {
|
|
119
|
-
async function fetchData() {
|
|
120
|
-
try {
|
|
121
|
-
const widgetsRes = await fetch('/api/dashboard/widgets');
|
|
122
|
-
let widgetsData: DashboardWidget[] = [];
|
|
123
|
-
|
|
124
|
-
if (widgetsRes.ok) {
|
|
125
|
-
widgetsData = await widgetsRes.json();
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Initialiser le layout par défaut uniquement à la toute première visite
|
|
129
|
-
// (pas de widgets ET jamais initialisé auparavant)
|
|
130
|
-
const hasBeenInitialized = localStorage.getItem('dashboard_initialized');
|
|
131
|
-
if (widgetsData.length === 0 && !hasBeenInitialized) {
|
|
132
|
-
const initRes = await fetch('/api/dashboard/widgets', {
|
|
133
|
-
method: 'POST',
|
|
134
|
-
headers: { 'Content-Type': 'application/json' },
|
|
135
|
-
body: JSON.stringify({ initDefault: true }),
|
|
136
|
-
});
|
|
137
|
-
if (initRes.ok) {
|
|
138
|
-
widgetsData = await initRes.json();
|
|
139
|
-
}
|
|
140
|
-
localStorage.setItem('dashboard_initialized', 'true');
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
setWidgets(widgetsData);
|
|
144
|
-
|
|
145
|
-
const statsRes = await fetch('/api/dashboard/stats');
|
|
146
|
-
if (statsRes.ok) {
|
|
147
|
-
const statsData = await statsRes.json();
|
|
148
|
-
setStats(statsData);
|
|
149
|
-
}
|
|
150
|
-
} catch (err) {
|
|
151
|
-
setError(err instanceof Error ? err.message : 'Une erreur est survenue');
|
|
152
|
-
} finally {
|
|
153
|
-
setLoading(false);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
fetchData();
|
|
158
|
-
}, []);
|
|
159
|
-
|
|
160
|
-
// Sauvegarder les positions avec debounce
|
|
161
|
-
const saveLayout = useCallback((updatedWidgets: DashboardWidget[]) => {
|
|
162
|
-
if (saveTimeoutRef.current) {
|
|
163
|
-
clearTimeout(saveTimeoutRef.current);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
saveTimeoutRef.current = setTimeout(async () => {
|
|
167
|
-
try {
|
|
168
|
-
await fetch('/api/dashboard/widgets', {
|
|
169
|
-
method: 'PUT',
|
|
170
|
-
headers: { 'Content-Type': 'application/json' },
|
|
171
|
-
body: JSON.stringify({
|
|
172
|
-
widgets: updatedWidgets.map((w) => ({
|
|
173
|
-
id: w.id,
|
|
174
|
-
x: w.x,
|
|
175
|
-
y: w.y,
|
|
176
|
-
w: w.w,
|
|
177
|
-
h: w.h,
|
|
178
|
-
})),
|
|
179
|
-
}),
|
|
180
|
-
});
|
|
181
|
-
} catch (err) {
|
|
182
|
-
console.error('Erreur sauvegarde layout:', err);
|
|
183
|
-
}
|
|
184
|
-
}, 500);
|
|
185
|
-
}, []);
|
|
186
|
-
|
|
187
|
-
// Gérer le changement de layout (drag/resize)
|
|
188
|
-
// Ne sauvegarde en base que quand on est en mode desktop (12 cols)
|
|
189
|
-
// pour ne pas écraser les positions desktop avec les positions mobile
|
|
190
|
-
const handleLayoutChange = useCallback(
|
|
191
|
-
(layout: Layout) => {
|
|
192
|
-
if (cols !== 12) return;
|
|
193
|
-
|
|
194
|
-
const updatedWidgets = widgets.map((widget) => {
|
|
195
|
-
const layoutItem = layout.find((l: LayoutItem) => l.i === widget.id);
|
|
196
|
-
if (layoutItem) {
|
|
197
|
-
return {
|
|
198
|
-
...widget,
|
|
199
|
-
x: layoutItem.x,
|
|
200
|
-
y: layoutItem.y,
|
|
201
|
-
w: layoutItem.w,
|
|
202
|
-
h: layoutItem.h,
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
return widget;
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
setWidgets(updatedWidgets);
|
|
209
|
-
saveLayout(updatedWidgets);
|
|
210
|
-
},
|
|
211
|
-
[widgets, saveLayout, cols],
|
|
212
|
-
);
|
|
213
|
-
|
|
214
|
-
// Ajouter un widget
|
|
215
|
-
const handleAddWidget = useCallback(async (type: string, w: number, h: number) => {
|
|
216
|
-
try {
|
|
217
|
-
const res = await fetch('/api/dashboard/widgets', {
|
|
218
|
-
method: 'POST',
|
|
219
|
-
headers: { 'Content-Type': 'application/json' },
|
|
220
|
-
body: JSON.stringify({ type, w, h }),
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
if (res.ok) {
|
|
224
|
-
const newWidget = await res.json();
|
|
225
|
-
setWidgets((prev) => [...prev, newWidget]);
|
|
226
|
-
}
|
|
227
|
-
} catch (err) {
|
|
228
|
-
console.error('Erreur ajout widget:', err);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
setShowAddDialog(false);
|
|
232
|
-
}, []);
|
|
233
|
-
|
|
234
|
-
// Supprimer un widget
|
|
235
|
-
const handleRemoveWidget = useCallback(async (widgetId: string) => {
|
|
236
|
-
try {
|
|
237
|
-
const res = await fetch(`/api/dashboard/widgets/${widgetId}`, {
|
|
238
|
-
method: 'DELETE',
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
if (res.ok) {
|
|
242
|
-
setWidgets((prev) => prev.filter((w) => w.id !== widgetId));
|
|
243
|
-
}
|
|
244
|
-
} catch (err) {
|
|
245
|
-
console.error('Erreur suppression widget:', err);
|
|
246
|
-
}
|
|
247
|
-
}, []);
|
|
248
|
-
|
|
249
|
-
// Réinitialiser le layout par défaut
|
|
250
|
-
const handleResetLayout = useCallback(async () => {
|
|
251
|
-
try {
|
|
252
|
-
// Supprimer tous les widgets existants
|
|
253
|
-
await Promise.all(
|
|
254
|
-
widgets.map((w) => fetch(`/api/dashboard/widgets/${w.id}`, { method: 'DELETE' })),
|
|
255
|
-
);
|
|
256
|
-
|
|
257
|
-
// Recréer le layout par défaut
|
|
258
|
-
const initRes = await fetch('/api/dashboard/widgets', {
|
|
259
|
-
method: 'POST',
|
|
260
|
-
headers: { 'Content-Type': 'application/json' },
|
|
261
|
-
body: JSON.stringify({ initDefault: true }),
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
if (initRes.ok) {
|
|
265
|
-
const newWidgets = await initRes.json();
|
|
266
|
-
setWidgets(newWidgets);
|
|
267
|
-
}
|
|
268
|
-
} catch (err) {
|
|
269
|
-
console.error('Erreur réinitialisation layout:', err);
|
|
270
|
-
}
|
|
271
|
-
}, [widgets]);
|
|
272
|
-
|
|
273
|
-
// Construire le layout adapté au nombre de colonnes actuel
|
|
274
|
-
const gridLayout = useMemo((): Layout => {
|
|
275
|
-
return widgets.map((widget) => {
|
|
276
|
-
const def = getWidgetDefinition(widget.type);
|
|
277
|
-
|
|
278
|
-
if (cols === 2) {
|
|
279
|
-
// Mobile : stat cards (w <= 3 en 12 cols) → 1 col, le reste → 2 cols (pleine largeur)
|
|
280
|
-
const isSmallWidget = widget.w <= 4;
|
|
281
|
-
const mobileW = isSmallWidget ? 1 : 2;
|
|
282
|
-
return {
|
|
283
|
-
i: widget.id,
|
|
284
|
-
x: isSmallWidget ? widget.x % 2 : 0,
|
|
285
|
-
y: widget.y,
|
|
286
|
-
w: mobileW,
|
|
287
|
-
h: widget.h,
|
|
288
|
-
minW: 1,
|
|
289
|
-
minH: def?.minH ?? 2,
|
|
290
|
-
maxH: def?.maxH,
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Desktop : layout stocké en base (grille 12 colonnes)
|
|
295
|
-
return {
|
|
296
|
-
i: widget.id,
|
|
297
|
-
x: widget.x,
|
|
298
|
-
y: widget.y,
|
|
299
|
-
w: widget.w,
|
|
300
|
-
h: widget.h,
|
|
301
|
-
minW: def?.minW ?? 2,
|
|
302
|
-
minH: def?.minH ?? 2,
|
|
303
|
-
maxH: def?.maxH,
|
|
304
|
-
};
|
|
305
|
-
});
|
|
306
|
-
}, [widgets, cols]);
|
|
307
|
-
|
|
308
|
-
// Rendu du contenu d'un widget selon son type
|
|
309
|
-
const renderWidgetContent = useCallback(
|
|
310
|
-
(widget: DashboardWidget) => {
|
|
311
|
-
if (!stats) return null;
|
|
29
|
+
async function DashboardData() {
|
|
30
|
+
const authUser = await getAuthUser();
|
|
312
31
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
<StatCard
|
|
317
|
-
title="Total Contacts"
|
|
318
|
-
value={stats.overview.totalContacts.toLocaleString('fr-FR')}
|
|
319
|
-
trend={{
|
|
320
|
-
value: stats.overview.contactsGrowth,
|
|
321
|
-
label: 'vs mois dernier',
|
|
322
|
-
}}
|
|
323
|
-
accentColor="dash-accent-bar"
|
|
324
|
-
/>
|
|
325
|
-
);
|
|
326
|
-
case 'stat_new_contacts':
|
|
327
|
-
return (
|
|
328
|
-
<StatCard
|
|
329
|
-
title="Nouveaux ce Mois"
|
|
330
|
-
value={stats.overview.contactsThisMonth.toLocaleString('fr-FR')}
|
|
331
|
-
subtitle="contacts créés"
|
|
332
|
-
accentColor="bg-emerald-500"
|
|
333
|
-
/>
|
|
334
|
-
);
|
|
335
|
-
case 'stat_completed_tasks':
|
|
336
|
-
return (
|
|
337
|
-
<StatCard
|
|
338
|
-
title="Tâches Complétées"
|
|
339
|
-
value={stats.tasks.completed.toLocaleString('fr-FR')}
|
|
340
|
-
subtitle={`sur ${stats.tasks.total} au total`}
|
|
341
|
-
accentColor="bg-blue-500"
|
|
342
|
-
/>
|
|
343
|
-
);
|
|
344
|
-
case 'stat_pending_tasks':
|
|
345
|
-
return (
|
|
346
|
-
<StatCard
|
|
347
|
-
title="Tâches en Attente"
|
|
348
|
-
value={stats.tasks.pending.toLocaleString('fr-FR')}
|
|
349
|
-
subtitle="à traiter"
|
|
350
|
-
accentColor="bg-amber-500"
|
|
351
|
-
/>
|
|
352
|
-
);
|
|
353
|
-
case 'contacts_chart':
|
|
354
|
-
return <ContactsChart data={stats.overview.monthsData} />;
|
|
355
|
-
case 'activity_chart':
|
|
356
|
-
return <ActivityChart data={stats.activity.last7Days} />;
|
|
357
|
-
case 'status_distribution':
|
|
358
|
-
return <StatusDistributionChart data={stats.statusDistribution} />;
|
|
359
|
-
case 'tasks_pie':
|
|
360
|
-
return <TasksPieChart completed={stats.tasks.completed} pending={stats.tasks.pending} />;
|
|
361
|
-
case 'upcoming_tasks':
|
|
362
|
-
return <UpcomingTasksList tasks={stats.tasks.upcoming} />;
|
|
363
|
-
case 'recent_activity':
|
|
364
|
-
return <RecentActivity interactions={stats.interactions.recent} />;
|
|
365
|
-
case 'top_contacts':
|
|
366
|
-
return <TopContactsList contacts={stats.topContacts} />;
|
|
367
|
-
case 'interactions_by_type':
|
|
368
|
-
return <InteractionsByTypeChart data={stats.interactions.byType} />;
|
|
369
|
-
default:
|
|
370
|
-
return (
|
|
371
|
-
<div className="flex h-full items-center justify-center rounded-2xl border border-gray-100 bg-white p-5 text-sm text-gray-400">
|
|
372
|
-
Widget inconnu : {widget.type}
|
|
373
|
-
</div>
|
|
374
|
-
);
|
|
375
|
-
}
|
|
376
|
-
},
|
|
377
|
-
[stats],
|
|
378
|
-
);
|
|
379
|
-
|
|
380
|
-
// Contenu intérieur selon l'état
|
|
381
|
-
const renderContent = () => {
|
|
382
|
-
if (loading || permissionsLoading) {
|
|
383
|
-
return (
|
|
384
|
-
<>
|
|
385
|
-
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
386
|
-
{[1, 2, 3, 4].map((i) => (
|
|
387
|
-
<div key={i} className="h-[100px] animate-pulse rounded-2xl bg-gray-100" />
|
|
388
|
-
))}
|
|
389
|
-
</div>
|
|
390
|
-
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
|
391
|
-
{[1, 2, 3, 4].map((i) => (
|
|
392
|
-
<div key={i} className="h-[300px] animate-pulse rounded-2xl bg-gray-100" />
|
|
393
|
-
))}
|
|
394
|
-
</div>
|
|
395
|
-
</>
|
|
396
|
-
);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
if (!canViewDashboard) {
|
|
400
|
-
return (
|
|
401
|
-
<div className="flex h-96 flex-col items-center justify-center rounded-2xl border-2 border-dashed border-gray-200 bg-gray-50/50">
|
|
402
|
-
<ShieldAlert className="h-12 w-12 text-gray-300" />
|
|
403
|
-
<h3 className="mt-4 text-base font-medium text-gray-600">Accès restreint</h3>
|
|
404
|
-
<p className="mt-1 text-center text-sm text-gray-400">
|
|
405
|
-
Vous n'avez pas la permission d'accéder au tableau de bord.<br />
|
|
406
|
-
Contactez votre administrateur pour obtenir les droits nécessaires.
|
|
407
|
-
</p>
|
|
408
|
-
</div>
|
|
409
|
-
);
|
|
410
|
-
}
|
|
32
|
+
if (!authUser) {
|
|
33
|
+
redirect('/signin');
|
|
34
|
+
}
|
|
411
35
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
<p className="text-sm text-red-500">{error}</p>
|
|
416
|
-
</div>
|
|
417
|
-
);
|
|
418
|
-
}
|
|
36
|
+
if (!authUser.permissions.includes('dashboard.view')) {
|
|
37
|
+
redirect('/contacts');
|
|
38
|
+
}
|
|
419
39
|
|
|
420
|
-
|
|
421
|
-
};
|
|
40
|
+
const stats = await getDashboardStats(authUser.session.user.id, authUser.permissions);
|
|
422
41
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
'--dash-50': theme.hex[50],
|
|
426
|
-
'--dash-100': theme.hex[100],
|
|
427
|
-
'--dash-200': theme.hex[200],
|
|
428
|
-
'--dash-300': theme.hex[300],
|
|
429
|
-
'--dash-400': theme.hex[400],
|
|
430
|
-
'--dash-500': theme.hex[500],
|
|
431
|
-
'--dash-600': theme.hex[600],
|
|
432
|
-
'--dash-700': theme.hex[700],
|
|
433
|
-
} as React.CSSProperties;
|
|
42
|
+
return <DashboardContent stats={stats} />;
|
|
43
|
+
}
|
|
434
44
|
|
|
45
|
+
export default function DashboardPage() {
|
|
435
46
|
return (
|
|
436
|
-
<
|
|
437
|
-
<
|
|
438
|
-
|
|
439
|
-
description="Vue d'ensemble de votre activité"
|
|
440
|
-
action={
|
|
441
|
-
!loading && !permissionsLoading && !error && canViewDashboard ? (
|
|
442
|
-
<div className="flex items-center gap-2">
|
|
443
|
-
<DashboardColorPicker />
|
|
444
|
-
{widgets.length > 0 && canResetDashboard && (
|
|
445
|
-
<button
|
|
446
|
-
onClick={handleResetLayout}
|
|
447
|
-
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm font-medium text-gray-600 shadow-sm transition-all duration-150 hover:bg-gray-50 hover:text-gray-900 hover:shadow-md active:scale-[0.98]"
|
|
448
|
-
>
|
|
449
|
-
<RotateCcw className="h-4 w-4" />
|
|
450
|
-
Réinitialiser
|
|
451
|
-
</button>
|
|
452
|
-
)}
|
|
453
|
-
{canManageWidgets && (
|
|
454
|
-
<button
|
|
455
|
-
onClick={() => setShowAddDialog(true)}
|
|
456
|
-
className="dash-btn inline-flex cursor-pointer items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium shadow-sm transition-all duration-150 hover:shadow-md active:scale-[0.98]"
|
|
457
|
-
>
|
|
458
|
-
<Plus className="h-4 w-4" />
|
|
459
|
-
Ajouter un Widget
|
|
460
|
-
</button>
|
|
461
|
-
)}
|
|
462
|
-
</div>
|
|
463
|
-
) : undefined
|
|
464
|
-
}
|
|
465
|
-
/>
|
|
466
|
-
|
|
467
|
-
{/* Le ref est TOUJOURS dans le DOM pour que useContainerWidth mesure correctement */}
|
|
468
|
-
<div ref={containerRef} className="p-4 sm:p-6">
|
|
469
|
-
{renderContent()}
|
|
470
|
-
{!loading && !permissionsLoading && !error && canViewDashboard && widgets.length === 0 && (
|
|
471
|
-
<div className="flex h-96 flex-col items-center justify-center rounded-2xl border-2 border-dashed border-gray-200 bg-gray-50/50">
|
|
472
|
-
<LayoutDashboard className="h-12 w-12 text-gray-300" />
|
|
473
|
-
<h3 className="mt-4 text-base font-medium text-gray-600">Aucun widget configuré</h3>
|
|
474
|
-
<p className="mt-1 text-sm text-gray-400">
|
|
475
|
-
{canManageWidgets
|
|
476
|
-
? 'Ajoutez des widgets pour personnaliser votre tableau de bord'
|
|
477
|
-
: 'Aucun widget n\'est configuré. Contactez votre administrateur.'}
|
|
478
|
-
</p>
|
|
479
|
-
{canManageWidgets && (
|
|
480
|
-
<button
|
|
481
|
-
onClick={() => setShowAddDialog(true)}
|
|
482
|
-
className="dash-btn mt-4 inline-flex cursor-pointer items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-colors"
|
|
483
|
-
>
|
|
484
|
-
<Plus className="h-4 w-4" />
|
|
485
|
-
Ajouter un Widget
|
|
486
|
-
</button>
|
|
487
|
-
)}
|
|
488
|
-
</div>
|
|
489
|
-
)}
|
|
490
|
-
|
|
491
|
-
{!loading && !permissionsLoading && !error && canViewDashboard && widgets.length > 0 && mounted && (
|
|
492
|
-
<GridLayout
|
|
493
|
-
className="layout"
|
|
494
|
-
width={containerWidth}
|
|
495
|
-
layout={gridLayout}
|
|
496
|
-
gridConfig={{
|
|
497
|
-
cols,
|
|
498
|
-
rowHeight: 60,
|
|
499
|
-
margin: [16, 16] as const,
|
|
500
|
-
containerPadding: [0, 0] as const,
|
|
501
|
-
maxRows: Infinity,
|
|
502
|
-
}}
|
|
503
|
-
onLayoutChange={handleLayoutChange}
|
|
504
|
-
dragConfig={{
|
|
505
|
-
handle: '.drag-handle',
|
|
506
|
-
enabled: canManageWidgets,
|
|
507
|
-
threshold: 3,
|
|
508
|
-
bounded: false,
|
|
509
|
-
}}
|
|
510
|
-
resizeConfig={{ enabled: canManageWidgets, handles: ['se'] }}
|
|
511
|
-
>
|
|
512
|
-
{widgets.map((widget) => (
|
|
513
|
-
<div key={widget.id}>
|
|
514
|
-
<WidgetWrapper
|
|
515
|
-
onRemove={canManageWidgets ? () => { handleRemoveWidget(widget.id); } : undefined}
|
|
516
|
-
>
|
|
517
|
-
{renderWidgetContent(widget)}
|
|
518
|
-
</WidgetWrapper>
|
|
519
|
-
</div>
|
|
520
|
-
))}
|
|
521
|
-
</GridLayout>
|
|
522
|
-
)}
|
|
523
|
-
</div>
|
|
524
|
-
|
|
525
|
-
<AddWidgetDialog
|
|
526
|
-
isOpen={showAddDialog}
|
|
527
|
-
onClose={() => setShowAddDialog(false)}
|
|
528
|
-
onAdd={handleAddWidget}
|
|
529
|
-
existingTypes={widgets.map((w) => w.type)}
|
|
530
|
-
/>
|
|
531
|
-
</div>
|
|
47
|
+
<Suspense fallback={<DashboardSkeleton />}>
|
|
48
|
+
<DashboardData />
|
|
49
|
+
</Suspense>
|
|
532
50
|
);
|
|
533
51
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
export default function DashboardError({
|
|
7
|
+
error,
|
|
8
|
+
reset,
|
|
9
|
+
}: {
|
|
10
|
+
error: Error & { digest?: string };
|
|
11
|
+
reset: () => void;
|
|
12
|
+
}) {
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
console.error('Dashboard error:', error);
|
|
15
|
+
}, [error]);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="flex h-full items-center justify-center p-6">
|
|
19
|
+
<div className="max-w-md text-center">
|
|
20
|
+
<AlertTriangle className="mx-auto h-12 w-12 text-red-500" />
|
|
21
|
+
<h2 className="mt-4 text-lg font-semibold text-gray-900">Une erreur est survenue</h2>
|
|
22
|
+
<p className="mt-2 text-sm text-gray-600">
|
|
23
|
+
{process.env.NODE_ENV === 'development'
|
|
24
|
+
? error.message
|
|
25
|
+
: "Quelque chose s'est mal passé. Veuillez réessayer."}
|
|
26
|
+
</p>
|
|
27
|
+
<button
|
|
28
|
+
onClick={reset}
|
|
29
|
+
className="mt-4 inline-flex cursor-pointer items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
|
30
|
+
>
|
|
31
|
+
<RefreshCw className="h-4 w-4" />
|
|
32
|
+
Réessayer
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -14,7 +14,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|
|
14
14
|
<MobileMenuProvider>
|
|
15
15
|
<SidebarProvider>
|
|
16
16
|
<TaskReminderProvider>
|
|
17
|
-
<div className="flex h-screen overflow-hidden bg-
|
|
17
|
+
<div className="flex h-screen overflow-hidden bg-surface-page">
|
|
18
18
|
<Sidebar />
|
|
19
19
|
<main className="flex flex-1 flex-col overflow-hidden lg:ml-0">
|
|
20
20
|
<Header />
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Skeleton } from '@/components/skeleton';
|
|
2
|
+
|
|
3
|
+
export default function SettingsLoading() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="h-full">
|
|
6
|
+
<div className="border-b bg-white px-4 py-4 sm:px-6">
|
|
7
|
+
<Skeleton className="h-7 w-32" />
|
|
8
|
+
<Skeleton className="mt-1 h-4 w-64" />
|
|
9
|
+
</div>
|
|
10
|
+
<div className="p-4 sm:p-6">
|
|
11
|
+
<div className="space-y-6">
|
|
12
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
13
|
+
<Skeleton key={i} className="h-40 rounded-lg" />
|
|
14
|
+
))}
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|