chore: import codes
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import { Fragment } from 'react';
|
||||
import { ChevronDownIcon } from '../../../shared/Icons.js';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
|
||||
const AdminHeader = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appName = 'ZEN' }) => {
|
||||
const router = useRouter();
|
||||
|
||||
const getImageUrl = (imageKey) => {
|
||||
if (!imageKey) return null;
|
||||
return `/zen/api/storage/${imageKey}`;
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
if (onLogout) {
|
||||
const result = await onLogout();
|
||||
if (result && result.success) {
|
||||
router.push('/auth/login');
|
||||
} else {
|
||||
console.error('Logout failed:', result?.error);
|
||||
router.push('/auth/login');
|
||||
}
|
||||
} else {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
router.push('/auth/login');
|
||||
}
|
||||
};
|
||||
|
||||
const getUserInitials = (name) => {
|
||||
if (!name) return 'U';
|
||||
return name
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
const quickLinks = [];
|
||||
|
||||
const userInitials = getUserInitials(user?.name);
|
||||
|
||||
return (
|
||||
<header className="bg-white dark:bg-black border-b border-neutral-200 dark:border-neutral-800/70 sticky top-0 z-30 h-14 flex items-center w-full">
|
||||
<div className="flex items-center justify-between lg:justify-end px-4 lg:px-6 py-2 w-full">
|
||||
{/* Left section - Mobile menu button + Logo (hidden on desktop) */}
|
||||
<div className="flex items-center space-x-3 lg:hidden">
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="p-2 rounded-lg bg-neutral-100 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-700/50 text-neutral-900 dark:text-white hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-colors duration-200"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg className={`h-5 w-5 transition-transform duration-200 ${isMobileMenuOpen ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={isMobileMenuOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"} />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-neutral-900 dark:text-white font-semibold text-lg">{appName}</h1>
|
||||
</div>
|
||||
|
||||
{/* Right Section - Theme Toggle + Quick Links + Profile */}
|
||||
<div className="flex items-center space-x-3 sm:space-x-4">
|
||||
{/* Quick Links - Hidden on very small screens */}
|
||||
<nav className="hidden sm:flex items-center space-x-4 lg:space-x-6">
|
||||
{quickLinks.map((link) => (
|
||||
<a
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
className="text-sm text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white transition-colors duration-200"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* User Profile Menu */}
|
||||
<Menu as="div" className="relative">
|
||||
<Menu.Button className="cursor-pointer flex items-center space-x-2 sm:space-x-3 px-2 sm:px-3 py-2 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-all duration-200 group ui-open:bg-neutral-100 dark:ui-open:bg-neutral-800/50 outline-none">
|
||||
{/* Avatar for desktop - hidden on mobile */}
|
||||
<div className="hidden sm:flex items-center space-x-3">
|
||||
{getImageUrl(user?.image) && (
|
||||
<img
|
||||
src={getImageUrl(user.image)}
|
||||
alt={user?.name || 'User'}
|
||||
className="w-8 h-8 rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-white">{user?.name || 'User'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Avatar for mobile - visible on mobile only */}
|
||||
<div className="sm:hidden">
|
||||
{getImageUrl(user?.image) && (
|
||||
<img
|
||||
src={getImageUrl(user.image)}
|
||||
alt={user?.name || 'User'}
|
||||
className="w-8 h-8 rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDownIcon className="h-4 w-4 text-neutral-400 transition-transform duration-200 ui-open:rotate-180" />
|
||||
</Menu.Button>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 -translate-y-2"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 -translate-y-2"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 mt-2 w-56 sm:w-64 bg-white dark:bg-neutral-900/95 backdrop-blur-sm border border-neutral-200 dark:border-neutral-700/50 rounded-xl shadow-xl z-50 outline-none">
|
||||
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700/50">
|
||||
<div className="flex items-center space-x-3">
|
||||
{getImageUrl(user?.image) && (
|
||||
<img
|
||||
src={getImageUrl(user.image)}
|
||||
alt={user?.name || 'User'}
|
||||
className="w-10 h-10 rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-white">{user?.name || 'User'}</p>
|
||||
<p className="text-xs text-neutral-400">{user?.email || 'email@example.com'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links for mobile */}
|
||||
{quickLinks.length > 0 && (
|
||||
<div className="sm:hidden py-2 border-b border-neutral-200 dark:border-neutral-700/50">
|
||||
{quickLinks.map((link) => (
|
||||
<Menu.Item key={link.name}>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href={link.href}
|
||||
className={`flex items-center px-4 py-3 text-sm transition-all duration-200 ${
|
||||
active
|
||||
? 'bg-neutral-100 dark:bg-neutral-800/50 text-neutral-900 dark:text-white'
|
||||
: 'text-neutral-600 dark:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<svg className="mr-3 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{link.name}
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="py-2">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href="/admin/profile"
|
||||
className={`flex items-center px-4 py-3 text-sm transition-all duration-200 group ${
|
||||
active
|
||||
? 'bg-neutral-100 dark:bg-neutral-800/50 text-neutral-900 dark:text-white'
|
||||
: 'text-neutral-600 dark:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<svg className="mr-3 h-4 w-4 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
Mon profil
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
|
||||
<div className="border-t border-neutral-200 dark:border-neutral-700/50 mt-2 pt-2">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={`w-full flex items-center px-4 py-3 text-sm transition-all duration-200 group text-left ${
|
||||
active
|
||||
? 'bg-red-500/10 text-red-300'
|
||||
: 'text-red-400'
|
||||
}`}
|
||||
>
|
||||
<svg className="mr-3 h-4 w-4 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Se déconnecter
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminHeader;
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Admin Pages Component
|
||||
*
|
||||
* This component handles both core admin pages and module pages.
|
||||
* Module pages are loaded dynamically on the client where hooks work properly.
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import DashboardPage from './pages/DashboardPage.js';
|
||||
import UsersPage from './pages/UsersPage.js';
|
||||
import UserEditPage from './pages/UserEditPage.js';
|
||||
import ProfilePage from './pages/ProfilePage.js';
|
||||
import { getModulePageLoader } from '../../../modules/modules.pages.js';
|
||||
|
||||
// Loading component for suspense
|
||||
function PageLoading() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPagesClient({
|
||||
params,
|
||||
user,
|
||||
dashboardStats = null,
|
||||
moduleStats = {},
|
||||
modulePageInfo = null,
|
||||
routeInfo = null,
|
||||
enabledModules = {}
|
||||
}) {
|
||||
// If this is a module page, render it with lazy loading
|
||||
if (modulePageInfo && routeInfo) {
|
||||
const LazyComponent = getModulePageLoader(modulePageInfo.module, modulePageInfo.path);
|
||||
if (LazyComponent) {
|
||||
// Build props for the page
|
||||
const pageProps = { user };
|
||||
if (routeInfo.action === 'edit' && routeInfo.id) {
|
||||
// Add ID props for edit pages (modules may use different prop names)
|
||||
pageProps.id = routeInfo.id;
|
||||
pageProps.invoiceId = routeInfo.id;
|
||||
pageProps.clientId = routeInfo.id;
|
||||
pageProps.itemId = routeInfo.id;
|
||||
pageProps.categoryId = routeInfo.id;
|
||||
pageProps.transactionId = routeInfo.id;
|
||||
pageProps.recurrenceId = routeInfo.id;
|
||||
pageProps.templateId = routeInfo.id;
|
||||
pageProps.postId = routeInfo.id;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<LazyComponent {...pageProps} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine core page from routeInfo or params
|
||||
let currentPage = 'dashboard';
|
||||
if (routeInfo?.path) {
|
||||
const parts = routeInfo.path.split('/').filter(Boolean);
|
||||
currentPage = parts[1] || 'dashboard'; // /admin/[page]
|
||||
} else if (params?.admin) {
|
||||
currentPage = params.admin[0] || 'dashboard';
|
||||
}
|
||||
|
||||
// Core page components mapping (non-module pages)
|
||||
const usersPageComponent = routeInfo?.action === 'edit' && routeInfo?.id
|
||||
? () => <UserEditPage userId={routeInfo.id} user={user} enabledModules={enabledModules} />
|
||||
: () => <UsersPage user={user} />;
|
||||
|
||||
const corePages = {
|
||||
dashboard: () => <DashboardPage user={user} stats={dashboardStats} moduleStats={moduleStats} enabledModules={enabledModules} />,
|
||||
users: usersPageComponent,
|
||||
profile: () => <ProfilePage user={user} />,
|
||||
};
|
||||
|
||||
// Render the appropriate core page or default to dashboard
|
||||
const CorePageComponent = corePages[currentPage];
|
||||
return CorePageComponent ? <CorePageComponent /> : <DashboardPage user={user} stats={dashboardStats} moduleStats={moduleStats} enabledModules={enabledModules} />;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import AdminSidebar from './AdminSidebar';
|
||||
import { useState } from 'react';
|
||||
import AdminHeader from './AdminHeader';
|
||||
|
||||
export default function AdminPagesLayout({ children, user, onLogout, appName, enabledModules, navigationSections }) {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-white dark:bg-black">
|
||||
<AdminSidebar
|
||||
isMobileMenuOpen={isMobileMenuOpen}
|
||||
setIsMobileMenuOpen={setIsMobileMenuOpen}
|
||||
appName={appName}
|
||||
enabledModules={enabledModules}
|
||||
navigationSections={navigationSections}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<AdminHeader isMobileMenuOpen={isMobileMenuOpen} setIsMobileMenuOpen={setIsMobileMenuOpen} user={user} onLogout={onLogout} appName={appName} />
|
||||
<main className="flex-1 overflow-y-auto bg-neutral-50 dark:bg-black">
|
||||
<div className="px-4 sm:px-6 lg:px-8 xl:px-12 pt-4 sm:pt-6 lg:pt-8 pb-32 max-w-[1400px] mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import * as Icons from '../../../shared/Icons.js';
|
||||
import { ChevronDownIcon } from '../../../shared/Icons.js';
|
||||
|
||||
/**
|
||||
* Resolve icon name (string) to icon component
|
||||
* Icons are passed as strings from server to avoid serialization issues
|
||||
*/
|
||||
function resolveIcon(iconNameOrComponent) {
|
||||
// If it's already a component (function), return it
|
||||
if (typeof iconNameOrComponent === 'function') {
|
||||
return iconNameOrComponent;
|
||||
}
|
||||
// If it's a string, look up in Icons
|
||||
if (typeof iconNameOrComponent === 'string') {
|
||||
return Icons[iconNameOrComponent] || Icons.DashboardSquare03Icon;
|
||||
}
|
||||
// Default fallback
|
||||
return Icons.DashboardSquare03Icon;
|
||||
}
|
||||
|
||||
const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledModules, navigationSections: serverNavigationSections }) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// State to manage collapsed sections (all open by default)
|
||||
const [collapsedSections, setCollapsedSections] = useState(new Set());
|
||||
|
||||
// Function to toggle a section's state
|
||||
const toggleSection = (sectionId) => {
|
||||
// Find the section to check if it has active items
|
||||
const section = navigationSections.find(s => s.id === sectionId);
|
||||
|
||||
// Don't allow collapsing sections with active items
|
||||
if (section && isSectionActive(section)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCollapsedSections(prev => {
|
||||
const newCollapsed = new Set(prev);
|
||||
if (newCollapsed.has(sectionId)) {
|
||||
newCollapsed.delete(sectionId);
|
||||
} else {
|
||||
newCollapsed.add(sectionId);
|
||||
}
|
||||
return newCollapsed;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle mobile menu closure when clicking on a link
|
||||
const handleMobileLinkClick = () => {
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
// Close mobile menu on screen size change
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth >= 1024) { // lg breakpoint
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [setIsMobileMenuOpen]);
|
||||
|
||||
// Function to check if any item in a section is currently active
|
||||
const isSectionActive = (section) => {
|
||||
return section.items.some(item => item.current);
|
||||
};
|
||||
|
||||
// Function to check if a section should be rendered as a direct link
|
||||
const shouldRenderAsDirectLink = (section) => {
|
||||
// Check if there's only one item and it has the same name as the section
|
||||
return section.items.length === 1 &&
|
||||
section.items[0].name.toLowerCase() === section.title.toLowerCase();
|
||||
};
|
||||
|
||||
// Update collapsed sections when pathname changes to ensure active sections are open
|
||||
useEffect(() => {
|
||||
setCollapsedSections(prev => {
|
||||
const newSet = new Set(prev);
|
||||
// Add any sections that have active items to ensure they stay open
|
||||
navigationSections.forEach(section => {
|
||||
if (isSectionActive(section)) {
|
||||
newSet.add(section.id);
|
||||
}
|
||||
});
|
||||
return newSet;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname]);
|
||||
|
||||
// Use server-provided navigation sections if available, otherwise use core-only fallback
|
||||
// Server navigation includes module navigation, fallback only has core pages
|
||||
// Update the 'current' property based on the actual pathname (client-side)
|
||||
const navigationSections = serverNavigationSections.map(section => ({
|
||||
...section,
|
||||
items: section.items.map(item => ({
|
||||
...item,
|
||||
current: pathname === item.href || pathname.startsWith(item.href + '/')
|
||||
}))
|
||||
}));
|
||||
|
||||
// Function to render a complete navigation section
|
||||
const renderNavSection = (section) => {
|
||||
const Icon = resolveIcon(section.icon);
|
||||
|
||||
// If section should be rendered as a direct link
|
||||
if (shouldRenderAsDirectLink(section)) {
|
||||
const item = section.items[0];
|
||||
return (
|
||||
<div key={section.id}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={handleMobileLinkClick}
|
||||
className={`${
|
||||
item.current
|
||||
? 'bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-white'
|
||||
: 'text-neutral-900 dark:text-white hover:text-neutral-500 dark:hover:text-neutral-300'
|
||||
} w-full flex items-center justify-between border-y border-neutral-200 dark:border-neutral-800/70 px-4 py-2.5 -mb-[1px] text-[13px] tracking-wide transition-colorsduration-0`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon className="mr-3 h-4 w-4 flex-shrink-0" />
|
||||
<span>{section.title}</span>
|
||||
</div>
|
||||
{item.badge && (
|
||||
<span className="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded-full font-medium">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Regular section with expandable sub-items
|
||||
const isCollapsed = !collapsedSections.has(section.id);
|
||||
|
||||
return (
|
||||
<div key={section.id}>
|
||||
<button
|
||||
onClick={() => toggleSection(section.id)}
|
||||
className="cursor-pointer w-full flex items-center justify-between border-y border-neutral-200 dark:border-neutral-800/70 px-4 py-2.5 -mb-[1px] text-[13px] text-neutral-900 dark:text-white tracking-wide hover:text-neutral-500 dark:hover:text-neutral-300 transition-colorsduration-0"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon className="mr-3 h-4 w-4 flex-shrink-0" />
|
||||
<span>{section.title}</span>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={`h-3 w-3 ${
|
||||
isCollapsed ? '-rotate-90' : 'rotate-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={`overflow-hidden ${
|
||||
isCollapsed
|
||||
? 'max-h-0 opacity-0'
|
||||
: 'max-h-[1000px] opacity-100'
|
||||
}`}
|
||||
>
|
||||
<ul className="flex flex-col gap-0">
|
||||
{section.items.map(renderNavItem)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Function to render a navigation item
|
||||
const renderNavItem = (item) => {
|
||||
const Icon = resolveIcon(item.icon);
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={handleMobileLinkClick}
|
||||
className={`${
|
||||
item.current
|
||||
? 'bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-white'
|
||||
: 'text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800/50 hover:text-neutral-900 dark:hover:text-white'
|
||||
} group flex items-center justify-between px-4 py-1.5 text-[12px] font-medium transition-allduration-0`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon className="mr-3 h-3.5 w-3.5 flex-shrink-0" />
|
||||
{item.name}
|
||||
</div>
|
||||
{item.badge && (
|
||||
<span className="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded-full font-medium">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
{isMobileMenuOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={`
|
||||
${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
fixed lg:static inset-y-0 left-0 z-40 w-64 bg-white dark:bg-black border-r border-neutral-200 dark:border-neutral-800/70 flex flex-col h-screen transition-transform duration-300 ease-in-out
|
||||
`}>
|
||||
{/* Logo Section */}
|
||||
<Link href="/admin" className="px-4 h-14 flex items-center justify-start gap-2 border-b border-neutral-200 dark:border-neutral-800/70">
|
||||
<h1 className="text-neutral-900 dark:text-white font-semibold">{appName}</h1>
|
||||
<span className="bg-red-700/10 border border-red-600/20 text-red-600 uppercase text-[10px] leading-none px-2 py-1 rounded-full font-semibold">
|
||||
Admin
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-0 overflow-y-auto flex flex-col gap-0 pb-12 -mt-[1px]">
|
||||
{navigationSections.map(renderNavSection)}
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSidebar;
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Sun01Icon, Moon02Icon, SunCloud02Icon, MoonCloudIcon } from '../../../shared/Icons.js';
|
||||
|
||||
function getNextTheme(current) {
|
||||
const systemIsDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (current === 'auto') return systemIsDark ? 'light' : 'dark';
|
||||
if (current === 'dark') return systemIsDark ? 'auto' : 'light';
|
||||
return systemIsDark ? 'dark' : 'auto';
|
||||
}
|
||||
|
||||
function getAutoIcon(systemIsDark) {
|
||||
return systemIsDark ? MoonCloudIcon : SunCloud02Icon;
|
||||
}
|
||||
|
||||
const THEME_ICONS = {
|
||||
light: Sun01Icon,
|
||||
dark: Moon02Icon,
|
||||
};
|
||||
|
||||
function getStoredTheme() {
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
} else if (theme === 'light') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
} else {
|
||||
localStorage.removeItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.classList.toggle('dark', prefersDark);
|
||||
}
|
||||
}
|
||||
|
||||
function useTheme() {
|
||||
const [theme, setTheme] = useState('auto');
|
||||
const [systemIsDark, setSystemIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(getStoredTheme());
|
||||
setSystemIsDark(window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
function onSystemChange(e) {
|
||||
setSystemIsDark(e.matches);
|
||||
if (localStorage.getItem('theme')) return;
|
||||
document.documentElement.classList.toggle('dark', e.matches);
|
||||
}
|
||||
mq.addEventListener('change', onSystemChange);
|
||||
return () => mq.removeEventListener('change', onSystemChange);
|
||||
}, []);
|
||||
|
||||
function toggle() {
|
||||
const next = getNextTheme(theme);
|
||||
setTheme(next);
|
||||
applyTheme(next);
|
||||
}
|
||||
|
||||
return { theme, toggle, systemIsDark };
|
||||
}
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { theme, toggle, systemIsDark } = useTheme();
|
||||
const Icon = theme === 'auto' ? getAutoIcon(systemIsDark) : THEME_ICONS[theme];
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-label="Changer le thème"
|
||||
title="Changer le thème"
|
||||
className="cursor-pointer p-2 rounded-lg text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800/50 hover:text-neutral-900 dark:hover:text-white transition-colors duration-200"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Admin Components Exports
|
||||
*/
|
||||
|
||||
export { default as AdminPagesClient } from './AdminPages.js';
|
||||
export { default as AdminPagesLayout } from './AdminPagesLayout.js';
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Admin Dashboard Page
|
||||
* Displays core stats and dynamically loads module dashboard widgets
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { StatCard } from '../../../../shared/components';
|
||||
import { UserMultiple02Icon } from '../../../../shared/Icons.js';
|
||||
import { getModuleDashboardWidgets } from '../../../../modules/modules.pages.js';
|
||||
|
||||
/**
|
||||
* Loading placeholder for widgets
|
||||
*/
|
||||
function WidgetLoading() {
|
||||
return (
|
||||
<div className="animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-lg h-32"></div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage({ user, stats, moduleStats = {}, enabledModules = {} }) {
|
||||
const loading = !stats;
|
||||
|
||||
// Get only enabled module dashboard widgets
|
||||
const allModuleWidgets = getModuleDashboardWidgets();
|
||||
const moduleWidgets = Object.fromEntries(
|
||||
Object.entries(allModuleWidgets).filter(([moduleName]) => enabledModules[moduleName])
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">
|
||||
Tableau de bord
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">Vue d'ensemble de votre application</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
{/* Module dashboard widgets (dynamically loaded) */}
|
||||
{Object.entries(moduleWidgets).map(([moduleName, widgets]) => (
|
||||
widgets.map((Widget, index) => (
|
||||
<Suspense key={`${moduleName}-widget-${index}`} fallback={<WidgetLoading />}>
|
||||
<Widget stats={moduleStats[moduleName]} />
|
||||
</Suspense>
|
||||
))
|
||||
))}
|
||||
|
||||
{/* Core stats - always shown */}
|
||||
<StatCard
|
||||
title="Nombre d'utilisateurs"
|
||||
value={loading ? '-' : String(stats?.totalUsers || 0)}
|
||||
icon={UserMultiple02Icon}
|
||||
color="text-purple-400"
|
||||
bgColor="bg-purple-500/10"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Card, Input, Button } from '../../../../shared/components';
|
||||
import { useToast } from '@hykocx/zen/toast';
|
||||
|
||||
const ProfilePage = ({ user: initialUser }) => {
|
||||
const toast = useToast();
|
||||
const [user, setUser] = useState(initialUser);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [imagePreview, setImagePreview] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: initialUser?.name || ''
|
||||
});
|
||||
|
||||
// Helper function to get image URL from storage key
|
||||
const getImageUrl = (imageKey) => {
|
||||
if (!imageKey) return null;
|
||||
return `/zen/api/storage/${imageKey}`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (initialUser) {
|
||||
setFormData({
|
||||
name: initialUser.name || ''
|
||||
});
|
||||
setImagePreview(getImageUrl(initialUser.image));
|
||||
}
|
||||
}, [initialUser]);
|
||||
|
||||
const handleChange = (value) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Le nom est requis');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/zen/api/users/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name: formData.name.trim()
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Échec de la mise à jour du profil');
|
||||
}
|
||||
|
||||
setUser(data.user);
|
||||
toast.success('Profil mis à jour avec succès');
|
||||
|
||||
// Refresh the page to update the user data in the header
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
toast.error(error.message || 'Échec de la mise à jour du profil');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFormData({
|
||||
name: user?.name || ''
|
||||
});
|
||||
};
|
||||
|
||||
const handleImageSelect = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Veuillez sélectionner un fichier image');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error("L'image doit faire moins de 5MB");
|
||||
return;
|
||||
}
|
||||
|
||||
// Show preview
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImagePreview(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Upload image
|
||||
setUploadingImage(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('/zen/api/users/profile/picture', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.message || 'Échec du téléchargement de l\'image');
|
||||
}
|
||||
|
||||
setUser(data.user);
|
||||
setImagePreview(getImageUrl(data.user.image));
|
||||
toast.success('Photo de profil mise à jour avec succès');
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
toast.error(error.message || 'Échec du téléchargement de l\'image');
|
||||
// Revert preview on error
|
||||
setImagePreview(getImageUrl(user?.image));
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = async () => {
|
||||
if (!user?.image) return;
|
||||
|
||||
setUploadingImage(true);
|
||||
try {
|
||||
const response = await fetch('/zen/api/users/profile/picture', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.message || 'Échec de la suppression de l\'image');
|
||||
}
|
||||
|
||||
setUser(data.user);
|
||||
setImagePreview(null);
|
||||
toast.success('Photo de profil supprimée avec succès');
|
||||
} catch (error) {
|
||||
console.error('Error removing image:', error);
|
||||
toast.error(error.message || 'Échec de la suppression de l\'image');
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getUserInitials = (name) => {
|
||||
if (!name) return 'U';
|
||||
return name
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
const hasChanges = formData.name !== user?.name;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">
|
||||
Mon profil
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">
|
||||
Gérez les informations de votre compte
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Photo de profil
|
||||
</h2>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative">
|
||||
{imagePreview ? (
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Profile"
|
||||
className="w-24 h-24 rounded-full object-cover border-2 border-neutral-300 dark:border-neutral-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-neutral-200 to-neutral-300 dark:from-neutral-800 dark:to-neutral-700 rounded-full flex items-center justify-center border-2 border-neutral-300 dark:border-neutral-700">
|
||||
<span className="text-neutral-700 dark:text-white font-semibold text-2xl">
|
||||
{getUserInitials(user?.name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{uploadingImage && (
|
||||
<div className="absolute inset-0 bg-black/50 rounded-full flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-neutral-400">
|
||||
Téléchargez une nouvelle photo de profil. Taille max 5MB.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingImage}
|
||||
>
|
||||
{uploadingImage ? 'Téléchargement...' : 'Télécharger une image'}
|
||||
</Button>
|
||||
{imagePreview && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleRemoveImage}
|
||||
disabled={uploadingImage}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Informations personnelles
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Nom complet"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Entrez votre nom complet"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Courriel"
|
||||
name="email"
|
||||
type="email"
|
||||
value={user?.email || ''}
|
||||
disabled
|
||||
readOnly
|
||||
description="L'email ne peut pas être modifié"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Compte créé"
|
||||
name="createdAt"
|
||||
type="text"
|
||||
value={user?.created_at ? new Date(user.created_at).toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}) : 'N/D'}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-neutral-200 dark:border-neutral-700/50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleReset}
|
||||
disabled={loading || !hasChanges}
|
||||
>
|
||||
Réinitialiser
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading || !hasChanges}
|
||||
loading={loading}
|
||||
>
|
||||
{loading ? 'Enregistrement...' : 'Enregistrer les modifications'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
@@ -0,0 +1,254 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Card, Input, Select, Loading } from '../../../../shared/components';
|
||||
import { useToast } from '@hykocx/zen/toast';
|
||||
|
||||
/**
|
||||
* User Edit Page Component
|
||||
* Page for editing an existing user (admin only)
|
||||
*/
|
||||
const UserEditPage = ({ userId, user, enabledModules = {} }) => {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const clientsModuleActive = Boolean(enabledModules?.clients);
|
||||
|
||||
const [userData, setUserData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [clients, setClients] = useState([]);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
role: 'user',
|
||||
email_verified: 'false',
|
||||
client_id: ''
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'user', label: 'Utilisateur' },
|
||||
{ value: 'admin', label: 'Admin' }
|
||||
];
|
||||
|
||||
const emailVerifiedOptions = [
|
||||
{ value: 'false', label: 'Non vérifié' },
|
||||
{ value: 'true', label: 'Vérifié' }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (clientsModuleActive) {
|
||||
fetch('/zen/api/admin/clients?limit=500', { credentials: 'include' })
|
||||
.then(res => res.json())
|
||||
.then(data => data.clients ? setClients(data.clients) : setClients([]))
|
||||
.catch(() => setClients([]));
|
||||
}
|
||||
}, [clientsModuleActive]);
|
||||
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/zen/api/users/${userId}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.user) {
|
||||
setUserData(data.user);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: data.user.name || '',
|
||||
role: data.user.role || 'user',
|
||||
email_verified: data.user.email_verified ? 'true' : 'false',
|
||||
client_id: data.linkedClient ? String(data.linkedClient.id) : ''
|
||||
}));
|
||||
} else {
|
||||
toast.error(data.message || 'Utilisateur introuvable');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading user:', error);
|
||||
toast.error('Impossible de charger l\'utilisateur');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
if (!formData.name || !formData.name.trim()) {
|
||||
newErrors.name = 'Le nom est requis';
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const response = await fetch(`/zen/api/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name: formData.name.trim(),
|
||||
role: formData.role,
|
||||
email_verified: formData.email_verified === 'true',
|
||||
...(clientsModuleActive && { client_id: formData.client_id ? parseInt(formData.client_id, 10) : null })
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success('Utilisateur mis à jour avec succès');
|
||||
router.push('/admin/users');
|
||||
} else {
|
||||
toast.error(data.message || data.error || 'Impossible de mettre à jour l\'utilisateur');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error);
|
||||
toast.error('Impossible de mettre à jour l\'utilisateur');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-64 flex items-center justify-center">
|
||||
<Loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!userData) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Modifier l'utilisateur</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">Utilisateur introuvable</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => router.push('/admin/users')}
|
||||
>
|
||||
← Retour aux utilisateurs
|
||||
</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-lg">
|
||||
<p className="font-medium">Utilisateur introuvable</p>
|
||||
<p className="text-sm mt-1">L'utilisateur que vous recherchez n'existe pas ou a été supprimé.</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Modifier l'utilisateur</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">{userData.email}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => router.push('/admin/users')}
|
||||
>
|
||||
← Retour aux utilisateurs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Informations de l'utilisateur</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Nom *"
|
||||
value={formData.name}
|
||||
onChange={(value) => handleInputChange('name', value)}
|
||||
placeholder="Nom de l'utilisateur"
|
||||
error={errors.name}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Email"
|
||||
value={userData.email}
|
||||
disabled
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Rôle"
|
||||
value={formData.role}
|
||||
onChange={(value) => handleInputChange('role', value)}
|
||||
options={roleOptions}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Email vérifié"
|
||||
value={formData.email_verified}
|
||||
onChange={(value) => handleInputChange('email_verified', value)}
|
||||
options={emailVerifiedOptions}
|
||||
/>
|
||||
|
||||
{clientsModuleActive && (
|
||||
<Select
|
||||
label="Client associé"
|
||||
value={formData.client_id}
|
||||
onChange={(value) => handleInputChange('client_id', value)}
|
||||
options={[
|
||||
{ value: '', label: 'Aucun' },
|
||||
...clients.map(c => ({
|
||||
value: String(c.id),
|
||||
label: [c.client_number, c.company_name || [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email].filter(Boolean).join(' – ')
|
||||
}))
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => router.push('/admin/users')}
|
||||
disabled={saving}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="success"
|
||||
loading={saving}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Enregistrement...' : 'Mettre à jour'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserEditPage;
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, Table, StatusBadge, Pagination, Button } from '../../../../shared/components';
|
||||
import { PencilEdit01Icon } from '../../../../shared/Icons.js';
|
||||
import { useToast } from '@hykocx/zen/toast';
|
||||
|
||||
const UsersPageClient = () => {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Pagination state
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
});
|
||||
|
||||
// Sort state
|
||||
const [sortBy, setSortBy] = useState('created_at');
|
||||
const [sortOrder, setSortOrder] = useState('desc');
|
||||
|
||||
// Table columns configuration
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Nom',
|
||||
sortable: true,
|
||||
render: (user) => (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-white">{user.name}</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-gray-400">ID: {user.id.slice(0, 8)}...</div>
|
||||
</div>
|
||||
),
|
||||
skeleton: {
|
||||
height: 'h-4', width: '60%',
|
||||
secondary: { height: 'h-3', width: '40%' }
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
sortable: true,
|
||||
render: (user) => <div className="text-sm font-medium text-neutral-900 dark:text-white">{user.email}</div>,
|
||||
skeleton: {
|
||||
height: 'h-4',
|
||||
width: '60%',
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: 'Rôle',
|
||||
sortable: true,
|
||||
render: (user) => <StatusBadge status={user.role} />,
|
||||
skeleton: { height: 'h-6', width: '80px', className: 'rounded-full' }
|
||||
},
|
||||
{
|
||||
key: 'email_verified',
|
||||
label: 'Statut',
|
||||
sortable: true,
|
||||
render: (user) => <StatusBadge status={user.email_verified ? 'verified' : 'unverified'} />,
|
||||
skeleton: { height: 'h-6', width: '90px', className: 'rounded-full' }
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Créé le',
|
||||
sortable: true,
|
||||
render: (user) => (
|
||||
<span className="text-sm text-neutral-600 dark:text-gray-300">
|
||||
{formatDate(user.created_at)}
|
||||
</span>
|
||||
),
|
||||
skeleton: { height: 'h-4', width: '70%' }
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
sortable: false,
|
||||
noWrap: true,
|
||||
render: (user) => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/admin/users/edit/${user.id}`)}
|
||||
icon={<PencilEdit01Icon className="w-4 h-4" />}
|
||||
>
|
||||
Modifier
|
||||
</Button>
|
||||
),
|
||||
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' }
|
||||
}
|
||||
];
|
||||
|
||||
// Fetch users function
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const searchParams = new URLSearchParams({
|
||||
page: pagination.page.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
sortBy,
|
||||
sortOrder
|
||||
});
|
||||
|
||||
const response = await fetch(`/zen/api/users?${searchParams}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setUsers(data.users);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: data.pagination.total,
|
||||
totalPages: data.pagination.totalPages,
|
||||
page: data.pagination.page
|
||||
}));
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Impossible de charger les utilisateurs');
|
||||
console.error('Error fetching users:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Effect to fetch users when sort or pagination change
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [sortBy, sortOrder, pagination.page, pagination.limit]);
|
||||
|
||||
// Handle pagination
|
||||
const handlePageChange = (newPage) => {
|
||||
setPagination(prev => ({ ...prev, page: newPage }));
|
||||
};
|
||||
|
||||
const handleLimitChange = (newLimit) => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
limit: newLimit,
|
||||
page: 1 // Reset to first page when changing limit
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (newSortBy) => {
|
||||
const newSortOrder = sortBy === newSortBy && sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
setSortBy(newSortBy);
|
||||
setSortOrder(newSortOrder);
|
||||
};
|
||||
|
||||
// Format date helper
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return 'Date invalide';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Users Table */}
|
||||
<Card variant="default" padding="none">
|
||||
<Table
|
||||
columns={columns}
|
||||
data={users}
|
||||
loading={loading}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSort={handleSort}
|
||||
emptyMessage="Aucun utilisateur trouvé"
|
||||
emptyDescription="La base de données est vide"
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
currentPage={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
onLimitChange={handleLimitChange}
|
||||
limit={pagination.limit}
|
||||
total={pagination.total}
|
||||
loading={loading}
|
||||
showPerPage={true}
|
||||
showStats={true}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UsersPage = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Utilisateurs</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">Gérez les comptes utilisateurs</p>
|
||||
</div>
|
||||
</div>
|
||||
<UsersPageClient />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersPage;
|
||||
Reference in New Issue
Block a user