chore: import codes

This commit is contained in:
2026-04-12 12:50:14 -04:00
parent 4bcb4898e8
commit 65ae3c6788
241 changed files with 48834 additions and 1 deletions
@@ -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>
);
}
+6
View File
@@ -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;