Compare commits
4 Commits
a3b61b8ae4
...
2b2361d87c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b2361d87c | ||
|
|
357c3cf1f9 | ||
|
|
04c61d0a71 | ||
|
|
6568b9bfed |
@ -16,36 +16,73 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
LogOut,
|
LogOut,
|
||||||
Users2,
|
Users2,
|
||||||
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@utils/cn';
|
import { cn } from '@utils/cn';
|
||||||
import { useUIStore } from '@stores/useUIStore';
|
import { useUIStore } from '@stores/useUIStore';
|
||||||
import { useAuthStore } from '@stores/useAuthStore';
|
import { useAuthStore } from '@stores/useAuthStore';
|
||||||
import { useIsMobile } from '@hooks/useMediaQuery';
|
import { useIsMobile } from '@hooks/useMediaQuery';
|
||||||
|
import { useFilteredNavigation, type NavigationItem } from '@hooks/useFilteredNavigation';
|
||||||
|
import { ThemeToggle } from '@components/atoms/ThemeToggle';
|
||||||
|
import { CommandPaletteWithRouter, useCommandPalette } from '@components/organisms/CommandPalette';
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigation = [
|
/**
|
||||||
|
* Search button component that opens the command palette
|
||||||
|
*/
|
||||||
|
function SearchButton() {
|
||||||
|
const { open } = useCommandPalette();
|
||||||
|
const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={open}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-lg px-3 py-1.5',
|
||||||
|
'border border-gray-200 bg-gray-50 text-gray-500',
|
||||||
|
'hover:bg-gray-100 hover:text-gray-700',
|
||||||
|
'dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400',
|
||||||
|
'dark:hover:bg-gray-600 dark:hover:text-gray-300',
|
||||||
|
'transition-colors duration-150'
|
||||||
|
)}
|
||||||
|
aria-label="Buscar"
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
<span className="hidden text-sm sm:inline">Buscar...</span>
|
||||||
|
<kbd className="hidden rounded border border-gray-300 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-500 sm:inline dark:border-gray-500 dark:bg-gray-600 dark:text-gray-400">
|
||||||
|
{isMac ? '⌘' : 'Ctrl'}+K
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigation: NavigationItem[] = [
|
||||||
{ name: 'Dashboard', href: '/dashboard', icon: Home },
|
{ name: 'Dashboard', href: '/dashboard', icon: Home },
|
||||||
{ name: 'Usuarios', href: '/users', icon: Users },
|
{ name: 'Usuarios', href: '/users', icon: Users, module: 'users' },
|
||||||
{ name: 'Empresas', href: '/companies', icon: Building2 },
|
{ name: 'Empresas', href: '/companies', icon: Building2, module: 'companies' },
|
||||||
{ name: 'Partners', href: '/partners', icon: Users2 },
|
{ name: 'Partners', href: '/partners', icon: Users2, module: 'partners' },
|
||||||
{ name: 'Inventario', href: '/inventory', icon: Package },
|
{ name: 'Inventario', href: '/inventory', icon: Package, module: 'inventory' },
|
||||||
{ name: 'Ventas', href: '/sales', icon: ShoppingCart },
|
{ name: 'Ventas', href: '/sales', icon: ShoppingCart, module: 'sales' },
|
||||||
{ name: 'Compras', href: '/purchases', icon: ShoppingCart },
|
{ name: 'Compras', href: '/purchases', icon: ShoppingCart, module: 'purchases' },
|
||||||
{ name: 'Finanzas', href: '/financial', icon: Receipt },
|
{ name: 'Finanzas', href: '/financial', icon: Receipt, module: 'finance' },
|
||||||
{ name: 'Proyectos', href: '/projects', icon: FolderKanban },
|
{ name: 'Proyectos', href: '/projects', icon: FolderKanban, module: 'projects' },
|
||||||
{ name: 'CRM', href: '/crm', icon: UserCircle },
|
{ name: 'CRM', href: '/crm', icon: UserCircle, module: 'crm' },
|
||||||
{ name: 'RRHH', href: '/hr', icon: Users },
|
{ name: 'RRHH', href: '/hr', icon: Users, module: 'hr' },
|
||||||
{ name: 'Configuración', href: '/settings', icon: Settings },
|
{ name: 'Configuración', href: '/settings', icon: Settings, roles: ['admin', 'super_admin'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
/**
|
||||||
|
* Internal layout component (used inside CommandPaletteWithRouter)
|
||||||
|
*/
|
||||||
|
function DashboardLayoutInner({ children }: DashboardLayoutProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { sidebarOpen, sidebarCollapsed, toggleSidebar, setSidebarOpen, setIsMobile } = useUIStore();
|
const { sidebarOpen, sidebarCollapsed, toggleSidebar, setSidebarOpen, setIsMobile } = useUIStore();
|
||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
|
const { items: filteredNavigation, isLoading: isNavigationLoading } = useFilteredNavigation(navigation);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsMobile(isMobile);
|
setIsMobile(isMobile);
|
||||||
@ -59,11 +96,11 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
}, [location.pathname, isMobile, setSidebarOpen]);
|
}, [location.pathname, isMobile, setSidebarOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
{/* Mobile sidebar backdrop */}
|
{/* Mobile sidebar backdrop */}
|
||||||
{isMobile && sidebarOpen && (
|
{isMobile && sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 bg-gray-600/75"
|
className="fixed inset-0 z-40 bg-gray-600/75 dark:bg-gray-900/80"
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -71,7 +108,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-y-0 left-0 z-50 flex flex-col bg-white shadow-lg transition-all duration-300',
|
'fixed inset-y-0 left-0 z-50 flex flex-col bg-white shadow-lg transition-all duration-300 dark:bg-gray-800 dark:shadow-gray-900/50',
|
||||||
isMobile
|
isMobile
|
||||||
? sidebarOpen
|
? sidebarOpen
|
||||||
? 'translate-x-0'
|
? 'translate-x-0'
|
||||||
@ -82,17 +119,17 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex h-16 items-center justify-between border-b px-4">
|
<div className="flex h-16 items-center justify-between border-b px-4 dark:border-gray-700">
|
||||||
{(!sidebarCollapsed || isMobile) && (
|
{(!sidebarCollapsed || isMobile) && (
|
||||||
<Link to="/dashboard" className="flex items-center">
|
<Link to="/dashboard" className="flex items-center">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600">
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600">
|
||||||
<span className="text-lg font-bold text-white">E</span>
|
<span className="text-lg font-bold text-white">E</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-2 text-lg font-bold text-gray-900">ERP</span>
|
<span className="ml-2 text-lg font-bold text-gray-900 dark:text-white">ERP</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<button onClick={() => setSidebarOpen(false)} className="p-2">
|
<button onClick={() => setSidebarOpen(false)} className="p-2 dark:text-gray-400" aria-label="Cerrar menú">
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -100,45 +137,53 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 space-y-1 overflow-y-auto p-2">
|
<nav className="flex-1 space-y-1 overflow-y-auto p-2">
|
||||||
{navigation.map((item) => {
|
{isNavigationLoading ? (
|
||||||
const isActive = location.pathname.startsWith(item.href);
|
<div className="flex items-center justify-center py-4">
|
||||||
return (
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary-600 border-t-transparent" />
|
||||||
<Link
|
</div>
|
||||||
key={item.name}
|
) : (
|
||||||
to={item.href}
|
filteredNavigation.map((item) => {
|
||||||
className={cn(
|
const isActive = location.pathname.startsWith(item.href);
|
||||||
'flex items-center rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
const Icon = item.icon;
|
||||||
isActive
|
return (
|
||||||
? 'bg-primary-50 text-primary-700'
|
<Link
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
key={item.name}
|
||||||
)}
|
to={item.href}
|
||||||
>
|
className={cn(
|
||||||
<item.icon className={cn('h-5 w-5 flex-shrink-0', isActive ? 'text-primary-600' : 'text-gray-400')} />
|
'flex items-center rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||||
{(!sidebarCollapsed || isMobile) && (
|
isActive
|
||||||
<span className="ml-3">{item.name}</span>
|
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400'
|
||||||
)}
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||||
</Link>
|
)}
|
||||||
);
|
>
|
||||||
})}
|
{Icon && <Icon className={cn('h-5 w-5 flex-shrink-0', isActive ? 'text-primary-600 dark:text-primary-400' : 'text-gray-400 dark:text-gray-500')} />}
|
||||||
|
{(!sidebarCollapsed || isMobile) && (
|
||||||
|
<span className="ml-3">{item.name}</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User menu */}
|
{/* User menu */}
|
||||||
<div className="border-t p-4">
|
<div className="border-t p-4 dark:border-gray-700">
|
||||||
{(!sidebarCollapsed || isMobile) ? (
|
{(!sidebarCollapsed || isMobile) ? (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary-100 text-primary-700">
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">
|
||||||
{user?.firstName?.[0]}{user?.lastName?.[0]}
|
{user?.firstName?.[0]}{user?.lastName?.[0]}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 flex-1 overflow-hidden">
|
<div className="ml-3 flex-1 overflow-hidden">
|
||||||
<p className="truncate text-sm font-medium text-gray-900">
|
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||||
{user?.firstName} {user?.lastName}
|
{user?.firstName} {user?.lastName}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-xs text-gray-500">{user?.email}</p>
|
<p className="truncate text-xs text-gray-500 dark:text-gray-400">{user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="p-2 text-gray-400 hover:text-gray-600"
|
className="p-2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||||
title="Cerrar sesión"
|
title="Cerrar sesión"
|
||||||
|
aria-label="Cerrar sesión"
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@ -146,8 +191,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="flex w-full items-center justify-center p-2 text-gray-400 hover:text-gray-600"
|
className="flex w-full items-center justify-center p-2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||||
title="Cerrar sesión"
|
title="Cerrar sesión"
|
||||||
|
aria-label="Cerrar sesión"
|
||||||
>
|
>
|
||||||
<LogOut className="h-5 w-5" />
|
<LogOut className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
@ -163,23 +209,28 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b bg-white px-4 shadow-sm">
|
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b bg-white px-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
<button
|
<div className="flex items-center gap-4">
|
||||||
onClick={toggleSidebar}
|
<button
|
||||||
className="rounded-lg p-2 hover:bg-gray-100"
|
onClick={toggleSidebar}
|
||||||
>
|
className="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
<Menu className="h-5 w-5" />
|
aria-label="Abrir menú"
|
||||||
</button>
|
>
|
||||||
|
<Menu className="h-5 w-5 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
<SearchButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<button className="relative rounded-lg p-2 hover:bg-gray-100">
|
<ThemeToggle />
|
||||||
<Bell className="h-5 w-5 text-gray-500" />
|
<button className="relative rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700" aria-label="Notificaciones">
|
||||||
|
<Bell className="h-5 w-5 text-gray-500 dark:text-gray-400" />
|
||||||
<span className="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-danger-500 text-xs text-white">
|
<span className="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-danger-500 text-xs text-white">
|
||||||
3
|
3
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 text-sm font-medium text-primary-700">
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 text-sm font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">
|
||||||
{user?.firstName?.[0]}{user?.lastName?.[0]}
|
{user?.firstName?.[0]}{user?.lastName?.[0]}
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||||
@ -193,3 +244,15 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard Layout with Command Palette support
|
||||||
|
* Wraps the layout with CommandPaletteWithRouter for Cmd+K / Ctrl+K navigation
|
||||||
|
*/
|
||||||
|
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||||
|
return (
|
||||||
|
<CommandPaletteWithRouter>
|
||||||
|
<DashboardLayoutInner>{children}</DashboardLayoutInner>
|
||||||
|
</CommandPaletteWithRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { ToastContainer } from '@components/organisms/Toast';
|
import { ToastContainer } from '@components/organisms/Toast';
|
||||||
|
import { ThemeProvider } from '@shared/providers/ThemeProvider';
|
||||||
|
|
||||||
interface AppProvidersProps {
|
interface AppProvidersProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@ -7,9 +8,9 @@ interface AppProvidersProps {
|
|||||||
|
|
||||||
export function AppProviders({ children }: AppProvidersProps) {
|
export function AppProviders({ children }: AppProvidersProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<ThemeProvider>
|
||||||
{children}
|
{children}
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
362
src/index.css
362
src/index.css
@ -2,39 +2,240 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
CSS VARIABLES - Design Tokens para Runtime Theming
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
:root {
|
||||||
|
/* =========================================================================
|
||||||
|
RGB VALUES FOR TAILWIND ALPHA SUPPORT
|
||||||
|
Format: R G B (space-separated for rgb(var(--color) / alpha) syntax)
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
/* Primary Palette - ISEM Blue (RGB values) */
|
||||||
|
--color-primary-50: 230 243 255;
|
||||||
|
--color-primary-100: 204 231 255;
|
||||||
|
--color-primary-200: 153 207 255;
|
||||||
|
--color-primary-300: 102 183 255;
|
||||||
|
--color-primary-400: 51 159 255;
|
||||||
|
--color-primary-500: 0 97 168;
|
||||||
|
--color-primary-600: 0 77 134;
|
||||||
|
--color-primary-700: 0 58 101;
|
||||||
|
--color-primary-800: 0 38 67;
|
||||||
|
--color-primary-900: 0 19 34;
|
||||||
|
|
||||||
|
/* Secondary Palette - ISEM Green (RGB values) */
|
||||||
|
--color-secondary-50: 230 255 245;
|
||||||
|
--color-secondary-100: 204 255 235;
|
||||||
|
--color-secondary-200: 153 255 214;
|
||||||
|
--color-secondary-300: 102 255 194;
|
||||||
|
--color-secondary-400: 51 255 173;
|
||||||
|
--color-secondary-500: 0 168 104;
|
||||||
|
--color-secondary-600: 0 134 83;
|
||||||
|
--color-secondary-700: 0 101 63;
|
||||||
|
--color-secondary-800: 0 67 42;
|
||||||
|
--color-secondary-900: 0 34 21;
|
||||||
|
|
||||||
|
/* Success Palette (RGB values) */
|
||||||
|
--color-success-50: 209 231 221;
|
||||||
|
--color-success-100: 209 231 221;
|
||||||
|
--color-success-500: 25 135 84;
|
||||||
|
--color-success-600: 21 115 71;
|
||||||
|
--color-success-700: 15 81 50;
|
||||||
|
|
||||||
|
/* Warning Palette (RGB values) */
|
||||||
|
--color-warning-50: 255 243 205;
|
||||||
|
--color-warning-100: 255 243 205;
|
||||||
|
--color-warning-500: 255 193 7;
|
||||||
|
--color-warning-600: 224 168 0;
|
||||||
|
--color-warning-700: 102 77 3;
|
||||||
|
|
||||||
|
/* Danger Palette (RGB values) */
|
||||||
|
--color-danger-50: 248 215 218;
|
||||||
|
--color-danger-100: 248 215 218;
|
||||||
|
--color-danger-500: 220 53 69;
|
||||||
|
--color-danger-600: 187 45 59;
|
||||||
|
--color-danger-700: 132 32 41;
|
||||||
|
|
||||||
|
/* Info Palette (RGB values) */
|
||||||
|
--color-info-50: 207 244 252;
|
||||||
|
--color-info-100: 207 244 252;
|
||||||
|
--color-info-500: 13 202 240;
|
||||||
|
--color-info-600: 10 162 192;
|
||||||
|
--color-info-700: 5 81 96;
|
||||||
|
|
||||||
|
/* Background Colors (RGB values) - Light Theme */
|
||||||
|
--color-background: 255 255 255;
|
||||||
|
--color-background-subtle: 248 249 251;
|
||||||
|
--color-background-muted: 241 243 245;
|
||||||
|
--color-background-emphasis: 233 236 239;
|
||||||
|
|
||||||
|
/* Foreground Colors (RGB values) - Light Theme */
|
||||||
|
--color-foreground: 33 37 41;
|
||||||
|
--color-foreground-muted: 108 117 125;
|
||||||
|
--color-foreground-subtle: 173 181 189;
|
||||||
|
|
||||||
|
/* Border Colors (RGB values) - Light Theme */
|
||||||
|
--color-border: 222 226 230;
|
||||||
|
--color-border-subtle: 233 236 239;
|
||||||
|
--color-border-emphasis: 206 212 218;
|
||||||
|
|
||||||
|
/* Surface Colors (RGB values) - Light Theme */
|
||||||
|
--color-surface: 249 250 251;
|
||||||
|
--color-surface-hover: 243 244 246;
|
||||||
|
--color-surface-card: 255 255 255;
|
||||||
|
--color-surface-popover: 255 255 255;
|
||||||
|
--color-surface-modal: 255 255 255;
|
||||||
|
--color-surface-dropdown: 255 255 255;
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
LEGACY HEX VALUES (Backward Compatibility)
|
||||||
|
Keep these for direct CSS usage and gradual migration
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
/* Colores de Marca ISEM (HEX) */
|
||||||
|
--color-brand-primary: #0061A8;
|
||||||
|
--color-brand-secondary: #00A868;
|
||||||
|
|
||||||
|
/* Colores Semanticos (HEX) */
|
||||||
|
--color-success-hex: #198754;
|
||||||
|
--color-success-light-hex: #D1E7DD;
|
||||||
|
--color-success-dark-hex: #0F5132;
|
||||||
|
--color-warning-hex: #FFC107;
|
||||||
|
--color-warning-light-hex: #FFF3CD;
|
||||||
|
--color-warning-dark-hex: #664D03;
|
||||||
|
--color-danger-hex: #DC3545;
|
||||||
|
--color-danger-light-hex: #F8D7DA;
|
||||||
|
--color-danger-dark-hex: #842029;
|
||||||
|
--color-info-hex: #0DCAF0;
|
||||||
|
--color-info-light-hex: #CFF4FC;
|
||||||
|
--color-info-dark-hex: #055160;
|
||||||
|
|
||||||
|
/* Semantic Aliases (HEX for legacy components) */
|
||||||
|
--color-success: #198754;
|
||||||
|
--color-success-light: #D1E7DD;
|
||||||
|
--color-success-dark: #0F5132;
|
||||||
|
--color-warning: #FFC107;
|
||||||
|
--color-warning-light: #FFF3CD;
|
||||||
|
--color-warning-dark: #664D03;
|
||||||
|
--color-danger: #DC3545;
|
||||||
|
--color-danger-light: #F8D7DA;
|
||||||
|
--color-danger-dark: #842029;
|
||||||
|
--color-info: #0DCAF0;
|
||||||
|
--color-info-light: #CFF4FC;
|
||||||
|
--color-info-dark: #055160;
|
||||||
|
|
||||||
|
/* Primary/Secondary HEX aliases */
|
||||||
|
--color-primary-hex: var(--color-brand-primary);
|
||||||
|
--color-secondary-hex: var(--color-brand-secondary);
|
||||||
|
|
||||||
|
/* Sombras Tema Claro */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
--shadow-default: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||||
|
|
||||||
|
/* Tipografia */
|
||||||
|
--font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace;
|
||||||
|
|
||||||
|
/* Animaciones */
|
||||||
|
--duration-fast: 150ms;
|
||||||
|
--duration-normal: 200ms;
|
||||||
|
--duration-slow: 300ms;
|
||||||
|
--easing-default: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
/* Bordes */
|
||||||
|
--radius-sm: 0.125rem;
|
||||||
|
--radius-default: 0.25rem;
|
||||||
|
--radius-md: 0.375rem;
|
||||||
|
--radius-lg: 0.5rem;
|
||||||
|
--radius-xl: 0.75rem;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tema Oscuro */
|
||||||
|
.dark {
|
||||||
|
/* Background Colors (RGB values) - Dark Theme */
|
||||||
|
--color-background: 27 30 35;
|
||||||
|
--color-background-subtle: 33 37 41;
|
||||||
|
--color-background-muted: 45 49 57;
|
||||||
|
--color-background-emphasis: 52 58 64;
|
||||||
|
|
||||||
|
/* Foreground Colors (RGB values) - Dark Theme */
|
||||||
|
--color-foreground: 236 236 236;
|
||||||
|
--color-foreground-muted: 160 160 160;
|
||||||
|
--color-foreground-subtle: 108 117 125;
|
||||||
|
|
||||||
|
/* Border Colors (RGB values) - Dark Theme */
|
||||||
|
--color-border: 73 80 87;
|
||||||
|
--color-border-subtle: 52 58 64;
|
||||||
|
--color-border-emphasis: 108 117 125;
|
||||||
|
|
||||||
|
/* Surface Colors (RGB values) - Dark Theme */
|
||||||
|
--color-surface: 31 41 55;
|
||||||
|
--color-surface-hover: 55 65 81;
|
||||||
|
--color-surface-card: 45 49 57;
|
||||||
|
--color-surface-popover: 52 58 64;
|
||||||
|
--color-surface-modal: 45 49 57;
|
||||||
|
--color-surface-dropdown: 52 58 64;
|
||||||
|
|
||||||
|
/* Sombras Tema Oscuro */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
||||||
|
--shadow-default: 0 1px 3px 0 rgb(0 0 0 / 0.4), 0 1px 2px -1px rgb(0 0 0 / 0.4);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.4);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.4);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.4), 0 8px 10px -6px rgb(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@apply antialiased;
|
@apply antialiased;
|
||||||
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-gray-50 text-gray-900;
|
background-color: rgb(var(--color-background));
|
||||||
|
color: rgb(var(--color-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@apply border-gray-200;
|
border-color: rgb(var(--color-border));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
COMPONENTES BASE
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
/* Botones */
|
||||||
.btn {
|
.btn {
|
||||||
@apply inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50;
|
@apply inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50;
|
||||||
|
transition-duration: var(--duration-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply bg-primary-600 text-white hover:bg-primary-700 focus-visible:ring-primary-500;
|
@apply bg-primary-500 text-white hover:bg-primary-600 focus-visible:ring-primary-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@apply bg-secondary-100 text-secondary-900 hover:bg-secondary-200 focus-visible:ring-secondary-500;
|
@apply bg-secondary-500 text-white hover:bg-secondary-600 focus-visible:ring-secondary-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline {
|
.btn-outline {
|
||||||
@apply border border-gray-300 bg-white hover:bg-gray-50 focus-visible:ring-gray-500;
|
@apply border border-border-emphasis bg-transparent hover:bg-background-muted focus-visible:ring-primary-500;
|
||||||
|
color: rgb(var(--color-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
@apply bg-transparent hover:bg-background-muted focus-visible:ring-primary-500;
|
||||||
|
color: rgb(var(--color-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
@apply bg-danger-600 text-white hover:bg-danger-700 focus-visible:ring-danger-500;
|
@apply bg-danger-500 text-white hover:bg-danger-600 focus-visible:ring-danger-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
@ -49,31 +250,125 @@
|
|||||||
@apply h-12 px-6 text-lg;
|
@apply h-12 px-6 text-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
.input {
|
.input {
|
||||||
@apply block w-full rounded-md border border-gray-300 px-3 py-2 text-sm placeholder-gray-400 shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500;
|
@apply block w-full rounded-md border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
|
background-color: rgb(var(--color-background));
|
||||||
|
border-color: rgb(var(--color-border));
|
||||||
|
color: rgb(var(--color-foreground));
|
||||||
|
transition-duration: var(--duration-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: rgb(var(--color-foreground-subtle));
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: rgb(var(--color-primary-500));
|
||||||
|
--tw-ring-color: rgb(var(--color-primary-500));
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-error {
|
.input-error {
|
||||||
@apply border-danger-500 focus:border-danger-500 focus:ring-danger-500;
|
@apply border-danger-500 focus:border-danger-500 focus:ring-danger-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Labels */
|
||||||
.label {
|
.label {
|
||||||
@apply block text-sm font-medium text-gray-700;
|
@apply block text-sm font-medium;
|
||||||
|
color: rgb(var(--color-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
.card {
|
.card {
|
||||||
@apply rounded-lg border bg-white shadow-sm;
|
@apply rounded-lg border;
|
||||||
|
background-color: rgb(var(--color-surface-card));
|
||||||
|
border-color: rgb(var(--color-border));
|
||||||
|
box-shadow: var(--shadow-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
.link {
|
.link {
|
||||||
@apply text-primary-600 hover:text-primary-700 hover:underline;
|
@apply hover:underline;
|
||||||
|
color: rgb(var(--color-primary-500));
|
||||||
|
transition-duration: var(--duration-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link:hover {
|
||||||
|
color: rgb(var(--color-primary-600));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
@apply inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-primary {
|
||||||
|
@apply bg-primary-100 text-primary-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-secondary {
|
||||||
|
@apply bg-secondary-100 text-secondary-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background-color: var(--color-success-light);
|
||||||
|
color: var(--color-success-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background-color: var(--color-warning-light);
|
||||||
|
color: var(--color-warning-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
background-color: var(--color-danger-light);
|
||||||
|
color: var(--color-danger-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
background-color: var(--color-info-light);
|
||||||
|
color: var(--color-info-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alerts */
|
||||||
|
.alert {
|
||||||
|
@apply rounded-lg border p-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background-color: var(--color-success-light);
|
||||||
|
border-color: var(--color-success);
|
||||||
|
color: var(--color-success-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-warning {
|
||||||
|
background-color: var(--color-warning-light);
|
||||||
|
border-color: var(--color-warning);
|
||||||
|
color: var(--color-warning-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background-color: var(--color-danger-light);
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
color: var(--color-danger-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info {
|
||||||
|
background-color: var(--color-info-light);
|
||||||
|
border-color: var(--color-info);
|
||||||
|
color: var(--color-info-dark);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
UTILIDADES
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
/* Scrollbar personalizado */
|
||||||
.scrollbar-thin {
|
.scrollbar-thin {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: theme('colors.gray.300') transparent;
|
scrollbar-color: rgb(var(--color-border)) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar {
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
@ -86,7 +381,50 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
background-color: theme('colors.gray.300');
|
background-color: rgb(var(--color-border));
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Focus visible mejorado */
|
||||||
|
.focus-ring {
|
||||||
|
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2;
|
||||||
|
--tw-ring-color: rgb(var(--color-primary-500));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Texto truncado */
|
||||||
|
.truncate-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truncate-3 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transiciones suaves */
|
||||||
|
.transition-theme {
|
||||||
|
transition-property: background-color, border-color, color, fill, stroke;
|
||||||
|
transition-duration: var(--duration-normal);
|
||||||
|
transition-timing-function: var(--easing-default);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
MULTI-TENANT THEMING (Placeholder para override por tenant)
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Los tenants pueden sobreescribir estas variables via JS:
|
||||||
|
* document.documentElement.style.setProperty('--color-brand-primary', '#FF0000');
|
||||||
|
*
|
||||||
|
* O cargar un CSS adicional con:
|
||||||
|
* [data-tenant="empresa-abc"] {
|
||||||
|
* --color-brand-primary: #123456;
|
||||||
|
* --color-brand-secondary: #654321;
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|||||||
@ -0,0 +1,169 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Globe, Check } from 'lucide-react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { useTranslation } from '@shared/i18n/useTranslation';
|
||||||
|
import type { Language } from '@shared/i18n';
|
||||||
|
|
||||||
|
interface LanguageOption {
|
||||||
|
value: Language;
|
||||||
|
label: string;
|
||||||
|
flag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultLanguageOption: LanguageOption = { value: 'es', label: 'Espanol', flag: '🇪🇸' };
|
||||||
|
|
||||||
|
const languageOptions: LanguageOption[] = [
|
||||||
|
defaultLanguageOption,
|
||||||
|
{ value: 'en', label: 'English', flag: '🇺🇸' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface LanguageSelectorProps {
|
||||||
|
/** Additional CSS classes */
|
||||||
|
className?: string;
|
||||||
|
/** Show flag icons */
|
||||||
|
showFlags?: boolean;
|
||||||
|
/** Compact mode (icon only) */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguageSelector({
|
||||||
|
className,
|
||||||
|
showFlags = true,
|
||||||
|
compact = true,
|
||||||
|
}: LanguageSelectorProps) {
|
||||||
|
const { language, changeLanguage, t } = useTranslation();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [position, setPosition] = useState({ top: 0, right: 0 });
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const currentOption = languageOptions.find((opt) => opt.value === language) ?? defaultLanguageOption;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && triggerRef.current) {
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
|
setPosition({
|
||||||
|
top: rect.bottom + 4,
|
||||||
|
right: window.innerWidth - rect.right,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
menuRef.current &&
|
||||||
|
!menuRef.current.contains(event.target as Node) &&
|
||||||
|
triggerRef.current &&
|
||||||
|
!triggerRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleSelect = (value: Language) => {
|
||||||
|
changeLanguage(value);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const menu = (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
ref={menuRef}
|
||||||
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="fixed z-50 min-w-[160px] rounded-lg border bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
style={{
|
||||||
|
top: position.top,
|
||||||
|
right: position.right,
|
||||||
|
}}
|
||||||
|
role="listbox"
|
||||||
|
aria-label={t('language.select')}
|
||||||
|
>
|
||||||
|
{languageOptions.map((option) => {
|
||||||
|
const isSelected = language === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
onClick={() => handleSelect(option.value)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{showFlags && <span className="text-base">{option.flag}</span>}
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</span>
|
||||||
|
{isSelected && <Check className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-label={t('language.select')}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
title={t('language.select')}
|
||||||
|
>
|
||||||
|
{compact ? (
|
||||||
|
<>
|
||||||
|
{showFlags ? (
|
||||||
|
<span className="text-lg">{currentOption.flag}</span>
|
||||||
|
) : (
|
||||||
|
<Globe className="h-5 w-5 text-gray-500 dark:text-gray-400" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{showFlags && <span className="text-lg">{currentOption.flag}</span>}
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">{currentOption.label}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{createPortal(menu, document.body)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LanguageSelector;
|
||||||
1
src/shared/components/atoms/LanguageSelector/index.ts
Normal file
1
src/shared/components/atoms/LanguageSelector/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './LanguageSelector';
|
||||||
47
src/shared/components/atoms/Skeleton/Skeleton.tsx
Normal file
47
src/shared/components/atoms/Skeleton/Skeleton.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
|
||||||
|
export interface SkeletonProps {
|
||||||
|
width?: string | number;
|
||||||
|
height?: string | number;
|
||||||
|
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full';
|
||||||
|
animate?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundedClasses = {
|
||||||
|
none: 'rounded-none',
|
||||||
|
sm: 'rounded-sm',
|
||||||
|
md: 'rounded-md',
|
||||||
|
lg: 'rounded-lg',
|
||||||
|
full: 'rounded-full',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Skeleton({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rounded = 'md',
|
||||||
|
animate = true,
|
||||||
|
className,
|
||||||
|
}: SkeletonProps) {
|
||||||
|
const style: React.CSSProperties = {};
|
||||||
|
|
||||||
|
if (width !== undefined) {
|
||||||
|
style.width = typeof width === 'number' ? `${width}px` : width;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (height !== undefined) {
|
||||||
|
style.height = typeof height === 'number' ? `${height}px` : height;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-gray-200 dark:bg-gray-700',
|
||||||
|
roundedClasses[rounded],
|
||||||
|
animate && 'animate-pulse',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/shared/components/atoms/Skeleton/SkeletonAvatar.tsx
Normal file
74
src/shared/components/atoms/Skeleton/SkeletonAvatar.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { Skeleton } from './Skeleton';
|
||||||
|
|
||||||
|
export interface SkeletonAvatarProps {
|
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||||
|
animate?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
xs: 'h-6 w-6',
|
||||||
|
sm: 'h-8 w-8',
|
||||||
|
md: 'h-10 w-10',
|
||||||
|
lg: 'h-12 w-12',
|
||||||
|
xl: 'h-16 w-16',
|
||||||
|
'2xl': 'h-20 w-20',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SkeletonAvatar({
|
||||||
|
size = 'md',
|
||||||
|
animate = true,
|
||||||
|
className,
|
||||||
|
}: SkeletonAvatarProps) {
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
rounded="full"
|
||||||
|
animate={animate}
|
||||||
|
className={cn(sizeClasses[size], className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avatar with text skeleton (common pattern)
|
||||||
|
export interface SkeletonAvatarWithTextProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
showSubtitle?: boolean;
|
||||||
|
animate?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonAvatarWithText({
|
||||||
|
size = 'md',
|
||||||
|
showSubtitle = true,
|
||||||
|
animate = true,
|
||||||
|
className,
|
||||||
|
}: SkeletonAvatarWithTextProps) {
|
||||||
|
const textSizes = {
|
||||||
|
sm: { title: '0.875rem', subtitle: '0.75rem' },
|
||||||
|
md: { title: '1rem', subtitle: '0.875rem' },
|
||||||
|
lg: { title: '1.125rem', subtitle: '1rem' },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-3', className)}>
|
||||||
|
<SkeletonAvatar size={size} animate={animate} />
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<Skeleton
|
||||||
|
width="60%"
|
||||||
|
height={textSizes[size].title}
|
||||||
|
rounded="sm"
|
||||||
|
animate={animate}
|
||||||
|
/>
|
||||||
|
{showSubtitle && (
|
||||||
|
<Skeleton
|
||||||
|
width="40%"
|
||||||
|
height={textSizes[size].subtitle}
|
||||||
|
rounded="sm"
|
||||||
|
animate={animate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/shared/components/atoms/Skeleton/SkeletonCard.tsx
Normal file
77
src/shared/components/atoms/Skeleton/SkeletonCard.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { Skeleton } from './Skeleton';
|
||||||
|
import { SkeletonText } from './SkeletonText';
|
||||||
|
|
||||||
|
export interface SkeletonCardProps {
|
||||||
|
showImage?: boolean;
|
||||||
|
imageHeight?: string | number;
|
||||||
|
showTitle?: boolean;
|
||||||
|
showDescription?: boolean;
|
||||||
|
descriptionLines?: number;
|
||||||
|
showFooter?: boolean;
|
||||||
|
animate?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonCard({
|
||||||
|
showImage = true,
|
||||||
|
imageHeight = 160,
|
||||||
|
showTitle = true,
|
||||||
|
showDescription = true,
|
||||||
|
descriptionLines = 2,
|
||||||
|
showFooter = false,
|
||||||
|
animate = true,
|
||||||
|
className,
|
||||||
|
}: SkeletonCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showImage && (
|
||||||
|
<Skeleton
|
||||||
|
width="100%"
|
||||||
|
height={imageHeight}
|
||||||
|
rounded="none"
|
||||||
|
animate={animate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{showTitle && (
|
||||||
|
<Skeleton
|
||||||
|
width="70%"
|
||||||
|
height="1.25rem"
|
||||||
|
rounded="sm"
|
||||||
|
animate={animate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showDescription && (
|
||||||
|
<SkeletonText
|
||||||
|
lines={descriptionLines}
|
||||||
|
lastLineWidth="80%"
|
||||||
|
lineHeight="0.875rem"
|
||||||
|
animate={animate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showFooter && (
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<Skeleton
|
||||||
|
width="5rem"
|
||||||
|
height="2rem"
|
||||||
|
rounded="md"
|
||||||
|
animate={animate}
|
||||||
|
/>
|
||||||
|
<Skeleton
|
||||||
|
width="5rem"
|
||||||
|
height="2rem"
|
||||||
|
rounded="md"
|
||||||
|
animate={animate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/shared/components/atoms/Skeleton/SkeletonTable.tsx
Normal file
116
src/shared/components/atoms/Skeleton/SkeletonTable.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { Skeleton } from './Skeleton';
|
||||||
|
|
||||||
|
export interface SkeletonTableProps {
|
||||||
|
rows?: number;
|
||||||
|
columns?: number;
|
||||||
|
showHeader?: boolean;
|
||||||
|
animate?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonTable({
|
||||||
|
rows = 5,
|
||||||
|
columns = 4,
|
||||||
|
showHeader = true,
|
||||||
|
animate = true,
|
||||||
|
className,
|
||||||
|
}: SkeletonTableProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<table className="w-full">
|
||||||
|
{showHeader && (
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||||
|
<th key={colIndex} className="px-4 py-3">
|
||||||
|
<Skeleton
|
||||||
|
width="80%"
|
||||||
|
height="0.875rem"
|
||||||
|
rounded="sm"
|
||||||
|
animate={animate}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
)}
|
||||||
|
<tbody className="bg-white dark:bg-gray-900">
|
||||||
|
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||||
|
<tr
|
||||||
|
key={rowIndex}
|
||||||
|
className="border-t border-gray-200 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||||
|
<td key={colIndex} className="px-4 py-3">
|
||||||
|
<Skeleton
|
||||||
|
width={colIndex === 0 ? '90%' : '70%'}
|
||||||
|
height="1rem"
|
||||||
|
rounded="sm"
|
||||||
|
animate={animate}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternative: Simple row-based skeleton for lists
|
||||||
|
export interface SkeletonTableRowsProps {
|
||||||
|
rows?: number;
|
||||||
|
animate?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonTableRows({
|
||||||
|
rows = 5,
|
||||||
|
animate = true,
|
||||||
|
className,
|
||||||
|
}: SkeletonTableRowsProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-2', className)}>
|
||||||
|
{Array.from({ length: rows }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-4 p-3 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<Skeleton
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
rounded="full"
|
||||||
|
animate={animate}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton
|
||||||
|
width="60%"
|
||||||
|
height="1rem"
|
||||||
|
rounded="sm"
|
||||||
|
animate={animate}
|
||||||
|
/>
|
||||||
|
<Skeleton
|
||||||
|
width="40%"
|
||||||
|
height="0.75rem"
|
||||||
|
rounded="sm"
|
||||||
|
animate={animate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Skeleton
|
||||||
|
width={80}
|
||||||
|
height="2rem"
|
||||||
|
rounded="md"
|
||||||
|
animate={animate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/shared/components/atoms/Skeleton/SkeletonText.tsx
Normal file
37
src/shared/components/atoms/Skeleton/SkeletonText.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { Skeleton } from './Skeleton';
|
||||||
|
|
||||||
|
export interface SkeletonTextProps {
|
||||||
|
lines?: number;
|
||||||
|
lastLineWidth?: string;
|
||||||
|
lineHeight?: string;
|
||||||
|
gap?: string;
|
||||||
|
animate?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonText({
|
||||||
|
lines = 3,
|
||||||
|
lastLineWidth = '60%',
|
||||||
|
lineHeight = '1rem',
|
||||||
|
gap = '0.75rem',
|
||||||
|
animate = true,
|
||||||
|
className,
|
||||||
|
}: SkeletonTextProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col', className)} style={{ gap }}>
|
||||||
|
{Array.from({ length: lines }).map((_, index) => {
|
||||||
|
const isLastLine = index === lines - 1;
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
key={index}
|
||||||
|
width={isLastLine ? lastLineWidth : '100%'}
|
||||||
|
height={lineHeight}
|
||||||
|
rounded="sm"
|
||||||
|
animate={animate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/shared/components/atoms/Skeleton/index.ts
Normal file
5
src/shared/components/atoms/Skeleton/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './Skeleton';
|
||||||
|
export * from './SkeletonText';
|
||||||
|
export * from './SkeletonCard';
|
||||||
|
export * from './SkeletonTable';
|
||||||
|
export * from './SkeletonAvatar';
|
||||||
134
src/shared/components/atoms/Switch/Switch.tsx
Normal file
134
src/shared/components/atoms/Switch/Switch.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { forwardRef, type InputHTMLAttributes } from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
|
||||||
|
const switchVariants = cva(
|
||||||
|
'relative inline-flex shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: 'h-4 w-7',
|
||||||
|
md: 'h-5 w-9',
|
||||||
|
lg: 'h-6 w-11',
|
||||||
|
},
|
||||||
|
checked: {
|
||||||
|
true: 'bg-primary-600',
|
||||||
|
false: 'bg-gray-200',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'md',
|
||||||
|
checked: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const thumbVariants = cva(
|
||||||
|
'pointer-events-none inline-block transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
sm: 'h-3 w-3',
|
||||||
|
md: 'h-4 w-4',
|
||||||
|
lg: 'h-5 w-5',
|
||||||
|
},
|
||||||
|
checked: {
|
||||||
|
true: '',
|
||||||
|
false: 'translate-x-0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compoundVariants: [
|
||||||
|
{ size: 'sm', checked: true, className: 'translate-x-3' },
|
||||||
|
{ size: 'md', checked: true, className: 'translate-x-4' },
|
||||||
|
{ size: 'lg', checked: true, className: 'translate-x-5' },
|
||||||
|
],
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'md',
|
||||||
|
checked: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface SwitchProps
|
||||||
|
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'onChange'>,
|
||||||
|
Omit<VariantProps<typeof switchVariants>, 'checked'> {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
size,
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const handleChange = () => {
|
||||||
|
if (!disabled) {
|
||||||
|
onChange(!checked);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleChange();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchElement = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={cn(switchVariants({ size, checked }), className)}
|
||||||
|
>
|
||||||
|
<span className={cn(thumbVariants({ size, checked }))} />
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="checkbox"
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => onChange(!checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="sr-only"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (label) {
|
||||||
|
return (
|
||||||
|
<label className="inline-flex cursor-pointer items-center gap-2">
|
||||||
|
{switchElement}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium text-gray-900',
|
||||||
|
disabled && 'opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return switchElement;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Switch.displayName = 'Switch';
|
||||||
1
src/shared/components/atoms/Switch/index.ts
Normal file
1
src/shared/components/atoms/Switch/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './Switch';
|
||||||
136
src/shared/components/atoms/ThemeToggle/ThemeToggle.tsx
Normal file
136
src/shared/components/atoms/ThemeToggle/ThemeToggle.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { useTheme } from '@hooks/useTheme';
|
||||||
|
import type { Theme } from '@shared/providers/ThemeProvider';
|
||||||
|
|
||||||
|
interface ThemeOption {
|
||||||
|
value: Theme;
|
||||||
|
label: string;
|
||||||
|
icon: typeof Sun;
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeOptions: ThemeOption[] = [
|
||||||
|
{ value: 'light', label: 'Claro', icon: Sun },
|
||||||
|
{ value: 'dark', label: 'Oscuro', icon: Moon },
|
||||||
|
{ value: 'system', label: 'Sistema', icon: Monitor },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface ThemeToggleProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeToggle({ className }: ThemeToggleProps) {
|
||||||
|
const { theme, resolvedTheme, setTheme } = useTheme();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [position, setPosition] = useState({ top: 0, right: 0 });
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const CurrentIcon = resolvedTheme === 'dark' ? Moon : Sun;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && triggerRef.current) {
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
|
setPosition({
|
||||||
|
top: rect.bottom + 4,
|
||||||
|
right: window.innerWidth - rect.right,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
menuRef.current &&
|
||||||
|
!menuRef.current.contains(event.target as Node) &&
|
||||||
|
triggerRef.current &&
|
||||||
|
!triggerRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleSelect = (value: Theme) => {
|
||||||
|
setTheme(value);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const menu = (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
ref={menuRef}
|
||||||
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="fixed z-50 min-w-[140px] rounded-lg border bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
style={{
|
||||||
|
top: position.top,
|
||||||
|
right: position.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{themeOptions.map((option) => {
|
||||||
|
const Icon = option.icon;
|
||||||
|
const isSelected = theme === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(option.value)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-label="Cambiar tema"
|
||||||
|
title="Cambiar tema"
|
||||||
|
>
|
||||||
|
<CurrentIcon className="h-5 w-5 text-gray-500 dark:text-gray-400" />
|
||||||
|
</button>
|
||||||
|
{createPortal(menu, document.body)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/shared/components/atoms/ThemeToggle/index.ts
Normal file
1
src/shared/components/atoms/ThemeToggle/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './ThemeToggle';
|
||||||
@ -5,3 +5,6 @@ export * from './Badge';
|
|||||||
export * from './Spinner';
|
export * from './Spinner';
|
||||||
export * from './Avatar';
|
export * from './Avatar';
|
||||||
export * from './Tooltip';
|
export * from './Tooltip';
|
||||||
|
export * from './ThemeToggle';
|
||||||
|
export * from './Skeleton';
|
||||||
|
export * from './LanguageSelector';
|
||||||
|
|||||||
123
src/shared/components/organisms/Calendar/Calendar.tsx
Normal file
123
src/shared/components/organisms/Calendar/Calendar.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { CalendarHeader } from './CalendarHeader';
|
||||||
|
import { CalendarGrid } from './CalendarGrid';
|
||||||
|
import type { CalendarProps, CalendarView } from './types';
|
||||||
|
|
||||||
|
export function Calendar({
|
||||||
|
events,
|
||||||
|
view: controlledView,
|
||||||
|
onViewChange,
|
||||||
|
onEventClick,
|
||||||
|
onDateClick,
|
||||||
|
onEventDrop: _onEventDrop,
|
||||||
|
selectedDate: controlledSelectedDate,
|
||||||
|
onSelectedDateChange,
|
||||||
|
locale = 'es-ES',
|
||||||
|
className,
|
||||||
|
}: CalendarProps) {
|
||||||
|
const [internalView, setInternalView] = useState<CalendarView>('month');
|
||||||
|
const [internalSelectedDate, setInternalSelectedDate] = useState<Date>(new Date());
|
||||||
|
const [currentDate, setCurrentDate] = useState<Date>(new Date());
|
||||||
|
|
||||||
|
const view = controlledView ?? internalView;
|
||||||
|
const selectedDate = controlledSelectedDate ?? internalSelectedDate;
|
||||||
|
|
||||||
|
const handleViewChange = useCallback(
|
||||||
|
(newView: CalendarView) => {
|
||||||
|
if (onViewChange) {
|
||||||
|
onViewChange(newView);
|
||||||
|
} else {
|
||||||
|
setInternalView(newView);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onViewChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectedDateChange = useCallback(
|
||||||
|
(date: Date) => {
|
||||||
|
if (onSelectedDateChange) {
|
||||||
|
onSelectedDateChange(date);
|
||||||
|
} else {
|
||||||
|
setInternalSelectedDate(date);
|
||||||
|
}
|
||||||
|
onDateClick?.(date);
|
||||||
|
},
|
||||||
|
[onSelectedDateChange, onDateClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePrevious = useCallback(() => {
|
||||||
|
setCurrentDate((prev) => {
|
||||||
|
const newDate = new Date(prev);
|
||||||
|
if (view === 'month') {
|
||||||
|
newDate.setMonth(newDate.getMonth() - 1);
|
||||||
|
} else if (view === 'week') {
|
||||||
|
newDate.setDate(newDate.getDate() - 7);
|
||||||
|
} else {
|
||||||
|
newDate.setDate(newDate.getDate() - 1);
|
||||||
|
}
|
||||||
|
return newDate;
|
||||||
|
});
|
||||||
|
}, [view]);
|
||||||
|
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
setCurrentDate((prev) => {
|
||||||
|
const newDate = new Date(prev);
|
||||||
|
if (view === 'month') {
|
||||||
|
newDate.setMonth(newDate.getMonth() + 1);
|
||||||
|
} else if (view === 'week') {
|
||||||
|
newDate.setDate(newDate.getDate() + 7);
|
||||||
|
} else {
|
||||||
|
newDate.setDate(newDate.getDate() + 1);
|
||||||
|
}
|
||||||
|
return newDate;
|
||||||
|
});
|
||||||
|
}, [view]);
|
||||||
|
|
||||||
|
const handleToday = useCallback(() => {
|
||||||
|
setCurrentDate(new Date());
|
||||||
|
handleSelectedDateChange(new Date());
|
||||||
|
}, [handleSelectedDateChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col overflow-hidden rounded-lg border bg-white',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarHeader
|
||||||
|
currentDate={currentDate}
|
||||||
|
view={view}
|
||||||
|
onViewChange={handleViewChange}
|
||||||
|
onPrevious={handlePrevious}
|
||||||
|
onNext={handleNext}
|
||||||
|
onToday={handleToday}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{view === 'month' && (
|
||||||
|
<CalendarGrid
|
||||||
|
currentDate={currentDate}
|
||||||
|
events={events}
|
||||||
|
selectedDate={selectedDate}
|
||||||
|
onDateClick={handleSelectedDateChange}
|
||||||
|
onEventClick={onEventClick}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'week' && (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-8 text-gray-500">
|
||||||
|
Vista semanal - En desarrollo
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'day' && (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-8 text-gray-500">
|
||||||
|
Vista diaria - En desarrollo
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/shared/components/organisms/Calendar/CalendarEvent.tsx
Normal file
32
src/shared/components/organisms/Calendar/CalendarEvent.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import type { CalendarEventProps } from './types';
|
||||||
|
|
||||||
|
const colorClasses = {
|
||||||
|
primary: 'bg-primary-100 text-primary-800 border-primary-200',
|
||||||
|
success: 'bg-success-100 text-success-800 border-success-200',
|
||||||
|
warning: 'bg-warning-100 text-warning-800 border-warning-200',
|
||||||
|
danger: 'bg-danger-100 text-danger-800 border-danger-200',
|
||||||
|
info: 'bg-info-100 text-info-800 border-info-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CalendarEvent({ event, onClick }: CalendarEventProps) {
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
'w-full truncate rounded border px-1.5 py-0.5 text-left text-xs font-medium',
|
||||||
|
'transition-colors hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-offset-1',
|
||||||
|
colorClasses[event.color || 'primary']
|
||||||
|
)}
|
||||||
|
title={event.title}
|
||||||
|
>
|
||||||
|
{event.title}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
src/shared/components/organisms/Calendar/CalendarGrid.tsx
Normal file
154
src/shared/components/organisms/Calendar/CalendarGrid.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { CalendarEvent } from './CalendarEvent';
|
||||||
|
import type { CalendarGridProps, DayCell, CalendarEvent as CalendarEventType } from './types';
|
||||||
|
|
||||||
|
const WEEKDAYS = ['Lun', 'Mar', 'Mie', 'Jue', 'Vie', 'Sab', 'Dom'];
|
||||||
|
const MAX_VISIBLE_EVENTS = 3;
|
||||||
|
|
||||||
|
function isSameDay(date1: Date, date2: Date): boolean {
|
||||||
|
return (
|
||||||
|
date1.getFullYear() === date2.getFullYear() &&
|
||||||
|
date1.getMonth() === date2.getMonth() &&
|
||||||
|
date1.getDate() === date2.getDate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonthDays(currentDate: Date, events: CalendarEventType[], selectedDate?: Date): DayCell[] {
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = currentDate.getMonth();
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const firstDayOfMonth = new Date(year, month, 1);
|
||||||
|
// lastDayOfMonth is not needed since we always render 42 cells (6 weeks)
|
||||||
|
void new Date(year, month + 1, 0);
|
||||||
|
|
||||||
|
let startDay = firstDayOfMonth.getDay() - 1;
|
||||||
|
if (startDay < 0) startDay = 6;
|
||||||
|
|
||||||
|
const startDate = new Date(firstDayOfMonth);
|
||||||
|
startDate.setDate(startDate.getDate() - startDay);
|
||||||
|
|
||||||
|
const days: DayCell[] = [];
|
||||||
|
const totalCells = 42;
|
||||||
|
|
||||||
|
for (let i = 0; i < totalCells; i++) {
|
||||||
|
const date = new Date(startDate);
|
||||||
|
date.setDate(startDate.getDate() + i);
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||||
|
|
||||||
|
const dayEvents = events.filter((event) => {
|
||||||
|
const eventStart = new Date(event.start);
|
||||||
|
eventStart.setHours(0, 0, 0, 0);
|
||||||
|
return isSameDay(eventStart, date);
|
||||||
|
});
|
||||||
|
|
||||||
|
days.push({
|
||||||
|
date,
|
||||||
|
isCurrentMonth: date.getMonth() === month,
|
||||||
|
isToday: isSameDay(date, today),
|
||||||
|
isSelected: selectedDate ? isSameDay(date, selectedDate) : false,
|
||||||
|
isWeekend,
|
||||||
|
events: dayEvents,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarGrid({
|
||||||
|
currentDate,
|
||||||
|
events,
|
||||||
|
selectedDate,
|
||||||
|
onDateClick,
|
||||||
|
onEventClick,
|
||||||
|
}: CalendarGridProps) {
|
||||||
|
const days = useMemo(
|
||||||
|
() => getMonthDays(currentDate, events, selectedDate),
|
||||||
|
[currentDate, events, selectedDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDateClick = (date: Date) => {
|
||||||
|
onDateClick?.(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 bg-white">
|
||||||
|
<div className="grid grid-cols-7 border-b">
|
||||||
|
{WEEKDAYS.map((day, index) => (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className={cn(
|
||||||
|
'border-r py-2 text-center text-xs font-medium uppercase tracking-wider last:border-r-0',
|
||||||
|
index >= 5 ? 'bg-gray-50 text-gray-500' : 'text-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 grid-rows-6">
|
||||||
|
{days.map((day, index) => {
|
||||||
|
const visibleEvents = day.events.slice(0, MAX_VISIBLE_EVENTS);
|
||||||
|
const hiddenCount = day.events.length - MAX_VISIBLE_EVENTS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
onClick={() => handleDateClick(day.date)}
|
||||||
|
className={cn(
|
||||||
|
'min-h-[100px] cursor-pointer border-b border-r p-1 transition-colors last:border-r-0',
|
||||||
|
'[&:nth-child(7n)]:border-r-0',
|
||||||
|
day.isCurrentMonth ? 'bg-white' : 'bg-gray-50',
|
||||||
|
day.isWeekend && day.isCurrentMonth && 'bg-gray-50/50',
|
||||||
|
day.isToday && 'bg-primary-50',
|
||||||
|
day.isSelected && 'ring-2 ring-inset ring-primary-500',
|
||||||
|
'hover:bg-gray-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-6 w-6 items-center justify-center rounded-full text-sm',
|
||||||
|
day.isToday && 'bg-primary-600 font-semibold text-white',
|
||||||
|
!day.isToday && day.isCurrentMonth && 'text-gray-900',
|
||||||
|
!day.isToday && !day.isCurrentMonth && 'text-gray-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day.date.getDate()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
{visibleEvents.map((event) => (
|
||||||
|
<CalendarEvent
|
||||||
|
key={event.id}
|
||||||
|
event={event}
|
||||||
|
onClick={onEventClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{hiddenCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left text-xs text-gray-500 hover:text-gray-700"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDateClick(day.date);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+{hiddenCount} mas
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/shared/components/organisms/Calendar/CalendarHeader.tsx
Normal file
91
src/shared/components/organisms/Calendar/CalendarHeader.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { Button } from '@components/atoms/Button';
|
||||||
|
import type { CalendarHeaderProps, CalendarView } from './types';
|
||||||
|
|
||||||
|
const viewLabels: Record<CalendarView, string> = {
|
||||||
|
month: 'Mes',
|
||||||
|
week: 'Semana',
|
||||||
|
day: 'Dia',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CalendarHeader({
|
||||||
|
currentDate,
|
||||||
|
view,
|
||||||
|
onViewChange,
|
||||||
|
onPrevious,
|
||||||
|
onNext,
|
||||||
|
onToday,
|
||||||
|
locale = 'es-ES',
|
||||||
|
}: CalendarHeaderProps) {
|
||||||
|
const formatTitle = () => {
|
||||||
|
if (view === 'month') {
|
||||||
|
return currentDate.toLocaleDateString(locale, {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (view === 'week') {
|
||||||
|
const startOfWeek = new Date(currentDate);
|
||||||
|
const day = startOfWeek.getDay();
|
||||||
|
const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1);
|
||||||
|
startOfWeek.setDate(diff);
|
||||||
|
|
||||||
|
const endOfWeek = new Date(startOfWeek);
|
||||||
|
endOfWeek.setDate(startOfWeek.getDate() + 6);
|
||||||
|
|
||||||
|
return `${startOfWeek.toLocaleDateString(locale, {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
})} - ${endOfWeek.toLocaleDateString(locale, {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
return currentDate.toLocaleDateString(locale, {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between border-b bg-white px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={onPrevious}>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={onNext}>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={onToday}>
|
||||||
|
Hoy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-lg font-semibold capitalize text-gray-900">
|
||||||
|
{formatTitle()}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{(['month', 'week', 'day'] as CalendarView[]).map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onViewChange(v)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||||
|
view === v
|
||||||
|
? 'bg-primary-100 text-primary-700'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{viewLabels[v]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/shared/components/organisms/Calendar/index.ts
Normal file
5
src/shared/components/organisms/Calendar/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './Calendar';
|
||||||
|
export * from './CalendarHeader';
|
||||||
|
export * from './CalendarGrid';
|
||||||
|
export * from './CalendarEvent';
|
||||||
|
export type { CalendarEvent as CalendarEventType, CalendarView, CalendarProps, CalendarHeaderProps, CalendarGridProps, CalendarEventProps, DayCell } from './types';
|
||||||
58
src/shared/components/organisms/Calendar/types.ts
Normal file
58
src/shared/components/organisms/Calendar/types.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
export interface CalendarEvent {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
start: Date;
|
||||||
|
end?: Date;
|
||||||
|
allDay?: boolean;
|
||||||
|
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||||
|
description?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CalendarView = 'month' | 'week' | 'day';
|
||||||
|
|
||||||
|
export interface CalendarProps {
|
||||||
|
events: CalendarEvent[];
|
||||||
|
view?: CalendarView;
|
||||||
|
onViewChange?: (view: CalendarView) => void;
|
||||||
|
onEventClick?: (event: CalendarEvent) => void;
|
||||||
|
onDateClick?: (date: Date) => void;
|
||||||
|
onEventDrop?: (event: CalendarEvent, newStart: Date) => void;
|
||||||
|
selectedDate?: Date;
|
||||||
|
onSelectedDateChange?: (date: Date) => void;
|
||||||
|
locale?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarHeaderProps {
|
||||||
|
currentDate: Date;
|
||||||
|
view: CalendarView;
|
||||||
|
onViewChange: (view: CalendarView) => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onToday: () => void;
|
||||||
|
locale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarGridProps {
|
||||||
|
currentDate: Date;
|
||||||
|
events: CalendarEvent[];
|
||||||
|
selectedDate?: Date;
|
||||||
|
onDateClick?: (date: Date) => void;
|
||||||
|
onEventClick?: (event: CalendarEvent) => void;
|
||||||
|
locale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarEventProps {
|
||||||
|
event: CalendarEvent;
|
||||||
|
onClick?: (event: CalendarEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DayCell {
|
||||||
|
date: Date;
|
||||||
|
isCurrentMonth: boolean;
|
||||||
|
isToday: boolean;
|
||||||
|
isSelected: boolean;
|
||||||
|
isWeekend: boolean;
|
||||||
|
events: CalendarEvent[];
|
||||||
|
}
|
||||||
90
src/shared/components/organisms/Chart/AreaChart.tsx
Normal file
90
src/shared/components/organisms/Chart/AreaChart.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { ChartContainer } from './ChartContainer';
|
||||||
|
import type { AreaChartProps } from './types';
|
||||||
|
import { CHART_COLORS_FALLBACK } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AreaChart - Area chart component wrapper
|
||||||
|
* @description Displays data as an area chart. Requires recharts library.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <AreaChart
|
||||||
|
* data={[
|
||||||
|
* { name: 'Jan', value: 100, baseline: 80 },
|
||||||
|
* { name: 'Feb', value: 150, baseline: 90 },
|
||||||
|
* ]}
|
||||||
|
* series={[
|
||||||
|
* { dataKey: 'value', name: 'Actual', color: '#3B82F6' },
|
||||||
|
* { dataKey: 'baseline', name: 'Baseline', color: '#22C55E' },
|
||||||
|
* ]}
|
||||||
|
* height={300}
|
||||||
|
* stacked={false}
|
||||||
|
* fillOpacity={0.3}
|
||||||
|
* showGrid
|
||||||
|
* showLegend
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function AreaChart({
|
||||||
|
data,
|
||||||
|
series,
|
||||||
|
height = 300,
|
||||||
|
showGrid: _showGrid = true,
|
||||||
|
showLegend: _showLegend = true,
|
||||||
|
showTooltip: _showTooltip = true,
|
||||||
|
stacked: _stacked = false,
|
||||||
|
curved: _curved = true,
|
||||||
|
fillOpacity: _fillOpacity = 0.3,
|
||||||
|
className,
|
||||||
|
}: AreaChartProps) {
|
||||||
|
// Placeholder implementation - recharts not installed
|
||||||
|
const hasData = data && data.length > 0;
|
||||||
|
const effectiveSeries = series || [{ dataKey: 'value', name: 'Value' }];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContainer height={height} className={className}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Visual representation placeholder */}
|
||||||
|
<div className="relative mb-4 h-20 w-48">
|
||||||
|
<svg viewBox="0 0 200 80" className="h-full w-full">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={CHART_COLORS_FALLBACK[0]} stopOpacity={0.4} />
|
||||||
|
<stop offset="100%" stopColor={CHART_COLORS_FALLBACK[0]} stopOpacity={0.1} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
d="M0,60 Q40,40 80,50 T160,30 T200,45 L200,80 L0,80 Z"
|
||||||
|
fill="url(#areaGradient)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M0,60 Q40,40 80,50 T160,30 T200,45"
|
||||||
|
fill="none"
|
||||||
|
stroke={CHART_COLORS_FALLBACK[0]}
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-gray-600">
|
||||||
|
Area Chart
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{hasData
|
||||||
|
? `${data.length} data points, ${effectiveSeries.length} series`
|
||||||
|
: 'No data'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-amber-600">
|
||||||
|
Install recharts: npm install recharts
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ChartContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/shared/components/organisms/Chart/BarChart.tsx
Normal file
81
src/shared/components/organisms/Chart/BarChart.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { ChartContainer } from './ChartContainer';
|
||||||
|
import type { BarChartProps } from './types';
|
||||||
|
import { CHART_COLORS_FALLBACK } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BarChart - Bar chart component wrapper
|
||||||
|
* @description Displays data as a bar chart. Requires recharts library.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <BarChart
|
||||||
|
* data={[
|
||||||
|
* { name: 'Q1', value: 100, target: 120 },
|
||||||
|
* { name: 'Q2', value: 150, target: 130 },
|
||||||
|
* ]}
|
||||||
|
* series={[
|
||||||
|
* { dataKey: 'value', name: 'Actual', color: '#3B82F6' },
|
||||||
|
* { dataKey: 'target', name: 'Target', color: '#22C55E' },
|
||||||
|
* ]}
|
||||||
|
* height={300}
|
||||||
|
* stacked={false}
|
||||||
|
* showGrid
|
||||||
|
* showLegend
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function BarChart({
|
||||||
|
data,
|
||||||
|
series,
|
||||||
|
height = 300,
|
||||||
|
showGrid: _showGrid = true,
|
||||||
|
showLegend: _showLegend = true,
|
||||||
|
showTooltip: _showTooltip = true,
|
||||||
|
stacked: _stacked = false,
|
||||||
|
horizontal: _horizontal = false,
|
||||||
|
className,
|
||||||
|
}: BarChartProps) {
|
||||||
|
// Placeholder implementation - recharts not installed
|
||||||
|
const hasData = data && data.length > 0;
|
||||||
|
const effectiveSeries = series || [{ dataKey: 'value', name: 'Value' }];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContainer height={height} className={className}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Visual representation placeholder */}
|
||||||
|
<div className="mb-4 flex items-end gap-2">
|
||||||
|
{[50, 75, 40, 90, 60, 85].map((h, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-10 rounded-t transition-all"
|
||||||
|
style={{
|
||||||
|
height: `${h}px`,
|
||||||
|
backgroundColor: CHART_COLORS_FALLBACK[i % CHART_COLORS_FALLBACK.length],
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-gray-600">
|
||||||
|
Bar Chart
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{hasData
|
||||||
|
? `${data.length} data points, ${effectiveSeries.length} series`
|
||||||
|
: 'No data'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-amber-600">
|
||||||
|
Install recharts: npm install recharts
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ChartContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/shared/components/organisms/Chart/ChartContainer.tsx
Normal file
21
src/shared/components/organisms/Chart/ChartContainer.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import type { ChartContainerProps } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChartContainer - Responsive wrapper for chart components
|
||||||
|
* @description Provides consistent sizing and responsive behavior
|
||||||
|
*/
|
||||||
|
export function ChartContainer({
|
||||||
|
children,
|
||||||
|
height = 300,
|
||||||
|
className,
|
||||||
|
}: ChartContainerProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('w-full', className)}
|
||||||
|
style={{ height, minHeight: height }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/shared/components/organisms/Chart/ChartLegend.tsx
Normal file
35
src/shared/components/organisms/Chart/ChartLegend.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import type { ChartLegendProps } from './types';
|
||||||
|
|
||||||
|
const alignClasses = {
|
||||||
|
left: 'justify-start',
|
||||||
|
center: 'justify-center',
|
||||||
|
right: 'justify-end',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChartLegend - Styled legend for chart components
|
||||||
|
* @description Custom legend styling matching the design system
|
||||||
|
*/
|
||||||
|
export function ChartLegend({
|
||||||
|
payload,
|
||||||
|
align = 'center',
|
||||||
|
}: ChartLegendProps) {
|
||||||
|
if (!payload || payload.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-wrap gap-4 px-4 py-2', alignClasses[align])}>
|
||||||
|
{payload.map((entry, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2 text-sm">
|
||||||
|
<span
|
||||||
|
className="h-3 w-3 rounded-full"
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-gray-600">{entry.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/shared/components/organisms/Chart/ChartTooltip.tsx
Normal file
38
src/shared/components/organisms/Chart/ChartTooltip.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import type { ChartTooltipProps } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChartTooltip - Styled tooltip for chart components
|
||||||
|
* @description Custom tooltip styling matching the design system
|
||||||
|
*/
|
||||||
|
export function ChartTooltip({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
label,
|
||||||
|
formatter,
|
||||||
|
}: ChartTooltipProps) {
|
||||||
|
if (!active || !payload || payload.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2 shadow-lg">
|
||||||
|
{label && (
|
||||||
|
<p className="mb-1 text-sm font-medium text-gray-900">{label}</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{payload.map((entry, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2 text-sm">
|
||||||
|
<span
|
||||||
|
className="h-3 w-3 rounded-full"
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-gray-600">{entry.name}:</span>
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{formatter ? formatter(entry.value, entry.name) : entry.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/shared/components/organisms/Chart/LineChart.tsx
Normal file
81
src/shared/components/organisms/Chart/LineChart.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { ChartContainer } from './ChartContainer';
|
||||||
|
import type { LineChartProps } from './types';
|
||||||
|
import { CHART_COLORS_FALLBACK } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LineChart - Line chart component wrapper
|
||||||
|
* @description Displays data as a line chart. Requires recharts library.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <LineChart
|
||||||
|
* data={[
|
||||||
|
* { name: 'Jan', value: 100, revenue: 200 },
|
||||||
|
* { name: 'Feb', value: 150, revenue: 250 },
|
||||||
|
* ]}
|
||||||
|
* series={[
|
||||||
|
* { dataKey: 'value', name: 'Ventas', color: '#3B82F6' },
|
||||||
|
* { dataKey: 'revenue', name: 'Ingresos', color: '#22C55E' },
|
||||||
|
* ]}
|
||||||
|
* height={300}
|
||||||
|
* showGrid
|
||||||
|
* showLegend
|
||||||
|
* showTooltip
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function LineChart({
|
||||||
|
data,
|
||||||
|
series,
|
||||||
|
height = 300,
|
||||||
|
showGrid: _showGrid = true,
|
||||||
|
showLegend: _showLegend = true,
|
||||||
|
showTooltip: _showTooltip = true,
|
||||||
|
curved: _curved = true,
|
||||||
|
showDots: _showDots = true,
|
||||||
|
className,
|
||||||
|
}: LineChartProps) {
|
||||||
|
// Placeholder implementation - recharts not installed
|
||||||
|
const hasData = data && data.length > 0;
|
||||||
|
const effectiveSeries = series || [{ dataKey: 'value', name: 'Value' }];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContainer height={height} className={className}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Visual representation placeholder */}
|
||||||
|
<div className="mb-4 flex items-end gap-1">
|
||||||
|
{[40, 60, 45, 80, 55, 70, 90].map((h, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-8 rounded-t transition-all"
|
||||||
|
style={{
|
||||||
|
height: `${h}px`,
|
||||||
|
backgroundColor: CHART_COLORS_FALLBACK[0],
|
||||||
|
opacity: 0.3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-gray-600">
|
||||||
|
Line Chart
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{hasData
|
||||||
|
? `${data.length} data points, ${effectiveSeries.length} series`
|
||||||
|
: 'No data'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-amber-600">
|
||||||
|
Install recharts: npm install recharts
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ChartContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/shared/components/organisms/Chart/PieChart.tsx
Normal file
93
src/shared/components/organisms/Chart/PieChart.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { ChartContainer } from './ChartContainer';
|
||||||
|
import type { PieChartProps } from './types';
|
||||||
|
import { CHART_COLORS_FALLBACK } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PieChart - Pie/Donut chart component wrapper
|
||||||
|
* @description Displays data as a pie or donut chart. Requires recharts library.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // Pie chart
|
||||||
|
* <PieChart
|
||||||
|
* data={[
|
||||||
|
* { name: 'Category A', value: 400 },
|
||||||
|
* { name: 'Category B', value: 300 },
|
||||||
|
* { name: 'Category C', value: 200 },
|
||||||
|
* ]}
|
||||||
|
* height={300}
|
||||||
|
* showLabels
|
||||||
|
* showLegend
|
||||||
|
* />
|
||||||
|
*
|
||||||
|
* // Donut chart
|
||||||
|
* <PieChart
|
||||||
|
* data={data}
|
||||||
|
* innerRadius={60}
|
||||||
|
* height={300}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function PieChart({
|
||||||
|
data,
|
||||||
|
height = 300,
|
||||||
|
innerRadius: _innerRadius = 0,
|
||||||
|
showLabels: _showLabels = true,
|
||||||
|
showLegend: _showLegend = true,
|
||||||
|
showTooltip: _showTooltip = true,
|
||||||
|
className,
|
||||||
|
}: PieChartProps) {
|
||||||
|
// Placeholder implementation - recharts not installed
|
||||||
|
const hasData = data && data.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContainer height={height} className={className}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Visual representation placeholder - pie chart */}
|
||||||
|
<div className="relative mb-4 h-24 w-24">
|
||||||
|
<svg viewBox="0 0 100 100" className="h-full w-full -rotate-90">
|
||||||
|
{/* Donut segments */}
|
||||||
|
{[
|
||||||
|
{ offset: 0, percent: 35, color: CHART_COLORS_FALLBACK[0] },
|
||||||
|
{ offset: 35, percent: 25, color: CHART_COLORS_FALLBACK[1] },
|
||||||
|
{ offset: 60, percent: 20, color: CHART_COLORS_FALLBACK[2] },
|
||||||
|
{ offset: 80, percent: 20, color: CHART_COLORS_FALLBACK[3] },
|
||||||
|
].map((segment, i) => (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="40"
|
||||||
|
fill="none"
|
||||||
|
stroke={segment.color}
|
||||||
|
strokeWidth="20"
|
||||||
|
strokeDasharray={`${segment.percent * 2.51} 251`}
|
||||||
|
strokeDashoffset={-segment.offset * 2.51}
|
||||||
|
opacity={0.7}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-medium text-gray-600">
|
||||||
|
Pie Chart
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{hasData
|
||||||
|
? `${data.length} segments`
|
||||||
|
: 'No data'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-amber-600">
|
||||||
|
Install recharts: npm install recharts
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ChartContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/shared/components/organisms/Chart/index.ts
Normal file
63
src/shared/components/organisms/Chart/index.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Chart Components
|
||||||
|
* @description Reusable chart wrapper components for data visualization
|
||||||
|
*
|
||||||
|
* NOTE: These components require recharts to be installed for full functionality.
|
||||||
|
* Install with: npm install recharts @types/recharts
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* import { LineChart, BarChart, PieChart, AreaChart } from '@components/organisms/Chart';
|
||||||
|
*
|
||||||
|
* // Line chart
|
||||||
|
* <LineChart
|
||||||
|
* data={salesData}
|
||||||
|
* series={[{ dataKey: 'revenue', name: 'Revenue' }]}
|
||||||
|
* height={300}
|
||||||
|
* showGrid
|
||||||
|
* showLegend
|
||||||
|
* />
|
||||||
|
*
|
||||||
|
* // Bar chart
|
||||||
|
* <BarChart
|
||||||
|
* data={quarterlyData}
|
||||||
|
* series={[
|
||||||
|
* { dataKey: 'actual', name: 'Actual', color: '#3B82F6' },
|
||||||
|
* { dataKey: 'target', name: 'Target', color: '#22C55E' },
|
||||||
|
* ]}
|
||||||
|
* stacked={false}
|
||||||
|
* />
|
||||||
|
*
|
||||||
|
* // Pie/Donut chart
|
||||||
|
* <PieChart
|
||||||
|
* data={categoryData}
|
||||||
|
* innerRadius={60} // Creates donut chart
|
||||||
|
* showLabels
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
ChartDataPoint,
|
||||||
|
ChartSeries,
|
||||||
|
BaseChartProps,
|
||||||
|
LineChartProps,
|
||||||
|
BarChartProps,
|
||||||
|
AreaChartProps,
|
||||||
|
PieChartProps,
|
||||||
|
ChartContainerProps,
|
||||||
|
ChartTooltipProps,
|
||||||
|
ChartLegendProps,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export { CHART_COLORS, CHART_COLORS_FALLBACK } from './types';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
export { ChartContainer } from './ChartContainer';
|
||||||
|
export { ChartTooltip } from './ChartTooltip';
|
||||||
|
export { ChartLegend } from './ChartLegend';
|
||||||
|
export { LineChart } from './LineChart';
|
||||||
|
export { BarChart } from './BarChart';
|
||||||
|
export { AreaChart } from './AreaChart';
|
||||||
|
export { PieChart } from './PieChart';
|
||||||
105
src/shared/components/organisms/Chart/types.ts
Normal file
105
src/shared/components/organisms/Chart/types.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Chart Component Types
|
||||||
|
* @description Type definitions for chart wrapper components
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ChartDataPoint {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
[key: string]: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartSeries {
|
||||||
|
dataKey: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
type?: 'line' | 'bar' | 'area';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseChartProps {
|
||||||
|
data: ChartDataPoint[];
|
||||||
|
series?: ChartSeries[];
|
||||||
|
height?: number;
|
||||||
|
showGrid?: boolean;
|
||||||
|
showLegend?: boolean;
|
||||||
|
showTooltip?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LineChartProps extends BaseChartProps {
|
||||||
|
curved?: boolean;
|
||||||
|
showDots?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarChartProps extends BaseChartProps {
|
||||||
|
stacked?: boolean;
|
||||||
|
horizontal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AreaChartProps extends BaseChartProps {
|
||||||
|
stacked?: boolean;
|
||||||
|
curved?: boolean;
|
||||||
|
fillOpacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PieChartProps {
|
||||||
|
data: ChartDataPoint[];
|
||||||
|
innerRadius?: number;
|
||||||
|
showLabels?: boolean;
|
||||||
|
showLegend?: boolean;
|
||||||
|
showTooltip?: boolean;
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartContainerProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartTooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
dataKey: string;
|
||||||
|
}>;
|
||||||
|
label?: string;
|
||||||
|
formatter?: (value: number, name: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartLegendProps {
|
||||||
|
payload?: Array<{
|
||||||
|
value: string;
|
||||||
|
color: string;
|
||||||
|
type?: string;
|
||||||
|
}>;
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
verticalAlign?: 'top' | 'middle' | 'bottom';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chart colors matching the design system
|
||||||
|
*/
|
||||||
|
export const CHART_COLORS = [
|
||||||
|
'rgb(var(--color-primary-500, 59 130 246))',
|
||||||
|
'rgb(var(--color-secondary-500, 107 114 128))',
|
||||||
|
'rgb(var(--color-success-500, 34 197 94))',
|
||||||
|
'rgb(var(--color-warning-500, 234 179 8))',
|
||||||
|
'rgb(var(--color-danger-500, 239 68 68))',
|
||||||
|
'rgb(var(--color-info-500, 6 182 212))',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback colors when CSS variables are not available
|
||||||
|
*/
|
||||||
|
export const CHART_COLORS_FALLBACK = [
|
||||||
|
'#3B82F6', // primary-500
|
||||||
|
'#6B7280', // secondary-500
|
||||||
|
'#22C55E', // success-500
|
||||||
|
'#EAB308', // warning-500
|
||||||
|
'#EF4444', // danger-500
|
||||||
|
'#06B6D4', // info-500
|
||||||
|
];
|
||||||
@ -0,0 +1,324 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Search, Command as CommandIcon } from 'lucide-react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { CommandPaletteGroup } from './CommandPaletteGroup';
|
||||||
|
import { fuzzySearchCommands, groupCommands } from './useCommandPalette';
|
||||||
|
import type { Command, CommandGroup, CommandPaletteProps } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command Palette component
|
||||||
|
* A modal overlay for quick navigation and actions
|
||||||
|
*/
|
||||||
|
export function CommandPalette({
|
||||||
|
commands,
|
||||||
|
placeholder = 'Buscar comandos...',
|
||||||
|
onClose,
|
||||||
|
isOpen,
|
||||||
|
}: CommandPaletteProps) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Filter commands based on search query
|
||||||
|
const filteredCommands = useMemo(
|
||||||
|
() => fuzzySearchCommands(commands, query),
|
||||||
|
[commands, query]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group filtered commands
|
||||||
|
const { groups, ungrouped } = useMemo(
|
||||||
|
() => groupCommands(filteredCommands),
|
||||||
|
[filteredCommands]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create array of groups for rendering
|
||||||
|
const commandGroups = useMemo<CommandGroup[]>(() => {
|
||||||
|
const result: CommandGroup[] = [];
|
||||||
|
|
||||||
|
// Add ungrouped commands first
|
||||||
|
if (ungrouped.length > 0) {
|
||||||
|
result.push({
|
||||||
|
id: '_ungrouped',
|
||||||
|
title: 'Acciones',
|
||||||
|
commands: ungrouped,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add other groups
|
||||||
|
groups.forEach((cmds, groupId) => {
|
||||||
|
result.push({
|
||||||
|
id: groupId,
|
||||||
|
title: groupId,
|
||||||
|
commands: cmds,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [groups, ungrouped]);
|
||||||
|
|
||||||
|
// Flatten commands for keyboard navigation
|
||||||
|
const flatCommands = useMemo(
|
||||||
|
() => commandGroups.flatMap((g) => g.commands),
|
||||||
|
[commandGroups]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset selection when query changes
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
// Focus input when opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setQuery('');
|
||||||
|
setSelectedIndex(0);
|
||||||
|
// Small delay to ensure DOM is ready
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Scroll selected item into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (listRef.current) {
|
||||||
|
const selectedElement = listRef.current.querySelector('[data-selected="true"]');
|
||||||
|
if (selectedElement) {
|
||||||
|
selectedElement.scrollIntoView({
|
||||||
|
block: 'nearest',
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
// Execute selected command
|
||||||
|
const executeCommand = useCallback(
|
||||||
|
(command: Command) => {
|
||||||
|
if (command.disabled) return;
|
||||||
|
command.action();
|
||||||
|
onClose?.();
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle keyboard navigation
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: React.KeyboardEvent) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
setSelectedIndex((prev) =>
|
||||||
|
prev < flatCommands.length - 1 ? prev + 1 : 0
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
setSelectedIndex((prev) =>
|
||||||
|
prev > 0 ? prev - 1 : flatCommands.length - 1
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Enter':
|
||||||
|
event.preventDefault();
|
||||||
|
if (flatCommands[selectedIndex]) {
|
||||||
|
executeCommand(flatCommands[selectedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Escape':
|
||||||
|
event.preventDefault();
|
||||||
|
onClose?.();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Tab':
|
||||||
|
// Prevent tab from leaving the modal
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[flatCommands, selectedIndex, executeCommand, onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle overlay click
|
||||||
|
const handleOverlayClick = useCallback(() => {
|
||||||
|
onClose?.();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Handle item click
|
||||||
|
const handleItemClick = useCallback(
|
||||||
|
(command: Command) => {
|
||||||
|
executeCommand(command);
|
||||||
|
},
|
||||||
|
[executeCommand]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle item hover
|
||||||
|
const handleItemHover = useCallback((index: number) => {
|
||||||
|
setSelectedIndex(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Calculate base index for each group
|
||||||
|
const getBaseIndex = useCallback(
|
||||||
|
(groupIndex: number) => {
|
||||||
|
let baseIndex = 0;
|
||||||
|
for (let i = 0; i < groupIndex; i++) {
|
||||||
|
baseIndex += commandGroups[i]?.commands.length ?? 0;
|
||||||
|
}
|
||||||
|
return baseIndex;
|
||||||
|
},
|
||||||
|
[commandGroups]
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className={cn(
|
||||||
|
'w-full max-w-[600px] overflow-hidden rounded-xl shadow-2xl',
|
||||||
|
'bg-white dark:bg-gray-800',
|
||||||
|
'border border-gray-200 dark:border-gray-700'
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="command-palette-title"
|
||||||
|
>
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="flex items-center gap-3 border-b border-gray-200 px-4 dark:border-gray-700">
|
||||||
|
<Search className="h-5 w-5 text-gray-400 dark:text-gray-500" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cn(
|
||||||
|
'h-14 flex-1 bg-transparent text-base outline-none',
|
||||||
|
'text-gray-900 placeholder-gray-400',
|
||||||
|
'dark:text-gray-100 dark:placeholder-gray-500'
|
||||||
|
)}
|
||||||
|
id="command-palette-title"
|
||||||
|
aria-label="Buscar comandos"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck="false"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<kbd
|
||||||
|
className={cn(
|
||||||
|
'hidden sm:inline-flex h-5 items-center rounded px-1.5',
|
||||||
|
'border border-gray-200 bg-gray-100',
|
||||||
|
'text-[10px] font-medium text-gray-500',
|
||||||
|
'dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
ESC
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
className={cn(
|
||||||
|
'max-h-[400px] overflow-y-auto',
|
||||||
|
'scrollbar-thin scrollbar-track-transparent scrollbar-thumb-gray-300',
|
||||||
|
'dark:scrollbar-thumb-gray-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{filteredCommands.length === 0 ? (
|
||||||
|
/* Empty state */
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<CommandIcon className="h-12 w-12 text-gray-300 dark:text-gray-600" />
|
||||||
|
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No se encontraron comandos
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Intenta con otro termino de busqueda
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Command groups */
|
||||||
|
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{commandGroups.map((group, groupIndex) => (
|
||||||
|
<CommandPaletteGroup
|
||||||
|
key={group.id}
|
||||||
|
group={group}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
baseIndex={getBaseIndex(groupIndex)}
|
||||||
|
onItemClick={handleItemClick}
|
||||||
|
onItemHover={handleItemHover}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between gap-4 border-t px-4 py-2.5',
|
||||||
|
'border-gray-200 bg-gray-50 text-xs text-gray-500',
|
||||||
|
'dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<kbd className="rounded border border-gray-300 bg-white px-1 py-0.5 text-[10px] dark:border-gray-600 dark:bg-gray-700">
|
||||||
|
↑
|
||||||
|
</kbd>
|
||||||
|
<kbd className="rounded border border-gray-300 bg-white px-1 py-0.5 text-[10px] dark:border-gray-600 dark:bg-gray-700">
|
||||||
|
↓
|
||||||
|
</kbd>
|
||||||
|
<span className="ml-1">navegar</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<kbd className="rounded border border-gray-300 bg-white px-1 py-0.5 text-[10px] dark:border-gray-600 dark:bg-gray-700">
|
||||||
|
↵
|
||||||
|
</kbd>
|
||||||
|
<span className="ml-1">seleccionar</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<kbd className="rounded border border-gray-300 bg-white px-1 py-0.5 text-[10px] dark:border-gray-600 dark:bg-gray-700">
|
||||||
|
⌘
|
||||||
|
</kbd>
|
||||||
|
<kbd className="rounded border border-gray-300 bg-white px-1 py-0.5 text-[10px] dark:border-gray-600 dark:bg-gray-700">
|
||||||
|
K
|
||||||
|
</kbd>
|
||||||
|
<span className="ml-1">abrir/cerrar</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(content, document.body);
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { CommandPaletteItem } from './CommandPaletteItem';
|
||||||
|
import type { CommandPaletteGroupProps } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A group of commands with a header
|
||||||
|
*/
|
||||||
|
export function CommandPaletteGroup({
|
||||||
|
group,
|
||||||
|
selectedIndex,
|
||||||
|
baseIndex,
|
||||||
|
onItemClick,
|
||||||
|
onItemHover,
|
||||||
|
}: CommandPaletteGroupProps) {
|
||||||
|
return (
|
||||||
|
<div className="py-2">
|
||||||
|
{/* Group header */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-xs font-semibold uppercase tracking-wider',
|
||||||
|
'text-gray-500 dark:text-gray-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{group.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Group items */}
|
||||||
|
<div className="space-y-0.5 px-1">
|
||||||
|
{group.commands.map((command, index) => {
|
||||||
|
const globalIndex = baseIndex + index;
|
||||||
|
return (
|
||||||
|
<CommandPaletteItem
|
||||||
|
key={command.id}
|
||||||
|
command={command}
|
||||||
|
isSelected={selectedIndex === globalIndex}
|
||||||
|
onClick={() => onItemClick(command)}
|
||||||
|
onMouseEnter={() => onItemHover(globalIndex)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import type { CommandPaletteItemProps } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders keyboard shortcut keys
|
||||||
|
*/
|
||||||
|
function ShortcutKeys({ keys }: { keys: string[] }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{keys.map((key, index) => (
|
||||||
|
<kbd
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-5 min-w-[20px] items-center justify-center rounded',
|
||||||
|
'border border-gray-200 bg-gray-100 px-1.5',
|
||||||
|
'text-[10px] font-medium text-gray-500',
|
||||||
|
'dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single item in the command palette
|
||||||
|
*/
|
||||||
|
export function CommandPaletteItem({
|
||||||
|
command,
|
||||||
|
isSelected,
|
||||||
|
onClick,
|
||||||
|
onMouseEnter,
|
||||||
|
}: CommandPaletteItemProps) {
|
||||||
|
const Icon = command.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
disabled={command.disabled}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left',
|
||||||
|
'transition-colors duration-100',
|
||||||
|
isSelected
|
||||||
|
? 'bg-primary-50 dark:bg-primary-900/30'
|
||||||
|
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50',
|
||||||
|
command.disabled && 'cursor-not-allowed opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
{Icon && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex-shrink-0',
|
||||||
|
isSelected
|
||||||
|
? 'text-primary-600 dark:text-primary-400'
|
||||||
|
: 'text-gray-400 dark:text-gray-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title and description */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium truncate',
|
||||||
|
isSelected
|
||||||
|
? 'text-primary-900 dark:text-primary-100'
|
||||||
|
: 'text-gray-900 dark:text-gray-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{command.title}
|
||||||
|
</div>
|
||||||
|
{command.description && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-xs truncate mt-0.5',
|
||||||
|
isSelected
|
||||||
|
? 'text-primary-700 dark:text-primary-300'
|
||||||
|
: 'text-gray-500 dark:text-gray-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{command.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keyboard shortcut */}
|
||||||
|
{command.shortcut && command.shortcut.length > 0 && (
|
||||||
|
<ShortcutKeys keys={command.shortcut} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,350 @@
|
|||||||
|
import { useMemo, type ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
Home,
|
||||||
|
Users,
|
||||||
|
Settings,
|
||||||
|
Building2,
|
||||||
|
FileText,
|
||||||
|
CreditCard,
|
||||||
|
Package,
|
||||||
|
ShoppingCart,
|
||||||
|
Truck,
|
||||||
|
BarChart3,
|
||||||
|
Bell,
|
||||||
|
UserPlus,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
LogOut,
|
||||||
|
HelpCircle,
|
||||||
|
Briefcase,
|
||||||
|
Calendar,
|
||||||
|
FolderKanban,
|
||||||
|
Users2,
|
||||||
|
Receipt,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { CommandPalette } from './CommandPalette';
|
||||||
|
import {
|
||||||
|
CommandPaletteContext,
|
||||||
|
useCommandPaletteState,
|
||||||
|
} from './useCommandPalette';
|
||||||
|
import type { Command } from './types';
|
||||||
|
|
||||||
|
interface CommandPaletteProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Additional commands to include */
|
||||||
|
additionalCommands?: Command[];
|
||||||
|
/** Whether to include default navigation commands */
|
||||||
|
includeDefaultCommands?: boolean;
|
||||||
|
/** Custom navigate function (for use outside router context) */
|
||||||
|
navigate?: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider component for the command palette
|
||||||
|
* Wraps the application and provides context for command registration
|
||||||
|
* Should be placed inside the router context if using default navigation commands
|
||||||
|
*/
|
||||||
|
export function CommandPaletteProvider({
|
||||||
|
children,
|
||||||
|
additionalCommands = [],
|
||||||
|
includeDefaultCommands = true,
|
||||||
|
navigate: externalNavigate,
|
||||||
|
}: CommandPaletteProviderProps) {
|
||||||
|
// Use provided navigate or fallback to window.location
|
||||||
|
const navigate = externalNavigate || ((path: string) => {
|
||||||
|
window.location.href = path;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default navigation commands
|
||||||
|
const defaultCommands = useMemo<Command[]>(() => {
|
||||||
|
if (!includeDefaultCommands) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
// Navigation - Dashboard
|
||||||
|
{
|
||||||
|
id: 'nav-dashboard',
|
||||||
|
title: 'Ir al Dashboard',
|
||||||
|
description: 'Ver panel principal',
|
||||||
|
icon: Home,
|
||||||
|
shortcut: ['G', 'D'],
|
||||||
|
action: () => navigate('/dashboard'),
|
||||||
|
group: 'Navegacion',
|
||||||
|
keywords: ['inicio', 'home', 'principal'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation - Users
|
||||||
|
{
|
||||||
|
id: 'nav-users',
|
||||||
|
title: 'Ir a Usuarios',
|
||||||
|
description: 'Gestionar usuarios del sistema',
|
||||||
|
icon: Users,
|
||||||
|
shortcut: ['G', 'U'],
|
||||||
|
action: () => navigate('/users'),
|
||||||
|
group: 'Navegacion',
|
||||||
|
keywords: ['personas', 'empleados', 'administrar'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation - Companies
|
||||||
|
{
|
||||||
|
id: 'nav-companies',
|
||||||
|
title: 'Ir a Empresas',
|
||||||
|
description: 'Gestionar empresas',
|
||||||
|
icon: Building2,
|
||||||
|
action: () => navigate('/companies'),
|
||||||
|
group: 'Navegacion',
|
||||||
|
keywords: ['organizaciones', 'negocios'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation - Partners
|
||||||
|
{
|
||||||
|
id: 'nav-partners',
|
||||||
|
title: 'Ir a Partners',
|
||||||
|
description: 'Gestionar partners',
|
||||||
|
icon: Users2,
|
||||||
|
action: () => navigate('/partners'),
|
||||||
|
group: 'Navegacion',
|
||||||
|
keywords: ['socios', 'aliados', 'colaboradores'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation - Settings
|
||||||
|
{
|
||||||
|
id: 'nav-settings',
|
||||||
|
title: 'Ir a Configuracion',
|
||||||
|
description: 'Ajustes del sistema',
|
||||||
|
icon: Settings,
|
||||||
|
shortcut: ['G', 'S'],
|
||||||
|
action: () => navigate('/settings'),
|
||||||
|
group: 'Navegacion',
|
||||||
|
keywords: ['ajustes', 'preferencias', 'opciones'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation - Inventory
|
||||||
|
{
|
||||||
|
id: 'nav-inventory',
|
||||||
|
title: 'Ir a Inventario',
|
||||||
|
description: 'Gestion de inventario',
|
||||||
|
icon: Package,
|
||||||
|
action: () => navigate('/inventory'),
|
||||||
|
group: 'Navegacion',
|
||||||
|
keywords: ['productos', 'almacen', 'stock'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation - Sales
|
||||||
|
{
|
||||||
|
id: 'nav-sales',
|
||||||
|
title: 'Ir a Ventas',
|
||||||
|
description: 'Modulo de ventas',
|
||||||
|
icon: ShoppingCart,
|
||||||
|
action: () => navigate('/sales'),
|
||||||
|
group: 'Navegacion',
|
||||||
|
keywords: ['pedidos', 'ordenes', 'clientes'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation - Purchases
|
||||||
|
{
|
||||||
|
id: 'nav-purchases',
|
||||||
|
title: 'Ir a Compras',
|
||||||
|
description: 'Modulo de compras',
|
||||||
|
icon: Truck,
|
||||||
|
action: () => navigate('/purchases'),
|
||||||
|
group: 'Navegacion',
|
||||||
|
keywords: ['proveedores', 'ordenes', 'adquisiciones'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation - Financial
|
||||||
|
{
|
||||||
|
id: 'nav-financial',
|
||||||
|
title: 'Ir a Finanzas',
|
||||||
|
description: 'Modulo financiero',
|
||||||
|
icon: Receipt,
|
||||||
|
action: () => navigate('/financial'),
|
||||||
|
group: 'Navegacion',
|
||||||
|
keywords: ['contabilidad', 'pagos', 'cobros', 'finanzas'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation - CRM
|
||||||
|
{
|
||||||
|
id: 'nav-crm',
|
||||||
|
title: 'Ir a CRM',
|
||||||
|
description: 'Gestion de clientes',
|
||||||
|
icon: Briefcase,
|
||||||
|
action: () => navigate('/crm'),
|
||||||
|
group: 'Navegacion',
|
||||||
|
keywords: ['clientes', 'prospectos', 'oportunidades'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation - Projects
|
||||||
|
{
|
||||||
|
id: 'nav-projects',
|
||||||
|
title: 'Ir a Proyectos',
|
||||||
|
description: 'Gestion de proyectos',
|
||||||
|
icon: FolderKanban,
|
||||||
|
action: () => navigate('/projects'),
|
||||||
|
group: 'Navegacion',
|
||||||
|
keywords: ['tareas', 'kanban', 'actividades'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation - HR
|
||||||
|
{
|
||||||
|
id: 'nav-hr',
|
||||||
|
title: 'Ir a RRHH',
|
||||||
|
description: 'Recursos Humanos',
|
||||||
|
icon: Users,
|
||||||
|
action: () => navigate('/hr'),
|
||||||
|
group: 'Navegacion',
|
||||||
|
keywords: ['empleados', 'nomina', 'personal', 'recursos humanos'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation - Reports
|
||||||
|
{
|
||||||
|
id: 'nav-reports',
|
||||||
|
title: 'Ir a Reportes',
|
||||||
|
description: 'Reportes y estadisticas',
|
||||||
|
icon: BarChart3,
|
||||||
|
action: () => navigate('/reports'),
|
||||||
|
group: 'Navegacion',
|
||||||
|
keywords: ['estadisticas', 'graficas', 'analisis'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation - Billing
|
||||||
|
{
|
||||||
|
id: 'nav-billing',
|
||||||
|
title: 'Ir a Facturacion',
|
||||||
|
description: 'Planes y facturacion',
|
||||||
|
icon: CreditCard,
|
||||||
|
action: () => navigate('/billing'),
|
||||||
|
group: 'Navegacion',
|
||||||
|
keywords: ['pagos', 'suscripcion', 'plan'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation - Calendar
|
||||||
|
{
|
||||||
|
id: 'nav-calendar',
|
||||||
|
title: 'Ir a Calendario',
|
||||||
|
description: 'Ver calendario de eventos',
|
||||||
|
icon: Calendar,
|
||||||
|
action: () => navigate('/calendar'),
|
||||||
|
group: 'Navegacion',
|
||||||
|
keywords: ['eventos', 'agenda', 'citas'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation - Notifications
|
||||||
|
{
|
||||||
|
id: 'nav-notifications',
|
||||||
|
title: 'Ver Notificaciones',
|
||||||
|
description: 'Centro de notificaciones',
|
||||||
|
icon: Bell,
|
||||||
|
action: () => navigate('/notifications'),
|
||||||
|
group: 'Navegacion',
|
||||||
|
keywords: ['alertas', 'mensajes', 'avisos'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Actions - Create user
|
||||||
|
{
|
||||||
|
id: 'action-create-user',
|
||||||
|
title: 'Crear nuevo usuario',
|
||||||
|
description: 'Agregar un nuevo usuario al sistema',
|
||||||
|
icon: UserPlus,
|
||||||
|
shortcut: ['Ctrl', 'Shift', 'U'],
|
||||||
|
action: () => navigate('/users/new'),
|
||||||
|
group: 'Acciones',
|
||||||
|
keywords: ['agregar', 'nuevo', 'registrar', 'persona'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Actions - Create invoice
|
||||||
|
{
|
||||||
|
id: 'action-create-invoice',
|
||||||
|
title: 'Crear nueva factura',
|
||||||
|
description: 'Generar una nueva factura',
|
||||||
|
icon: FileText,
|
||||||
|
shortcut: ['Ctrl', 'Shift', 'I'],
|
||||||
|
action: () => navigate('/invoices/new'),
|
||||||
|
group: 'Acciones',
|
||||||
|
keywords: ['agregar', 'nueva', 'documento', 'venta'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Actions - Toggle dark mode
|
||||||
|
{
|
||||||
|
id: 'action-toggle-theme',
|
||||||
|
title: 'Cambiar tema',
|
||||||
|
description: 'Alternar entre modo claro y oscuro',
|
||||||
|
icon: Moon,
|
||||||
|
shortcut: ['Ctrl', 'Shift', 'T'],
|
||||||
|
action: () => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.toggle('dark');
|
||||||
|
},
|
||||||
|
group: 'Acciones',
|
||||||
|
keywords: ['oscuro', 'claro', 'dark', 'light', 'apariencia'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Actions - Light mode
|
||||||
|
{
|
||||||
|
id: 'action-light-mode',
|
||||||
|
title: 'Modo claro',
|
||||||
|
description: 'Activar tema claro',
|
||||||
|
icon: Sun,
|
||||||
|
action: () => {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
},
|
||||||
|
group: 'Acciones',
|
||||||
|
keywords: ['light', 'dia', 'brillante'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Actions - Dark mode
|
||||||
|
{
|
||||||
|
id: 'action-dark-mode',
|
||||||
|
title: 'Modo oscuro',
|
||||||
|
description: 'Activar tema oscuro',
|
||||||
|
icon: Moon,
|
||||||
|
action: () => {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
},
|
||||||
|
group: 'Acciones',
|
||||||
|
keywords: ['dark', 'noche', 'negro'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Actions - Help
|
||||||
|
{
|
||||||
|
id: 'action-help',
|
||||||
|
title: 'Ayuda',
|
||||||
|
description: 'Ver documentacion y ayuda',
|
||||||
|
icon: HelpCircle,
|
||||||
|
shortcut: ['F1'],
|
||||||
|
action: () => navigate('/help'),
|
||||||
|
group: 'Acciones',
|
||||||
|
keywords: ['soporte', 'documentacion', 'guia', 'tutorial'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Actions - Logout
|
||||||
|
{
|
||||||
|
id: 'action-logout',
|
||||||
|
title: 'Cerrar sesion',
|
||||||
|
description: 'Salir de la aplicacion',
|
||||||
|
icon: LogOut,
|
||||||
|
action: () => navigate('/logout'),
|
||||||
|
group: 'Acciones',
|
||||||
|
keywords: ['salir', 'desconectar', 'logout'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [includeDefaultCommands, navigate]);
|
||||||
|
|
||||||
|
// Combine default and additional commands
|
||||||
|
const initialCommands = useMemo(
|
||||||
|
() => [...defaultCommands, ...additionalCommands],
|
||||||
|
[defaultCommands, additionalCommands]
|
||||||
|
);
|
||||||
|
|
||||||
|
const state = useCommandPaletteState(initialCommands);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandPaletteContext.Provider value={state}>
|
||||||
|
{children}
|
||||||
|
<CommandPalette
|
||||||
|
commands={state.commands}
|
||||||
|
isOpen={state.isOpen}
|
||||||
|
onClose={state.close}
|
||||||
|
/>
|
||||||
|
</CommandPaletteContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { CommandPaletteProvider } from './CommandPaletteProvider';
|
||||||
|
import type { Command } from './types';
|
||||||
|
|
||||||
|
interface CommandPaletteWithRouterProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Additional commands to include */
|
||||||
|
additionalCommands?: Command[];
|
||||||
|
/** Whether to include default navigation commands */
|
||||||
|
includeDefaultCommands?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command Palette Provider that uses React Router's useNavigate
|
||||||
|
* Must be used inside a Router context (BrowserRouter, RouterProvider, etc.)
|
||||||
|
*/
|
||||||
|
export function CommandPaletteWithRouter({
|
||||||
|
children,
|
||||||
|
additionalCommands = [],
|
||||||
|
includeDefaultCommands = true,
|
||||||
|
}: CommandPaletteWithRouterProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandPaletteProvider
|
||||||
|
additionalCommands={additionalCommands}
|
||||||
|
includeDefaultCommands={includeDefaultCommands}
|
||||||
|
navigate={navigate}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</CommandPaletteProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/shared/components/organisms/CommandPalette/index.ts
Normal file
22
src/shared/components/organisms/CommandPalette/index.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
export { CommandPalette } from './CommandPalette';
|
||||||
|
export { CommandPaletteItem } from './CommandPaletteItem';
|
||||||
|
export { CommandPaletteGroup } from './CommandPaletteGroup';
|
||||||
|
export { CommandPaletteProvider } from './CommandPaletteProvider';
|
||||||
|
export { CommandPaletteWithRouter } from './CommandPaletteWithRouter';
|
||||||
|
export {
|
||||||
|
useCommandPalette,
|
||||||
|
useCommandPaletteState,
|
||||||
|
useRegisterCommand,
|
||||||
|
useRegisterCommands,
|
||||||
|
CommandPaletteContext,
|
||||||
|
fuzzySearchCommands,
|
||||||
|
groupCommands,
|
||||||
|
} from './useCommandPalette';
|
||||||
|
export type {
|
||||||
|
Command,
|
||||||
|
CommandGroup,
|
||||||
|
CommandPaletteProps,
|
||||||
|
CommandPaletteItemProps,
|
||||||
|
CommandPaletteGroupProps,
|
||||||
|
CommandPaletteContextValue,
|
||||||
|
} from './types';
|
||||||
101
src/shared/components/organisms/CommandPalette/types.ts
Normal file
101
src/shared/components/organisms/CommandPalette/types.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a single command in the command palette
|
||||||
|
*/
|
||||||
|
export interface Command {
|
||||||
|
/** Unique identifier for the command */
|
||||||
|
id: string;
|
||||||
|
/** Display title for the command */
|
||||||
|
title: string;
|
||||||
|
/** Optional description shown below the title */
|
||||||
|
description?: string;
|
||||||
|
/** Optional icon component */
|
||||||
|
icon?: ComponentType<{ className?: string }>;
|
||||||
|
/** Keyboard shortcut keys (e.g., ['Ctrl', 'N']) */
|
||||||
|
shortcut?: string[];
|
||||||
|
/** Function to execute when command is selected */
|
||||||
|
action: () => void;
|
||||||
|
/** Group identifier for grouping commands */
|
||||||
|
group?: string;
|
||||||
|
/** Additional search terms for fuzzy matching */
|
||||||
|
keywords?: string[];
|
||||||
|
/** Whether the command is disabled */
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a group of commands
|
||||||
|
*/
|
||||||
|
export interface CommandGroup {
|
||||||
|
/** Unique identifier for the group */
|
||||||
|
id: string;
|
||||||
|
/** Display title for the group */
|
||||||
|
title: string;
|
||||||
|
/** Commands in this group */
|
||||||
|
commands: Command[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the CommandPalette component
|
||||||
|
*/
|
||||||
|
export interface CommandPaletteProps {
|
||||||
|
/** Array of commands to display */
|
||||||
|
commands: Command[];
|
||||||
|
/** Placeholder text for the search input */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Callback when the palette is closed */
|
||||||
|
onClose?: () => void;
|
||||||
|
/** Whether the palette is open */
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for a single command palette item
|
||||||
|
*/
|
||||||
|
export interface CommandPaletteItemProps {
|
||||||
|
/** The command to display */
|
||||||
|
command: Command;
|
||||||
|
/** Whether the item is selected */
|
||||||
|
isSelected: boolean;
|
||||||
|
/** Callback when the item is clicked */
|
||||||
|
onClick: () => void;
|
||||||
|
/** Callback when the item is hovered */
|
||||||
|
onMouseEnter: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for a command group header
|
||||||
|
*/
|
||||||
|
export interface CommandPaletteGroupProps {
|
||||||
|
/** The group to display */
|
||||||
|
group: CommandGroup;
|
||||||
|
/** Index of the currently selected item */
|
||||||
|
selectedIndex: number;
|
||||||
|
/** Base index for the group items */
|
||||||
|
baseIndex: number;
|
||||||
|
/** Callback when an item is clicked */
|
||||||
|
onItemClick: (command: Command) => void;
|
||||||
|
/** Callback when an item is hovered */
|
||||||
|
onItemHover: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context value for the command palette provider
|
||||||
|
*/
|
||||||
|
export interface CommandPaletteContextValue {
|
||||||
|
/** Whether the palette is open */
|
||||||
|
isOpen: boolean;
|
||||||
|
/** Open the command palette */
|
||||||
|
open: () => void;
|
||||||
|
/** Close the command palette */
|
||||||
|
close: () => void;
|
||||||
|
/** Toggle the command palette */
|
||||||
|
toggle: () => void;
|
||||||
|
/** Register a command */
|
||||||
|
registerCommand: (command: Command) => void;
|
||||||
|
/** Unregister a command by id */
|
||||||
|
unregisterCommand: (id: string) => void;
|
||||||
|
/** All registered commands */
|
||||||
|
commands: Command[];
|
||||||
|
}
|
||||||
@ -0,0 +1,195 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import type { Command, CommandPaletteContextValue } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context for the command palette
|
||||||
|
*/
|
||||||
|
export const CommandPaletteContext = createContext<CommandPaletteContextValue | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access the command palette context
|
||||||
|
* @throws Error if used outside CommandPaletteProvider
|
||||||
|
*/
|
||||||
|
export function useCommandPalette(): CommandPaletteContextValue {
|
||||||
|
const context = useContext(CommandPaletteContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useCommandPalette must be used within a CommandPaletteProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to create command palette state and actions
|
||||||
|
* Used internally by CommandPaletteProvider
|
||||||
|
*/
|
||||||
|
export function useCommandPaletteState(initialCommands: Command[] = []) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [commands, setCommands] = useState<Command[]>(initialCommands);
|
||||||
|
|
||||||
|
const open = useCallback(() => setIsOpen(true), []);
|
||||||
|
const close = useCallback(() => setIsOpen(false), []);
|
||||||
|
const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
|
||||||
|
|
||||||
|
const registerCommand = useCallback((command: Command) => {
|
||||||
|
setCommands((prev) => {
|
||||||
|
// Replace if exists, otherwise add
|
||||||
|
const exists = prev.some((c) => c.id === command.id);
|
||||||
|
if (exists) {
|
||||||
|
return prev.map((c) => (c.id === command.id ? command : c));
|
||||||
|
}
|
||||||
|
return [...prev, command];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unregisterCommand = useCallback((id: string) => {
|
||||||
|
setCommands((prev) => prev.filter((c) => c.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Global keyboard listener for Cmd+K / Ctrl+K
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
// Check for Cmd+K (Mac) or Ctrl+K (Windows/Linux)
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
||||||
|
event.preventDefault();
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape to close
|
||||||
|
if (event.key === 'Escape' && isOpen) {
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, toggle, close]);
|
||||||
|
|
||||||
|
const value = useMemo<CommandPaletteContextValue>(
|
||||||
|
() => ({
|
||||||
|
isOpen,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
toggle,
|
||||||
|
registerCommand,
|
||||||
|
unregisterCommand,
|
||||||
|
commands,
|
||||||
|
}),
|
||||||
|
[isOpen, open, close, toggle, registerCommand, unregisterCommand, commands]
|
||||||
|
);
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to register a command when a component mounts
|
||||||
|
* Automatically unregisters on unmount
|
||||||
|
*/
|
||||||
|
export function useRegisterCommand(command: Command) {
|
||||||
|
const { registerCommand, unregisterCommand } = useCommandPalette();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerCommand(command);
|
||||||
|
return () => unregisterCommand(command.id);
|
||||||
|
}, [command, registerCommand, unregisterCommand]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to register multiple commands
|
||||||
|
*/
|
||||||
|
export function useRegisterCommands(commands: Command[]) {
|
||||||
|
const { registerCommand, unregisterCommand } = useCommandPalette();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
commands.forEach(registerCommand);
|
||||||
|
return () => {
|
||||||
|
commands.forEach((cmd) => unregisterCommand(cmd.id));
|
||||||
|
};
|
||||||
|
}, [commands, registerCommand, unregisterCommand]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuzzy search implementation for commands
|
||||||
|
* Searches in title, description, and keywords
|
||||||
|
*/
|
||||||
|
export function fuzzySearchCommands(commands: Command[], query: string): Command[] {
|
||||||
|
if (!query.trim()) {
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchTerm = query.toLowerCase().trim();
|
||||||
|
const searchTerms = searchTerm.split(/\s+/);
|
||||||
|
|
||||||
|
return commands
|
||||||
|
.map((command) => {
|
||||||
|
const searchableText = [
|
||||||
|
command.title,
|
||||||
|
command.description || '',
|
||||||
|
...(command.keywords || []),
|
||||||
|
command.group || '',
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
// Calculate score based on matches
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// Exact title match gets highest score
|
||||||
|
if (command.title.toLowerCase() === searchTerm) {
|
||||||
|
score += 100;
|
||||||
|
} else if (command.title.toLowerCase().startsWith(searchTerm)) {
|
||||||
|
score += 75;
|
||||||
|
} else if (command.title.toLowerCase().includes(searchTerm)) {
|
||||||
|
score += 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each search term
|
||||||
|
for (const term of searchTerms) {
|
||||||
|
if (searchableText.includes(term)) {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check keywords specifically
|
||||||
|
if (command.keywords) {
|
||||||
|
for (const keyword of command.keywords) {
|
||||||
|
if (keyword.toLowerCase().includes(searchTerm)) {
|
||||||
|
score += 25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { command, score };
|
||||||
|
})
|
||||||
|
.filter(({ score }) => score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.map(({ command }) => command);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group commands by their group property
|
||||||
|
*/
|
||||||
|
export function groupCommands(commands: Command[]) {
|
||||||
|
const groups: Map<string, Command[]> = new Map();
|
||||||
|
const ungrouped: Command[] = [];
|
||||||
|
|
||||||
|
for (const command of commands) {
|
||||||
|
if (command.group) {
|
||||||
|
const existing = groups.get(command.group) || [];
|
||||||
|
groups.set(command.group, [...existing, command]);
|
||||||
|
} else {
|
||||||
|
ungrouped.push(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { groups, ungrouped };
|
||||||
|
}
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { usePermissions } from '@shared/hooks/usePermissions';
|
||||||
|
import type { Widget, DashboardConfig, WidgetSize } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget size classes mapping
|
||||||
|
*/
|
||||||
|
const SIZE_CLASSES: Record<WidgetSize, string> = {
|
||||||
|
sm: 'col-span-1',
|
||||||
|
md: 'col-span-1 lg:col-span-2',
|
||||||
|
lg: 'col-span-1 lg:col-span-3',
|
||||||
|
full: 'col-span-full',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DashboardGridProps {
|
||||||
|
config: DashboardConfig;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DashboardGrid - Grid layout for dashboard widgets
|
||||||
|
* @description Renders widgets filtered by permissions in a responsive grid
|
||||||
|
*/
|
||||||
|
export function DashboardGrid({ config, className }: DashboardGridProps) {
|
||||||
|
const { hasAnyPermission, hasAnyRole, isAdmin } = usePermissions();
|
||||||
|
|
||||||
|
// Filter widgets based on permissions and roles
|
||||||
|
const visibleWidgets = useMemo(() => {
|
||||||
|
return config.widgets.filter((widget) => {
|
||||||
|
// Check default visibility
|
||||||
|
if (widget.defaultVisible === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin sees all widgets
|
||||||
|
if (isAdmin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check required permission
|
||||||
|
if (widget.requiredPermission) {
|
||||||
|
if (!hasAnyPermission(widget.requiredPermission)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check required roles
|
||||||
|
if (widget.requiredRole && widget.requiredRole.length > 0) {
|
||||||
|
if (!hasAnyRole(...widget.requiredRole)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [config.widgets, isAdmin, hasAnyPermission, hasAnyRole]);
|
||||||
|
|
||||||
|
if (visibleWidgets.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
No hay widgets disponibles para mostrar
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid gap-6',
|
||||||
|
config.layout === 'masonry'
|
||||||
|
? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
|
||||||
|
: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{visibleWidgets.map((widget) => {
|
||||||
|
const WidgetComponent = widget.component;
|
||||||
|
const sizeClass = SIZE_CLASSES[widget.size || 'md'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={widget.id} className={sizeClass}>
|
||||||
|
<WidgetComponent />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create a widget configuration
|
||||||
|
*/
|
||||||
|
export function createWidget<T extends object>(
|
||||||
|
config: Omit<Widget, 'component'> & {
|
||||||
|
component: React.ComponentType<T>;
|
||||||
|
}
|
||||||
|
): Widget {
|
||||||
|
return config as Widget;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create a dashboard configuration
|
||||||
|
*/
|
||||||
|
export function createDashboardConfig(
|
||||||
|
widgets: Widget[],
|
||||||
|
options?: Partial<Omit<DashboardConfig, 'widgets'>>
|
||||||
|
): DashboardConfig {
|
||||||
|
return {
|
||||||
|
widgets,
|
||||||
|
layout: options?.layout || 'grid',
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,117 @@
|
|||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { LineChart } from '../Chart/LineChart';
|
||||||
|
import { BarChart } from '../Chart/BarChart';
|
||||||
|
import { AreaChart } from '../Chart/AreaChart';
|
||||||
|
import type { PerformanceChartProps } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo chart data
|
||||||
|
*/
|
||||||
|
const DEMO_DATA = [
|
||||||
|
{ name: 'Ene', value: 12000, ventas: 12000, gastos: 8000 },
|
||||||
|
{ name: 'Feb', value: 15000, ventas: 15000, gastos: 9500 },
|
||||||
|
{ name: 'Mar', value: 18000, ventas: 18000, gastos: 10000 },
|
||||||
|
{ name: 'Abr', value: 14000, ventas: 14000, gastos: 8500 },
|
||||||
|
{ name: 'May', value: 21000, ventas: 21000, gastos: 11000 },
|
||||||
|
{ name: 'Jun', value: 25000, ventas: 25000, gastos: 12500 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PerformanceChart - Chart widget wrapper
|
||||||
|
* @description Wraps chart components with loading state and title
|
||||||
|
*/
|
||||||
|
export function PerformanceChart({
|
||||||
|
title: _title = 'Rendimiento',
|
||||||
|
data = DEMO_DATA,
|
||||||
|
type = 'line',
|
||||||
|
height = 250,
|
||||||
|
className,
|
||||||
|
loading = false,
|
||||||
|
}: PerformanceChartProps) {
|
||||||
|
if (loading) {
|
||||||
|
return <PerformanceChartSkeleton height={height} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center text-gray-500',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
No hay datos disponibles
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartProps = {
|
||||||
|
data,
|
||||||
|
height,
|
||||||
|
showGrid: true,
|
||||||
|
showLegend: false,
|
||||||
|
showTooltip: true,
|
||||||
|
series: [
|
||||||
|
{ dataKey: 'value', name: 'Valor', color: '#3B82F6' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderChart = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'bar':
|
||||||
|
return <BarChart {...chartProps} />;
|
||||||
|
case 'area':
|
||||||
|
return <AreaChart {...chartProps} />;
|
||||||
|
case 'line':
|
||||||
|
default:
|
||||||
|
return <LineChart {...chartProps} curved showDots />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div className={className}>{renderChart()}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading skeleton for PerformanceChart
|
||||||
|
*/
|
||||||
|
function PerformanceChartSkeleton({
|
||||||
|
height,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
height: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('animate-pulse', className)}
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
<div className="w-full h-full bg-gray-100 rounded-lg flex items-end justify-around p-4 gap-2">
|
||||||
|
{/* Simulated bar chart skeleton */}
|
||||||
|
{[40, 60, 45, 80, 55, 70, 65].map((h, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-gray-200 rounded-t w-full"
|
||||||
|
style={{ height: `${h}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-configured performance charts for common use cases
|
||||||
|
*/
|
||||||
|
export function SalesPerformanceChart(props: Omit<PerformanceChartProps, 'title'>) {
|
||||||
|
return <PerformanceChart title="Ventas" type="area" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RevenuePerformanceChart(props: Omit<PerformanceChartProps, 'title'>) {
|
||||||
|
return <PerformanceChart title="Ingresos" type="line" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrdersPerformanceChart(props: Omit<PerformanceChartProps, 'title'>) {
|
||||||
|
return <PerformanceChart title="Pedidos" type="bar" {...props} />;
|
||||||
|
}
|
||||||
@ -0,0 +1,187 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Users,
|
||||||
|
ShoppingCart,
|
||||||
|
FileText,
|
||||||
|
Package,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { usePermissions } from '@shared/hooks/usePermissions';
|
||||||
|
import type { QuickActionsProps, QuickActionItem } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default quick actions for ERP
|
||||||
|
*/
|
||||||
|
const DEFAULT_ACTIONS: QuickActionItem[] = [
|
||||||
|
{
|
||||||
|
id: 'new-sale',
|
||||||
|
label: 'Nueva Venta',
|
||||||
|
icon: ShoppingCart,
|
||||||
|
href: '/sales/orders/new',
|
||||||
|
permission: 'sales:create',
|
||||||
|
variant: 'primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'new-customer',
|
||||||
|
label: 'Nuevo Cliente',
|
||||||
|
icon: Users,
|
||||||
|
href: '/customers/new',
|
||||||
|
permission: 'customers:create',
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'new-invoice',
|
||||||
|
label: 'Nueva Factura',
|
||||||
|
icon: FileText,
|
||||||
|
href: '/invoices/new',
|
||||||
|
permission: 'invoices:create',
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'new-product',
|
||||||
|
label: 'Nuevo Producto',
|
||||||
|
icon: Package,
|
||||||
|
href: '/products/new',
|
||||||
|
permission: 'products:create',
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QuickActions - Grid of quick action buttons
|
||||||
|
* @description Displays action buttons filtered by user permissions
|
||||||
|
*/
|
||||||
|
export function QuickActions({
|
||||||
|
actions = DEFAULT_ACTIONS,
|
||||||
|
columns = 4,
|
||||||
|
className,
|
||||||
|
}: QuickActionsProps) {
|
||||||
|
const { hasAnyPermission, isAdmin } = usePermissions();
|
||||||
|
|
||||||
|
// Filter actions based on permissions
|
||||||
|
const filteredActions = actions.filter((action) => {
|
||||||
|
if (isAdmin) return true;
|
||||||
|
if (!action.permission) return true;
|
||||||
|
return hasAnyPermission(action.permission);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filteredActions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-4 text-gray-500">
|
||||||
|
No hay acciones disponibles
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid gap-3',
|
||||||
|
columns === 2 && 'grid-cols-2',
|
||||||
|
columns === 3 && 'grid-cols-2 sm:grid-cols-3',
|
||||||
|
columns === 4 && 'grid-cols-2 sm:grid-cols-4',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{filteredActions.map((action) => (
|
||||||
|
<QuickActionButton key={action.id} action={action} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual quick action button
|
||||||
|
*/
|
||||||
|
function QuickActionButton({ action }: { action: QuickActionItem }) {
|
||||||
|
const { label, icon: Icon, href, onClick, variant = 'default' } = action;
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
default:
|
||||||
|
'bg-gray-50 hover:bg-gray-100 text-gray-700 border-gray-200',
|
||||||
|
primary:
|
||||||
|
'bg-primary-50 hover:bg-primary-100 text-primary-700 border-primary-200',
|
||||||
|
success:
|
||||||
|
'bg-success-50 hover:bg-success-100 text-success-700 border-success-200',
|
||||||
|
warning:
|
||||||
|
'bg-warning-50 hover:bg-warning-100 text-warning-700 border-warning-200',
|
||||||
|
danger:
|
||||||
|
'bg-danger-50 hover:bg-danger-100 text-danger-700 border-danger-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonContent = (
|
||||||
|
<>
|
||||||
|
<Icon className="h-5 w-5 mb-1" />
|
||||||
|
<span className="text-xs font-medium">{label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseClasses = cn(
|
||||||
|
'flex flex-col items-center justify-center p-4 rounded-lg border',
|
||||||
|
'transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||||
|
variantClasses[variant]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link to={href} className={baseClasses}>
|
||||||
|
{buttonContent}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} className={baseClasses}>
|
||||||
|
{buttonContent}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick action button for creating a custom action
|
||||||
|
*/
|
||||||
|
export interface CreateQuickActionProps {
|
||||||
|
label: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateQuickAction({
|
||||||
|
label,
|
||||||
|
icon: Icon = Plus,
|
||||||
|
href,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
}: CreateQuickActionProps) {
|
||||||
|
const baseClasses = cn(
|
||||||
|
'flex items-center gap-2 px-4 py-2 rounded-lg border border-dashed',
|
||||||
|
'border-gray-300 text-gray-600 hover:border-primary-400 hover:text-primary-600',
|
||||||
|
'transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500',
|
||||||
|
className
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link to={href} className={baseClasses}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} className={baseClasses}>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,211 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
LogIn,
|
||||||
|
Activity,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { formatDistanceToNow } from '@utils/formatters';
|
||||||
|
import type { RecentActivityProps, ActivityItem } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity type configuration
|
||||||
|
*/
|
||||||
|
const ACTIVITY_CONFIG: Record<
|
||||||
|
ActivityItem['type'],
|
||||||
|
{ icon: LucideIcon; color: string; bgColor: string }
|
||||||
|
> = {
|
||||||
|
create: {
|
||||||
|
icon: Plus,
|
||||||
|
color: 'text-success-600',
|
||||||
|
bgColor: 'bg-success-50',
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
icon: Pencil,
|
||||||
|
color: 'text-primary-600',
|
||||||
|
bgColor: 'bg-primary-50',
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
icon: Trash2,
|
||||||
|
color: 'text-danger-600',
|
||||||
|
bgColor: 'bg-danger-50',
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
icon: LogIn,
|
||||||
|
color: 'text-gray-600',
|
||||||
|
bgColor: 'bg-gray-100',
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
icon: Activity,
|
||||||
|
color: 'text-gray-600',
|
||||||
|
bgColor: 'bg-gray-100',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo activities for development
|
||||||
|
*/
|
||||||
|
const DEMO_ACTIVITIES: ActivityItem[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
type: 'create',
|
||||||
|
message: 'Se creó la orden de venta SO-001239',
|
||||||
|
user: 'Juan Pérez',
|
||||||
|
timestamp: new Date(Date.now() - 5 * 60 * 1000),
|
||||||
|
link: '/sales/orders/SO-001239',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
type: 'update',
|
||||||
|
message: 'Se actualizó el cliente Empresa ABC',
|
||||||
|
user: 'María García',
|
||||||
|
timestamp: new Date(Date.now() - 30 * 60 * 1000),
|
||||||
|
link: '/customers/1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
type: 'create',
|
||||||
|
message: 'Se registró un nuevo producto: Widget X',
|
||||||
|
user: 'Carlos López',
|
||||||
|
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000),
|
||||||
|
link: '/products/45',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
type: 'login',
|
||||||
|
message: 'Inicio de sesión desde nueva ubicación',
|
||||||
|
timestamp: new Date(Date.now() - 4 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
type: 'delete',
|
||||||
|
message: 'Se eliminó la cotización QT-000123',
|
||||||
|
user: 'Ana Martínez',
|
||||||
|
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RecentActivity - Activity feed widget
|
||||||
|
* @description Displays a list of recent system activities
|
||||||
|
*/
|
||||||
|
export function RecentActivity({
|
||||||
|
activities = DEMO_ACTIVITIES,
|
||||||
|
maxItems = 5,
|
||||||
|
className,
|
||||||
|
loading = false,
|
||||||
|
}: RecentActivityProps) {
|
||||||
|
if (loading) {
|
||||||
|
return <RecentActivitySkeleton count={maxItems} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayedActivities = activities.slice(0, maxItems);
|
||||||
|
|
||||||
|
if (displayedActivities.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={cn('text-center py-8 text-gray-500', className)}>
|
||||||
|
No hay actividad reciente
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
{displayedActivities.map((activity, index) => (
|
||||||
|
<ActivityItemRow
|
||||||
|
key={activity.id}
|
||||||
|
activity={activity}
|
||||||
|
isLast={index === displayedActivities.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual activity row
|
||||||
|
*/
|
||||||
|
function ActivityItemRow({
|
||||||
|
activity,
|
||||||
|
isLast,
|
||||||
|
}: {
|
||||||
|
activity: ActivityItem;
|
||||||
|
isLast: boolean;
|
||||||
|
}) {
|
||||||
|
const config = ACTIVITY_CONFIG[activity.type];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
const timeAgo = formatDistanceToNow(
|
||||||
|
typeof activity.timestamp === 'string'
|
||||||
|
? new Date(activity.timestamp)
|
||||||
|
: activity.timestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-full flex-shrink-0',
|
||||||
|
config.bgColor
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn('h-4 w-4', config.color)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-gray-900 line-clamp-2">{activity.message}</p>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
{activity.user && <span>{activity.user}</span>}
|
||||||
|
{activity.user && <span>·</span>}
|
||||||
|
<span>{timeAgo}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const containerClasses = cn(
|
||||||
|
'block py-3',
|
||||||
|
!isLast && 'border-b border-gray-100',
|
||||||
|
activity.link && 'hover:bg-gray-50 -mx-2 px-2 rounded-md transition-colors'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activity.link) {
|
||||||
|
return (
|
||||||
|
<Link to={activity.link} className={containerClasses}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={containerClasses}>{content}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading skeleton for RecentActivity
|
||||||
|
*/
|
||||||
|
function RecentActivitySkeleton({
|
||||||
|
count,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
count: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
|
<div key={index} className="flex items-start gap-3 animate-pulse">
|
||||||
|
<div className="h-8 w-8 bg-gray-200 rounded-full" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-3/4" />
|
||||||
|
<div className="mt-2 h-3 bg-gray-200 rounded w-1/3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/shared/components/organisms/DashboardWidgets/StatCard.tsx
Normal file
148
src/shared/components/organisms/DashboardWidgets/StatCard.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { TrendingUp, TrendingDown, Minus, type LucideIcon } from 'lucide-react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { formatCurrency } from '@utils/formatters';
|
||||||
|
import type { StatCardProps } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatCard - KPI stat card for dashboard
|
||||||
|
* @description Displays a metric with optional trend indicator and icon
|
||||||
|
*/
|
||||||
|
export function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
change,
|
||||||
|
changeType = 'neutral',
|
||||||
|
changeLabel = 'vs mes anterior',
|
||||||
|
icon: Icon,
|
||||||
|
format = 'number',
|
||||||
|
className,
|
||||||
|
loading = false,
|
||||||
|
}: StatCardProps) {
|
||||||
|
const formattedValue = formatValue(value, format);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <StatCardSkeleton className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border bg-white shadow-sm p-6',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-500 truncate">{title}</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-gray-900 truncate">
|
||||||
|
{formattedValue}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{Icon && (
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary-50 flex-shrink-0 ml-4">
|
||||||
|
<Icon className="h-6 w-6 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{change !== undefined && (
|
||||||
|
<div className="mt-4 flex items-center">
|
||||||
|
<TrendIndicator type={changeType} />
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'ml-1 text-sm font-medium',
|
||||||
|
changeType === 'increase' && 'text-success-600',
|
||||||
|
changeType === 'decrease' && 'text-danger-600',
|
||||||
|
changeType === 'neutral' && 'text-gray-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{changeType === 'increase' && '+'}
|
||||||
|
{change}%
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-sm text-gray-500">{changeLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format value based on format type
|
||||||
|
*/
|
||||||
|
function formatValue(
|
||||||
|
value: number | string,
|
||||||
|
format: 'currency' | 'number' | 'percent'
|
||||||
|
): string {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'currency':
|
||||||
|
return formatCurrency(value);
|
||||||
|
case 'percent':
|
||||||
|
return `${value.toFixed(1)}%`;
|
||||||
|
case 'number':
|
||||||
|
default:
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trend indicator icon
|
||||||
|
*/
|
||||||
|
function TrendIndicator({
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
type: 'increase' | 'decrease' | 'neutral';
|
||||||
|
}) {
|
||||||
|
const iconProps = { className: 'h-4 w-4' };
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'increase':
|
||||||
|
return <TrendingUp {...iconProps} className={cn(iconProps.className, 'text-success-500')} />;
|
||||||
|
case 'decrease':
|
||||||
|
return <TrendingDown {...iconProps} className={cn(iconProps.className, 'text-danger-500')} />;
|
||||||
|
case 'neutral':
|
||||||
|
default:
|
||||||
|
return <Minus {...iconProps} className={cn(iconProps.className, 'text-gray-400')} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading skeleton for StatCard
|
||||||
|
*/
|
||||||
|
function StatCardSkeleton({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={cn('rounded-lg border bg-white shadow-sm p-6', className)}>
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-24" />
|
||||||
|
<div className="mt-2 h-8 bg-gray-200 rounded w-32" />
|
||||||
|
</div>
|
||||||
|
<div className="h-12 w-12 bg-gray-200 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
<div className="h-4 w-4 bg-gray-200 rounded" />
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatCard with custom icon component
|
||||||
|
* @description Convenience component for StatCard with explicit icon prop
|
||||||
|
*/
|
||||||
|
export interface StatCardWithIconProps extends Omit<StatCardProps, 'icon'> {
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCardWithIcon({
|
||||||
|
icon: Icon,
|
||||||
|
...props
|
||||||
|
}: StatCardWithIconProps) {
|
||||||
|
return <StatCard {...props} icon={Icon} />;
|
||||||
|
}
|
||||||
@ -0,0 +1,281 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
Circle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { formatDistanceToNow } from '@utils/formatters';
|
||||||
|
import type { UpcomingTasksProps, TaskItem } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priority configuration
|
||||||
|
*/
|
||||||
|
const PRIORITY_CONFIG: Record<
|
||||||
|
NonNullable<TaskItem['priority']>,
|
||||||
|
{ label: string; color: string; bgColor: string }
|
||||||
|
> = {
|
||||||
|
low: {
|
||||||
|
label: 'Baja',
|
||||||
|
color: 'text-gray-600',
|
||||||
|
bgColor: 'bg-gray-100',
|
||||||
|
},
|
||||||
|
medium: {
|
||||||
|
label: 'Media',
|
||||||
|
color: 'text-primary-600',
|
||||||
|
bgColor: 'bg-primary-50',
|
||||||
|
},
|
||||||
|
high: {
|
||||||
|
label: 'Alta',
|
||||||
|
color: 'text-warning-600',
|
||||||
|
bgColor: 'bg-warning-50',
|
||||||
|
},
|
||||||
|
urgent: {
|
||||||
|
label: 'Urgente',
|
||||||
|
color: 'text-danger-600',
|
||||||
|
bgColor: 'bg-danger-50',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status configuration
|
||||||
|
*/
|
||||||
|
const STATUS_CONFIG: Record<
|
||||||
|
NonNullable<TaskItem['status']>,
|
||||||
|
{ icon: typeof Circle; color: string }
|
||||||
|
> = {
|
||||||
|
pending: {
|
||||||
|
icon: Circle,
|
||||||
|
color: 'text-gray-400',
|
||||||
|
},
|
||||||
|
in_progress: {
|
||||||
|
icon: Clock,
|
||||||
|
color: 'text-primary-500',
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
icon: CheckCircle2,
|
||||||
|
color: 'text-success-500',
|
||||||
|
},
|
||||||
|
overdue: {
|
||||||
|
icon: AlertCircle,
|
||||||
|
color: 'text-danger-500',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo tasks for development
|
||||||
|
*/
|
||||||
|
const DEMO_TASKS: TaskItem[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Revisar cotización pendiente QT-001234',
|
||||||
|
dueDate: new Date(Date.now() + 2 * 60 * 60 * 1000),
|
||||||
|
priority: 'urgent',
|
||||||
|
status: 'pending',
|
||||||
|
assignee: 'Yo',
|
||||||
|
link: '/sales/quotations/QT-001234',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Contactar cliente Empresa XYZ',
|
||||||
|
dueDate: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||||
|
priority: 'high',
|
||||||
|
status: 'in_progress',
|
||||||
|
link: '/customers/5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: 'Actualizar inventario productos importados',
|
||||||
|
dueDate: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
|
||||||
|
priority: 'medium',
|
||||||
|
status: 'pending',
|
||||||
|
assignee: 'Carlos López',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
title: 'Generar reporte mensual de ventas',
|
||||||
|
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||||
|
priority: 'low',
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
title: 'Revisar facturas vencidas',
|
||||||
|
dueDate: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||||
|
priority: 'urgent',
|
||||||
|
status: 'overdue',
|
||||||
|
link: '/invoices?filter=overdue',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UpcomingTasks - Tasks/reminders widget
|
||||||
|
* @description Displays a list of upcoming tasks and deadlines
|
||||||
|
*/
|
||||||
|
export function UpcomingTasks({
|
||||||
|
tasks = DEMO_TASKS,
|
||||||
|
maxItems = 5,
|
||||||
|
className,
|
||||||
|
loading = false,
|
||||||
|
}: UpcomingTasksProps) {
|
||||||
|
if (loading) {
|
||||||
|
return <UpcomingTasksSkeleton count={maxItems} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort tasks by due date and priority
|
||||||
|
const sortedTasks = [...tasks]
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Overdue tasks first
|
||||||
|
const aOverdue = a.status === 'overdue';
|
||||||
|
const bOverdue = b.status === 'overdue';
|
||||||
|
if (aOverdue && !bOverdue) return -1;
|
||||||
|
if (!aOverdue && bOverdue) return 1;
|
||||||
|
|
||||||
|
// Then by due date
|
||||||
|
const aDate = a.dueDate ? new Date(a.dueDate).getTime() : Infinity;
|
||||||
|
const bDate = b.dueDate ? new Date(b.dueDate).getTime() : Infinity;
|
||||||
|
return aDate - bDate;
|
||||||
|
})
|
||||||
|
.slice(0, maxItems);
|
||||||
|
|
||||||
|
if (sortedTasks.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={cn('text-center py-8 text-gray-500', className)}>
|
||||||
|
<Calendar className="h-8 w-8 mx-auto mb-2 text-gray-400" />
|
||||||
|
<p>No hay tareas pendientes</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-3', className)}>
|
||||||
|
{sortedTasks.map((task) => (
|
||||||
|
<TaskItemRow key={task.id} task={task} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual task row
|
||||||
|
*/
|
||||||
|
function TaskItemRow({ task }: { task: TaskItem }) {
|
||||||
|
const status = task.status || 'pending';
|
||||||
|
const statusConfig = STATUS_CONFIG[status];
|
||||||
|
const StatusIcon = statusConfig.icon;
|
||||||
|
|
||||||
|
const priorityConfig = task.priority
|
||||||
|
? PRIORITY_CONFIG[task.priority]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const dueDate = task.dueDate
|
||||||
|
? typeof task.dueDate === 'string'
|
||||||
|
? new Date(task.dueDate)
|
||||||
|
: task.dueDate
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isOverdue =
|
||||||
|
status === 'overdue' || (dueDate && dueDate < new Date() && status !== 'completed');
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Status icon */}
|
||||||
|
<StatusIcon className={cn('h-5 w-5 mt-0.5 flex-shrink-0', statusConfig.color)} />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium line-clamp-2',
|
||||||
|
status === 'completed' ? 'text-gray-400 line-through' : 'text-gray-900'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
{/* Due date */}
|
||||||
|
{dueDate && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-xs',
|
||||||
|
isOverdue ? 'text-danger-600 font-medium' : 'text-gray-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isOverdue
|
||||||
|
? `Vencida hace ${formatDistanceToNow(dueDate)}`
|
||||||
|
: `Vence ${formatDistanceToNow(dueDate)}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Priority badge */}
|
||||||
|
{priorityConfig && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium',
|
||||||
|
priorityConfig.bgColor,
|
||||||
|
priorityConfig.color
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{priorityConfig.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Assignee */}
|
||||||
|
{task.assignee && (
|
||||||
|
<span className="text-xs text-gray-500">· {task.assignee}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const containerClasses = cn(
|
||||||
|
'block p-3 rounded-lg border border-gray-100',
|
||||||
|
'hover:border-gray-200 hover:bg-gray-50 transition-colors',
|
||||||
|
isOverdue && 'border-danger-200 bg-danger-50/30'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (task.link) {
|
||||||
|
return (
|
||||||
|
<Link to={task.link} className={containerClasses}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={containerClasses}>{content}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading skeleton for UpcomingTasks
|
||||||
|
*/
|
||||||
|
function UpcomingTasksSkeleton({
|
||||||
|
count,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
count: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-3', className)}>
|
||||||
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-start gap-3 p-3 rounded-lg border border-gray-100 animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="h-5 w-5 bg-gray-200 rounded-full" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-4/5" />
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-20" />
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-12" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,126 @@
|
|||||||
|
import { useState, useCallback, type ReactNode } from 'react';
|
||||||
|
import { ChevronDown, ChevronUp, AlertCircle } from 'lucide-react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import type { WidgetContainerProps } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WidgetContainer - Common wrapper for dashboard widgets
|
||||||
|
* @description Provides consistent header, actions, collapsible behavior, and loading/error states
|
||||||
|
*/
|
||||||
|
export function WidgetContainer({
|
||||||
|
title,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
collapsible = false,
|
||||||
|
defaultCollapsed = false,
|
||||||
|
loading = false,
|
||||||
|
error = null,
|
||||||
|
className,
|
||||||
|
headerClassName,
|
||||||
|
contentClassName,
|
||||||
|
}: WidgetContainerProps) {
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
||||||
|
|
||||||
|
const handleToggleCollapse = useCallback(() => {
|
||||||
|
if (collapsible) {
|
||||||
|
setIsCollapsed((prev) => !prev);
|
||||||
|
}
|
||||||
|
}, [collapsible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border bg-white shadow-sm overflow-hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between px-6 py-4 border-b',
|
||||||
|
collapsible && 'cursor-pointer hover:bg-gray-50 transition-colors',
|
||||||
|
headerClassName
|
||||||
|
)}
|
||||||
|
onClick={handleToggleCollapse}
|
||||||
|
role={collapsible ? 'button' : undefined}
|
||||||
|
tabIndex={collapsible ? 0 : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
collapsible
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleToggleCollapse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{actions && !isCollapsed && (
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>{actions}</div>
|
||||||
|
)}
|
||||||
|
{collapsible && (
|
||||||
|
<span className="text-gray-400">
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronDown className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className={cn('px-6 py-4', contentClassName)}>
|
||||||
|
{loading ? (
|
||||||
|
<WidgetLoadingSkeleton />
|
||||||
|
) : error ? (
|
||||||
|
<WidgetError message={error} />
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading skeleton for widget content
|
||||||
|
*/
|
||||||
|
function WidgetLoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-3/4" />
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-1/2" />
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-5/6" />
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-2/3" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error display for widget
|
||||||
|
*/
|
||||||
|
function WidgetError({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 text-danger-600 bg-danger-50 rounded-md p-4">
|
||||||
|
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||||
|
<p className="text-sm">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetActionsProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container for widget header actions
|
||||||
|
*/
|
||||||
|
export function WidgetActions({ children }: WidgetActionsProps) {
|
||||||
|
return <div className="flex items-center gap-2">{children}</div>;
|
||||||
|
}
|
||||||
25
src/shared/components/organisms/DashboardWidgets/index.ts
Normal file
25
src/shared/components/organisms/DashboardWidgets/index.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard Widgets
|
||||||
|
* @description Role-based dashboard widget components
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export * from './types';
|
||||||
|
|
||||||
|
// Core components
|
||||||
|
export { WidgetContainer, WidgetActions } from './WidgetContainer';
|
||||||
|
export { StatCard, StatCardWithIcon } from './StatCard';
|
||||||
|
export { QuickActions, CreateQuickAction } from './QuickActions';
|
||||||
|
export { RecentActivity } from './RecentActivity';
|
||||||
|
export { UpcomingTasks } from './UpcomingTasks';
|
||||||
|
export {
|
||||||
|
PerformanceChart,
|
||||||
|
SalesPerformanceChart,
|
||||||
|
RevenuePerformanceChart,
|
||||||
|
OrdersPerformanceChart,
|
||||||
|
} from './PerformanceChart';
|
||||||
|
export {
|
||||||
|
DashboardGrid,
|
||||||
|
createWidget,
|
||||||
|
createDashboardConfig,
|
||||||
|
} from './DashboardGrid';
|
||||||
160
src/shared/components/organisms/DashboardWidgets/types.ts
Normal file
160
src/shared/components/organisms/DashboardWidgets/types.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard Widget Types
|
||||||
|
* @description Type definitions for role-based dashboard widgets
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode, ComponentType } from 'react';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget size options for grid layout
|
||||||
|
*/
|
||||||
|
export type WidgetSize = 'sm' | 'md' | 'lg' | 'full';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget configuration
|
||||||
|
*/
|
||||||
|
export interface Widget {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
component: ComponentType<WidgetComponentProps>;
|
||||||
|
requiredPermission?: string;
|
||||||
|
requiredRole?: string[];
|
||||||
|
size?: WidgetSize;
|
||||||
|
defaultVisible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard layout configuration
|
||||||
|
*/
|
||||||
|
export interface DashboardConfig {
|
||||||
|
widgets: Widget[];
|
||||||
|
layout?: 'grid' | 'masonry';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props passed to widget components
|
||||||
|
*/
|
||||||
|
export interface WidgetComponentProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatCard props
|
||||||
|
*/
|
||||||
|
export interface StatCardProps {
|
||||||
|
title: string;
|
||||||
|
value: number | string;
|
||||||
|
change?: number;
|
||||||
|
changeType?: 'increase' | 'decrease' | 'neutral';
|
||||||
|
changeLabel?: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
format?: 'currency' | 'number' | 'percent';
|
||||||
|
className?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick action item
|
||||||
|
*/
|
||||||
|
export interface QuickActionItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
permission?: string;
|
||||||
|
variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QuickActions props
|
||||||
|
*/
|
||||||
|
export interface QuickActionsProps {
|
||||||
|
actions?: QuickActionItem[];
|
||||||
|
columns?: 2 | 3 | 4;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity item
|
||||||
|
*/
|
||||||
|
export interface ActivityItem {
|
||||||
|
id: string;
|
||||||
|
type: 'create' | 'update' | 'delete' | 'login' | 'other';
|
||||||
|
message: string;
|
||||||
|
user?: string;
|
||||||
|
timestamp: Date | string;
|
||||||
|
link?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RecentActivity props
|
||||||
|
*/
|
||||||
|
export interface RecentActivityProps {
|
||||||
|
activities?: ActivityItem[];
|
||||||
|
maxItems?: number;
|
||||||
|
className?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task item
|
||||||
|
*/
|
||||||
|
export interface TaskItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
dueDate?: Date | string;
|
||||||
|
priority?: 'low' | 'medium' | 'high' | 'urgent';
|
||||||
|
status?: 'pending' | 'in_progress' | 'completed' | 'overdue';
|
||||||
|
assignee?: string;
|
||||||
|
link?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UpcomingTasks props
|
||||||
|
*/
|
||||||
|
export interface UpcomingTasksProps {
|
||||||
|
tasks?: TaskItem[];
|
||||||
|
maxItems?: number;
|
||||||
|
className?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PerformanceChart props
|
||||||
|
*/
|
||||||
|
export interface PerformanceChartProps {
|
||||||
|
title?: string;
|
||||||
|
data?: Array<{ name: string; value: number; [key: string]: string | number }>;
|
||||||
|
type?: 'line' | 'bar' | 'area';
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WidgetContainer props
|
||||||
|
*/
|
||||||
|
export interface WidgetContainerProps {
|
||||||
|
title: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
collapsible?: boolean;
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
className?: string;
|
||||||
|
headerClassName?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget grid size classes mapping
|
||||||
|
*/
|
||||||
|
export const WIDGET_SIZE_CLASSES: Record<WidgetSize, string> = {
|
||||||
|
sm: 'col-span-1',
|
||||||
|
md: 'col-span-1 lg:col-span-2',
|
||||||
|
lg: 'col-span-1 lg:col-span-3',
|
||||||
|
full: 'col-span-full',
|
||||||
|
};
|
||||||
171
src/shared/components/organisms/Kanban/Kanban.tsx
Normal file
171
src/shared/components/organisms/Kanban/Kanban.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import { useState, type KeyboardEvent } from 'react';
|
||||||
|
import { Plus, X } from 'lucide-react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { KanbanColumn } from './KanbanColumn';
|
||||||
|
import type { KanbanProps, KanbanItem } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic Kanban board component
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const columns = [
|
||||||
|
* { id: 'todo', title: 'To Do', color: 'blue', items: [...] },
|
||||||
|
* { id: 'in-progress', title: 'In Progress', color: 'amber', items: [...] },
|
||||||
|
* { id: 'done', title: 'Done', color: 'green', items: [...] },
|
||||||
|
* ];
|
||||||
|
*
|
||||||
|
* <Kanban
|
||||||
|
* columns={columns}
|
||||||
|
* onItemMove={(itemId, from, to) => console.log('moved', itemId)}
|
||||||
|
* allowAddCard
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function Kanban({
|
||||||
|
columns,
|
||||||
|
onItemClick,
|
||||||
|
onItemMove,
|
||||||
|
onAddItem,
|
||||||
|
onColumnAdd,
|
||||||
|
renderCard,
|
||||||
|
renderColumnStats,
|
||||||
|
className,
|
||||||
|
allowAddCard = false,
|
||||||
|
allowAddColumn = false,
|
||||||
|
emptyColumnMessage = 'No items',
|
||||||
|
dragOverMessage = 'Drop here',
|
||||||
|
}: KanbanProps) {
|
||||||
|
const [isAddingColumn, setIsAddingColumn] = useState(false);
|
||||||
|
const [newColumnTitle, setNewColumnTitle] = useState('');
|
||||||
|
|
||||||
|
const handleDrop = (targetColumnId: string) => (item: KanbanItem, newIndex: number) => {
|
||||||
|
// Find the source column
|
||||||
|
const sourceColumn = columns.find((col) =>
|
||||||
|
col.items.some((i) => i.id === item.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sourceColumn) return;
|
||||||
|
|
||||||
|
// Don't trigger if dropping in the same column at the same position
|
||||||
|
if (sourceColumn.id === targetColumnId) {
|
||||||
|
const currentIndex = sourceColumn.items.findIndex((i) => i.id === item.id);
|
||||||
|
if (currentIndex === newIndex) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onItemMove?.(item.id, sourceColumn.id, targetColumnId, newIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddItem = (columnId: string) => (title: string) => {
|
||||||
|
onAddItem?.(columnId, title);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddColumn = () => {
|
||||||
|
const trimmedTitle = newColumnTitle.trim();
|
||||||
|
if (trimmedTitle) {
|
||||||
|
onColumnAdd?.(trimmedTitle);
|
||||||
|
setNewColumnTitle('');
|
||||||
|
setIsAddingColumn(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelAddColumn = () => {
|
||||||
|
setNewColumnTitle('');
|
||||||
|
setIsAddingColumn(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleAddColumn();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
handleCancelAddColumn();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex-1 overflow-x-auto', className)}>
|
||||||
|
<div className="flex gap-4 min-w-max p-1">
|
||||||
|
{/* Columns */}
|
||||||
|
{columns.map((column) => (
|
||||||
|
<KanbanColumn
|
||||||
|
key={column.id}
|
||||||
|
column={column}
|
||||||
|
onDrop={handleDrop(column.id)}
|
||||||
|
onItemClick={(item) => onItemClick?.(item, column.id)}
|
||||||
|
onAddItem={allowAddCard ? handleAddItem(column.id) : undefined}
|
||||||
|
renderCard={renderCard ? (item) => renderCard(item, column.id) : undefined}
|
||||||
|
renderStats={renderColumnStats}
|
||||||
|
allowAddCard={allowAddCard}
|
||||||
|
emptyMessage={emptyColumnMessage}
|
||||||
|
dragOverMessage={dragOverMessage}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add Column Button/Form */}
|
||||||
|
{allowAddColumn && onColumnAdd && (
|
||||||
|
<div className="min-w-[280px] max-w-[320px]">
|
||||||
|
{isAddingColumn ? (
|
||||||
|
<div className="rounded-lg border-2 border-dashed border-gray-300 bg-white p-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newColumnTitle}
|
||||||
|
onChange={(e) => setNewColumnTitle(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Enter column title..."
|
||||||
|
autoFocus
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded border border-gray-300 px-3 py-2 text-sm',
|
||||||
|
'placeholder:text-gray-400',
|
||||||
|
'focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddColumn}
|
||||||
|
disabled={!newColumnTitle.trim()}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white',
|
||||||
|
'hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
'transition-colors duration-150'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Add column
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancelAddColumn}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md p-1.5 text-gray-500',
|
||||||
|
'hover:bg-gray-100 hover:text-gray-700',
|
||||||
|
'transition-colors duration-150'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsAddingColumn(true)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-center gap-2 rounded-lg',
|
||||||
|
'border-2 border-dashed border-gray-300 bg-gray-50',
|
||||||
|
'px-4 py-8 text-sm text-gray-500',
|
||||||
|
'hover:border-gray-400 hover:bg-gray-100 hover:text-gray-700',
|
||||||
|
'transition-colors duration-150'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
<span>Add column</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Kanban;
|
||||||
108
src/shared/components/organisms/Kanban/KanbanAddCard.tsx
Normal file
108
src/shared/components/organisms/Kanban/KanbanAddCard.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { useState, useRef, useEffect, type KeyboardEvent } from 'react';
|
||||||
|
import { Plus, X } from 'lucide-react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import type { KanbanAddCardProps } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick add card form component for Kanban columns
|
||||||
|
*/
|
||||||
|
export function KanbanAddCard({
|
||||||
|
onAdd,
|
||||||
|
placeholder = 'Enter a title...',
|
||||||
|
buttonText = 'Add card',
|
||||||
|
}: KanbanAddCardProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isEditing]);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const trimmedTitle = title.trim();
|
||||||
|
if (trimmedTitle) {
|
||||||
|
onAdd(trimmedTitle);
|
||||||
|
setTitle('');
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setTitle('');
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isEditing) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm',
|
||||||
|
'text-gray-500 hover:bg-gray-100 hover:text-gray-700',
|
||||||
|
'transition-colors duration-150'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span>{buttonText}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-white p-2 shadow-sm">
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={2}
|
||||||
|
className={cn(
|
||||||
|
'w-full resize-none rounded border-0 p-2 text-sm',
|
||||||
|
'placeholder:text-gray-400',
|
||||||
|
'focus:outline-none focus:ring-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!title.trim()}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white',
|
||||||
|
'hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
'transition-colors duration-150'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md p-1.5 text-gray-500',
|
||||||
|
'hover:bg-gray-100 hover:text-gray-700',
|
||||||
|
'transition-colors duration-150'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default KanbanAddCard;
|
||||||
118
src/shared/components/organisms/Kanban/KanbanCard.tsx
Normal file
118
src/shared/components/organisms/Kanban/KanbanCard.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import type { DragEvent } from 'react';
|
||||||
|
import { Calendar } from 'lucide-react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import type { KanbanCardProps } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date for display
|
||||||
|
*/
|
||||||
|
const formatDate = (date: Date): string => {
|
||||||
|
return date.toLocaleDateString('es-MX', { day: '2-digit', month: 'short' });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get initials from a name
|
||||||
|
*/
|
||||||
|
const getInitials = (name: string): string => {
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return `${parts[0]?.[0] || ''}${parts[1]?.[0] || ''}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return name.slice(0, 2).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic Kanban card component with drag support
|
||||||
|
*/
|
||||||
|
export function KanbanCard({ item, columnId, onDragStart, onClick }: KanbanCardProps) {
|
||||||
|
const handleDragStart = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData(
|
||||||
|
'application/json',
|
||||||
|
JSON.stringify({ item, sourceColumnId: columnId })
|
||||||
|
);
|
||||||
|
onDragStart(e, item);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onClick={() => onClick?.(item)}
|
||||||
|
className={cn(
|
||||||
|
'cursor-grab rounded-lg border bg-white p-3 shadow-sm transition-all',
|
||||||
|
'hover:shadow-md hover:border-gray-300',
|
||||||
|
'active:cursor-grabbing active:shadow-lg'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Labels */}
|
||||||
|
{item.labels && item.labels.length > 0 && (
|
||||||
|
<div className="mb-2 flex flex-wrap gap-1">
|
||||||
|
{item.labels.map((label, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
||||||
|
`bg-${label.color}-100 text-${label.color}-700`
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: `var(--color-${label.color}-100, #e0e7ff)`,
|
||||||
|
color: `var(--color-${label.color}-700, #4338ca)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h4 className="font-medium text-gray-900 line-clamp-2">{item.title}</h4>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{item.description && (
|
||||||
|
<p className="mt-1 text-sm text-gray-500 line-clamp-2">{item.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{(item.dueDate || item.assignee) && (
|
||||||
|
<div className="mt-3 flex items-center justify-between pt-2 border-t border-gray-100">
|
||||||
|
{/* Due Date */}
|
||||||
|
{item.dueDate ? (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
|
<span>{formatDate(item.dueDate)}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Assignee */}
|
||||||
|
{item.assignee && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex items-center justify-center overflow-hidden rounded-full',
|
||||||
|
'h-6 w-6 text-xs bg-gray-100'
|
||||||
|
)}
|
||||||
|
title={item.assignee.name}
|
||||||
|
>
|
||||||
|
{item.assignee.avatar ? (
|
||||||
|
<img
|
||||||
|
src={item.assignee.avatar}
|
||||||
|
alt={item.assignee.name}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium text-gray-600">
|
||||||
|
{getInitials(item.assignee.name)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default KanbanCard;
|
||||||
215
src/shared/components/organisms/Kanban/KanbanColumn.tsx
Normal file
215
src/shared/components/organisms/Kanban/KanbanColumn.tsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import { useState, type DragEvent } from 'react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import { KanbanCard } from './KanbanCard';
|
||||||
|
import { KanbanAddCard } from './KanbanAddCard';
|
||||||
|
import type { KanbanColumnProps, KanbanItem, ColumnColors } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color configurations for different column themes
|
||||||
|
*/
|
||||||
|
const columnColors: Record<string, ColumnColors> = {
|
||||||
|
blue: {
|
||||||
|
bg: 'bg-blue-50',
|
||||||
|
border: 'border-blue-200',
|
||||||
|
header: 'bg-blue-100',
|
||||||
|
text: 'text-blue-700',
|
||||||
|
},
|
||||||
|
indigo: {
|
||||||
|
bg: 'bg-indigo-50',
|
||||||
|
border: 'border-indigo-200',
|
||||||
|
header: 'bg-indigo-100',
|
||||||
|
text: 'text-indigo-700',
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
bg: 'bg-amber-50',
|
||||||
|
border: 'border-amber-200',
|
||||||
|
header: 'bg-amber-100',
|
||||||
|
text: 'text-amber-700',
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
bg: 'bg-green-50',
|
||||||
|
border: 'border-green-200',
|
||||||
|
header: 'bg-green-100',
|
||||||
|
text: 'text-green-700',
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
bg: 'bg-red-50',
|
||||||
|
border: 'border-red-200',
|
||||||
|
header: 'bg-red-100',
|
||||||
|
text: 'text-red-700',
|
||||||
|
},
|
||||||
|
purple: {
|
||||||
|
bg: 'bg-purple-50',
|
||||||
|
border: 'border-purple-200',
|
||||||
|
header: 'bg-purple-100',
|
||||||
|
text: 'text-purple-700',
|
||||||
|
},
|
||||||
|
pink: {
|
||||||
|
bg: 'bg-pink-50',
|
||||||
|
border: 'border-pink-200',
|
||||||
|
header: 'bg-pink-100',
|
||||||
|
text: 'text-pink-700',
|
||||||
|
},
|
||||||
|
gray: {
|
||||||
|
bg: 'bg-gray-50',
|
||||||
|
border: 'border-gray-200',
|
||||||
|
header: 'bg-gray-100',
|
||||||
|
text: 'text-gray-700',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic Kanban column component with drag-and-drop support
|
||||||
|
*/
|
||||||
|
export function KanbanColumn({
|
||||||
|
column,
|
||||||
|
onDrop,
|
||||||
|
onItemClick,
|
||||||
|
onAddItem,
|
||||||
|
renderCard,
|
||||||
|
renderStats,
|
||||||
|
allowAddCard = false,
|
||||||
|
emptyMessage = 'No items',
|
||||||
|
dragOverMessage = 'Drop here',
|
||||||
|
}: KanbanColumnProps) {
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
|
// Get colors with fallback - gray is guaranteed to exist
|
||||||
|
const colorKey = column.color && column.color in columnColors ? column.color : 'gray';
|
||||||
|
const colors = columnColors[colorKey] as ColumnColors;
|
||||||
|
const itemCount = column.items.length;
|
||||||
|
const isOverLimit = column.limit !== undefined && itemCount > column.limit;
|
||||||
|
|
||||||
|
const handleDragStart = (_e: DragEvent<HTMLDivElement>, _item: KanbanItem) => {
|
||||||
|
// Data transfer is handled by KanbanCard
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
setIsDragOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = e.dataTransfer.getData('application/json');
|
||||||
|
const { item, sourceColumnId } = JSON.parse(data) as {
|
||||||
|
item: KanbanItem;
|
||||||
|
sourceColumnId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only trigger drop if item is from a different column
|
||||||
|
// or we want to reorder within the same column
|
||||||
|
if (sourceColumnId !== column.id || column.items.some((i) => i.id === item.id)) {
|
||||||
|
// Calculate drop index based on mouse position
|
||||||
|
const dropIndex = column.items.length;
|
||||||
|
onDrop(item, dropIndex);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error parsing dropped data:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col rounded-lg border-2 transition-all min-w-[280px] max-w-[320px] w-full',
|
||||||
|
colors.bg,
|
||||||
|
colors.border,
|
||||||
|
isDragOver && 'border-primary-400 ring-2 ring-primary-200'
|
||||||
|
)}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{/* Column Header */}
|
||||||
|
<div className={cn('rounded-t-md p-3', colors.header)}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className={cn('font-semibold', colors.text)}>{column.title}</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* WIP Limit Indicator */}
|
||||||
|
{column.limit !== undefined && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-2 py-0.5 text-xs font-medium',
|
||||||
|
isOverLimit
|
||||||
|
? 'bg-red-100 text-red-700'
|
||||||
|
: 'bg-white/50 text-gray-600'
|
||||||
|
)}
|
||||||
|
title={`WIP Limit: ${column.limit}`}
|
||||||
|
>
|
||||||
|
{itemCount}/{column.limit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Item Count */}
|
||||||
|
{column.limit === undefined && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-2 py-0.5 text-xs font-medium',
|
||||||
|
colors.bg,
|
||||||
|
colors.text
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Stats */}
|
||||||
|
{renderStats && <div className="mt-1">{renderStats(column)}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cards Container */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex-1 space-y-2 overflow-y-auto p-2',
|
||||||
|
'min-h-[200px] max-h-[calc(100vh-320px)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{column.items.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-20 items-center justify-center rounded-lg border-2 border-dashed',
|
||||||
|
colors.border,
|
||||||
|
'text-sm text-gray-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isDragOver ? dragOverMessage : emptyMessage}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
column.items.map((item) =>
|
||||||
|
renderCard ? (
|
||||||
|
<div key={item.id}>{renderCard(item)}</div>
|
||||||
|
) : (
|
||||||
|
<KanbanCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
columnId={column.id}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onClick={onItemClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Add Card */}
|
||||||
|
{allowAddCard && onAddItem && (
|
||||||
|
<div className="p-2 pt-0">
|
||||||
|
<KanbanAddCard onAdd={onAddItem} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default KanbanColumn;
|
||||||
14
src/shared/components/organisms/Kanban/index.ts
Normal file
14
src/shared/components/organisms/Kanban/index.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export { Kanban } from './Kanban';
|
||||||
|
export { KanbanColumn } from './KanbanColumn';
|
||||||
|
export { KanbanCard } from './KanbanCard';
|
||||||
|
export { KanbanAddCard } from './KanbanAddCard';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
KanbanItem,
|
||||||
|
KanbanColumn as KanbanColumnType,
|
||||||
|
KanbanProps,
|
||||||
|
KanbanColumnProps,
|
||||||
|
KanbanCardProps,
|
||||||
|
KanbanAddCardProps,
|
||||||
|
ColumnColors,
|
||||||
|
} from './types';
|
||||||
138
src/shared/components/organisms/Kanban/types.ts
Normal file
138
src/shared/components/organisms/Kanban/types.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a single item/card in the Kanban board
|
||||||
|
*/
|
||||||
|
export interface KanbanItem {
|
||||||
|
/** Unique identifier for the item */
|
||||||
|
id: string;
|
||||||
|
/** Title displayed on the card */
|
||||||
|
title: string;
|
||||||
|
/** Optional description/subtitle */
|
||||||
|
description?: string;
|
||||||
|
/** Optional labels/tags */
|
||||||
|
labels?: Array<{
|
||||||
|
text: string;
|
||||||
|
color: string;
|
||||||
|
}>;
|
||||||
|
/** Optional assignee information */
|
||||||
|
assignee?: {
|
||||||
|
name: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
|
/** Optional due date */
|
||||||
|
dueDate?: Date;
|
||||||
|
/** Custom metadata for domain-specific data */
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a column in the Kanban board
|
||||||
|
*/
|
||||||
|
export interface KanbanColumn {
|
||||||
|
/** Unique identifier for the column */
|
||||||
|
id: string;
|
||||||
|
/** Title displayed in the column header */
|
||||||
|
title: string;
|
||||||
|
/** Optional color theme for the column (tailwind color name without prefix) */
|
||||||
|
color?: 'blue' | 'indigo' | 'amber' | 'green' | 'red' | 'purple' | 'pink' | 'gray';
|
||||||
|
/** Items in this column */
|
||||||
|
items: KanbanItem[];
|
||||||
|
/** Optional WIP (Work in Progress) limit */
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column color configuration
|
||||||
|
*/
|
||||||
|
export interface ColumnColors {
|
||||||
|
bg: string;
|
||||||
|
border: string;
|
||||||
|
header: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the main Kanban board component
|
||||||
|
*/
|
||||||
|
export interface KanbanProps {
|
||||||
|
/** Array of columns to display */
|
||||||
|
columns: KanbanColumn[];
|
||||||
|
/** Callback when an item is clicked */
|
||||||
|
onItemClick?: (item: KanbanItem, columnId: string) => void;
|
||||||
|
/** Callback when an item is moved between columns */
|
||||||
|
onItemMove?: (
|
||||||
|
itemId: string,
|
||||||
|
fromColumnId: string,
|
||||||
|
toColumnId: string,
|
||||||
|
newIndex: number
|
||||||
|
) => void;
|
||||||
|
/** Callback when a new item is added via quick add */
|
||||||
|
onAddItem?: (columnId: string, title: string) => void;
|
||||||
|
/** Callback when a new column is added */
|
||||||
|
onColumnAdd?: (title: string) => void;
|
||||||
|
/** Custom render function for cards */
|
||||||
|
renderCard?: (item: KanbanItem, columnId: string) => ReactNode;
|
||||||
|
/** Custom render function for column header stats */
|
||||||
|
renderColumnStats?: (column: KanbanColumn) => ReactNode;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
className?: string;
|
||||||
|
/** Allow quick add card at bottom of columns */
|
||||||
|
allowAddCard?: boolean;
|
||||||
|
/** Allow adding new columns */
|
||||||
|
allowAddColumn?: boolean;
|
||||||
|
/** Empty state message when a column has no items */
|
||||||
|
emptyColumnMessage?: string;
|
||||||
|
/** Drag over message shown in drop zone */
|
||||||
|
dragOverMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for individual Kanban column
|
||||||
|
*/
|
||||||
|
export interface KanbanColumnProps {
|
||||||
|
/** Column data */
|
||||||
|
column: KanbanColumn;
|
||||||
|
/** Callback when an item is dropped in this column */
|
||||||
|
onDrop: (item: KanbanItem, newIndex: number) => void;
|
||||||
|
/** Callback when an item is clicked */
|
||||||
|
onItemClick?: (item: KanbanItem) => void;
|
||||||
|
/** Callback for quick add */
|
||||||
|
onAddItem?: (title: string) => void;
|
||||||
|
/** Custom render function for cards */
|
||||||
|
renderCard?: (item: KanbanItem) => ReactNode;
|
||||||
|
/** Custom render function for column stats */
|
||||||
|
renderStats?: (column: KanbanColumn) => ReactNode;
|
||||||
|
/** Allow quick add card */
|
||||||
|
allowAddCard?: boolean;
|
||||||
|
/** Empty state message */
|
||||||
|
emptyMessage?: string;
|
||||||
|
/** Drag over message */
|
||||||
|
dragOverMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for individual Kanban card
|
||||||
|
*/
|
||||||
|
export interface KanbanCardProps {
|
||||||
|
/** Item data */
|
||||||
|
item: KanbanItem;
|
||||||
|
/** Column ID this card belongs to */
|
||||||
|
columnId: string;
|
||||||
|
/** Callback when drag starts */
|
||||||
|
onDragStart: (e: React.DragEvent<HTMLDivElement>, item: KanbanItem) => void;
|
||||||
|
/** Callback when card is clicked */
|
||||||
|
onClick?: (item: KanbanItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for quick add card form
|
||||||
|
*/
|
||||||
|
export interface KanbanAddCardProps {
|
||||||
|
/** Callback when a new card is submitted */
|
||||||
|
onAdd: (title: string) => void;
|
||||||
|
/** Placeholder text for input */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Button text */
|
||||||
|
buttonText?: string;
|
||||||
|
}
|
||||||
@ -87,6 +87,7 @@ export function Modal({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
aria-label="Cerrar"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -0,0 +1,77 @@
|
|||||||
|
import { useContext, useEffect } from 'react';
|
||||||
|
import { TourContext } from './TourProvider';
|
||||||
|
import { TourStep } from './TourStep';
|
||||||
|
import type { TourConfig } from './types';
|
||||||
|
|
||||||
|
export interface OnboardingTourProps {
|
||||||
|
config: TourConfig;
|
||||||
|
autoStart?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingTour({ config, autoStart = false }: OnboardingTourProps) {
|
||||||
|
const context = useContext(TourContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('OnboardingTour must be used within a TourProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
configs,
|
||||||
|
registerTour,
|
||||||
|
unregisterTour,
|
||||||
|
startTour,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
skipTour,
|
||||||
|
hasCompletedTour,
|
||||||
|
} = context;
|
||||||
|
|
||||||
|
// Register tour config on mount
|
||||||
|
useEffect(() => {
|
||||||
|
registerTour(config);
|
||||||
|
return () => unregisterTour(config.id);
|
||||||
|
}, [config, registerTour, unregisterTour]);
|
||||||
|
|
||||||
|
// Auto-start tour if configured and not completed
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoStart && !hasCompletedTour(config.id) && !state.isActive) {
|
||||||
|
// Small delay to ensure elements are rendered
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
startTour(config.id);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [autoStart, config.id, hasCompletedTour, state.isActive, startTour]);
|
||||||
|
|
||||||
|
// Don't render if tour is not active or this is not the current tour
|
||||||
|
if (!state.isActive || state.tourId !== config.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentConfig = configs.get(config.id);
|
||||||
|
if (!currentConfig) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStep = currentConfig.steps[state.currentStep];
|
||||||
|
if (!currentStep) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFirst = state.currentStep === 0;
|
||||||
|
const isLast = state.currentStep === currentConfig.steps.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TourStep
|
||||||
|
step={currentStep}
|
||||||
|
currentIndex={state.currentStep}
|
||||||
|
totalSteps={currentConfig.steps.length}
|
||||||
|
onNext={nextStep}
|
||||||
|
onPrev={prevStep}
|
||||||
|
onSkip={skipTour}
|
||||||
|
isFirst={isFirst}
|
||||||
|
isLast={isLast}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
src/shared/components/organisms/OnboardingTour/TourProvider.tsx
Normal file
192
src/shared/components/organisms/OnboardingTour/TourProvider.tsx
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import { createContext, useCallback, useMemo, useState, useEffect } from 'react';
|
||||||
|
import type { TourConfig, TourState, TourContextValue, TourProviderProps } from './types';
|
||||||
|
|
||||||
|
const STORAGE_KEY_PREFIX = 'erp-tour-completed';
|
||||||
|
|
||||||
|
export const TourContext = createContext<TourContextValue | null>(null);
|
||||||
|
|
||||||
|
const initialState: TourState = {
|
||||||
|
isActive: false,
|
||||||
|
currentStep: 0,
|
||||||
|
tourId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TourProvider({
|
||||||
|
children,
|
||||||
|
storageKey = STORAGE_KEY_PREFIX,
|
||||||
|
}: TourProviderProps) {
|
||||||
|
const [state, setState] = useState<TourState>(initialState);
|
||||||
|
const [configs, setConfigs] = useState<Map<string, TourConfig>>(new Map());
|
||||||
|
const [completedTours, setCompletedTours] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Load completed tours from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(storageKey);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
setCompletedTours(new Set(parsed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage errors
|
||||||
|
}
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
// Persist completed tours to localStorage
|
||||||
|
const persistCompletedTours = useCallback(
|
||||||
|
(tours: Set<string>) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(Array.from(tours)));
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage errors
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[storageKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
const registerTour = useCallback((config: TourConfig) => {
|
||||||
|
setConfigs((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(config.id, config);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unregisterTour = useCallback((tourId: string) => {
|
||||||
|
setConfigs((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(tourId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startTour = useCallback(
|
||||||
|
(tourId: string) => {
|
||||||
|
const config = configs.get(tourId);
|
||||||
|
if (!config || config.steps.length === 0) {
|
||||||
|
console.warn(`Tour "${tourId}" not found or has no steps`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't start if already completed (unless reset)
|
||||||
|
if (completedTours.has(tourId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({
|
||||||
|
isActive: true,
|
||||||
|
currentStep: 0,
|
||||||
|
tourId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[configs, completedTours]
|
||||||
|
);
|
||||||
|
|
||||||
|
const endTour = useCallback(() => {
|
||||||
|
const currentConfig = state.tourId ? configs.get(state.tourId) : null;
|
||||||
|
if (currentConfig) {
|
||||||
|
currentConfig.onComplete?.();
|
||||||
|
|
||||||
|
// Mark as completed
|
||||||
|
setCompletedTours((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(state.tourId!);
|
||||||
|
persistCompletedTours(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(initialState);
|
||||||
|
}, [state.tourId, configs, persistCompletedTours]);
|
||||||
|
|
||||||
|
const nextStep = useCallback(() => {
|
||||||
|
const currentConfig = state.tourId ? configs.get(state.tourId) : null;
|
||||||
|
if (!currentConfig) return;
|
||||||
|
|
||||||
|
const isLast = state.currentStep >= currentConfig.steps.length - 1;
|
||||||
|
if (isLast) {
|
||||||
|
endTour();
|
||||||
|
} else {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
currentStep: prev.currentStep + 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [state.tourId, state.currentStep, configs, endTour]);
|
||||||
|
|
||||||
|
const prevStep = useCallback(() => {
|
||||||
|
if (state.currentStep > 0) {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
currentStep: prev.currentStep - 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [state.currentStep]);
|
||||||
|
|
||||||
|
const skipTour = useCallback(() => {
|
||||||
|
const currentConfig = state.tourId ? configs.get(state.tourId) : null;
|
||||||
|
if (currentConfig) {
|
||||||
|
currentConfig.onSkip?.();
|
||||||
|
|
||||||
|
// Mark as completed even when skipped
|
||||||
|
setCompletedTours((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(state.tourId!);
|
||||||
|
persistCompletedTours(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(initialState);
|
||||||
|
}, [state.tourId, configs, persistCompletedTours]);
|
||||||
|
|
||||||
|
const hasCompletedTour = useCallback(
|
||||||
|
(tourId: string) => completedTours.has(tourId),
|
||||||
|
[completedTours]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetTour = useCallback(
|
||||||
|
(tourId: string) => {
|
||||||
|
setCompletedTours((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(tourId);
|
||||||
|
persistCompletedTours(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[persistCompletedTours]
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo<TourContextValue>(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
configs,
|
||||||
|
registerTour,
|
||||||
|
unregisterTour,
|
||||||
|
startTour,
|
||||||
|
endTour,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
skipTour,
|
||||||
|
hasCompletedTour,
|
||||||
|
resetTour,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
state,
|
||||||
|
configs,
|
||||||
|
registerTour,
|
||||||
|
unregisterTour,
|
||||||
|
startTour,
|
||||||
|
endTour,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
skipTour,
|
||||||
|
hasCompletedTour,
|
||||||
|
resetTour,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <TourContext.Provider value={value}>{children}</TourContext.Provider>;
|
||||||
|
}
|
||||||
160
src/shared/components/organisms/OnboardingTour/TourStep.tsx
Normal file
160
src/shared/components/organisms/OnboardingTour/TourStep.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { TourTooltip } from './TourTooltip';
|
||||||
|
import type { TourStepProps } from './types';
|
||||||
|
|
||||||
|
const DEFAULT_SPOTLIGHT_PADDING = 8;
|
||||||
|
|
||||||
|
export function TourStep({
|
||||||
|
step,
|
||||||
|
currentIndex,
|
||||||
|
totalSteps,
|
||||||
|
onNext,
|
||||||
|
onPrev,
|
||||||
|
onSkip,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
}: TourStepProps) {
|
||||||
|
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
|
||||||
|
// targetElement is tracked for potential future use (e.g., focus management)
|
||||||
|
const [, setTargetElement] = useState<Element | null>(null);
|
||||||
|
|
||||||
|
const spotlightPadding = step.spotlightPadding ?? DEFAULT_SPOTLIGHT_PADDING;
|
||||||
|
|
||||||
|
const updateTargetRect = useCallback(() => {
|
||||||
|
const element = document.querySelector(step.target);
|
||||||
|
if (element) {
|
||||||
|
setTargetElement(element);
|
||||||
|
setTargetRect(element.getBoundingClientRect());
|
||||||
|
} else {
|
||||||
|
setTargetElement(null);
|
||||||
|
setTargetRect(null);
|
||||||
|
}
|
||||||
|
}, [step.target]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateTargetRect();
|
||||||
|
|
||||||
|
// Scroll element into view
|
||||||
|
const element = document.querySelector(step.target);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
inline: 'center',
|
||||||
|
});
|
||||||
|
// Update rect after scroll completes
|
||||||
|
const timeoutId = setTimeout(updateTargetRect, 300);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [step.target, updateTargetRect]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => updateTargetRect();
|
||||||
|
const handleScroll = () => updateTargetRect();
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
window.addEventListener('scroll', handleScroll, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
window.removeEventListener('scroll', handleScroll, true);
|
||||||
|
};
|
||||||
|
}, [updateTargetRect]);
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
step.onNext?.();
|
||||||
|
onNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
step.onPrev?.();
|
||||||
|
onPrev();
|
||||||
|
};
|
||||||
|
|
||||||
|
const spotlightStyle = targetRect
|
||||||
|
? {
|
||||||
|
top: targetRect.top - spotlightPadding,
|
||||||
|
left: targetRect.left - spotlightPadding,
|
||||||
|
width: targetRect.width + spotlightPadding * 2,
|
||||||
|
height: targetRect.height + spotlightPadding * 2,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<div key={step.id} className="fixed inset-0 z-[10000]">
|
||||||
|
{/* Overlay with spotlight cutout */}
|
||||||
|
{!step.disableOverlay && (
|
||||||
|
<svg
|
||||||
|
className="fixed inset-0 h-full w-full"
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<mask id={`spotlight-mask-${step.id}`}>
|
||||||
|
<rect x="0" y="0" width="100%" height="100%" fill="white" />
|
||||||
|
{spotlightStyle && (
|
||||||
|
<motion.rect
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
x={spotlightStyle.left}
|
||||||
|
y={spotlightStyle.top}
|
||||||
|
width={spotlightStyle.width}
|
||||||
|
height={spotlightStyle.height}
|
||||||
|
rx="8"
|
||||||
|
fill="black"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<motion.rect
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
fill="rgba(0, 0, 0, 0.5)"
|
||||||
|
mask={`url(#spotlight-mask-${step.id})`}
|
||||||
|
style={{ pointerEvents: 'auto' }}
|
||||||
|
onClick={onSkip}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Spotlight border highlight */}
|
||||||
|
{spotlightStyle && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="pointer-events-none fixed rounded-lg ring-2 ring-primary-500 ring-offset-2"
|
||||||
|
style={{
|
||||||
|
top: spotlightStyle.top,
|
||||||
|
left: spotlightStyle.left,
|
||||||
|
width: spotlightStyle.width,
|
||||||
|
height: spotlightStyle.height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
<TourTooltip
|
||||||
|
step={step}
|
||||||
|
currentIndex={currentIndex}
|
||||||
|
totalSteps={totalSteps}
|
||||||
|
onNext={handleNext}
|
||||||
|
onPrev={handlePrev}
|
||||||
|
onSkip={onSkip}
|
||||||
|
isFirst={isFirst}
|
||||||
|
isLast={isLast}
|
||||||
|
targetRect={targetRect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(content, document.body);
|
||||||
|
}
|
||||||
203
src/shared/components/organisms/OnboardingTour/TourTooltip.tsx
Normal file
203
src/shared/components/organisms/OnboardingTour/TourTooltip.tsx
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||||
|
import { cn } from '@utils/cn';
|
||||||
|
import type { TourTooltipProps } from './types';
|
||||||
|
|
||||||
|
const TOOLTIP_OFFSET = 12;
|
||||||
|
const ARROW_SIZE = 8;
|
||||||
|
|
||||||
|
export function TourTooltip({
|
||||||
|
step,
|
||||||
|
currentIndex,
|
||||||
|
totalSteps,
|
||||||
|
onNext,
|
||||||
|
onPrev,
|
||||||
|
onSkip,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
targetRect,
|
||||||
|
}: TourTooltipProps) {
|
||||||
|
const placement = step.placement ?? 'bottom';
|
||||||
|
|
||||||
|
const position = useMemo(() => {
|
||||||
|
if (!targetRect) {
|
||||||
|
return { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollX = window.scrollX;
|
||||||
|
const scrollY = window.scrollY;
|
||||||
|
|
||||||
|
switch (placement) {
|
||||||
|
case 'top':
|
||||||
|
return {
|
||||||
|
top: targetRect.top + scrollY - TOOLTIP_OFFSET,
|
||||||
|
left: targetRect.left + scrollX + targetRect.width / 2,
|
||||||
|
transform: 'translate(-50%, -100%)',
|
||||||
|
};
|
||||||
|
case 'bottom':
|
||||||
|
return {
|
||||||
|
top: targetRect.bottom + scrollY + TOOLTIP_OFFSET,
|
||||||
|
left: targetRect.left + scrollX + targetRect.width / 2,
|
||||||
|
transform: 'translate(-50%, 0)',
|
||||||
|
};
|
||||||
|
case 'left':
|
||||||
|
return {
|
||||||
|
top: targetRect.top + scrollY + targetRect.height / 2,
|
||||||
|
left: targetRect.left + scrollX - TOOLTIP_OFFSET,
|
||||||
|
transform: 'translate(-100%, -50%)',
|
||||||
|
};
|
||||||
|
case 'right':
|
||||||
|
return {
|
||||||
|
top: targetRect.top + scrollY + targetRect.height / 2,
|
||||||
|
left: targetRect.right + scrollX + TOOLTIP_OFFSET,
|
||||||
|
transform: 'translate(0, -50%)',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
top: targetRect.bottom + scrollY + TOOLTIP_OFFSET,
|
||||||
|
left: targetRect.left + scrollX + targetRect.width / 2,
|
||||||
|
transform: 'translate(-50%, 0)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [targetRect, placement]);
|
||||||
|
|
||||||
|
const arrowStyles = useMemo(() => {
|
||||||
|
const base = {
|
||||||
|
position: 'absolute' as const,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
borderStyle: 'solid' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (placement) {
|
||||||
|
case 'top':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
bottom: -ARROW_SIZE,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
borderWidth: `${ARROW_SIZE}px ${ARROW_SIZE}px 0 ${ARROW_SIZE}px`,
|
||||||
|
borderColor: 'white transparent transparent transparent',
|
||||||
|
};
|
||||||
|
case 'bottom':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
top: -ARROW_SIZE,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
borderWidth: `0 ${ARROW_SIZE}px ${ARROW_SIZE}px ${ARROW_SIZE}px`,
|
||||||
|
borderColor: 'transparent transparent white transparent',
|
||||||
|
};
|
||||||
|
case 'left':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
right: -ARROW_SIZE,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
borderWidth: `${ARROW_SIZE}px 0 ${ARROW_SIZE}px ${ARROW_SIZE}px`,
|
||||||
|
borderColor: 'transparent transparent transparent white',
|
||||||
|
};
|
||||||
|
case 'right':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
left: -ARROW_SIZE,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
borderWidth: `${ARROW_SIZE}px ${ARROW_SIZE}px ${ARROW_SIZE}px 0`,
|
||||||
|
borderColor: 'transparent white transparent transparent',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
}, [placement]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed z-[10001] max-w-sm"
|
||||||
|
style={{
|
||||||
|
top: typeof position.top === 'number' ? `${position.top}px` : position.top,
|
||||||
|
left: typeof position.left === 'number' ? `${position.left}px` : position.left,
|
||||||
|
transform: position.transform,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative rounded-lg bg-white shadow-xl',
|
||||||
|
'dark:bg-gray-800 dark:border dark:border-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Arrow */}
|
||||||
|
<div style={arrowStyles} className="dark:border-gray-800" />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between border-b border-gray-100 px-4 py-3 dark:border-gray-700">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white">{step.title}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSkip}
|
||||||
|
className="ml-4 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
|
||||||
|
aria-label="Cerrar tour"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{typeof step.content === 'string' ? <p>{step.content}</p> : step.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between border-t border-gray-100 px-4 py-3 dark:border-gray-700">
|
||||||
|
{/* Progress indicator */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{Array.from({ length: totalSteps }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
'h-1.5 w-1.5 rounded-full transition-colors',
|
||||||
|
index === currentIndex
|
||||||
|
? 'bg-primary-600'
|
||||||
|
: 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isFirst && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPrev}
|
||||||
|
className="flex items-center gap-1 rounded-md px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNext}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 rounded-md px-3 py-1.5 text-sm font-medium',
|
||||||
|
'bg-primary-600 text-white hover:bg-primary-700',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLast ? 'Finalizar' : 'Siguiente'}
|
||||||
|
{!isLast && <ChevronRight className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/shared/components/organisms/OnboardingTour/index.ts
Normal file
6
src/shared/components/organisms/OnboardingTour/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export type { TourStep as TourStepType, TourConfig, TourState, TourContextValue, TourProviderProps, TourTooltipProps, TourStepProps } from './types';
|
||||||
|
export * from './OnboardingTour';
|
||||||
|
export * from './TourProvider';
|
||||||
|
export * from './TourStep';
|
||||||
|
export * from './TourTooltip';
|
||||||
|
export * from './useTour';
|
||||||
68
src/shared/components/organisms/OnboardingTour/types.ts
Normal file
68
src/shared/components/organisms/OnboardingTour/types.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface TourStep {
|
||||||
|
id: string;
|
||||||
|
target: string; // CSS selector for element to highlight
|
||||||
|
title: string;
|
||||||
|
content: string | ReactNode;
|
||||||
|
placement?: 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
spotlightPadding?: number;
|
||||||
|
disableOverlay?: boolean;
|
||||||
|
onNext?: () => void;
|
||||||
|
onPrev?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TourConfig {
|
||||||
|
id: string; // Tour identifier (e.g., 'dashboard-intro')
|
||||||
|
steps: TourStep[];
|
||||||
|
onComplete?: () => void;
|
||||||
|
onSkip?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TourState {
|
||||||
|
isActive: boolean;
|
||||||
|
currentStep: number;
|
||||||
|
tourId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TourContextValue {
|
||||||
|
state: TourState;
|
||||||
|
configs: Map<string, TourConfig>;
|
||||||
|
registerTour: (config: TourConfig) => void;
|
||||||
|
unregisterTour: (tourId: string) => void;
|
||||||
|
startTour: (tourId: string) => void;
|
||||||
|
endTour: () => void;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
skipTour: () => void;
|
||||||
|
hasCompletedTour: (tourId: string) => boolean;
|
||||||
|
resetTour: (tourId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TourProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
storageKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TourTooltipProps {
|
||||||
|
step: TourStep;
|
||||||
|
currentIndex: number;
|
||||||
|
totalSteps: number;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrev: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
isFirst: boolean;
|
||||||
|
isLast: boolean;
|
||||||
|
targetRect: DOMRect | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TourStepProps {
|
||||||
|
step: TourStep;
|
||||||
|
currentIndex: number;
|
||||||
|
totalSteps: number;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrev: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
isFirst: boolean;
|
||||||
|
isLast: boolean;
|
||||||
|
}
|
||||||
56
src/shared/components/organisms/OnboardingTour/useTour.ts
Normal file
56
src/shared/components/organisms/OnboardingTour/useTour.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { TourContext } from './TourProvider';
|
||||||
|
import type { TourStep } from './types';
|
||||||
|
|
||||||
|
export interface UseTourReturn {
|
||||||
|
startTour: (tourId: string) => void;
|
||||||
|
endTour: () => void;
|
||||||
|
nextStep: () => void;
|
||||||
|
prevStep: () => void;
|
||||||
|
skipTour: () => void;
|
||||||
|
currentStep: TourStep | null;
|
||||||
|
isActive: boolean;
|
||||||
|
progress: { current: number; total: number };
|
||||||
|
hasCompletedTour: (tourId: string) => boolean;
|
||||||
|
resetTour: (tourId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTour(): UseTourReturn {
|
||||||
|
const context = useContext(TourContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useTour must be used within a TourProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
configs,
|
||||||
|
startTour,
|
||||||
|
endTour,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
skipTour,
|
||||||
|
hasCompletedTour,
|
||||||
|
resetTour,
|
||||||
|
} = context;
|
||||||
|
|
||||||
|
const currentConfig = state.tourId ? configs.get(state.tourId) : null;
|
||||||
|
const currentStep = currentConfig?.steps[state.currentStep] ?? null;
|
||||||
|
const totalSteps = currentConfig?.steps.length ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
startTour,
|
||||||
|
endTour,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
skipTour,
|
||||||
|
currentStep,
|
||||||
|
isActive: state.isActive,
|
||||||
|
progress: {
|
||||||
|
current: state.currentStep + 1,
|
||||||
|
total: totalSteps,
|
||||||
|
},
|
||||||
|
hasCompletedTour,
|
||||||
|
resetTour,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -8,3 +8,7 @@ export * from './Pagination';
|
|||||||
export * from './DatePicker';
|
export * from './DatePicker';
|
||||||
export * from './Sidebar';
|
export * from './Sidebar';
|
||||||
export * from './Breadcrumbs';
|
export * from './Breadcrumbs';
|
||||||
|
export * from './Calendar';
|
||||||
|
export * from './Kanban';
|
||||||
|
export * from './Chart';
|
||||||
|
export * from './DashboardWidgets';
|
||||||
|
|||||||
@ -1,13 +1,6 @@
|
|||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import {
|
import { Plus } from 'lucide-react';
|
||||||
FileSearch,
|
|
||||||
Inbox,
|
|
||||||
AlertCircle,
|
|
||||||
Search,
|
|
||||||
Plus,
|
|
||||||
type LucideIcon
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Button } from '@components/atoms/Button';
|
import { Button } from '@components/atoms/Button';
|
||||||
import { cn } from '@utils/cn';
|
import { cn } from '@utils/cn';
|
||||||
|
|
||||||
@ -32,11 +25,92 @@ export interface EmptyStateProps {
|
|||||||
animated?: boolean;
|
animated?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantIcons: Record<Exclude<EmptyStateVariant, 'custom'>, LucideIcon> = {
|
// Inline SVG illustrations - simple line art style
|
||||||
default: Inbox,
|
const InboxIllustration = ({ className }: { className?: string }) => (
|
||||||
search: Search,
|
<svg
|
||||||
error: AlertCircle,
|
viewBox="0 0 120 120"
|
||||||
'no-data': FileSearch,
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<rect x="20" y="30" width="80" height="60" rx="4" />
|
||||||
|
<path d="M20 50 L60 75 L100 50" />
|
||||||
|
<path d="M35 45 L60 60 L85 45" strokeDasharray="4 2" opacity="0.5" />
|
||||||
|
<circle cx="95" cy="35" r="12" fill="currentColor" opacity="0.1" />
|
||||||
|
<path d="M92 35 L95 38 L101 32" strokeWidth="2.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SearchIllustration = ({ className }: { className?: string }) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 120 120"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<circle cx="52" cy="52" r="28" />
|
||||||
|
<path d="M72 72 L95 95" strokeWidth="3" />
|
||||||
|
<path d="M40 45 L64 45" opacity="0.4" />
|
||||||
|
<path d="M40 55 L58 55" opacity="0.4" />
|
||||||
|
<path d="M30 80 L25 95" strokeDasharray="3 3" opacity="0.3" />
|
||||||
|
<path d="M74 80 L79 95" strokeDasharray="3 3" opacity="0.3" />
|
||||||
|
<circle cx="95" cy="95" r="6" fill="currentColor" opacity="0.15" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ErrorIllustration = ({ className }: { className?: string }) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 120 120"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<circle cx="60" cy="60" r="35" />
|
||||||
|
<path d="M60 40 L60 65" strokeWidth="3" />
|
||||||
|
<circle cx="60" cy="78" r="3" fill="currentColor" />
|
||||||
|
<path d="M25 25 L35 35" opacity="0.4" />
|
||||||
|
<path d="M95 25 L85 35" opacity="0.4" />
|
||||||
|
<path d="M20 60 L30 60" opacity="0.3" />
|
||||||
|
<path d="M90 60 L100 60" opacity="0.3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NoDataIllustration = ({ className }: { className?: string }) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 120 120"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<rect x="25" y="20" width="50" height="65" rx="3" />
|
||||||
|
<path d="M35 35 L65 35" opacity="0.5" />
|
||||||
|
<path d="M35 45 L60 45" opacity="0.5" />
|
||||||
|
<path d="M35 55 L55 55" opacity="0.5" />
|
||||||
|
<path d="M35 65 L50 65" opacity="0.5" />
|
||||||
|
<circle cx="80" cy="75" r="22" />
|
||||||
|
<path d="M95 90 L105 100" strokeWidth="3" />
|
||||||
|
<path d="M73 75 L87 75" />
|
||||||
|
<path d="M80 68 L80 82" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const variantIllustrations: Record<Exclude<EmptyStateVariant, 'custom'>, React.FC<{ className?: string }>> = {
|
||||||
|
default: InboxIllustration,
|
||||||
|
search: SearchIllustration,
|
||||||
|
error: ErrorIllustration,
|
||||||
|
'no-data': NoDataIllustration,
|
||||||
};
|
};
|
||||||
|
|
||||||
const variantColors: Record<Exclude<EmptyStateVariant, 'custom'>, string> = {
|
const variantColors: Record<Exclude<EmptyStateVariant, 'custom'>, string> = {
|
||||||
@ -57,7 +131,7 @@ export function EmptyState({
|
|||||||
compact = false,
|
compact = false,
|
||||||
animated = true,
|
animated = true,
|
||||||
}: EmptyStateProps) {
|
}: EmptyStateProps) {
|
||||||
const IconComponent = variant !== 'custom' ? variantIcons[variant] : null;
|
const IllustrationComponent = variant !== 'custom' ? variantIllustrations[variant] : null;
|
||||||
const iconColor = variant !== 'custom' ? variantColors[variant] : 'text-gray-400';
|
const iconColor = variant !== 'custom' ? variantColors[variant] : 'text-gray-400';
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
@ -68,15 +142,20 @@ export function EmptyState({
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Icon */}
|
{/* Illustration */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-center rounded-full bg-gray-100',
|
'flex items-center justify-center',
|
||||||
compact ? 'h-12 w-12 mb-3' : 'h-16 w-16 mb-4'
|
compact ? 'mb-3' : 'mb-6'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon || (IconComponent && (
|
{icon || (IllustrationComponent && (
|
||||||
<IconComponent className={cn(compact ? 'h-6 w-6' : 'h-8 w-8', iconColor)} />
|
<IllustrationComponent
|
||||||
|
className={cn(
|
||||||
|
compact ? 'w-16 h-16' : 'w-24 h-24',
|
||||||
|
iconColor
|
||||||
|
)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -161,16 +240,16 @@ export function NoResultsEmptyState({
|
|||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
variant="search"
|
variant="search"
|
||||||
title="No se encontraron resultados"
|
title="Sin resultados de busqueda"
|
||||||
description={
|
description={
|
||||||
searchTerm
|
searchTerm
|
||||||
? `No hay resultados para "${searchTerm}". Intenta con otros términos de búsqueda.`
|
? `No encontramos coincidencias para "${searchTerm}". Prueba con terminos diferentes o revisa la ortografia.`
|
||||||
: 'No se encontraron elementos que coincidan con tu búsqueda.'
|
: 'Tu busqueda no produjo resultados. Intenta ajustar los filtros o usar palabras clave distintas.'
|
||||||
}
|
}
|
||||||
primaryAction={
|
primaryAction={
|
||||||
onClearSearch
|
onClearSearch
|
||||||
? {
|
? {
|
||||||
label: 'Limpiar búsqueda',
|
label: 'Limpiar busqueda',
|
||||||
onClick: onClearSearch,
|
onClick: onClearSearch,
|
||||||
variant: 'outline',
|
variant: 'outline',
|
||||||
}
|
}
|
||||||
@ -195,12 +274,12 @@ export function NoDataEmptyState({
|
|||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
variant="no-data"
|
variant="no-data"
|
||||||
title={`No hay ${entityName}`}
|
title={`Aun no hay ${entityName}`}
|
||||||
description={`Aún no se han creado ${entityName}. Comienza agregando el primero.`}
|
description={`Este espacio esta esperando tus primeros ${entityName}. Crea uno para comenzar a organizar tu informacion.`}
|
||||||
primaryAction={
|
primaryAction={
|
||||||
onCreateNew
|
onCreateNew
|
||||||
? {
|
? {
|
||||||
label: `Crear ${entityName.slice(0, -1)}`,
|
label: `Agregar ${entityName.slice(0, -1)}`,
|
||||||
onClick: onCreateNew,
|
onClick: onCreateNew,
|
||||||
icon: <Plus className="h-4 w-4 mr-1" />,
|
icon: <Plus className="h-4 w-4 mr-1" />,
|
||||||
}
|
}
|
||||||
@ -219,8 +298,8 @@ export interface ErrorEmptyStateProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorEmptyState({
|
export function ErrorEmptyState({
|
||||||
title = 'Ocurrió un error',
|
title = 'Algo salio mal',
|
||||||
description = 'No pudimos cargar la información. Por favor intenta de nuevo.',
|
description = 'Hubo un problema al cargar los datos. Esto puede ser temporal, intenta de nuevo en unos momentos.',
|
||||||
onRetry,
|
onRetry,
|
||||||
className,
|
className,
|
||||||
}: ErrorEmptyStateProps) {
|
}: ErrorEmptyStateProps) {
|
||||||
@ -232,7 +311,7 @@ export function ErrorEmptyState({
|
|||||||
primaryAction={
|
primaryAction={
|
||||||
onRetry
|
onRetry
|
||||||
? {
|
? {
|
||||||
label: 'Reintentar',
|
label: 'Volver a intentar',
|
||||||
onClick: onRetry,
|
onClick: onRetry,
|
||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
}
|
}
|
||||||
@ -257,9 +336,65 @@ export function ComingSoonEmptyState({
|
|||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
variant="default"
|
variant="default"
|
||||||
title={`${featureName} próximamente`}
|
title={`${featureName} en camino`}
|
||||||
description={description || 'Esta funcionalidad estará disponible en una próxima actualización.'}
|
description={description || 'Estamos trabajando en esta funcionalidad. Pronto estara disponible para ti.'}
|
||||||
className={className}
|
className={className}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New SearchEmptyState preset
|
||||||
|
export interface SearchEmptyStateProps {
|
||||||
|
query?: string;
|
||||||
|
suggestions?: string[];
|
||||||
|
onSuggestionClick?: (suggestion: string) => void;
|
||||||
|
onClearFilters?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchEmptyState({
|
||||||
|
query,
|
||||||
|
suggestions,
|
||||||
|
onSuggestionClick,
|
||||||
|
onClearFilters,
|
||||||
|
className,
|
||||||
|
}: SearchEmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<EmptyState
|
||||||
|
variant="search"
|
||||||
|
title="No encontramos lo que buscas"
|
||||||
|
description={
|
||||||
|
query
|
||||||
|
? `No hay resultados para "${query}". Verifica que este bien escrito o prueba con sinonimos.`
|
||||||
|
: 'Parece que no hay coincidencias con los filtros actuales.'
|
||||||
|
}
|
||||||
|
primaryAction={
|
||||||
|
onClearFilters
|
||||||
|
? {
|
||||||
|
label: 'Limpiar filtros',
|
||||||
|
onClick: onClearFilters,
|
||||||
|
variant: 'outline',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{suggestions && suggestions.length > 0 && onSuggestionClick && (
|
||||||
|
<div className="flex flex-col items-center mt-4">
|
||||||
|
<p className="text-sm text-gray-500 mb-2">Sugerencias:</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
{suggestions.map((suggestion) => (
|
||||||
|
<button
|
||||||
|
key={suggestion}
|
||||||
|
onClick={() => onSuggestionClick(suggestion)}
|
||||||
|
className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
export * from './useDebounce';
|
export * from './useDebounce';
|
||||||
|
export * from './useFilteredNavigation';
|
||||||
export * from './useLocalStorage';
|
export * from './useLocalStorage';
|
||||||
export * from './useMediaQuery';
|
export * from './useMediaQuery';
|
||||||
export * from './usePermissions';
|
export * from './usePermissions';
|
||||||
|
export * from './useTenantTheme';
|
||||||
|
export * from './useTheme';
|
||||||
|
|||||||
174
src/shared/hooks/useFilteredNavigation.ts
Normal file
174
src/shared/hooks/useFilteredNavigation.ts
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { usePermissions } from './usePermissions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation item with optional permission requirements
|
||||||
|
*/
|
||||||
|
export interface NavigationItem {
|
||||||
|
/** Display name */
|
||||||
|
name: string;
|
||||||
|
/** Route path */
|
||||||
|
href: string;
|
||||||
|
/** Icon component */
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
/** Required permission code (e.g., 'users:read') */
|
||||||
|
permission?: string;
|
||||||
|
/** Required module (e.g., 'users') - requires any action on this module */
|
||||||
|
module?: string;
|
||||||
|
/** Child items (for submenus) */
|
||||||
|
children?: NavigationItem[];
|
||||||
|
/** Badge to display */
|
||||||
|
badge?: string | number;
|
||||||
|
/** Whether the item is disabled */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Whether link is external */
|
||||||
|
external?: boolean;
|
||||||
|
/** Roles that can access this item */
|
||||||
|
roles?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook options
|
||||||
|
*/
|
||||||
|
export interface UseFilteredNavigationOptions {
|
||||||
|
/** Keep items that have accessible children even if parent has no permission */
|
||||||
|
keepParentIfChildrenAccessible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return type for the hook
|
||||||
|
*/
|
||||||
|
export interface UseFilteredNavigationReturn {
|
||||||
|
/** Filtered navigation items based on user permissions */
|
||||||
|
items: NavigationItem[];
|
||||||
|
/** Whether permissions are still loading */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Whether user has any accessible navigation items */
|
||||||
|
hasAccessibleItems: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to filter navigation items based on user permissions
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const navigation = [
|
||||||
|
* { name: 'Dashboard', href: '/dashboard', icon: Home },
|
||||||
|
* { name: 'Users', href: '/users', icon: Users, permission: 'users:read' },
|
||||||
|
* { name: 'Settings', href: '/settings', icon: Settings, module: 'settings' },
|
||||||
|
* ];
|
||||||
|
*
|
||||||
|
* function Sidebar() {
|
||||||
|
* const { items, isLoading } = useFilteredNavigation(navigation);
|
||||||
|
*
|
||||||
|
* if (isLoading) return <Spinner />;
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <nav>
|
||||||
|
* {items.map(item => (
|
||||||
|
* <Link key={item.href} to={item.href}>
|
||||||
|
* {item.name}
|
||||||
|
* </Link>
|
||||||
|
* ))}
|
||||||
|
* </nav>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useFilteredNavigation(
|
||||||
|
navigationItems: NavigationItem[],
|
||||||
|
options: UseFilteredNavigationOptions = {}
|
||||||
|
): UseFilteredNavigationReturn {
|
||||||
|
const { keepParentIfChildrenAccessible = true } = options;
|
||||||
|
const { canAny, hasAnyRole, isLoading, roles } = usePermissions();
|
||||||
|
|
||||||
|
const isSuperAdmin = roles.includes('super_admin');
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
/**
|
||||||
|
* Check if user has access to a navigation item
|
||||||
|
*/
|
||||||
|
const hasAccess = (item: NavigationItem): boolean => {
|
||||||
|
// Super admin has access to everything
|
||||||
|
if (isSuperAdmin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check role requirements
|
||||||
|
if (item.roles && item.roles.length > 0) {
|
||||||
|
if (!hasAnyRole(...item.roles)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check specific permission requirement
|
||||||
|
if (item.permission) {
|
||||||
|
return canAny(item.permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check module requirement (any action on the module)
|
||||||
|
if (item.module) {
|
||||||
|
const modulePermissions = [
|
||||||
|
`${item.module}:read`,
|
||||||
|
`${item.module}:write`,
|
||||||
|
`${item.module}:create`,
|
||||||
|
`${item.module}:update`,
|
||||||
|
`${item.module}:delete`,
|
||||||
|
`${item.module}:admin`,
|
||||||
|
];
|
||||||
|
return canAny(...modulePermissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No permission requirement - item is accessible
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively filter navigation items
|
||||||
|
*/
|
||||||
|
const filterItems = (navItems: NavigationItem[]): NavigationItem[] => {
|
||||||
|
return navItems.reduce<NavigationItem[]>((filtered, item) => {
|
||||||
|
// Check if this item has children
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
const filteredChildren = filterItems(item.children);
|
||||||
|
|
||||||
|
// If we should keep parent when children are accessible
|
||||||
|
if (keepParentIfChildrenAccessible && filteredChildren.length > 0) {
|
||||||
|
filtered.push({
|
||||||
|
...item,
|
||||||
|
children: filteredChildren,
|
||||||
|
});
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, check parent permission too
|
||||||
|
if (hasAccess(item) && filteredChildren.length > 0) {
|
||||||
|
filtered.push({
|
||||||
|
...item,
|
||||||
|
children: filteredChildren,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaf item - check permission
|
||||||
|
if (hasAccess(item)) {
|
||||||
|
filtered.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
|
|
||||||
|
return filterItems(navigationItems);
|
||||||
|
}, [navigationItems, canAny, hasAnyRole, isSuperAdmin, keepParentIfChildrenAccessible]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
isLoading,
|
||||||
|
hasAccessibleItems: items.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useFilteredNavigation;
|
||||||
@ -87,8 +87,13 @@ export function usePermissions() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user is super_admin (can do anything)
|
* Check if user is super_admin (can do anything)
|
||||||
|
* @deprecated Use isSuperAdmin boolean property instead
|
||||||
*/
|
*/
|
||||||
const isSuperAdmin = useCallback(() => roles.includes('super_admin'), [roles]);
|
const isSuperAdminFn = useCallback(() => roles.includes('super_admin'), [roles]);
|
||||||
|
|
||||||
|
// Computed role checks as boolean properties
|
||||||
|
const isSuperAdmin = roles.includes('super_admin');
|
||||||
|
const isAdmin = roles.includes('admin') || isSuperAdmin;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Force refresh permissions from server
|
* Force refresh permissions from server
|
||||||
@ -114,10 +119,15 @@ export function usePermissions() {
|
|||||||
hasAnyPermission,
|
hasAnyPermission,
|
||||||
hasAllPermissions,
|
hasAllPermissions,
|
||||||
|
|
||||||
// Role checks
|
// Role checks (boolean properties)
|
||||||
|
isAdmin,
|
||||||
|
isSuperAdmin,
|
||||||
|
|
||||||
|
// Role checks (functions)
|
||||||
hasRole,
|
hasRole,
|
||||||
hasAnyRole,
|
hasAnyRole,
|
||||||
isSuperAdmin,
|
/** @deprecated Use isSuperAdmin boolean instead */
|
||||||
|
isSuperAdminFn,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
refresh,
|
refresh,
|
||||||
|
|||||||
15
src/shared/hooks/useTenantTheme.ts
Normal file
15
src/shared/hooks/useTenantTheme.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import {
|
||||||
|
TenantThemeContext,
|
||||||
|
type TenantThemeContextValue,
|
||||||
|
} from '@shared/providers/TenantThemeProvider';
|
||||||
|
|
||||||
|
export function useTenantTheme(): TenantThemeContextValue {
|
||||||
|
const context = useContext(TenantThemeContext);
|
||||||
|
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useTenantTheme must be used within a TenantThemeProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
12
src/shared/hooks/useTheme.ts
Normal file
12
src/shared/hooks/useTheme.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { ThemeContext, type ThemeContextValue } from '@shared/providers/ThemeProvider';
|
||||||
|
|
||||||
|
export function useTheme(): ThemeContextValue {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useTheme must be used within a ThemeProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
167
src/shared/i18n/index.ts
Normal file
167
src/shared/i18n/index.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* i18n Configuration
|
||||||
|
*
|
||||||
|
* IMPORTANT: This module requires the following packages to be installed:
|
||||||
|
* - i18next
|
||||||
|
* - react-i18next
|
||||||
|
*
|
||||||
|
* Install with: npm install i18next react-i18next
|
||||||
|
*
|
||||||
|
* Until packages are installed, this exports a placeholder that won't break the app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import es from './locales/es.json';
|
||||||
|
import en from './locales/en.json';
|
||||||
|
|
||||||
|
// Export resources for external use
|
||||||
|
export const resources = {
|
||||||
|
es: { translation: es },
|
||||||
|
en: { translation: en },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Language = 'es' | 'en';
|
||||||
|
|
||||||
|
export const defaultLanguage: Language = 'es';
|
||||||
|
export const fallbackLanguage: Language = 'es';
|
||||||
|
|
||||||
|
export const supportedLanguages: Language[] = ['es', 'en'];
|
||||||
|
|
||||||
|
export const languageNames: Record<Language, string> = {
|
||||||
|
es: 'Espanol',
|
||||||
|
en: 'English',
|
||||||
|
};
|
||||||
|
|
||||||
|
// i18n instance type (generic to avoid import errors when packages not installed)
|
||||||
|
interface I18nInstance {
|
||||||
|
use: (plugin: unknown) => I18nInstance;
|
||||||
|
init: (options: unknown) => Promise<unknown>;
|
||||||
|
changeLanguage: (lang: string) => void;
|
||||||
|
language: string;
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to import i18next, fallback to placeholder if not installed
|
||||||
|
let i18n: I18nInstance | null = null;
|
||||||
|
let isI18nInitialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize i18n
|
||||||
|
* Call this function in your app's entry point (main.tsx)
|
||||||
|
*/
|
||||||
|
export async function initI18n(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Dynamic imports - will fail gracefully if packages not installed
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const i18next = await import(/* webpackIgnore: true */ 'i18next' as any);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const reactI18next = await import(/* webpackIgnore: true */ 'react-i18next' as any);
|
||||||
|
const { initReactI18next } = reactI18next;
|
||||||
|
|
||||||
|
i18n = i18next.default as I18nInstance;
|
||||||
|
|
||||||
|
await i18n!
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources,
|
||||||
|
lng: getStoredLanguage(),
|
||||||
|
fallbackLng: fallbackLanguage,
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
react: {
|
||||||
|
useSuspense: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
isI18nInitialized = true;
|
||||||
|
console.log('[i18n] Initialized successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[i18n] i18next not installed. Install with: npm install i18next react-i18next');
|
||||||
|
isI18nInitialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored language from localStorage
|
||||||
|
*/
|
||||||
|
export function getStoredLanguage(): Language {
|
||||||
|
if (typeof window === 'undefined') return defaultLanguage;
|
||||||
|
|
||||||
|
const stored = localStorage.getItem('app-language');
|
||||||
|
if (stored && supportedLanguages.includes(stored as Language)) {
|
||||||
|
return stored as Language;
|
||||||
|
}
|
||||||
|
return defaultLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set language and persist to localStorage
|
||||||
|
*/
|
||||||
|
export function setLanguage(lang: Language): void {
|
||||||
|
if (!supportedLanguages.includes(lang)) {
|
||||||
|
console.warn(`[i18n] Unsupported language: ${lang}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('app-language', lang);
|
||||||
|
|
||||||
|
if (i18n && isI18nInitialized) {
|
||||||
|
i18n.changeLanguage(lang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current language
|
||||||
|
*/
|
||||||
|
export function getCurrentLanguage(): Language {
|
||||||
|
if (i18n && isI18nInitialized) {
|
||||||
|
return (i18n.language || defaultLanguage) as Language;
|
||||||
|
}
|
||||||
|
return getStoredLanguage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if i18n is available
|
||||||
|
*/
|
||||||
|
export function isI18nAvailable(): boolean {
|
||||||
|
return isI18nInitialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder translation function when i18next is not installed
|
||||||
|
* This allows the app to work without i18next, returning translation keys
|
||||||
|
*/
|
||||||
|
export function t(key: string, options?: Record<string, unknown>): string {
|
||||||
|
if (i18n && isI18nInitialized) {
|
||||||
|
return i18n.t(key, options) as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Try to get value from loaded JSON
|
||||||
|
const lang = getStoredLanguage();
|
||||||
|
const translations = lang === 'es' ? es : en;
|
||||||
|
|
||||||
|
const keys = key.split('.');
|
||||||
|
let value: unknown = translations;
|
||||||
|
|
||||||
|
for (const k of keys) {
|
||||||
|
if (value && typeof value === 'object' && k in value) {
|
||||||
|
value = (value as Record<string, unknown>)[k];
|
||||||
|
} else {
|
||||||
|
return key; // Return key if not found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
// Handle interpolation
|
||||||
|
if (options) {
|
||||||
|
return value.replace(/\{\{(\w+)\}\}/g, (_, match) => {
|
||||||
|
return String(options[match] ?? `{{${match}}}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { initI18n, t, setLanguage, getCurrentLanguage, isI18nAvailable };
|
||||||
137
src/shared/i18n/locales/en.json
Normal file
137
src/shared/i18n/locales/en.json
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"create": "Create",
|
||||||
|
"search": "Search",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"noData": "No data",
|
||||||
|
"error": "Error",
|
||||||
|
"success": "Success",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"close": "Close",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"previous": "Previous",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"actions": "Actions",
|
||||||
|
"filter": "Filter",
|
||||||
|
"reset": "Reset",
|
||||||
|
"apply": "Apply",
|
||||||
|
"select": "Select",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"deselectAll": "Deselect all",
|
||||||
|
"copy": "Copy",
|
||||||
|
"paste": "Paste",
|
||||||
|
"download": "Download",
|
||||||
|
"upload": "Upload",
|
||||||
|
"export": "Export",
|
||||||
|
"import": "Import",
|
||||||
|
"print": "Print",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"view": "View",
|
||||||
|
"details": "Details",
|
||||||
|
"add": "Add",
|
||||||
|
"remove": "Remove",
|
||||||
|
"new": "New",
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"pending": "Pending",
|
||||||
|
"completed": "Completed",
|
||||||
|
"inProgress": "In progress",
|
||||||
|
"total": "Total",
|
||||||
|
"of": "of"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"users": "Users",
|
||||||
|
"companies": "Companies",
|
||||||
|
"partners": "Partners",
|
||||||
|
"settings": "Settings",
|
||||||
|
"profile": "Profile",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"reports": "Reports",
|
||||||
|
"analytics": "Analytics",
|
||||||
|
"configuration": "Configuration",
|
||||||
|
"administration": "Administration",
|
||||||
|
"help": "Help",
|
||||||
|
"support": "Support"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": "Login",
|
||||||
|
"logout": "Logout",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"confirmPassword": "Confirm password",
|
||||||
|
"forgotPassword": "Forgot your password?",
|
||||||
|
"resetPassword": "Reset password",
|
||||||
|
"changePassword": "Change password",
|
||||||
|
"register": "Register",
|
||||||
|
"rememberMe": "Remember me",
|
||||||
|
"signIn": "Sign in",
|
||||||
|
"signOut": "Sign out",
|
||||||
|
"signUp": "Sign up",
|
||||||
|
"welcome": "Welcome",
|
||||||
|
"welcomeBack": "Welcome back"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"required": "This field is required",
|
||||||
|
"email": "Invalid email",
|
||||||
|
"minLength": "Minimum {{min}} characters",
|
||||||
|
"maxLength": "Maximum {{max}} characters",
|
||||||
|
"min": "Minimum value is {{min}}",
|
||||||
|
"max": "Maximum value is {{max}}",
|
||||||
|
"pattern": "Invalid format",
|
||||||
|
"passwordMismatch": "Passwords do not match",
|
||||||
|
"invalidFormat": "Invalid format",
|
||||||
|
"numberOnly": "Numbers only",
|
||||||
|
"lettersOnly": "Letters only",
|
||||||
|
"alphanumeric": "Letters and numbers only",
|
||||||
|
"phone": "Invalid phone number",
|
||||||
|
"url": "Invalid URL",
|
||||||
|
"date": "Invalid date",
|
||||||
|
"dateRange": "Invalid date range",
|
||||||
|
"unique": "This value already exists"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"general": "An error occurred",
|
||||||
|
"network": "Connection error",
|
||||||
|
"unauthorized": "Unauthorized",
|
||||||
|
"forbidden": "Access denied",
|
||||||
|
"notFound": "Not found",
|
||||||
|
"serverError": "Server error",
|
||||||
|
"timeout": "Request timeout",
|
||||||
|
"tryAgain": "Please try again"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"saved": "Saved successfully",
|
||||||
|
"updated": "Updated successfully",
|
||||||
|
"deleted": "Deleted successfully",
|
||||||
|
"created": "Created successfully",
|
||||||
|
"confirmDelete": "Are you sure you want to delete this item?",
|
||||||
|
"confirmAction": "Are you sure you want to perform this action?",
|
||||||
|
"noResults": "No results found",
|
||||||
|
"loading": "Loading data...",
|
||||||
|
"processing": "Processing..."
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"rowsPerPage": "Rows per page",
|
||||||
|
"showing": "Showing",
|
||||||
|
"of": "of",
|
||||||
|
"entries": "entries",
|
||||||
|
"noData": "No data to display",
|
||||||
|
"loading": "Loading data...",
|
||||||
|
"sortAsc": "Sort ascending",
|
||||||
|
"sortDesc": "Sort descending"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"select": "Select language",
|
||||||
|
"es": "Spanish",
|
||||||
|
"en": "English"
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/shared/i18n/locales/es.json
Normal file
137
src/shared/i18n/locales/es.json
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"save": "Guardar",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
"edit": "Editar",
|
||||||
|
"create": "Crear",
|
||||||
|
"search": "Buscar",
|
||||||
|
"loading": "Cargando...",
|
||||||
|
"noData": "No hay datos",
|
||||||
|
"error": "Error",
|
||||||
|
"success": "Exito",
|
||||||
|
"confirm": "Confirmar",
|
||||||
|
"close": "Cerrar",
|
||||||
|
"back": "Volver",
|
||||||
|
"next": "Siguiente",
|
||||||
|
"previous": "Anterior",
|
||||||
|
"yes": "Si",
|
||||||
|
"no": "No",
|
||||||
|
"actions": "Acciones",
|
||||||
|
"filter": "Filtrar",
|
||||||
|
"reset": "Restablecer",
|
||||||
|
"apply": "Aplicar",
|
||||||
|
"select": "Seleccionar",
|
||||||
|
"selectAll": "Seleccionar todo",
|
||||||
|
"deselectAll": "Deseleccionar todo",
|
||||||
|
"copy": "Copiar",
|
||||||
|
"paste": "Pegar",
|
||||||
|
"download": "Descargar",
|
||||||
|
"upload": "Subir",
|
||||||
|
"export": "Exportar",
|
||||||
|
"import": "Importar",
|
||||||
|
"print": "Imprimir",
|
||||||
|
"refresh": "Actualizar",
|
||||||
|
"view": "Ver",
|
||||||
|
"details": "Detalles",
|
||||||
|
"add": "Agregar",
|
||||||
|
"remove": "Quitar",
|
||||||
|
"new": "Nuevo",
|
||||||
|
"active": "Activo",
|
||||||
|
"inactive": "Inactivo",
|
||||||
|
"enabled": "Habilitado",
|
||||||
|
"disabled": "Deshabilitado",
|
||||||
|
"pending": "Pendiente",
|
||||||
|
"completed": "Completado",
|
||||||
|
"inProgress": "En progreso",
|
||||||
|
"total": "Total",
|
||||||
|
"of": "de"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"users": "Usuarios",
|
||||||
|
"companies": "Empresas",
|
||||||
|
"partners": "Partners",
|
||||||
|
"settings": "Configuracion",
|
||||||
|
"profile": "Perfil",
|
||||||
|
"notifications": "Notificaciones",
|
||||||
|
"reports": "Reportes",
|
||||||
|
"analytics": "Analiticas",
|
||||||
|
"configuration": "Configuracion",
|
||||||
|
"administration": "Administracion",
|
||||||
|
"help": "Ayuda",
|
||||||
|
"support": "Soporte"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": "Iniciar sesion",
|
||||||
|
"logout": "Cerrar sesion",
|
||||||
|
"email": "Correo electronico",
|
||||||
|
"password": "Contrasena",
|
||||||
|
"confirmPassword": "Confirmar contrasena",
|
||||||
|
"forgotPassword": "¿Olvidaste tu contrasena?",
|
||||||
|
"resetPassword": "Restablecer contrasena",
|
||||||
|
"changePassword": "Cambiar contrasena",
|
||||||
|
"register": "Registrarse",
|
||||||
|
"rememberMe": "Recordarme",
|
||||||
|
"signIn": "Entrar",
|
||||||
|
"signOut": "Salir",
|
||||||
|
"signUp": "Crear cuenta",
|
||||||
|
"welcome": "Bienvenido",
|
||||||
|
"welcomeBack": "Bienvenido de nuevo"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"required": "Este campo es requerido",
|
||||||
|
"email": "Correo electronico invalido",
|
||||||
|
"minLength": "Minimo {{min}} caracteres",
|
||||||
|
"maxLength": "Maximo {{max}} caracteres",
|
||||||
|
"min": "El valor minimo es {{min}}",
|
||||||
|
"max": "El valor maximo es {{max}}",
|
||||||
|
"pattern": "Formato invalido",
|
||||||
|
"passwordMismatch": "Las contrasenas no coinciden",
|
||||||
|
"invalidFormat": "Formato invalido",
|
||||||
|
"numberOnly": "Solo numeros",
|
||||||
|
"lettersOnly": "Solo letras",
|
||||||
|
"alphanumeric": "Solo letras y numeros",
|
||||||
|
"phone": "Numero de telefono invalido",
|
||||||
|
"url": "URL invalida",
|
||||||
|
"date": "Fecha invalida",
|
||||||
|
"dateRange": "Rango de fechas invalido",
|
||||||
|
"unique": "Este valor ya existe"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"general": "Ha ocurrido un error",
|
||||||
|
"network": "Error de conexion",
|
||||||
|
"unauthorized": "No autorizado",
|
||||||
|
"forbidden": "Acceso denegado",
|
||||||
|
"notFound": "No encontrado",
|
||||||
|
"serverError": "Error del servidor",
|
||||||
|
"timeout": "Tiempo de espera agotado",
|
||||||
|
"tryAgain": "Por favor, intenta de nuevo"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"saved": "Guardado exitosamente",
|
||||||
|
"updated": "Actualizado exitosamente",
|
||||||
|
"deleted": "Eliminado exitosamente",
|
||||||
|
"created": "Creado exitosamente",
|
||||||
|
"confirmDelete": "¿Estas seguro de que deseas eliminar este elemento?",
|
||||||
|
"confirmAction": "¿Estas seguro de que deseas realizar esta accion?",
|
||||||
|
"noResults": "No se encontraron resultados",
|
||||||
|
"loading": "Cargando datos...",
|
||||||
|
"processing": "Procesando..."
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"rowsPerPage": "Filas por pagina",
|
||||||
|
"showing": "Mostrando",
|
||||||
|
"of": "de",
|
||||||
|
"entries": "registros",
|
||||||
|
"noData": "No hay datos para mostrar",
|
||||||
|
"loading": "Cargando datos...",
|
||||||
|
"sortAsc": "Ordenar ascendente",
|
||||||
|
"sortDesc": "Ordenar descendente"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"select": "Seleccionar idioma",
|
||||||
|
"es": "Espanol",
|
||||||
|
"en": "Ingles"
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/shared/i18n/useTranslation.ts
Normal file
122
src/shared/i18n/useTranslation.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Type-safe translation hook wrapper
|
||||||
|
*
|
||||||
|
* This hook provides a consistent API whether i18next is installed or not.
|
||||||
|
* When i18next is available, it uses react-i18next's useTranslation.
|
||||||
|
* When not available, it falls back to a simple implementation using the JSON files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import type { Language } from './index';
|
||||||
|
import {
|
||||||
|
t as translateFn,
|
||||||
|
getCurrentLanguage,
|
||||||
|
setLanguage,
|
||||||
|
isI18nAvailable,
|
||||||
|
supportedLanguages,
|
||||||
|
languageNames,
|
||||||
|
} from './index';
|
||||||
|
|
||||||
|
export interface UseTranslationReturn {
|
||||||
|
/** Translate a key with optional interpolation */
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
/** Current language */
|
||||||
|
language: Language;
|
||||||
|
/** Change the current language */
|
||||||
|
changeLanguage: (lang: Language) => void;
|
||||||
|
/** Whether i18next is available and initialized */
|
||||||
|
ready: boolean;
|
||||||
|
/** List of supported languages */
|
||||||
|
languages: readonly Language[];
|
||||||
|
/** Language display names */
|
||||||
|
languageNames: Record<Language, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to use translations in components
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* function MyComponent() {
|
||||||
|
* const { t, language, changeLanguage } = useTranslation();
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <div>
|
||||||
|
* <p>{t('common.save')}</p>
|
||||||
|
* <p>{t('validation.minLength', { min: 3 })}</p>
|
||||||
|
* <button onClick={() => changeLanguage('en')}>
|
||||||
|
* {language === 'es' ? 'English' : 'Español'}
|
||||||
|
* </button>
|
||||||
|
* </div>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useTranslation(): UseTranslationReturn {
|
||||||
|
const [language, setLanguageState] = useState<Language>(getCurrentLanguage);
|
||||||
|
const [ready, setReady] = useState(isI18nAvailable);
|
||||||
|
|
||||||
|
// Subscribe to language changes when i18next is available
|
||||||
|
useEffect(() => {
|
||||||
|
const checkI18n = async () => {
|
||||||
|
if (isI18nAvailable()) {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const i18next = await import(/* webpackIgnore: true */ 'i18next' as any);
|
||||||
|
const i18n = i18next.default;
|
||||||
|
const handleLanguageChange = (lang: string) => {
|
||||||
|
setLanguageState(lang as Language);
|
||||||
|
};
|
||||||
|
|
||||||
|
i18n.on('languageChanged', handleLanguageChange);
|
||||||
|
setLanguageState(i18n.language as Language);
|
||||||
|
setReady(true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
i18n.off('languageChanged', handleLanguageChange);
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// i18next not available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkI18n();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Listen for storage changes (for cross-tab sync)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStorageChange = (e: StorageEvent) => {
|
||||||
|
if (e.key === 'app-language' && e.newValue) {
|
||||||
|
const newLang = e.newValue as Language;
|
||||||
|
if (supportedLanguages.includes(newLang)) {
|
||||||
|
setLanguageState(newLang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('storage', handleStorageChange);
|
||||||
|
return () => window.removeEventListener('storage', handleStorageChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const changeLanguage = useCallback((lang: Language) => {
|
||||||
|
setLanguage(lang);
|
||||||
|
setLanguageState(lang);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const t = useCallback(
|
||||||
|
(key: string, options?: Record<string, unknown>) => translateFn(key, options),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
t,
|
||||||
|
language,
|
||||||
|
changeLanguage,
|
||||||
|
ready,
|
||||||
|
languages: supportedLanguages,
|
||||||
|
languageNames,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useTranslation;
|
||||||
@ -4,3 +4,5 @@ export * from './stores';
|
|||||||
export * from './utils';
|
export * from './utils';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './constants';
|
export * from './constants';
|
||||||
|
export * from './i18n';
|
||||||
|
export { useTranslation } from './i18n/useTranslation';
|
||||||
|
|||||||
319
src/shared/providers/TenantThemeProvider.tsx
Normal file
319
src/shared/providers/TenantThemeProvider.tsx
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TYPES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface TenantTheme {
|
||||||
|
primaryColor?: string; // HEX color e.g., "#FF5500"
|
||||||
|
secondaryColor?: string;
|
||||||
|
logo?: string; // URL to logo
|
||||||
|
companyName?: string;
|
||||||
|
favicon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantThemeContextValue {
|
||||||
|
theme: TenantTheme | null;
|
||||||
|
setTheme: (theme: TenantTheme) => void;
|
||||||
|
resetTheme: () => void;
|
||||||
|
isCustomized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CONTEXT
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const TenantThemeContext = createContext<TenantThemeContextValue | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// COLOR UTILITIES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts HEX color to RGB space-separated format for CSS variables
|
||||||
|
* @param hex - HEX color string (e.g., "#FF5500" or "#F50")
|
||||||
|
* @returns RGB string in "R G B" format (e.g., "255 85 0")
|
||||||
|
*/
|
||||||
|
function hexToRgb(hex: string): string {
|
||||||
|
// Remove # if present
|
||||||
|
let cleanHex = hex.replace('#', '');
|
||||||
|
|
||||||
|
// Expand shorthand (e.g., "F50" -> "FF5500")
|
||||||
|
if (cleanHex.length === 3) {
|
||||||
|
cleanHex = cleanHex
|
||||||
|
.split('')
|
||||||
|
.map((char) => char + char)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = parseInt(cleanHex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(cleanHex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(cleanHex.substring(4, 6), 16);
|
||||||
|
|
||||||
|
return `${r} ${g} ${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mixes two RGB colors
|
||||||
|
* @param rgb1 - First RGB values [r, g, b]
|
||||||
|
* @param rgb2 - Second RGB values [r, g, b]
|
||||||
|
* @param ratio - Mix ratio (0 = all rgb1, 1 = all rgb2)
|
||||||
|
* @returns Mixed RGB string in "R G B" format
|
||||||
|
*/
|
||||||
|
function mixRgb(
|
||||||
|
rgb1: [number, number, number],
|
||||||
|
rgb2: [number, number, number],
|
||||||
|
ratio: number
|
||||||
|
): string {
|
||||||
|
const r = Math.round(rgb1[0] + (rgb2[0] - rgb1[0]) * ratio);
|
||||||
|
const g = Math.round(rgb1[1] + (rgb2[1] - rgb1[1]) * ratio);
|
||||||
|
const b = Math.round(rgb1[2] + (rgb2[2] - rgb1[2]) * ratio);
|
||||||
|
return `${r} ${g} ${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses RGB string to tuple
|
||||||
|
* @param rgbString - RGB string in "R G B" format
|
||||||
|
* @returns Tuple [r, g, b]
|
||||||
|
*/
|
||||||
|
function parseRgb(rgbString: string): [number, number, number] {
|
||||||
|
const parts = rgbString.split(' ').map(Number);
|
||||||
|
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a full color scale (50-900) from a base color
|
||||||
|
* @param baseHex - Base HEX color (used as 500)
|
||||||
|
* @returns Object with shade numbers as keys and RGB strings as values
|
||||||
|
*/
|
||||||
|
function generateColorScale(baseHex: string): Record<string, string> {
|
||||||
|
const baseRgb = parseRgb(hexToRgb(baseHex));
|
||||||
|
const white: [number, number, number] = [255, 255, 255];
|
||||||
|
const black: [number, number, number] = [0, 0, 0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
'50': mixRgb(baseRgb, white, 0.9),
|
||||||
|
'100': mixRgb(baseRgb, white, 0.8),
|
||||||
|
'200': mixRgb(baseRgb, white, 0.6),
|
||||||
|
'300': mixRgb(baseRgb, white, 0.4),
|
||||||
|
'400': mixRgb(baseRgb, white, 0.2),
|
||||||
|
'500': `${baseRgb[0]} ${baseRgb[1]} ${baseRgb[2]}`,
|
||||||
|
'600': mixRgb(baseRgb, black, 0.2),
|
||||||
|
'700': mixRgb(baseRgb, black, 0.4),
|
||||||
|
'800': mixRgb(baseRgb, black, 0.6),
|
||||||
|
'900': mixRgb(baseRgb, black, 0.8),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts HEX to CSS hex format (ensures # prefix)
|
||||||
|
*/
|
||||||
|
function toHexCss(hex: string): string {
|
||||||
|
return hex.startsWith('#') ? hex : `#${hex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// DEFAULT VALUES (from index.css)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const DEFAULT_PRIMARY_SCALE: Record<string, string> = {
|
||||||
|
'50': '230 243 255',
|
||||||
|
'100': '204 231 255',
|
||||||
|
'200': '153 207 255',
|
||||||
|
'300': '102 183 255',
|
||||||
|
'400': '51 159 255',
|
||||||
|
'500': '0 97 168',
|
||||||
|
'600': '0 77 134',
|
||||||
|
'700': '0 58 101',
|
||||||
|
'800': '0 38 67',
|
||||||
|
'900': '0 19 34',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SECONDARY_SCALE: Record<string, string> = {
|
||||||
|
'50': '230 255 245',
|
||||||
|
'100': '204 255 235',
|
||||||
|
'200': '153 255 214',
|
||||||
|
'300': '102 255 194',
|
||||||
|
'400': '51 255 173',
|
||||||
|
'500': '0 168 104',
|
||||||
|
'600': '0 134 83',
|
||||||
|
'700': '0 101 63',
|
||||||
|
'800': '0 67 42',
|
||||||
|
'900': '0 34 21',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_BRAND_PRIMARY = '#0061A8';
|
||||||
|
const DEFAULT_BRAND_SECONDARY = '#00A868';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// THEME APPLICATION
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a color scale to CSS variables
|
||||||
|
* @param prefix - CSS variable prefix (e.g., "primary" or "secondary")
|
||||||
|
* @param scale - Color scale object
|
||||||
|
*/
|
||||||
|
function applyColorScale(prefix: string, scale: Record<string, string>): void {
|
||||||
|
const root = document.documentElement;
|
||||||
|
Object.entries(scale).forEach(([shade, rgb]) => {
|
||||||
|
root.style.setProperty(`--color-${prefix}-${shade}`, rgb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes inline styles for a color scale
|
||||||
|
* @param prefix - CSS variable prefix (e.g., "primary" or "secondary")
|
||||||
|
*/
|
||||||
|
function removeColorScale(prefix: string): void {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const shades = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'];
|
||||||
|
shades.forEach((shade) => {
|
||||||
|
root.style.removeProperty(`--color-${prefix}-${shade}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the full tenant theme to CSS variables
|
||||||
|
* @param theme - Tenant theme configuration
|
||||||
|
*/
|
||||||
|
function applyTheme(theme: TenantTheme): void {
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
if (theme.primaryColor) {
|
||||||
|
const primaryScale = generateColorScale(theme.primaryColor);
|
||||||
|
applyColorScale('primary', primaryScale);
|
||||||
|
root.style.setProperty('--color-brand-primary', toHexCss(theme.primaryColor));
|
||||||
|
root.style.setProperty('--color-primary-hex', toHexCss(theme.primaryColor));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (theme.secondaryColor) {
|
||||||
|
const secondaryScale = generateColorScale(theme.secondaryColor);
|
||||||
|
applyColorScale('secondary', secondaryScale);
|
||||||
|
root.style.setProperty('--color-brand-secondary', toHexCss(theme.secondaryColor));
|
||||||
|
root.style.setProperty('--color-secondary-hex', toHexCss(theme.secondaryColor));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (theme.favicon) {
|
||||||
|
updateFavicon(theme.favicon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets all theme customizations to defaults
|
||||||
|
*/
|
||||||
|
function resetThemeStyles(): void {
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
// Reset primary colors
|
||||||
|
removeColorScale('primary');
|
||||||
|
Object.entries(DEFAULT_PRIMARY_SCALE).forEach(([shade, rgb]) => {
|
||||||
|
root.style.setProperty(`--color-primary-${shade}`, rgb);
|
||||||
|
});
|
||||||
|
root.style.setProperty('--color-brand-primary', DEFAULT_BRAND_PRIMARY);
|
||||||
|
root.style.setProperty('--color-primary-hex', DEFAULT_BRAND_PRIMARY);
|
||||||
|
|
||||||
|
// Reset secondary colors
|
||||||
|
removeColorScale('secondary');
|
||||||
|
Object.entries(DEFAULT_SECONDARY_SCALE).forEach(([shade, rgb]) => {
|
||||||
|
root.style.setProperty(`--color-secondary-${shade}`, rgb);
|
||||||
|
});
|
||||||
|
root.style.setProperty('--color-brand-secondary', DEFAULT_BRAND_SECONDARY);
|
||||||
|
root.style.setProperty('--color-secondary-hex', DEFAULT_BRAND_SECONDARY);
|
||||||
|
|
||||||
|
// Reset favicon to default
|
||||||
|
resetFavicon();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the page favicon
|
||||||
|
* @param url - URL to the new favicon
|
||||||
|
*/
|
||||||
|
function updateFavicon(url: string): void {
|
||||||
|
let link: HTMLLinkElement | null = document.querySelector("link[rel~='icon']");
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement('link');
|
||||||
|
link.rel = 'icon';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
link.href = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets favicon to default
|
||||||
|
*/
|
||||||
|
function resetFavicon(): void {
|
||||||
|
const link: HTMLLinkElement | null = document.querySelector("link[rel~='icon']");
|
||||||
|
if (link) {
|
||||||
|
link.href = '/favicon.ico';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// PROVIDER COMPONENT
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface TenantThemeProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
initialTheme?: TenantTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TenantThemeProvider({
|
||||||
|
children,
|
||||||
|
initialTheme,
|
||||||
|
}: TenantThemeProviderProps) {
|
||||||
|
const [theme, setThemeState] = useState<TenantTheme | null>(initialTheme ?? null);
|
||||||
|
|
||||||
|
const isCustomized = useMemo(() => {
|
||||||
|
if (!theme) return false;
|
||||||
|
return !!(theme.primaryColor || theme.secondaryColor || theme.logo || theme.favicon);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const setTheme = useCallback((newTheme: TenantTheme) => {
|
||||||
|
setThemeState(newTheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetTheme = useCallback(() => {
|
||||||
|
setThemeState(null);
|
||||||
|
resetThemeStyles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Apply theme when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme) {
|
||||||
|
applyTheme(theme);
|
||||||
|
}
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
// Apply initial theme on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialTheme) {
|
||||||
|
applyTheme(initialTheme);
|
||||||
|
}
|
||||||
|
}, [initialTheme]);
|
||||||
|
|
||||||
|
const value = useMemo<TenantThemeContextValue>(
|
||||||
|
() => ({
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
resetTheme,
|
||||||
|
isCustomized,
|
||||||
|
}),
|
||||||
|
[theme, setTheme, resetTheme, isCustomized]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TenantThemeContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</TenantThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/shared/providers/ThemeProvider.tsx
Normal file
91
src/shared/providers/ThemeProvider.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark' | 'system';
|
||||||
|
export type ResolvedTheme = 'light' | 'dark';
|
||||||
|
|
||||||
|
export interface ThemeContextValue {
|
||||||
|
theme: Theme;
|
||||||
|
resolvedTheme: ResolvedTheme;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'theme';
|
||||||
|
|
||||||
|
function getSystemTheme(): ResolvedTheme {
|
||||||
|
if (typeof window === 'undefined') return 'light';
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredTheme(): Theme {
|
||||||
|
if (typeof window === 'undefined') return 'system';
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === 'light' || stored === 'dark' || stored === 'system') {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
return 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemeProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
defaultTheme?: Theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, defaultTheme }: ThemeProviderProps) {
|
||||||
|
const [theme, setThemeState] = useState<Theme>(() => {
|
||||||
|
return defaultTheme ?? getStoredTheme();
|
||||||
|
});
|
||||||
|
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(getSystemTheme);
|
||||||
|
|
||||||
|
const resolvedTheme: ResolvedTheme = theme === 'system' ? systemTheme : theme;
|
||||||
|
|
||||||
|
const setTheme = useCallback((newTheme: Theme) => {
|
||||||
|
setThemeState(newTheme);
|
||||||
|
localStorage.setItem(STORAGE_KEY, newTheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Apply dark class to document
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (resolvedTheme === 'dark') {
|
||||||
|
root.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}, [resolvedTheme]);
|
||||||
|
|
||||||
|
// Listen for system preference changes
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
|
const handleChange = (e: MediaQueryListEvent) => {
|
||||||
|
setSystemTheme(e.matches ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
|
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<ThemeContextValue>(
|
||||||
|
() => ({
|
||||||
|
theme,
|
||||||
|
resolvedTheme,
|
||||||
|
setTheme,
|
||||||
|
}),
|
||||||
|
[theme, resolvedTheme, setTheme]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/shared/providers/index.ts
Normal file
2
src/shared/providers/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './TenantThemeProvider';
|
||||||
|
export * from './ThemeProvider';
|
||||||
@ -44,3 +44,14 @@ export function truncate(str: string, length: number): string {
|
|||||||
if (str.length <= length) return str;
|
if (str.length <= length) return str;
|
||||||
return `${str.slice(0, length)}...`;
|
return `${str.slice(0, length)}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format distance to now (e.g., "hace 5 minutos", "en 2 días")
|
||||||
|
*/
|
||||||
|
export function formatDistanceToNowLocale(date: Date | string): string {
|
||||||
|
const d = typeof date === 'string' ? parseISO(date) : date;
|
||||||
|
return formatDistanceToNow(d, { locale: es });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export for widget compatibility
|
||||||
|
export { formatDistanceToNowLocale as formatDistanceToNow };
|
||||||
|
|||||||
@ -1,78 +1,234 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
|
||||||
|
// Helper function to create CSS variable color with alpha support
|
||||||
|
const withAlpha = (variableName) => `rgb(var(${variableName}) / <alpha-value>)`
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
content: [
|
content: [
|
||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
// =================================================================
|
||||||
|
// CSS VARIABLE COLORS (Dynamic Theming Support)
|
||||||
|
// These reference CSS variables defined in index.css
|
||||||
|
// Supports alpha via: bg-primary-500/50, text-foreground/80, etc.
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
// Primary Palette - Uses CSS Variables
|
||||||
primary: {
|
primary: {
|
||||||
50: '#f0f9ff',
|
50: withAlpha('--color-primary-50'),
|
||||||
100: '#e0f2fe',
|
100: withAlpha('--color-primary-100'),
|
||||||
200: '#bae6fd',
|
200: withAlpha('--color-primary-200'),
|
||||||
300: '#7dd3fc',
|
300: withAlpha('--color-primary-300'),
|
||||||
400: '#38bdf8',
|
400: withAlpha('--color-primary-400'),
|
||||||
500: '#0ea5e9',
|
500: withAlpha('--color-primary-500'),
|
||||||
600: '#0284c7',
|
600: withAlpha('--color-primary-600'),
|
||||||
700: '#0369a1',
|
700: withAlpha('--color-primary-700'),
|
||||||
800: '#075985',
|
800: withAlpha('--color-primary-800'),
|
||||||
900: '#0c4a6e',
|
900: withAlpha('--color-primary-900'),
|
||||||
950: '#082f49',
|
DEFAULT: withAlpha('--color-primary-500'),
|
||||||
},
|
},
|
||||||
|
// Secondary Palette - Uses CSS Variables
|
||||||
secondary: {
|
secondary: {
|
||||||
50: '#f8fafc',
|
50: withAlpha('--color-secondary-50'),
|
||||||
100: '#f1f5f9',
|
100: withAlpha('--color-secondary-100'),
|
||||||
200: '#e2e8f0',
|
200: withAlpha('--color-secondary-200'),
|
||||||
300: '#cbd5e1',
|
300: withAlpha('--color-secondary-300'),
|
||||||
400: '#94a3b8',
|
400: withAlpha('--color-secondary-400'),
|
||||||
500: '#64748b',
|
500: withAlpha('--color-secondary-500'),
|
||||||
600: '#475569',
|
600: withAlpha('--color-secondary-600'),
|
||||||
700: '#334155',
|
700: withAlpha('--color-secondary-700'),
|
||||||
800: '#1e293b',
|
800: withAlpha('--color-secondary-800'),
|
||||||
900: '#0f172a',
|
900: withAlpha('--color-secondary-900'),
|
||||||
950: '#020617',
|
DEFAULT: withAlpha('--color-secondary-500'),
|
||||||
},
|
},
|
||||||
|
// Semantic Colors - Uses CSS Variables
|
||||||
success: {
|
success: {
|
||||||
50: '#f0fdf4',
|
50: withAlpha('--color-success-50'),
|
||||||
500: '#22c55e',
|
100: withAlpha('--color-success-100'),
|
||||||
600: '#16a34a',
|
500: withAlpha('--color-success-500'),
|
||||||
700: '#15803d',
|
600: withAlpha('--color-success-600'),
|
||||||
|
700: withAlpha('--color-success-700'),
|
||||||
|
DEFAULT: withAlpha('--color-success-500'),
|
||||||
|
light: withAlpha('--color-success-50'),
|
||||||
|
dark: withAlpha('--color-success-700'),
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
50: '#fffbeb',
|
50: withAlpha('--color-warning-50'),
|
||||||
500: '#f59e0b',
|
100: withAlpha('--color-warning-100'),
|
||||||
600: '#d97706',
|
500: withAlpha('--color-warning-500'),
|
||||||
700: '#b45309',
|
600: withAlpha('--color-warning-600'),
|
||||||
|
700: withAlpha('--color-warning-700'),
|
||||||
|
DEFAULT: withAlpha('--color-warning-500'),
|
||||||
|
light: withAlpha('--color-warning-50'),
|
||||||
|
dark: withAlpha('--color-warning-700'),
|
||||||
},
|
},
|
||||||
danger: {
|
danger: {
|
||||||
50: '#fef2f2',
|
50: withAlpha('--color-danger-50'),
|
||||||
500: '#ef4444',
|
100: withAlpha('--color-danger-100'),
|
||||||
600: '#dc2626',
|
500: withAlpha('--color-danger-500'),
|
||||||
700: '#b91c1c',
|
600: withAlpha('--color-danger-600'),
|
||||||
|
700: withAlpha('--color-danger-700'),
|
||||||
|
DEFAULT: withAlpha('--color-danger-500'),
|
||||||
|
light: withAlpha('--color-danger-50'),
|
||||||
|
dark: withAlpha('--color-danger-700'),
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
50: withAlpha('--color-info-50'),
|
||||||
|
100: withAlpha('--color-info-100'),
|
||||||
|
500: withAlpha('--color-info-500'),
|
||||||
|
600: withAlpha('--color-info-600'),
|
||||||
|
700: withAlpha('--color-info-700'),
|
||||||
|
DEFAULT: withAlpha('--color-info-500'),
|
||||||
|
light: withAlpha('--color-info-50'),
|
||||||
|
dark: withAlpha('--color-info-700'),
|
||||||
|
},
|
||||||
|
// Neutral Colors - Uses CSS Variables (auto dark/light)
|
||||||
|
background: {
|
||||||
|
DEFAULT: withAlpha('--color-background'),
|
||||||
|
subtle: withAlpha('--color-background-subtle'),
|
||||||
|
muted: withAlpha('--color-background-muted'),
|
||||||
|
emphasis: withAlpha('--color-background-emphasis'),
|
||||||
|
},
|
||||||
|
foreground: {
|
||||||
|
DEFAULT: withAlpha('--color-foreground'),
|
||||||
|
muted: withAlpha('--color-foreground-muted'),
|
||||||
|
subtle: withAlpha('--color-foreground-subtle'),
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
DEFAULT: withAlpha('--color-border'),
|
||||||
|
subtle: withAlpha('--color-border-subtle'),
|
||||||
|
emphasis: withAlpha('--color-border-emphasis'),
|
||||||
|
},
|
||||||
|
surface: {
|
||||||
|
DEFAULT: withAlpha('--color-surface'),
|
||||||
|
hover: withAlpha('--color-surface-hover'),
|
||||||
|
card: withAlpha('--color-surface-card'),
|
||||||
|
popover: withAlpha('--color-surface-popover'),
|
||||||
|
modal: withAlpha('--color-surface-modal'),
|
||||||
|
dropdown: withAlpha('--color-surface-dropdown'),
|
||||||
|
},
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// STATIC HEX COLORS (Backward Compatibility)
|
||||||
|
// Keep these for legacy code - prefix with 'static-'
|
||||||
|
// =================================================================
|
||||||
|
'static-primary': {
|
||||||
|
50: '#E6F3FF',
|
||||||
|
100: '#CCE7FF',
|
||||||
|
200: '#99CFFF',
|
||||||
|
300: '#66B7FF',
|
||||||
|
400: '#339FFF',
|
||||||
|
500: '#0061A8',
|
||||||
|
600: '#004D86',
|
||||||
|
700: '#003A65',
|
||||||
|
800: '#002643',
|
||||||
|
900: '#001322',
|
||||||
|
},
|
||||||
|
'static-secondary': {
|
||||||
|
50: '#E6FFF5',
|
||||||
|
100: '#CCFFEB',
|
||||||
|
200: '#99FFD6',
|
||||||
|
300: '#66FFC2',
|
||||||
|
400: '#33FFAD',
|
||||||
|
500: '#00A868',
|
||||||
|
600: '#008653',
|
||||||
|
700: '#00653F',
|
||||||
|
800: '#00432A',
|
||||||
|
900: '#002215',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', 'monospace'],
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
xs: ['0.75rem', { lineHeight: '1rem' }],
|
||||||
|
sm: ['0.875rem', { lineHeight: '1.25rem' }],
|
||||||
|
base: ['1rem', { lineHeight: '1.5rem' }],
|
||||||
|
lg: ['1.125rem', { lineHeight: '1.75rem' }],
|
||||||
|
xl: ['1.25rem', { lineHeight: '1.75rem' }],
|
||||||
|
'2xl': ['1.5rem', { lineHeight: '2rem' }],
|
||||||
|
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
|
||||||
|
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
|
||||||
},
|
},
|
||||||
spacing: {
|
spacing: {
|
||||||
'18': '4.5rem',
|
'18': '4.5rem',
|
||||||
'88': '22rem',
|
'88': '22rem',
|
||||||
},
|
},
|
||||||
|
borderRadius: {
|
||||||
|
sm: '0.125rem',
|
||||||
|
DEFAULT: '0.25rem',
|
||||||
|
md: '0.375rem',
|
||||||
|
lg: '0.5rem',
|
||||||
|
xl: '0.75rem',
|
||||||
|
'2xl': '1rem',
|
||||||
|
'3xl': '1.5rem',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||||
|
DEFAULT: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
|
||||||
|
md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||||
|
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||||
|
xl: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
|
||||||
|
'2xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||||
|
inner: 'inset 0 2px 4px 0 rgb(0 0 0 / 0.05)',
|
||||||
|
},
|
||||||
|
zIndex: {
|
||||||
|
dropdown: '1000',
|
||||||
|
sticky: '1020',
|
||||||
|
fixed: '1030',
|
||||||
|
modalBackdrop: '1040',
|
||||||
|
modal: '1050',
|
||||||
|
popover: '1060',
|
||||||
|
tooltip: '1070',
|
||||||
|
toast: '1080',
|
||||||
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'fade-in': 'fadeIn 0.2s ease-out',
|
'fade-in': 'fadeIn 0.2s ease-out',
|
||||||
|
'fade-out': 'fadeOut 0.2s ease-in',
|
||||||
'slide-in': 'slideIn 0.2s ease-out',
|
'slide-in': 'slideIn 0.2s ease-out',
|
||||||
|
'slide-out': 'slideOut 0.2s ease-in',
|
||||||
'spin-slow': 'spin 2s linear infinite',
|
'spin-slow': 'spin 2s linear infinite',
|
||||||
|
'pulse-slow': 'pulse 3s ease-in-out infinite',
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
fadeIn: {
|
fadeIn: {
|
||||||
'0%': { opacity: '0' },
|
'0%': { opacity: '0' },
|
||||||
'100%': { opacity: '1' },
|
'100%': { opacity: '1' },
|
||||||
},
|
},
|
||||||
|
fadeOut: {
|
||||||
|
'0%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0' },
|
||||||
|
},
|
||||||
slideIn: {
|
slideIn: {
|
||||||
'0%': { transform: 'translateY(-10px)', opacity: '0' },
|
'0%': { transform: 'translateY(-10px)', opacity: '0' },
|
||||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||||
},
|
},
|
||||||
|
slideOut: {
|
||||||
|
'0%': { transform: 'translateY(0)', opacity: '1' },
|
||||||
|
'100%': { transform: 'translateY(-10px)', opacity: '0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transitionDuration: {
|
||||||
|
'0': '0ms',
|
||||||
|
'75': '75ms',
|
||||||
|
'100': '100ms',
|
||||||
|
'150': '150ms',
|
||||||
|
'200': '200ms',
|
||||||
|
'300': '300ms',
|
||||||
|
'500': '500ms',
|
||||||
|
'700': '700ms',
|
||||||
|
'1000': '1000ms',
|
||||||
|
},
|
||||||
|
transitionTimingFunction: {
|
||||||
|
'ease-in': 'cubic-bezier(0.4, 0, 1, 1)',
|
||||||
|
'ease-out': 'cubic-bezier(0, 0, 0.2, 1)',
|
||||||
|
'ease-in-out': 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user