Initial commit - erp-core-frontend-web
This commit is contained in:
commit
d3fdc0b9c0
27
.eslintrc.cjs
Normal file
27
.eslintrc.cjs
Normal file
@ -0,0 +1,27 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'react/prop-types': 'off',
|
||||
},
|
||||
}
|
||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@ -0,0 +1,35 @@
|
||||
# =============================================================================
|
||||
# ERP-CORE Frontend - Dockerfile
|
||||
# =============================================================================
|
||||
# Multi-stage build with Nginx
|
||||
# =============================================================================
|
||||
|
||||
# Stage 1: Build
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production with Nginx
|
||||
FROM nginx:alpine AS runner
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Security: run as non-root
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html && \
|
||||
chmod -R 755 /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80 || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
17
index.html
Normal file
17
index.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="ERP Generic - Sistema de gestión empresarial" />
|
||||
<title>ERP Generic</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
35
nginx.conf
Normal file
35
nginx.conf
Normal file
@ -0,0 +1,35 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA routing - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
return 200 'OK';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
7509
package-lock.json
generated
Normal file
7509
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
package.json
Normal file
53
package.json
Normal file
@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@erp-generic/frontend-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint:fix": "eslint . --ext ts,tsx --fix",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"zustand": "^5.0.1",
|
||||
"axios": "^1.7.7",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"zod": "^3.23.8",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"lucide-react": "^0.460.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^11.11.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/node": "^22.9.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"vite": "^5.4.11",
|
||||
"typescript": "^5.6.3",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"postcss": "^8.4.49",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"@typescript-eslint/eslint-plugin": "^8.14.0",
|
||||
"@typescript-eslint/parser": "^8.14.0",
|
||||
"vitest": "^2.1.5",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"jsdom": "^25.0.1"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
75
src/app/layouts/AuthLayout.tsx
Normal file
75
src/app/layouts/AuthLayout.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export function AuthLayout({ children, title, subtitle }: AuthLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Left side - Form */}
|
||||
<div className="flex flex-1 flex-col justify-center px-4 py-12 sm:px-6 lg:flex-none lg:px-20 xl:px-24">
|
||||
<div className="mx-auto w-full max-w-sm lg:w-96">
|
||||
{/* Logo */}
|
||||
<div className="mb-8">
|
||||
<Link to="/" className="flex items-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-600">
|
||||
<span className="text-xl font-bold text-white">E</span>
|
||||
</div>
|
||||
<span className="ml-3 text-xl font-bold text-gray-900">ERP Generic</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900">{title}</h2>
|
||||
{subtitle && (
|
||||
<p className="mt-2 text-sm text-gray-600">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Image/Branding */}
|
||||
<div className="relative hidden w-0 flex-1 lg:block">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary-600 to-primary-800">
|
||||
<div className="flex h-full flex-col items-center justify-center px-12 text-white">
|
||||
<div className="max-w-md text-center">
|
||||
<h1 className="text-4xl font-bold">
|
||||
Sistema de Gestión Empresarial
|
||||
</h1>
|
||||
<p className="mt-4 text-lg text-primary-100">
|
||||
Administra tu empresa de forma eficiente con nuestra plataforma
|
||||
integral de gestión.
|
||||
</p>
|
||||
<div className="mt-8 grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="rounded-lg bg-white/10 p-4">
|
||||
<div className="text-2xl font-bold">309</div>
|
||||
<div className="text-primary-200">Endpoints API</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/10 p-4">
|
||||
<div className="text-2xl font-bold">15</div>
|
||||
<div className="text-primary-200">Módulos</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/10 p-4">
|
||||
<div className="text-2xl font-bold">100%</div>
|
||||
<div className="text-primary-200">Multi-tenant</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/10 p-4">
|
||||
<div className="text-2xl font-bold">24/7</div>
|
||||
<div className="text-primary-200">Disponible</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
src/app/layouts/DashboardLayout.tsx
Normal file
195
src/app/layouts/DashboardLayout.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Home,
|
||||
Users,
|
||||
Building2,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
Receipt,
|
||||
FolderKanban,
|
||||
UserCircle,
|
||||
Settings,
|
||||
Bell,
|
||||
Menu,
|
||||
X,
|
||||
ChevronDown,
|
||||
LogOut,
|
||||
Users2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@utils/cn';
|
||||
import { useUIStore } from '@stores/useUIStore';
|
||||
import { useAuthStore } from '@stores/useAuthStore';
|
||||
import { useIsMobile } from '@hooks/useMediaQuery';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: Home },
|
||||
{ name: 'Usuarios', href: '/users', icon: Users },
|
||||
{ name: 'Empresas', href: '/companies', icon: Building2 },
|
||||
{ name: 'Partners', href: '/partners', icon: Users2 },
|
||||
{ name: 'Inventario', href: '/inventory', icon: Package },
|
||||
{ name: 'Ventas', href: '/sales', icon: ShoppingCart },
|
||||
{ name: 'Compras', href: '/purchases', icon: ShoppingCart },
|
||||
{ name: 'Finanzas', href: '/financial', icon: Receipt },
|
||||
{ name: 'Proyectos', href: '/projects', icon: FolderKanban },
|
||||
{ name: 'CRM', href: '/crm', icon: UserCircle },
|
||||
{ name: 'RRHH', href: '/hr', icon: Users },
|
||||
{ name: 'Configuración', href: '/settings', icon: Settings },
|
||||
];
|
||||
|
||||
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const location = useLocation();
|
||||
const isMobile = useIsMobile();
|
||||
const { sidebarOpen, sidebarCollapsed, toggleSidebar, setSidebarOpen, setIsMobile } = useUIStore();
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
setIsMobile(isMobile);
|
||||
}, [isMobile, setIsMobile]);
|
||||
|
||||
// Close sidebar on mobile when route changes
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}, [location.pathname, isMobile, setSidebarOpen]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Mobile sidebar backdrop */}
|
||||
{isMobile && sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-gray-600/75"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-50 flex flex-col bg-white shadow-lg transition-all duration-300',
|
||||
isMobile
|
||||
? sidebarOpen
|
||||
? 'translate-x-0'
|
||||
: '-translate-x-full'
|
||||
: sidebarCollapsed
|
||||
? 'w-16'
|
||||
: 'w-64'
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center justify-between border-b px-4">
|
||||
{(!sidebarCollapsed || isMobile) && (
|
||||
<Link to="/dashboard" className="flex items-center">
|
||||
<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>
|
||||
</div>
|
||||
<span className="ml-2 text-lg font-bold text-gray-900">ERP</span>
|
||||
</Link>
|
||||
)}
|
||||
{isMobile && (
|
||||
<button onClick={() => setSidebarOpen(false)} className="p-2">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto p-2">
|
||||
{navigation.map((item) => {
|
||||
const isActive = location.pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={cn(
|
||||
'flex items-center rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
<item.icon className={cn('h-5 w-5 flex-shrink-0', isActive ? 'text-primary-600' : 'text-gray-400')} />
|
||||
{(!sidebarCollapsed || isMobile) && (
|
||||
<span className="ml-3">{item.name}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User menu */}
|
||||
<div className="border-t p-4">
|
||||
{(!sidebarCollapsed || isMobile) ? (
|
||||
<div className="flex items-center">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary-100 text-primary-700">
|
||||
{user?.firstName?.[0]}{user?.lastName?.[0]}
|
||||
</div>
|
||||
<div className="ml-3 flex-1 overflow-hidden">
|
||||
<p className="truncate text-sm font-medium text-gray-900">
|
||||
{user?.firstName} {user?.lastName}
|
||||
</p>
|
||||
<p className="truncate text-xs text-gray-500">{user?.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="p-2 text-gray-400 hover:text-gray-600"
|
||||
title="Cerrar sesión"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex w-full items-center justify-center p-2 text-gray-400 hover:text-gray-600"
|
||||
title="Cerrar sesión"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div
|
||||
className={cn(
|
||||
'transition-all duration-300',
|
||||
isMobile ? 'ml-0' : sidebarCollapsed ? 'ml-16' : 'ml-64'
|
||||
)}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b bg-white px-4 shadow-sm">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="rounded-lg p-2 hover:bg-gray-100"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<button className="relative rounded-lg p-2 hover:bg-gray-100">
|
||||
<Bell className="h-5 w-5 text-gray-500" />
|
||||
<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
|
||||
</span>
|
||||
</button>
|
||||
<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">
|
||||
{user?.firstName?.[0]}{user?.lastName?.[0]}
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
src/app/layouts/index.ts
Normal file
2
src/app/layouts/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './AuthLayout';
|
||||
export * from './DashboardLayout';
|
||||
15
src/app/providers/index.tsx
Normal file
15
src/app/providers/index.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { ToastContainer } from '@components/organisms/Toast';
|
||||
|
||||
interface AppProvidersProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AppProviders({ children }: AppProvidersProps) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
src/app/router/ProtectedRoute.tsx
Normal file
31
src/app/router/ProtectedRoute.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '@stores/useAuthStore';
|
||||
import { FullPageSpinner } from '@components/atoms/Spinner';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
requiredRoles?: string[];
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children, requiredRoles }: ProtectedRouteProps) {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, isLoading, user } = useAuthStore();
|
||||
|
||||
if (isLoading) {
|
||||
return <FullPageSpinner />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
if (requiredRoles && requiredRoles.length > 0 && user) {
|
||||
const userRoleName = user.role?.name;
|
||||
const hasRequiredRole = userRoleName ? requiredRoles.includes(userRoleName) : false;
|
||||
if (!hasRequiredRole) {
|
||||
return <Navigate to="/unauthorized" replace />;
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
8
src/app/router/index.tsx
Normal file
8
src/app/router/index.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { router } from './routes';
|
||||
|
||||
export function AppRouter() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
export * from './ProtectedRoute';
|
||||
272
src/app/router/routes.tsx
Normal file
272
src/app/router/routes.tsx
Normal file
@ -0,0 +1,272 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
import { DashboardLayout } from '@app/layouts/DashboardLayout';
|
||||
import { FullPageSpinner } from '@components/atoms/Spinner';
|
||||
|
||||
// Lazy load pages
|
||||
const LoginPage = lazy(() => import('@pages/auth/LoginPage'));
|
||||
const RegisterPage = lazy(() => import('@pages/auth/RegisterPage'));
|
||||
const ForgotPasswordPage = lazy(() => import('@pages/auth/ForgotPasswordPage'));
|
||||
const DashboardPage = lazy(() => import('@pages/dashboard/DashboardPage'));
|
||||
const NotFoundPage = lazy(() => import('@pages/NotFoundPage'));
|
||||
|
||||
// Users pages
|
||||
const UsersListPage = lazy(() => import('@pages/users/UsersListPage'));
|
||||
const UserDetailPage = lazy(() => import('@pages/users/UserDetailPage'));
|
||||
const UserCreatePage = lazy(() => import('@pages/users/UserCreatePage'));
|
||||
const UserEditPage = lazy(() => import('@pages/users/UserEditPage'));
|
||||
|
||||
// Companies pages
|
||||
const CompaniesListPage = lazy(() => import('@pages/companies/CompaniesListPage'));
|
||||
const CompanyDetailPage = lazy(() => import('@pages/companies/CompanyDetailPage'));
|
||||
const CompanyCreatePage = lazy(() => import('@pages/companies/CompanyCreatePage'));
|
||||
const CompanyEditPage = lazy(() => import('@pages/companies/CompanyEditPage'));
|
||||
|
||||
// Partners pages
|
||||
const PartnersListPage = lazy(() => import('@pages/partners/PartnersListPage'));
|
||||
const PartnerDetailPage = lazy(() => import('@pages/partners/PartnerDetailPage'));
|
||||
const PartnerCreatePage = lazy(() => import('@pages/partners/PartnerCreatePage'));
|
||||
const PartnerEditPage = lazy(() => import('@pages/partners/PartnerEditPage'));
|
||||
|
||||
function LazyWrapper({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={<FullPageSpinner />}>{children}</Suspense>;
|
||||
}
|
||||
|
||||
function DashboardWrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<DashboardLayout>
|
||||
<LazyWrapper>{children}</LazyWrapper>
|
||||
</DashboardLayout>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
// Public routes
|
||||
{
|
||||
path: '/login',
|
||||
element: (
|
||||
<LazyWrapper>
|
||||
<LoginPage />
|
||||
</LazyWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
element: (
|
||||
<LazyWrapper>
|
||||
<RegisterPage />
|
||||
</LazyWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
element: (
|
||||
<LazyWrapper>
|
||||
<ForgotPasswordPage />
|
||||
</LazyWrapper>
|
||||
),
|
||||
},
|
||||
|
||||
// Protected routes
|
||||
{
|
||||
path: '/',
|
||||
element: <Navigate to="/dashboard" replace />,
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<DashboardPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
|
||||
// Users routes
|
||||
{
|
||||
path: '/users',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<UsersListPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/users/new',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<UserCreatePage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/users/:id',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<UserDetailPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/users/:id/edit',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<UserEditPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
// Companies routes
|
||||
{
|
||||
path: '/companies',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<CompaniesListPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/companies/new',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<CompanyCreatePage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/companies/:id',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<CompanyDetailPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/companies/:id/edit',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<CompanyEditPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
|
||||
// Partners routes
|
||||
{
|
||||
path: '/partners',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<PartnersListPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/partners/new',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<PartnerCreatePage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/partners/:id',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<PartnerDetailPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/partners/:id/edit',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<PartnerEditPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/inventory/*',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<div className="text-center text-gray-500">Módulo de Inventario - En desarrollo</div>
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/sales/*',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<div className="text-center text-gray-500">Módulo de Ventas - En desarrollo</div>
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/purchases/*',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<div className="text-center text-gray-500">Módulo de Compras - En desarrollo</div>
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/financial/*',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<div className="text-center text-gray-500">Módulo Financiero - En desarrollo</div>
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/projects/*',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<div className="text-center text-gray-500">Módulo de Proyectos - En desarrollo</div>
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/crm/*',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<div className="text-center text-gray-500">Módulo CRM - En desarrollo</div>
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/hr/*',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<div className="text-center text-gray-500">Módulo RRHH - En desarrollo</div>
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/settings/*',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<div className="text-center text-gray-500">Configuración - En desarrollo</div>
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
|
||||
// Error pages
|
||||
{
|
||||
path: '/unauthorized',
|
||||
element: (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900">403</h1>
|
||||
<p className="mt-2 text-gray-600">No tienes permiso para acceder a esta página</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: (
|
||||
<LazyWrapper>
|
||||
<NotFoundPage />
|
||||
</LazyWrapper>
|
||||
),
|
||||
},
|
||||
]);
|
||||
55
src/features/companies/api/companies.api.ts
Normal file
55
src/features/companies/api/companies.api.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { api } from '@services/api/axios-instance';
|
||||
import type {
|
||||
Company,
|
||||
CreateCompanyDto,
|
||||
UpdateCompanyDto,
|
||||
CompanyFilters,
|
||||
CompaniesResponse,
|
||||
} from '../types';
|
||||
|
||||
const BASE_URL = '/api/v1/companies';
|
||||
|
||||
export const companiesApi = {
|
||||
// Get all companies with filters
|
||||
getAll: async (filters?: CompanyFilters): Promise<CompaniesResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.parentCompanyId) params.append('parentCompanyId', filters.parentCompanyId);
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
if (filters?.limit) params.append('limit', String(filters.limit));
|
||||
if (filters?.sortBy) params.append('sortBy', filters.sortBy);
|
||||
if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder);
|
||||
|
||||
const response = await api.get<CompaniesResponse>(`${BASE_URL}?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get company by ID
|
||||
getById: async (id: string): Promise<Company> => {
|
||||
const response = await api.get<Company>(`${BASE_URL}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create company
|
||||
create: async (data: CreateCompanyDto): Promise<Company> => {
|
||||
const response = await api.post<Company>(BASE_URL, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update company
|
||||
update: async (id: string, data: UpdateCompanyDto): Promise<Company> => {
|
||||
const response = await api.patch<Company>(`${BASE_URL}/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete company
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`${BASE_URL}/${id}`);
|
||||
},
|
||||
|
||||
// Get child companies (subsidiaries)
|
||||
getChildren: async (parentId: string): Promise<Company[]> => {
|
||||
const response = await api.get<Company[]>(`${BASE_URL}/${parentId}/children`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
1
src/features/companies/api/index.ts
Normal file
1
src/features/companies/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './companies.api';
|
||||
104
src/features/companies/components/CompanyFiltersPanel.tsx
Normal file
104
src/features/companies/components/CompanyFiltersPanel.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { Search, Filter, X } from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Select } from '@components/organisms/Select';
|
||||
import { useDebounce } from '@hooks/useDebounce';
|
||||
import { useCompanies } from '../hooks';
|
||||
import type { CompanyFilters } from '../types';
|
||||
|
||||
interface CompanyFiltersPanelProps {
|
||||
filters: CompanyFilters;
|
||||
onFiltersChange: (filters: CompanyFilters) => void;
|
||||
}
|
||||
|
||||
export function CompanyFiltersPanel({ filters, onFiltersChange }: CompanyFiltersPanelProps) {
|
||||
const [searchTerm, setSearchTerm] = useState(filters.search || '');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const { companies } = useCompanies({ limit: 100 });
|
||||
|
||||
const debouncedSearch = useDebounce(searchTerm, 300);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
};
|
||||
|
||||
// Update filters when debounced search changes
|
||||
if (debouncedSearch !== filters.search) {
|
||||
onFiltersChange({ ...filters, search: debouncedSearch, page: 1 });
|
||||
}
|
||||
|
||||
// Get parent companies (those that can be parents)
|
||||
const parentOptions = [
|
||||
{ value: '', label: 'Todas las empresas' },
|
||||
...companies
|
||||
.filter((c) => !c.parentCompanyId) // Only root companies as filter options
|
||||
.map((c) => ({ value: c.id, label: c.name })),
|
||||
];
|
||||
|
||||
const handleParentChange = (value: string | string[]) => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
parentCompanyId: (value as string) || undefined,
|
||||
page: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchTerm('');
|
||||
onFiltersChange({ page: 1, limit: filters.limit });
|
||||
};
|
||||
|
||||
const hasActiveFilters = filters.search || filters.parentCompanyId;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="Buscar por nombre o RFC..."
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toggle filters */}
|
||||
<Button
|
||||
variant={showFilters ? 'secondary' : 'outline'}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Filtros
|
||||
</Button>
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" onClick={clearFilters}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded filters */}
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap gap-4 rounded-lg border bg-gray-50 p-4">
|
||||
<div className="w-64">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Empresa matriz
|
||||
</label>
|
||||
<Select
|
||||
options={parentOptions}
|
||||
value={filters.parentCompanyId || ''}
|
||||
onChange={handleParentChange}
|
||||
placeholder="Seleccionar..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
324
src/features/companies/components/CompanyForm.tsx
Normal file
324
src/features/companies/components/CompanyForm.tsx
Normal file
@ -0,0 +1,324 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { FormField } from '@components/molecules/FormField';
|
||||
import { Select } from '@components/organisms/Select';
|
||||
import { useCompanies } from '../hooks';
|
||||
import type { Company, CreateCompanyDto, UpdateCompanyDto } from '../types';
|
||||
|
||||
const createCompanySchema = z.object({
|
||||
name: z.string().min(2, 'Mínimo 2 caracteres'),
|
||||
legalName: z.string().optional(),
|
||||
taxId: z.string().optional(),
|
||||
parentCompanyId: z.string().optional(),
|
||||
settings: z.object({
|
||||
email: z.string().email('Email inválido').optional().or(z.literal('')),
|
||||
phone: z.string().optional(),
|
||||
website: z.string().optional(),
|
||||
taxRegime: z.string().optional(),
|
||||
fiscalPosition: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
zipCode: z.string().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
const updateCompanySchema = createCompanySchema;
|
||||
|
||||
type CreateFormData = z.infer<typeof createCompanySchema>;
|
||||
type UpdateFormData = z.infer<typeof updateCompanySchema>;
|
||||
|
||||
interface CompanyFormProps {
|
||||
company?: Company;
|
||||
onSubmit: (data: CreateCompanyDto | UpdateCompanyDto) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function CompanyForm({ company, onSubmit, onCancel, isLoading }: CompanyFormProps) {
|
||||
const isEditing = !!company;
|
||||
const { companies } = useCompanies({ limit: 100 });
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<CreateFormData | UpdateFormData>({
|
||||
resolver: zodResolver(isEditing ? updateCompanySchema : createCompanySchema),
|
||||
defaultValues: company
|
||||
? {
|
||||
name: company.name,
|
||||
legalName: company.legalName || '',
|
||||
taxId: company.taxId || '',
|
||||
parentCompanyId: company.parentCompanyId || '',
|
||||
settings: {
|
||||
email: company.settings?.email || '',
|
||||
phone: company.settings?.phone || '',
|
||||
website: company.settings?.website || '',
|
||||
taxRegime: company.settings?.taxRegime || '',
|
||||
fiscalPosition: company.settings?.fiscalPosition || '',
|
||||
address: company.settings?.address || '',
|
||||
city: company.settings?.city || '',
|
||||
state: company.settings?.state || '',
|
||||
country: company.settings?.country || '',
|
||||
zipCode: company.settings?.zipCode || '',
|
||||
},
|
||||
}
|
||||
: {
|
||||
name: '',
|
||||
legalName: '',
|
||||
taxId: '',
|
||||
parentCompanyId: '',
|
||||
settings: {
|
||||
email: '',
|
||||
phone: '',
|
||||
website: '',
|
||||
taxRegime: '',
|
||||
fiscalPosition: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
country: '',
|
||||
zipCode: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const selectedParentId = watch('parentCompanyId');
|
||||
|
||||
// Filter out current company from parent options (can't be its own parent)
|
||||
const parentOptions = [
|
||||
{ value: '', label: 'Sin empresa matriz' },
|
||||
...companies
|
||||
.filter((c) => c.id !== company?.id)
|
||||
.map((c) => ({ value: c.id, label: c.name })),
|
||||
];
|
||||
|
||||
const handleFormSubmit = async (data: CreateFormData | UpdateFormData) => {
|
||||
// Clean up empty optional fields
|
||||
const cleanData: CreateCompanyDto | UpdateCompanyDto = {
|
||||
name: data.name,
|
||||
...(data.legalName && { legalName: data.legalName }),
|
||||
...(data.taxId && { taxId: data.taxId }),
|
||||
...(data.parentCompanyId && { parentCompanyId: data.parentCompanyId }),
|
||||
settings: {
|
||||
...(data.settings?.email && { email: data.settings.email }),
|
||||
...(data.settings?.phone && { phone: data.settings.phone }),
|
||||
...(data.settings?.website && { website: data.settings.website }),
|
||||
...(data.settings?.taxRegime && { taxRegime: data.settings.taxRegime }),
|
||||
...(data.settings?.fiscalPosition && { fiscalPosition: data.settings.fiscalPosition }),
|
||||
...(data.settings?.address && { address: data.settings.address }),
|
||||
...(data.settings?.city && { city: data.settings.city }),
|
||||
...(data.settings?.state && { state: data.settings.state }),
|
||||
...(data.settings?.country && { country: data.settings.country }),
|
||||
...(data.settings?.zipCode && { zipCode: data.settings.zipCode }),
|
||||
},
|
||||
};
|
||||
await onSubmit(cleanData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Información básica</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
label="Nombre comercial"
|
||||
error={errors.name?.message}
|
||||
required
|
||||
>
|
||||
<input
|
||||
{...register('name')}
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Mi Empresa S.A."
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Razón social"
|
||||
error={errors.legalName?.message}
|
||||
>
|
||||
<input
|
||||
{...register('legalName')}
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Mi Empresa Sociedad Anónima de Capital Variable"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
label="RFC / Tax ID"
|
||||
error={errors.taxId?.message}
|
||||
>
|
||||
<input
|
||||
{...register('taxId')}
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="ABC123456XYZ"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Empresa matriz"
|
||||
error={errors.parentCompanyId?.message}
|
||||
>
|
||||
<Select
|
||||
options={parentOptions}
|
||||
value={selectedParentId || ''}
|
||||
onChange={(value) => setValue('parentCompanyId', value as string)}
|
||||
placeholder="Seleccionar..."
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Información de contacto</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
label="Email"
|
||||
error={errors.settings?.email?.message}
|
||||
>
|
||||
<input
|
||||
{...register('settings.email')}
|
||||
type="email"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="contacto@miempresa.com"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Teléfono"
|
||||
error={errors.settings?.phone?.message}
|
||||
>
|
||||
<input
|
||||
{...register('settings.phone')}
|
||||
type="tel"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="+52 55 1234 5678"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Sitio web"
|
||||
error={errors.settings?.website?.message}
|
||||
>
|
||||
<input
|
||||
{...register('settings.website')}
|
||||
type="url"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="https://www.miempresa.com"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{/* Fiscal Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Información fiscal</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
label="Régimen fiscal"
|
||||
error={errors.settings?.taxRegime?.message}
|
||||
>
|
||||
<input
|
||||
{...register('settings.taxRegime')}
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="601 - General de Ley"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Posición fiscal"
|
||||
error={errors.settings?.fiscalPosition?.message}
|
||||
>
|
||||
<input
|
||||
{...register('settings.fiscalPosition')}
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="General"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Dirección</h3>
|
||||
|
||||
<FormField
|
||||
label="Dirección"
|
||||
error={errors.settings?.address?.message}
|
||||
>
|
||||
<input
|
||||
{...register('settings.address')}
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Av. Principal #123, Col. Centro"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<FormField label="Ciudad" error={errors.settings?.city?.message}>
|
||||
<input
|
||||
{...register('settings.city')}
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Ciudad de México"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Estado" error={errors.settings?.state?.message}>
|
||||
<input
|
||||
{...register('settings.state')}
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="CDMX"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="País" error={errors.settings?.country?.message}>
|
||||
<input
|
||||
{...register('settings.country')}
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="México"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="C.P." error={errors.settings?.zipCode?.message}>
|
||||
<input
|
||||
{...register('settings.zipCode')}
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="06600"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
{isEditing ? 'Guardar cambios' : 'Crear empresa'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
2
src/features/companies/components/index.ts
Normal file
2
src/features/companies/components/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './CompanyForm';
|
||||
export * from './CompanyFiltersPanel';
|
||||
1
src/features/companies/hooks/index.ts
Normal file
1
src/features/companies/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './useCompanies';
|
||||
148
src/features/companies/hooks/useCompanies.ts
Normal file
148
src/features/companies/hooks/useCompanies.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { companiesApi } from '../api';
|
||||
import type { Company, CompanyFilters, CreateCompanyDto, UpdateCompanyDto } from '../types';
|
||||
|
||||
interface UseCompaniesState {
|
||||
companies: Company[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface UseCompaniesReturn extends UseCompaniesState {
|
||||
filters: CompanyFilters;
|
||||
setFilters: (filters: CompanyFilters) => void;
|
||||
refresh: () => Promise<void>;
|
||||
createCompany: (data: CreateCompanyDto) => Promise<Company>;
|
||||
updateCompany: (id: string, data: UpdateCompanyDto) => Promise<Company>;
|
||||
deleteCompany: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useCompanies(initialFilters?: CompanyFilters): UseCompaniesReturn {
|
||||
const [state, setState] = useState<UseCompaniesState>({
|
||||
companies: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState<CompanyFilters>(initialFilters || { page: 1, limit: 10 });
|
||||
|
||||
const fetchCompanies = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
try {
|
||||
const response = await companiesApi.getAll(filters);
|
||||
setState({
|
||||
companies: response.data,
|
||||
total: response.meta.total,
|
||||
page: response.meta.page,
|
||||
totalPages: response.meta.totalPages,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Error al cargar empresas',
|
||||
}));
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCompanies();
|
||||
}, [fetchCompanies]);
|
||||
|
||||
const createCompany = async (data: CreateCompanyDto): Promise<Company> => {
|
||||
const company = await companiesApi.create(data);
|
||||
await fetchCompanies();
|
||||
return company;
|
||||
};
|
||||
|
||||
const updateCompany = async (id: string, data: UpdateCompanyDto): Promise<Company> => {
|
||||
const company = await companiesApi.update(id, data);
|
||||
await fetchCompanies();
|
||||
return company;
|
||||
};
|
||||
|
||||
const deleteCompany = async (id: string): Promise<void> => {
|
||||
await companiesApi.delete(id);
|
||||
await fetchCompanies();
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh: fetchCompanies,
|
||||
createCompany,
|
||||
updateCompany,
|
||||
deleteCompany,
|
||||
};
|
||||
}
|
||||
|
||||
// Hook for single company
|
||||
export function useCompany(id: string | undefined) {
|
||||
const [company, setCompany] = useState<Company | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchCompany = useCallback(async () => {
|
||||
if (!id) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await companiesApi.getById(id);
|
||||
setCompany(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar empresa');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCompany();
|
||||
}, [fetchCompany]);
|
||||
|
||||
return { company, isLoading, error, refresh: fetchCompany };
|
||||
}
|
||||
|
||||
// Hook for company children (subsidiaries)
|
||||
export function useCompanyChildren(parentId: string | undefined) {
|
||||
const [children, setChildren] = useState<Company[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!parentId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchChildren = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await companiesApi.getChildren(parentId);
|
||||
setChildren(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar subsidiarias');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchChildren();
|
||||
}, [parentId]);
|
||||
|
||||
return { children, isLoading, error };
|
||||
}
|
||||
69
src/features/companies/types/company.types.ts
Normal file
69
src/features/companies/types/company.types.ts
Normal file
@ -0,0 +1,69 @@
|
||||
export interface Company {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
legalName?: string;
|
||||
taxId?: string;
|
||||
currencyId?: string;
|
||||
currencyCode?: string;
|
||||
parentCompanyId?: string | null;
|
||||
parentCompanyName?: string | null;
|
||||
partnerId?: string | null;
|
||||
settings?: CompanySettings;
|
||||
createdAt: string;
|
||||
createdBy?: string | null;
|
||||
updatedAt?: string | null;
|
||||
updatedBy?: string | null;
|
||||
deletedAt?: string | null;
|
||||
deletedBy?: string | null;
|
||||
}
|
||||
|
||||
export interface CompanySettings {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
website?: string;
|
||||
taxRegime?: string;
|
||||
fiscalPosition?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
zipCode?: string;
|
||||
}
|
||||
|
||||
export interface CreateCompanyDto {
|
||||
name: string;
|
||||
legalName?: string;
|
||||
taxId?: string;
|
||||
currencyId?: string;
|
||||
parentCompanyId?: string;
|
||||
settings?: CompanySettings;
|
||||
}
|
||||
|
||||
export interface UpdateCompanyDto {
|
||||
name?: string;
|
||||
legalName?: string;
|
||||
taxId?: string;
|
||||
currencyId?: string;
|
||||
parentCompanyId?: string | null;
|
||||
settings?: CompanySettings;
|
||||
}
|
||||
|
||||
export interface CompanyFilters {
|
||||
search?: string;
|
||||
parentCompanyId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface CompaniesResponse {
|
||||
data: Company[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
1
src/features/companies/types/index.ts
Normal file
1
src/features/companies/types/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './company.types';
|
||||
1
src/features/partners/api/index.ts
Normal file
1
src/features/partners/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './partners.api';
|
||||
74
src/features/partners/api/partners.api.ts
Normal file
74
src/features/partners/api/partners.api.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { api } from '@services/api/axios-instance';
|
||||
import type {
|
||||
Partner,
|
||||
CreatePartnerDto,
|
||||
UpdatePartnerDto,
|
||||
PartnerFilters,
|
||||
PartnersResponse,
|
||||
} from '../types';
|
||||
|
||||
const BASE_URL = '/api/v1/partners';
|
||||
|
||||
export const partnersApi = {
|
||||
// Get all partners with filters
|
||||
getAll: async (filters?: PartnerFilters): Promise<PartnersResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.partnerType) params.append('partnerType', filters.partnerType);
|
||||
if (filters?.isCustomer !== undefined) params.append('isCustomer', String(filters.isCustomer));
|
||||
if (filters?.isSupplier !== undefined) params.append('isSupplier', String(filters.isSupplier));
|
||||
if (filters?.active !== undefined) params.append('active', String(filters.active));
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
if (filters?.limit) params.append('limit', String(filters.limit));
|
||||
if (filters?.sortBy) params.append('sortBy', filters.sortBy);
|
||||
if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder);
|
||||
|
||||
const response = await api.get<PartnersResponse>(`${BASE_URL}?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get partner by ID
|
||||
getById: async (id: string): Promise<Partner> => {
|
||||
const response = await api.get<Partner>(`${BASE_URL}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create partner
|
||||
create: async (data: CreatePartnerDto): Promise<Partner> => {
|
||||
const response = await api.post<Partner>(BASE_URL, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update partner
|
||||
update: async (id: string, data: UpdatePartnerDto): Promise<Partner> => {
|
||||
const response = await api.patch<Partner>(`${BASE_URL}/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete partner
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`${BASE_URL}/${id}`);
|
||||
},
|
||||
|
||||
// Get customers only
|
||||
getCustomers: async (filters?: PartnerFilters): Promise<PartnersResponse> => {
|
||||
return partnersApi.getAll({ ...filters, isCustomer: true });
|
||||
},
|
||||
|
||||
// Get suppliers only
|
||||
getSuppliers: async (filters?: PartnerFilters): Promise<PartnersResponse> => {
|
||||
return partnersApi.getAll({ ...filters, isSupplier: true });
|
||||
},
|
||||
|
||||
// Activate partner
|
||||
activate: async (id: string): Promise<Partner> => {
|
||||
const response = await api.post<Partner>(`${BASE_URL}/${id}/activate`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Deactivate partner
|
||||
deactivate: async (id: string): Promise<Partner> => {
|
||||
const response = await api.post<Partner>(`${BASE_URL}/${id}/deactivate`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
165
src/features/partners/components/PartnerFiltersPanel.tsx
Normal file
165
src/features/partners/components/PartnerFiltersPanel.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { useState } from 'react';
|
||||
import { Search, Filter, X } from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Select } from '@components/organisms/Select';
|
||||
import { useDebounce } from '@hooks/useDebounce';
|
||||
import type { PartnerFilters, PartnerType } from '../types';
|
||||
|
||||
interface PartnerFiltersPanelProps {
|
||||
filters: PartnerFilters;
|
||||
onFiltersChange: (filters: PartnerFilters) => void;
|
||||
}
|
||||
|
||||
const partnerTypeOptions = [
|
||||
{ value: '', label: 'Todos los tipos' },
|
||||
{ value: 'person', label: 'Persona física' },
|
||||
{ value: 'company', label: 'Empresa' },
|
||||
];
|
||||
|
||||
const roleOptions = [
|
||||
{ value: '', label: 'Todos' },
|
||||
{ value: 'customer', label: 'Clientes' },
|
||||
{ value: 'supplier', label: 'Proveedores' },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
{ value: 'true', label: 'Activos' },
|
||||
{ value: 'false', label: 'Inactivos' },
|
||||
];
|
||||
|
||||
export function PartnerFiltersPanel({ filters, onFiltersChange }: PartnerFiltersPanelProps) {
|
||||
const [searchTerm, setSearchTerm] = useState(filters.search || '');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState('');
|
||||
|
||||
const debouncedSearch = useDebounce(searchTerm, 300);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
};
|
||||
|
||||
// Update filters when debounced search changes
|
||||
if (debouncedSearch !== filters.search) {
|
||||
onFiltersChange({ ...filters, search: debouncedSearch, page: 1 });
|
||||
}
|
||||
|
||||
const handleTypeChange = (value: string | string[]) => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
partnerType: (value as PartnerType) || undefined,
|
||||
page: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRoleChange = (value: string | string[]) => {
|
||||
const role = value as string;
|
||||
setSelectedRole(role);
|
||||
|
||||
if (role === 'customer') {
|
||||
onFiltersChange({ ...filters, isCustomer: true, isSupplier: undefined, page: 1 });
|
||||
} else if (role === 'supplier') {
|
||||
onFiltersChange({ ...filters, isSupplier: true, isCustomer: undefined, page: 1 });
|
||||
} else {
|
||||
onFiltersChange({ ...filters, isCustomer: undefined, isSupplier: undefined, page: 1 });
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = (value: string | string[]) => {
|
||||
const status = value as string;
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
active: status ? status === 'true' : undefined,
|
||||
page: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchTerm('');
|
||||
setSelectedRole('');
|
||||
onFiltersChange({ page: 1, limit: filters.limit });
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
filters.search ||
|
||||
filters.partnerType ||
|
||||
filters.isCustomer !== undefined ||
|
||||
filters.isSupplier !== undefined ||
|
||||
filters.active !== undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="Buscar por nombre, email o RFC..."
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toggle filters */}
|
||||
<Button
|
||||
variant={showFilters ? 'secondary' : 'outline'}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Filtros
|
||||
</Button>
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" onClick={clearFilters}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded filters */}
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap gap-4 rounded-lg border bg-gray-50 p-4">
|
||||
<div className="w-48">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Tipo
|
||||
</label>
|
||||
<Select
|
||||
options={partnerTypeOptions}
|
||||
value={filters.partnerType || ''}
|
||||
onChange={handleTypeChange}
|
||||
placeholder="Seleccionar..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-48">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Rol
|
||||
</label>
|
||||
<Select
|
||||
options={roleOptions}
|
||||
value={selectedRole}
|
||||
onChange={handleRoleChange}
|
||||
placeholder="Seleccionar..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-48">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Estado
|
||||
</label>
|
||||
<Select
|
||||
options={statusOptions}
|
||||
value={filters.active !== undefined ? String(filters.active) : ''}
|
||||
onChange={handleStatusChange}
|
||||
placeholder="Seleccionar..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
322
src/features/partners/components/PartnerForm.tsx
Normal file
322
src/features/partners/components/PartnerForm.tsx
Normal file
@ -0,0 +1,322 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { FormField } from '@components/molecules/FormField';
|
||||
import { Select } from '@components/organisms/Select';
|
||||
import type { Partner, CreatePartnerDto, UpdatePartnerDto, PartnerType } from '../types';
|
||||
|
||||
const partnerSchema = z.object({
|
||||
name: z.string().min(2, 'Mínimo 2 caracteres'),
|
||||
legalName: z.string().optional(),
|
||||
partnerType: z.enum(['person', 'company'] as const),
|
||||
isCustomer: z.boolean(),
|
||||
isSupplier: z.boolean(),
|
||||
isEmployee: z.boolean(),
|
||||
email: z.string().email('Email inválido').optional().or(z.literal('')),
|
||||
phone: z.string().optional(),
|
||||
mobile: z.string().optional(),
|
||||
website: z.string().optional(),
|
||||
taxId: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
internalNotes: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof partnerSchema>;
|
||||
|
||||
interface PartnerFormProps {
|
||||
partner?: Partner;
|
||||
onSubmit: (data: CreatePartnerDto | UpdatePartnerDto) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const partnerTypeOptions = [
|
||||
{ value: 'person', label: 'Persona física' },
|
||||
{ value: 'company', label: 'Empresa' },
|
||||
];
|
||||
|
||||
const languageOptions = [
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'en', label: 'English' },
|
||||
];
|
||||
|
||||
export function PartnerForm({ partner, onSubmit, onCancel, isLoading }: PartnerFormProps) {
|
||||
const isEditing = !!partner;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(partnerSchema),
|
||||
defaultValues: partner
|
||||
? {
|
||||
name: partner.name,
|
||||
legalName: partner.legalName || '',
|
||||
partnerType: partner.partnerType,
|
||||
isCustomer: partner.isCustomer,
|
||||
isSupplier: partner.isSupplier,
|
||||
isEmployee: partner.isEmployee,
|
||||
email: partner.email || '',
|
||||
phone: partner.phone || '',
|
||||
mobile: partner.mobile || '',
|
||||
website: partner.website || '',
|
||||
taxId: partner.taxId || '',
|
||||
language: partner.language || 'es',
|
||||
notes: partner.notes || '',
|
||||
internalNotes: partner.internalNotes || '',
|
||||
}
|
||||
: {
|
||||
name: '',
|
||||
legalName: '',
|
||||
partnerType: 'company' as PartnerType,
|
||||
isCustomer: true,
|
||||
isSupplier: false,
|
||||
isEmployee: false,
|
||||
email: '',
|
||||
phone: '',
|
||||
mobile: '',
|
||||
website: '',
|
||||
taxId: '',
|
||||
language: 'es',
|
||||
notes: '',
|
||||
internalNotes: '',
|
||||
},
|
||||
});
|
||||
|
||||
const selectedType = watch('partnerType');
|
||||
const selectedLanguage = watch('language');
|
||||
|
||||
const handleFormSubmit = async (data: FormData) => {
|
||||
const cleanData: CreatePartnerDto | UpdatePartnerDto = {
|
||||
name: data.name,
|
||||
partnerType: data.partnerType,
|
||||
isCustomer: data.isCustomer,
|
||||
isSupplier: data.isSupplier,
|
||||
isEmployee: data.isEmployee,
|
||||
isCompany: data.partnerType === 'company',
|
||||
...(data.legalName && { legalName: data.legalName }),
|
||||
...(data.email && { email: data.email }),
|
||||
...(data.phone && { phone: data.phone }),
|
||||
...(data.mobile && { mobile: data.mobile }),
|
||||
...(data.website && { website: data.website }),
|
||||
...(data.taxId && { taxId: data.taxId }),
|
||||
...(data.language && { language: data.language }),
|
||||
...(data.notes && { notes: data.notes }),
|
||||
...(data.internalNotes && { internalNotes: data.internalNotes }),
|
||||
};
|
||||
await onSubmit(cleanData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Información básica</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
label="Nombre"
|
||||
error={errors.name?.message}
|
||||
required
|
||||
>
|
||||
<input
|
||||
{...register('name')}
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Nombre del partner"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Tipo"
|
||||
error={errors.partnerType?.message}
|
||||
required
|
||||
>
|
||||
<Select
|
||||
options={partnerTypeOptions}
|
||||
value={selectedType}
|
||||
onChange={(value) => setValue('partnerType', value as PartnerType)}
|
||||
placeholder="Seleccionar tipo..."
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Razón social"
|
||||
error={errors.legalName?.message}
|
||||
>
|
||||
<input
|
||||
{...register('legalName')}
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Razón social (empresas)"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Partner Roles */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Tipo de relación
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register('isCustomer')}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Cliente</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register('isSupplier')}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Proveedor</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register('isEmployee')}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Empleado</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Información de contacto</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
label="Email"
|
||||
error={errors.email?.message}
|
||||
>
|
||||
<input
|
||||
{...register('email')}
|
||||
type="email"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="email@ejemplo.com"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Teléfono"
|
||||
error={errors.phone?.message}
|
||||
>
|
||||
<input
|
||||
{...register('phone')}
|
||||
type="tel"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="+52 55 1234 5678"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
label="Móvil"
|
||||
error={errors.mobile?.message}
|
||||
>
|
||||
<input
|
||||
{...register('mobile')}
|
||||
type="tel"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="+52 55 8765 4321"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Sitio web"
|
||||
error={errors.website?.message}
|
||||
>
|
||||
<input
|
||||
{...register('website')}
|
||||
type="url"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="https://www.ejemplo.com"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fiscal Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Información fiscal</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
label="RFC / Tax ID"
|
||||
error={errors.taxId?.message}
|
||||
>
|
||||
<input
|
||||
{...register('taxId')}
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="ABC123456XYZ"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Idioma"
|
||||
error={errors.language?.message}
|
||||
>
|
||||
<Select
|
||||
options={languageOptions}
|
||||
value={selectedLanguage || 'es'}
|
||||
onChange={(value) => setValue('language', value as string)}
|
||||
placeholder="Seleccionar idioma..."
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Notas</h3>
|
||||
|
||||
<FormField
|
||||
label="Notas públicas"
|
||||
error={errors.notes?.message}
|
||||
>
|
||||
<textarea
|
||||
{...register('notes')}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Notas visibles para el partner..."
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Notas internas"
|
||||
error={errors.internalNotes?.message}
|
||||
>
|
||||
<textarea
|
||||
{...register('internalNotes')}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Notas internas (solo visibles para empleados)..."
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
{isEditing ? 'Guardar cambios' : 'Crear partner'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
29
src/features/partners/components/PartnerStatusBadge.tsx
Normal file
29
src/features/partners/components/PartnerStatusBadge.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { CheckCircle, XCircle } from 'lucide-react';
|
||||
|
||||
interface PartnerStatusBadgeProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export function PartnerStatusBadge({ active }: PartnerStatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{active ? (
|
||||
<>
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Activo
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-3 w-3" />
|
||||
Inactivo
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
38
src/features/partners/components/PartnerTypeBadge.tsx
Normal file
38
src/features/partners/components/PartnerTypeBadge.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { User, Building2 } from 'lucide-react';
|
||||
import type { PartnerType } from '../types';
|
||||
|
||||
interface PartnerTypeBadgeProps {
|
||||
type: PartnerType;
|
||||
isCustomer?: boolean;
|
||||
isSupplier?: boolean;
|
||||
}
|
||||
|
||||
export function PartnerTypeBadge({ type, isCustomer, isSupplier }: PartnerTypeBadgeProps) {
|
||||
const roles: string[] = [];
|
||||
if (isCustomer) roles.push('Cliente');
|
||||
if (isSupplier) roles.push('Proveedor');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
type === 'company'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-purple-100 text-purple-800'
|
||||
}`}
|
||||
>
|
||||
{type === 'company' ? (
|
||||
<Building2 className="h-3 w-3" />
|
||||
) : (
|
||||
<User className="h-3 w-3" />
|
||||
)}
|
||||
{type === 'company' ? 'Empresa' : 'Persona'}
|
||||
</span>
|
||||
{roles.length > 0 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{roles.join(' / ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
src/features/partners/components/index.ts
Normal file
4
src/features/partners/components/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './PartnerForm';
|
||||
export * from './PartnerFiltersPanel';
|
||||
export * from './PartnerTypeBadge';
|
||||
export * from './PartnerStatusBadge';
|
||||
1
src/features/partners/hooks/index.ts
Normal file
1
src/features/partners/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './usePartners';
|
||||
141
src/features/partners/hooks/usePartners.ts
Normal file
141
src/features/partners/hooks/usePartners.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { partnersApi } from '../api';
|
||||
import type { Partner, PartnerFilters, CreatePartnerDto, UpdatePartnerDto } from '../types';
|
||||
|
||||
interface UsePartnersState {
|
||||
partners: Partner[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface UsePartnersReturn extends UsePartnersState {
|
||||
filters: PartnerFilters;
|
||||
setFilters: (filters: PartnerFilters) => void;
|
||||
refresh: () => Promise<void>;
|
||||
createPartner: (data: CreatePartnerDto) => Promise<Partner>;
|
||||
updatePartner: (id: string, data: UpdatePartnerDto) => Promise<Partner>;
|
||||
deletePartner: (id: string) => Promise<void>;
|
||||
activatePartner: (id: string) => Promise<void>;
|
||||
deactivatePartner: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePartners(initialFilters?: PartnerFilters): UsePartnersReturn {
|
||||
const [state, setState] = useState<UsePartnersState>({
|
||||
partners: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState<PartnerFilters>(initialFilters || { page: 1, limit: 10 });
|
||||
|
||||
const fetchPartners = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
try {
|
||||
const response = await partnersApi.getAll(filters);
|
||||
setState({
|
||||
partners: response.data,
|
||||
total: response.meta.total,
|
||||
page: response.meta.page,
|
||||
totalPages: response.meta.totalPages,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Error al cargar partners',
|
||||
}));
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPartners();
|
||||
}, [fetchPartners]);
|
||||
|
||||
const createPartner = async (data: CreatePartnerDto): Promise<Partner> => {
|
||||
const partner = await partnersApi.create(data);
|
||||
await fetchPartners();
|
||||
return partner;
|
||||
};
|
||||
|
||||
const updatePartner = async (id: string, data: UpdatePartnerDto): Promise<Partner> => {
|
||||
const partner = await partnersApi.update(id, data);
|
||||
await fetchPartners();
|
||||
return partner;
|
||||
};
|
||||
|
||||
const deletePartner = async (id: string): Promise<void> => {
|
||||
await partnersApi.delete(id);
|
||||
await fetchPartners();
|
||||
};
|
||||
|
||||
const activatePartner = async (id: string): Promise<void> => {
|
||||
await partnersApi.activate(id);
|
||||
await fetchPartners();
|
||||
};
|
||||
|
||||
const deactivatePartner = async (id: string): Promise<void> => {
|
||||
await partnersApi.deactivate(id);
|
||||
await fetchPartners();
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh: fetchPartners,
|
||||
createPartner,
|
||||
updatePartner,
|
||||
deletePartner,
|
||||
activatePartner,
|
||||
deactivatePartner,
|
||||
};
|
||||
}
|
||||
|
||||
// Hook for single partner
|
||||
export function usePartner(id: string | undefined) {
|
||||
const [partner, setPartner] = useState<Partner | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchPartner = useCallback(async () => {
|
||||
if (!id) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await partnersApi.getById(id);
|
||||
setPartner(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar partner');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPartner();
|
||||
}, [fetchPartner]);
|
||||
|
||||
return { partner, isLoading, error, refresh: fetchPartner };
|
||||
}
|
||||
|
||||
// Hook for customers only
|
||||
export function useCustomers(initialFilters?: PartnerFilters) {
|
||||
return usePartners({ ...initialFilters, isCustomer: true });
|
||||
}
|
||||
|
||||
// Hook for suppliers only
|
||||
export function useSuppliers(initialFilters?: PartnerFilters) {
|
||||
return usePartners({ ...initialFilters, isSupplier: true });
|
||||
}
|
||||
1
src/features/partners/types/index.ts
Normal file
1
src/features/partners/types/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './partner.types';
|
||||
102
src/features/partners/types/partner.types.ts
Normal file
102
src/features/partners/types/partner.types.ts
Normal file
@ -0,0 +1,102 @@
|
||||
export type PartnerType = 'person' | 'company';
|
||||
|
||||
export interface Partner {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
legalName?: string | null;
|
||||
partnerType: PartnerType;
|
||||
isCustomer: boolean;
|
||||
isSupplier: boolean;
|
||||
isEmployee: boolean;
|
||||
isCompany: boolean;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
mobile?: string | null;
|
||||
website?: string | null;
|
||||
taxId?: string | null;
|
||||
companyId?: string | null;
|
||||
companyName?: string | null;
|
||||
parentId?: string | null;
|
||||
parentName?: string | null;
|
||||
userId?: string | null;
|
||||
paymentTermId?: string | null;
|
||||
pricelistId?: string | null;
|
||||
language: string;
|
||||
currencyId?: string | null;
|
||||
currencyCode?: string | null;
|
||||
notes?: string | null;
|
||||
internalNotes?: string | null;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
createdBy?: string | null;
|
||||
updatedAt?: string | null;
|
||||
updatedBy?: string | null;
|
||||
deletedAt?: string | null;
|
||||
deletedBy?: string | null;
|
||||
}
|
||||
|
||||
export interface CreatePartnerDto {
|
||||
name: string;
|
||||
legalName?: string;
|
||||
partnerType: PartnerType;
|
||||
isCustomer?: boolean;
|
||||
isSupplier?: boolean;
|
||||
isEmployee?: boolean;
|
||||
isCompany?: boolean;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
mobile?: string;
|
||||
website?: string;
|
||||
taxId?: string;
|
||||
companyId?: string;
|
||||
parentId?: string;
|
||||
language?: string;
|
||||
currencyId?: string;
|
||||
notes?: string;
|
||||
internalNotes?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePartnerDto {
|
||||
name?: string;
|
||||
legalName?: string;
|
||||
partnerType?: PartnerType;
|
||||
isCustomer?: boolean;
|
||||
isSupplier?: boolean;
|
||||
isEmployee?: boolean;
|
||||
isCompany?: boolean;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
mobile?: string;
|
||||
website?: string;
|
||||
taxId?: string;
|
||||
companyId?: string;
|
||||
parentId?: string | null;
|
||||
language?: string;
|
||||
currencyId?: string;
|
||||
notes?: string;
|
||||
internalNotes?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface PartnerFilters {
|
||||
search?: string;
|
||||
partnerType?: PartnerType;
|
||||
isCustomer?: boolean;
|
||||
isSupplier?: boolean;
|
||||
active?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface PartnersResponse {
|
||||
data: Partner[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
1
src/features/users/api/index.ts
Normal file
1
src/features/users/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './users.api';
|
||||
81
src/features/users/api/users.api.ts
Normal file
81
src/features/users/api/users.api.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { api } from '@services/api/axios-instance';
|
||||
import type {
|
||||
User,
|
||||
Role,
|
||||
CreateUserDto,
|
||||
UpdateUserDto,
|
||||
UserFilters,
|
||||
UsersResponse,
|
||||
} from '../types';
|
||||
|
||||
const BASE_URL = '/api/v1/users';
|
||||
|
||||
export const usersApi = {
|
||||
// Get all users with filters
|
||||
getAll: async (filters?: UserFilters): Promise<UsersResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
if (filters?.status) params.append('status', filters.status);
|
||||
if (filters?.roleId) params.append('roleId', filters.roleId);
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
if (filters?.limit) params.append('limit', String(filters.limit));
|
||||
if (filters?.sortBy) params.append('sortBy', filters.sortBy);
|
||||
if (filters?.sortOrder) params.append('sortOrder', filters.sortOrder);
|
||||
|
||||
const response = await api.get<UsersResponse>(`${BASE_URL}?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get user by ID
|
||||
getById: async (id: string): Promise<User> => {
|
||||
const response = await api.get<User>(`${BASE_URL}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create user
|
||||
create: async (data: CreateUserDto): Promise<User> => {
|
||||
const response = await api.post<User>(BASE_URL, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update user
|
||||
update: async (id: string, data: UpdateUserDto): Promise<User> => {
|
||||
const response = await api.patch<User>(`${BASE_URL}/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete user
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`${BASE_URL}/${id}`);
|
||||
},
|
||||
|
||||
// Activate user
|
||||
activate: async (id: string): Promise<User> => {
|
||||
const response = await api.post<User>(`${BASE_URL}/${id}/activate`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Deactivate user
|
||||
deactivate: async (id: string): Promise<User> => {
|
||||
const response = await api.post<User>(`${BASE_URL}/${id}/deactivate`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Reset password
|
||||
resetPassword: async (id: string): Promise<void> => {
|
||||
await api.post(`${BASE_URL}/${id}/reset-password`);
|
||||
},
|
||||
};
|
||||
|
||||
// Roles API
|
||||
export const rolesApi = {
|
||||
getAll: async (): Promise<Role[]> => {
|
||||
const response = await api.get<Role[]>('/api/v1/roles');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Role> => {
|
||||
const response = await api.get<Role>(`/api/v1/roles/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
130
src/features/users/components/UserFiltersPanel.tsx
Normal file
130
src/features/users/components/UserFiltersPanel.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { useState } from 'react';
|
||||
import { Search, Filter, X } from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Select } from '@components/organisms/Select';
|
||||
import { useRoles } from '../hooks';
|
||||
import { useDebounce } from '@hooks/useDebounce';
|
||||
import type { UserFilters, UserStatus } from '../types';
|
||||
|
||||
interface UserFiltersPanelProps {
|
||||
filters: UserFilters;
|
||||
onFiltersChange: (filters: UserFilters) => void;
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
{ value: 'active', label: 'Activo' },
|
||||
{ value: 'inactive', label: 'Inactivo' },
|
||||
{ value: 'pending', label: 'Pendiente' },
|
||||
{ value: 'suspended', label: 'Suspendido' },
|
||||
];
|
||||
|
||||
export function UserFiltersPanel({ filters, onFiltersChange }: UserFiltersPanelProps) {
|
||||
const [searchTerm, setSearchTerm] = useState(filters.search || '');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const { roles } = useRoles();
|
||||
|
||||
const debouncedSearch = useDebounce(searchTerm, 300);
|
||||
|
||||
// Update filters when debounced search changes
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
};
|
||||
|
||||
// Effect handled by parent through debouncedSearch
|
||||
if (debouncedSearch !== filters.search) {
|
||||
onFiltersChange({ ...filters, search: debouncedSearch, page: 1 });
|
||||
}
|
||||
|
||||
const roleOptions = [
|
||||
{ value: '', label: 'Todos los roles' },
|
||||
...roles.map((role) => ({ value: role.id, label: role.name })),
|
||||
];
|
||||
|
||||
const handleStatusChange = (value: string | string[]) => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
status: (value as UserStatus) || undefined,
|
||||
page: 1
|
||||
});
|
||||
};
|
||||
|
||||
const handleRoleChange = (value: string | string[]) => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
roleId: (value as string) || undefined,
|
||||
page: 1
|
||||
});
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchTerm('');
|
||||
onFiltersChange({ page: 1, limit: filters.limit });
|
||||
};
|
||||
|
||||
const hasActiveFilters = filters.search || filters.status || filters.roleId;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder="Buscar por nombre o email..."
|
||||
className="w-full rounded-lg border border-gray-300 py-2 pl-10 pr-4 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toggle filters */}
|
||||
<Button
|
||||
variant={showFilters ? 'secondary' : 'outline'}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Filtros
|
||||
</Button>
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" onClick={clearFilters}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded filters */}
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap gap-4 rounded-lg border bg-gray-50 p-4">
|
||||
<div className="w-48">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Estado
|
||||
</label>
|
||||
<Select
|
||||
options={statusOptions}
|
||||
value={filters.status || ''}
|
||||
onChange={handleStatusChange}
|
||||
placeholder="Seleccionar..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-48">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Rol
|
||||
</label>
|
||||
<Select
|
||||
options={roleOptions}
|
||||
value={filters.roleId || ''}
|
||||
onChange={handleRoleChange}
|
||||
placeholder="Seleccionar..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/features/users/components/UserForm.tsx
Normal file
165
src/features/users/components/UserForm.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { FormField } from '@components/molecules/FormField';
|
||||
import { Select } from '@components/organisms/Select';
|
||||
import { useRoles } from '../hooks';
|
||||
import type { User, CreateUserDto, UpdateUserDto } from '../types';
|
||||
|
||||
const createUserSchema = z.object({
|
||||
email: z.string().email('Email inválido'),
|
||||
password: z.string().min(8, 'Mínimo 8 caracteres'),
|
||||
firstName: z.string().min(2, 'Mínimo 2 caracteres'),
|
||||
lastName: z.string().min(2, 'Mínimo 2 caracteres'),
|
||||
phone: z.string().optional(),
|
||||
roleId: z.string().min(1, 'Selecciona un rol'),
|
||||
});
|
||||
|
||||
const updateUserSchema = z.object({
|
||||
firstName: z.string().min(2, 'Mínimo 2 caracteres'),
|
||||
lastName: z.string().min(2, 'Mínimo 2 caracteres'),
|
||||
phone: z.string().optional(),
|
||||
roleId: z.string().min(1, 'Selecciona un rol'),
|
||||
});
|
||||
|
||||
type CreateFormData = z.infer<typeof createUserSchema>;
|
||||
type UpdateFormData = z.infer<typeof updateUserSchema>;
|
||||
|
||||
interface UserFormProps {
|
||||
user?: User;
|
||||
onSubmit: (data: CreateUserDto | UpdateUserDto) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function UserForm({ user, onSubmit, onCancel, isLoading }: UserFormProps) {
|
||||
const isEditing = !!user;
|
||||
const { roles, isLoading: rolesLoading } = useRoles();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<CreateFormData | UpdateFormData>({
|
||||
resolver: zodResolver(isEditing ? updateUserSchema : createUserSchema),
|
||||
defaultValues: user
|
||||
? {
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
phone: user.phone || '',
|
||||
roleId: user.roleId,
|
||||
}
|
||||
: {
|
||||
email: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: '',
|
||||
roleId: '',
|
||||
},
|
||||
});
|
||||
|
||||
const selectedRoleId = watch('roleId');
|
||||
|
||||
const roleOptions = roles.map((role) => ({
|
||||
value: role.id,
|
||||
label: role.name,
|
||||
}));
|
||||
|
||||
const handleFormSubmit = async (data: CreateFormData | UpdateFormData) => {
|
||||
await onSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
label="Nombre"
|
||||
error={errors.firstName?.message}
|
||||
required
|
||||
>
|
||||
<input
|
||||
{...register('firstName')}
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Juan"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Apellido"
|
||||
error={errors.lastName?.message}
|
||||
required
|
||||
>
|
||||
<input
|
||||
{...register('lastName')}
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Pérez"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<>
|
||||
<FormField
|
||||
label="Email"
|
||||
error={(errors as { email?: { message?: string } }).email?.message}
|
||||
required
|
||||
>
|
||||
<input
|
||||
{...register('email')}
|
||||
type="email"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="juan@ejemplo.com"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Contraseña"
|
||||
error={(errors as { password?: { message?: string } }).password?.message}
|
||||
required
|
||||
>
|
||||
<input
|
||||
{...register('password')}
|
||||
type="password"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField label="Teléfono" error={errors.phone?.message}>
|
||||
<input
|
||||
{...register('phone')}
|
||||
type="tel"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="+52 55 1234 5678"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Rol" error={errors.roleId?.message} required>
|
||||
<Select
|
||||
options={roleOptions}
|
||||
value={selectedRoleId}
|
||||
onChange={(value) => setValue('roleId', value as string)}
|
||||
placeholder="Seleccionar rol..."
|
||||
disabled={rolesLoading}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
{isEditing ? 'Guardar cambios' : 'Crear usuario'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
18
src/features/users/components/UserStatusBadge.tsx
Normal file
18
src/features/users/components/UserStatusBadge.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Badge } from '@components/atoms/Badge';
|
||||
import type { UserStatus } from '../types';
|
||||
|
||||
interface UserStatusBadgeProps {
|
||||
status: UserStatus;
|
||||
}
|
||||
|
||||
const statusConfig: Record<UserStatus, { label: string; variant: 'success' | 'danger' | 'warning' | 'default' }> = {
|
||||
active: { label: 'Activo', variant: 'success' },
|
||||
inactive: { label: 'Inactivo', variant: 'default' },
|
||||
pending: { label: 'Pendiente', variant: 'warning' },
|
||||
suspended: { label: 'Suspendido', variant: 'danger' },
|
||||
};
|
||||
|
||||
export function UserStatusBadge({ status }: UserStatusBadgeProps) {
|
||||
const config = statusConfig[status];
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
}
|
||||
3
src/features/users/components/index.ts
Normal file
3
src/features/users/components/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './UserStatusBadge';
|
||||
export * from './UserForm';
|
||||
export * from './UserFiltersPanel';
|
||||
1
src/features/users/hooks/index.ts
Normal file
1
src/features/users/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './useUsers';
|
||||
155
src/features/users/hooks/useUsers.ts
Normal file
155
src/features/users/hooks/useUsers.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { usersApi, rolesApi } from '../api';
|
||||
import type { User, Role, UserFilters, CreateUserDto, UpdateUserDto } from '../types';
|
||||
|
||||
interface UseUsersState {
|
||||
users: User[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface UseUsersReturn extends UseUsersState {
|
||||
filters: UserFilters;
|
||||
setFilters: (filters: UserFilters) => void;
|
||||
refresh: () => Promise<void>;
|
||||
createUser: (data: CreateUserDto) => Promise<User>;
|
||||
updateUser: (id: string, data: UpdateUserDto) => Promise<User>;
|
||||
deleteUser: (id: string) => Promise<void>;
|
||||
activateUser: (id: string) => Promise<void>;
|
||||
deactivateUser: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useUsers(initialFilters?: UserFilters): UseUsersReturn {
|
||||
const [state, setState] = useState<UseUsersState>({
|
||||
users: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState<UserFilters>(initialFilters || { page: 1, limit: 10 });
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
try {
|
||||
const response = await usersApi.getAll(filters);
|
||||
setState({
|
||||
users: response.data,
|
||||
total: response.meta.total,
|
||||
page: response.meta.page,
|
||||
totalPages: response.meta.totalPages,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Error al cargar usuarios',
|
||||
}));
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const createUser = async (data: CreateUserDto): Promise<User> => {
|
||||
const user = await usersApi.create(data);
|
||||
await fetchUsers();
|
||||
return user;
|
||||
};
|
||||
|
||||
const updateUser = async (id: string, data: UpdateUserDto): Promise<User> => {
|
||||
const user = await usersApi.update(id, data);
|
||||
await fetchUsers();
|
||||
return user;
|
||||
};
|
||||
|
||||
const deleteUser = async (id: string): Promise<void> => {
|
||||
await usersApi.delete(id);
|
||||
await fetchUsers();
|
||||
};
|
||||
|
||||
const activateUser = async (id: string): Promise<void> => {
|
||||
await usersApi.activate(id);
|
||||
await fetchUsers();
|
||||
};
|
||||
|
||||
const deactivateUser = async (id: string): Promise<void> => {
|
||||
await usersApi.deactivate(id);
|
||||
await fetchUsers();
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
filters,
|
||||
setFilters,
|
||||
refresh: fetchUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
activateUser,
|
||||
deactivateUser,
|
||||
};
|
||||
}
|
||||
|
||||
// Hook for single user
|
||||
export function useUser(id: string | undefined) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchUser = useCallback(async () => {
|
||||
if (!id) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await usersApi.getById(id);
|
||||
setUser(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar usuario');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, [fetchUser]);
|
||||
|
||||
return { user, isLoading, error, refresh: fetchUser };
|
||||
}
|
||||
|
||||
// Hook for roles
|
||||
export function useRoles() {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const data = await rolesApi.getAll();
|
||||
setRoles(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar roles');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRoles();
|
||||
}, []);
|
||||
|
||||
return { roles, isLoading, error };
|
||||
}
|
||||
1
src/features/users/types/index.ts
Normal file
1
src/features/users/types/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './user.types';
|
||||
61
src/features/users/types/user.types.ts
Normal file
61
src/features/users/types/user.types.ts
Normal file
@ -0,0 +1,61 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone?: string;
|
||||
avatar?: string;
|
||||
roleId: string;
|
||||
role?: Role;
|
||||
status: UserStatus;
|
||||
tenantId: string;
|
||||
lastLoginAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export type UserStatus = 'active' | 'inactive' | 'pending' | 'suspended';
|
||||
|
||||
export interface CreateUserDto {
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone?: string;
|
||||
roleId: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserDto {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
phone?: string;
|
||||
roleId?: string;
|
||||
status?: UserStatus;
|
||||
}
|
||||
|
||||
export interface UserFilters {
|
||||
search?: string;
|
||||
status?: UserStatus;
|
||||
roleId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface UsersResponse {
|
||||
data: User[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
92
src/index.css
Normal file
92
src/index.css
Normal file
@ -0,0 +1,92 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
@apply antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white hover:bg-primary-700 focus-visible:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-secondary-100 text-secondary-900 hover:bg-secondary-200 focus-visible:ring-secondary-500;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply border border-gray-300 bg-white hover:bg-gray-50 focus-visible:ring-gray-500;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-danger-600 text-white hover:bg-danger-700 focus-visible:ring-danger-500;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply h-8 px-3 text-sm;
|
||||
}
|
||||
|
||||
.btn-md {
|
||||
@apply h-10 px-4;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
@apply h-12 px-6 text-lg;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
@apply border-danger-500 focus:border-danger-500 focus:ring-danger-500;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-lg border bg-white shadow-sm;
|
||||
}
|
||||
|
||||
.link {
|
||||
@apply text-primary-600 hover:text-primary-700 hover:underline;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: theme('colors.gray.300') transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: theme('colors.gray.300');
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
13
src/main.tsx
Normal file
13
src/main.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { AppProviders } from '@app/providers';
|
||||
import { AppRouter } from '@app/router';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AppProviders>
|
||||
<AppRouter />
|
||||
</AppProviders>
|
||||
</StrictMode>
|
||||
);
|
||||
26
src/pages/NotFoundPage.tsx
Normal file
26
src/pages/NotFoundPage.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Home } from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-50 px-4">
|
||||
<div className="text-center">
|
||||
<h1 className="text-9xl font-bold text-primary-600">404</h1>
|
||||
<h2 className="mt-4 text-3xl font-bold text-gray-900">
|
||||
Página no encontrada
|
||||
</h2>
|
||||
<p className="mt-2 text-lg text-gray-600">
|
||||
Lo sentimos, no pudimos encontrar la página que buscas.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<Link to="/dashboard">
|
||||
<Button leftIcon={<Home className="h-4 w-4" />}>
|
||||
Ir al inicio
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/pages/auth/ForgotPasswordPage.tsx
Normal file
106
src/pages/auth/ForgotPasswordPage.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Mail, ArrowLeft, CheckCircle } from 'lucide-react';
|
||||
import { AuthLayout } from '@app/layouts/AuthLayout';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { FormField } from '@components/molecules/FormField';
|
||||
import { Alert } from '@components/molecules/Alert';
|
||||
import { authApi } from '@services/api/auth.api';
|
||||
|
||||
const forgotPasswordSchema = z.object({
|
||||
email: z.string().email('Email inválido'),
|
||||
});
|
||||
|
||||
type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<ForgotPasswordFormData>({
|
||||
resolver: zodResolver(forgotPasswordSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: ForgotPasswordFormData) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await authApi.forgotPassword(data.email);
|
||||
setIsSuccess(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al enviar el correo');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Correo enviado"
|
||||
subtitle="Revisa tu bandeja de entrada"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-success-100">
|
||||
<CheckCircle className="h-6 w-6 text-success-600" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
Hemos enviado un correo con instrucciones para restablecer tu contraseña.
|
||||
Si no lo encuentras, revisa tu carpeta de spam.
|
||||
</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="mt-6 inline-flex items-center text-sm font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Volver al inicio de sesión
|
||||
</Link>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="¿Olvidaste tu contraseña?"
|
||||
subtitle="Ingresa tu email y te enviaremos instrucciones para restablecerla"
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="danger" onClose={() => setError(null)} className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="tu@email.com"
|
||||
error={errors.email?.message}
|
||||
leftIcon={<Mail className="h-5 w-5" />}
|
||||
{...register('email')}
|
||||
/>
|
||||
|
||||
<Button type="submit" fullWidth isLoading={isLoading}>
|
||||
Enviar instrucciones
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Link
|
||||
to="/login"
|
||||
className="mt-6 flex items-center justify-center text-sm font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Volver al inicio de sesión
|
||||
</Link>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
116
src/pages/auth/LoginPage.tsx
Normal file
116
src/pages/auth/LoginPage.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Mail, Lock, Eye, EyeOff } from 'lucide-react';
|
||||
import { AuthLayout } from '@app/layouts/AuthLayout';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { FormField } from '@components/molecules/FormField';
|
||||
import { Alert } from '@components/molecules/Alert';
|
||||
import { useAuthStore } from '@stores/useAuthStore';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Email inválido'),
|
||||
password: z.string().min(1, 'La contraseña es requerida'),
|
||||
});
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>;
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login, isLoading, error, clearError } = useAuthStore();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const from = (location.state as { from?: Location })?.from?.pathname || '/dashboard';
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
try {
|
||||
await login(data);
|
||||
navigate(from, { replace: true });
|
||||
} catch {
|
||||
// Error is handled in the store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Iniciar sesión"
|
||||
subtitle="Ingresa tus credenciales para acceder a tu cuenta"
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="danger" onClose={clearError} className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="tu@email.com"
|
||||
error={errors.email?.message}
|
||||
leftIcon={<Mail className="h-5 w-5" />}
|
||||
{...register('email')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Contraseña"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="••••••••"
|
||||
error={errors.password?.message}
|
||||
leftIcon={<Lock className="h-5 w-5" />}
|
||||
rightIcon={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
}
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-600">Recordarme</span>
|
||||
</label>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
¿Olvidaste tu contraseña?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button type="submit" fullWidth isLoading={isLoading}>
|
||||
Iniciar sesión
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
¿No tienes cuenta?{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
Regístrate aquí
|
||||
</Link>
|
||||
</p>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
150
src/pages/auth/RegisterPage.tsx
Normal file
150
src/pages/auth/RegisterPage.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Mail, Lock, User, Building2, Eye, EyeOff } from 'lucide-react';
|
||||
import { AuthLayout } from '@app/layouts/AuthLayout';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { FormField } from '@components/molecules/FormField';
|
||||
import { Alert } from '@components/molecules/Alert';
|
||||
import { useAuthStore } from '@stores/useAuthStore';
|
||||
|
||||
const registerSchema = z.object({
|
||||
firstName: z.string().min(2, 'Nombre debe tener al menos 2 caracteres'),
|
||||
lastName: z.string().min(2, 'Apellido debe tener al menos 2 caracteres'),
|
||||
email: z.string().email('Email inválido'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'La contraseña debe tener al menos 8 caracteres')
|
||||
.regex(/[A-Z]/, 'Debe contener al menos una mayúscula')
|
||||
.regex(/[a-z]/, 'Debe contener al menos una minúscula')
|
||||
.regex(/[0-9]/, 'Debe contener al menos un número'),
|
||||
confirmPassword: z.string(),
|
||||
companyName: z.string().optional(),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Las contraseñas no coinciden',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type RegisterFormData = z.infer<typeof registerSchema>;
|
||||
|
||||
export default function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const { register: registerUser, isLoading, error, clearError } = useAuthStore();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterFormData>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: RegisterFormData) => {
|
||||
try {
|
||||
await registerUser({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
companyName: data.companyName,
|
||||
});
|
||||
navigate('/dashboard', { replace: true });
|
||||
} catch {
|
||||
// Error is handled in the store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Crear cuenta"
|
||||
subtitle="Completa el formulario para registrarte"
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="danger" onClose={clearError} className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="Nombre"
|
||||
placeholder="Juan"
|
||||
error={errors.firstName?.message}
|
||||
leftIcon={<User className="h-5 w-5" />}
|
||||
{...register('firstName')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Apellido"
|
||||
placeholder="Pérez"
|
||||
error={errors.lastName?.message}
|
||||
{...register('lastName')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="tu@email.com"
|
||||
error={errors.email?.message}
|
||||
leftIcon={<Mail className="h-5 w-5" />}
|
||||
{...register('email')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Empresa (opcional)"
|
||||
placeholder="Mi Empresa S.A."
|
||||
error={errors.companyName?.message}
|
||||
leftIcon={<Building2 className="h-5 w-5" />}
|
||||
{...register('companyName')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Contraseña"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="••••••••"
|
||||
error={errors.password?.message}
|
||||
hint="Mínimo 8 caracteres, con mayúscula, minúscula y número"
|
||||
leftIcon={<Lock className="h-5 w-5" />}
|
||||
rightIcon={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
}
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Confirmar contraseña"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="••••••••"
|
||||
error={errors.confirmPassword?.message}
|
||||
leftIcon={<Lock className="h-5 w-5" />}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
|
||||
<Button type="submit" fullWidth isLoading={isLoading}>
|
||||
Crear cuenta
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
¿Ya tienes cuenta?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
Inicia sesión
|
||||
</Link>
|
||||
</p>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
226
src/pages/companies/CompaniesListPage.tsx
Normal file
226
src/pages/companies/CompaniesListPage.tsx
Normal file
@ -0,0 +1,226 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
|
||||
import { ConfirmModal } from '@components/organisms/Modal';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { useCompanies } from '@features/companies/hooks';
|
||||
import { CompanyFiltersPanel } from '@features/companies/components/CompanyFiltersPanel';
|
||||
import type { Company } from '@features/companies/types';
|
||||
import { formatDate } from '@utils/formatters';
|
||||
|
||||
export function CompaniesListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [companyToDelete, setCompanyToDelete] = useState<Company | null>(null);
|
||||
|
||||
const {
|
||||
companies,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
filters,
|
||||
setFilters,
|
||||
deleteCompany,
|
||||
refresh,
|
||||
} = useCompanies({ page: 1, limit: 10 });
|
||||
|
||||
const getActionsMenu = (company: Company): DropdownItem[] => {
|
||||
return [
|
||||
{
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/companies/${company.id}`),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Editar',
|
||||
icon: <Edit className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/companies/${company.id}/edit`),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Eliminar',
|
||||
icon: <Trash2 className="h-4 w-4" />,
|
||||
danger: true,
|
||||
onClick: () => setCompanyToDelete(company),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const columns: Column<Company>[] = [
|
||||
{
|
||||
key: 'company',
|
||||
header: 'Empresa',
|
||||
render: (company) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-100">
|
||||
<Building2 className="h-5 w-5 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{company.name}</div>
|
||||
<div className="text-sm text-gray-500">{company.taxId || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'legalName',
|
||||
header: 'Razón social',
|
||||
render: (company) => (
|
||||
<span className="text-sm text-gray-600">{company.legalName || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'parent',
|
||||
header: 'Empresa matriz',
|
||||
render: (company) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{company.parentCompanyName || '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'currency',
|
||||
header: 'Moneda',
|
||||
render: (company) => (
|
||||
<span className="inline-flex rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">
|
||||
{company.currencyCode || 'MXN'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: 'Creado',
|
||||
sortable: true,
|
||||
render: (company) => (
|
||||
<span className="text-sm text-gray-500">
|
||||
{formatDate(company.createdAt, 'short')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (company) => (
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="rounded p-1 hover:bg-gray-100">
|
||||
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
}
|
||||
items={getActionsMenu(company)}
|
||||
align="right"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setFilters({ ...filters, page: newPage });
|
||||
};
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
const newOrder = filters.sortBy === key && filters.sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
setFilters({ ...filters, sortBy: key, sortOrder: newOrder });
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (companyToDelete) {
|
||||
await deleteCompany(companyToDelete.id);
|
||||
setCompanyToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState onRetry={refresh} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs items={[{ label: 'Empresas' }]} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Empresas</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Gestiona las empresas del sistema
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate('/companies/new')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nueva empresa
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lista de empresas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<CompanyFiltersPanel
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
|
||||
{companies.length === 0 && !isLoading ? (
|
||||
<NoDataEmptyState
|
||||
entityName="empresas"
|
||||
onCreateNew={() => navigate('/companies/new')}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
data={companies}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
pagination={{
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
limit: filters.limit || 10,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
sorting={{
|
||||
sortBy: filters.sortBy || null,
|
||||
sortOrder: (filters.sortOrder as 'asc' | 'desc') || 'asc',
|
||||
onSort: handleSort,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!companyToDelete}
|
||||
onClose={() => setCompanyToDelete(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="Eliminar empresa"
|
||||
message={`¿Estás seguro de que deseas eliminar "${companyToDelete?.name}"? Esta acción no se puede deshacer.`}
|
||||
variant="danger"
|
||||
confirmText="Eliminar"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CompaniesListPage;
|
||||
91
src/pages/companies/CompanyCreatePage.tsx
Normal file
91
src/pages/companies/CompanyCreatePage.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { Alert } from '@components/molecules/Alert';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { useToast } from '@components/organisms/Toast';
|
||||
import { CompanyForm } from '@features/companies/components/CompanyForm';
|
||||
import { companiesApi } from '@features/companies/api';
|
||||
import type { CreateCompanyDto, UpdateCompanyDto } from '@features/companies/types';
|
||||
|
||||
export function CompanyCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (data: CreateCompanyDto | UpdateCompanyDto) => {
|
||||
const createData = data as CreateCompanyDto;
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const company = await companiesApi.create(createData);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Empresa creada',
|
||||
message: `${company.name} ha sido creada exitosamente.`,
|
||||
});
|
||||
navigate('/companies');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Error al crear empresa';
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Empresas', href: '/companies' },
|
||||
{ label: 'Nueva empresa' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/companies')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Nueva empresa</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Crea una nueva empresa en el sistema
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información de la empresa</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<Alert
|
||||
variant="danger"
|
||||
title="Error"
|
||||
className="mb-4"
|
||||
onClose={() => setError(null)}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<CompanyForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => navigate('/companies')}
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CompanyCreatePage;
|
||||
314
src/pages/companies/CompanyDetailPage.tsx
Normal file
314
src/pages/companies/CompanyDetailPage.tsx
Normal file
@ -0,0 +1,314 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
Trash2,
|
||||
Mail,
|
||||
Phone,
|
||||
Globe,
|
||||
Building2,
|
||||
MapPin,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Spinner } from '@components/atoms/Spinner';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { ConfirmModal } from '@components/organisms/Modal';
|
||||
import { useToast } from '@components/organisms/Toast';
|
||||
import { ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { useCompany, useCompanyChildren } from '@features/companies/hooks';
|
||||
import { companiesApi } from '@features/companies/api';
|
||||
import { formatDate } from '@utils/formatters';
|
||||
|
||||
export function CompanyDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { company, isLoading, error, refresh } = useCompany(id);
|
||||
const { children } = useCompanyChildren(id);
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await companiesApi.delete(id);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Empresa eliminada',
|
||||
message: 'La empresa ha sido eliminada exitosamente.',
|
||||
});
|
||||
navigate('/companies');
|
||||
} catch {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'No se pudo eliminar la empresa.',
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setShowDeleteModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !company) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState
|
||||
title="Empresa no encontrada"
|
||||
description="No se pudo cargar la información de la empresa."
|
||||
onRetry={refresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Empresas', href: '/companies' },
|
||||
{ label: company.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/companies')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Detalle de empresa</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => navigate(`/companies/${id}/edit`)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => setShowDeleteModal(true)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Eliminar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Company info card */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-xl bg-primary-100">
|
||||
<Building2 className="h-10 w-10 text-primary-600" />
|
||||
</div>
|
||||
<h2 className="mt-4 text-xl font-semibold text-gray-900">
|
||||
{company.name}
|
||||
</h2>
|
||||
{company.legalName && (
|
||||
<p className="text-sm text-gray-500">{company.legalName}</p>
|
||||
)}
|
||||
{company.taxId && (
|
||||
<span className="mt-2 inline-flex rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-700">
|
||||
{company.taxId}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="mt-6 w-full space-y-3 text-left">
|
||||
{company.settings?.email && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Mail className="h-4 w-4 text-gray-400" />
|
||||
<a href={`mailto:${company.settings.email}`} className="text-primary-600 hover:underline">
|
||||
{company.settings.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{company.settings?.phone && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Phone className="h-4 w-4 text-gray-400" />
|
||||
<span>{company.settings.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{company.settings?.website && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Globe className="h-4 w-4 text-gray-400" />
|
||||
<a href={company.settings.website} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">
|
||||
{company.settings.website}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Address */}
|
||||
{(company.settings?.address || company.settings?.city) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MapPin className="h-5 w-5" />
|
||||
Dirección
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1">
|
||||
{company.settings?.address && (
|
||||
<p className="text-gray-900">{company.settings.address}</p>
|
||||
)}
|
||||
<p className="text-gray-600">
|
||||
{[
|
||||
company.settings?.city,
|
||||
company.settings?.state,
|
||||
company.settings?.zipCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</p>
|
||||
{company.settings?.country && (
|
||||
<p className="text-gray-600">{company.settings.country}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Fiscal Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Información fiscal
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">RFC / Tax ID</dt>
|
||||
<dd className="font-medium text-gray-900">{company.taxId || 'No especificado'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Moneda</dt>
|
||||
<dd className="font-medium text-gray-900">{company.currencyCode || 'MXN'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Régimen fiscal</dt>
|
||||
<dd className="font-medium text-gray-900">
|
||||
{company.settings?.taxRegime || 'No especificado'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Posición fiscal</dt>
|
||||
<dd className="font-medium text-gray-900">
|
||||
{company.settings?.fiscalPosition || 'No especificado'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Hierarchy */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5" />
|
||||
Jerarquía corporativa
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Empresa matriz</dt>
|
||||
<dd className="font-medium text-gray-900">
|
||||
{company.parentCompanyName || 'Ninguna (empresa raíz)'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Subsidiarias</dt>
|
||||
<dd className="font-medium text-gray-900">
|
||||
{children.length > 0 ? (
|
||||
<ul className="list-inside list-disc">
|
||||
{children.map((child) => (
|
||||
<li key={child.id}>
|
||||
<button
|
||||
onClick={() => navigate(`/companies/${child.id}`)}
|
||||
className="text-primary-600 hover:underline"
|
||||
>
|
||||
{child.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
'Ninguna'
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información del sistema</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">ID</dt>
|
||||
<dd className="font-mono text-sm text-gray-900">{company.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Tenant ID</dt>
|
||||
<dd className="font-mono text-sm text-gray-900">{company.tenantId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Creado</dt>
|
||||
<dd className="text-gray-900">{formatDate(company.createdAt, 'full')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Actualizado</dt>
|
||||
<dd className="text-gray-900">
|
||||
{company.updatedAt ? formatDate(company.updatedAt, 'full') : '-'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="Eliminar empresa"
|
||||
message={`¿Estás seguro de que deseas eliminar "${company.name}"? Esta acción no se puede deshacer.`}
|
||||
variant="danger"
|
||||
confirmText="Eliminar"
|
||||
isLoading={isProcessing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CompanyDetailPage;
|
||||
119
src/pages/companies/CompanyEditPage.tsx
Normal file
119
src/pages/companies/CompanyEditPage.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Spinner } from '@components/atoms/Spinner';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { Alert } from '@components/molecules/Alert';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { useToast } from '@components/organisms/Toast';
|
||||
import { ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { CompanyForm } from '@features/companies/components/CompanyForm';
|
||||
import { useCompany } from '@features/companies/hooks';
|
||||
import { companiesApi } from '@features/companies/api';
|
||||
import type { CreateCompanyDto, UpdateCompanyDto } from '@features/companies/types';
|
||||
|
||||
export function CompanyEditPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { company, isLoading, error, refresh } = useCompany(id);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (data: CreateCompanyDto | UpdateCompanyDto) => {
|
||||
if (!id) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitError(null);
|
||||
|
||||
try {
|
||||
await companiesApi.update(id, data as UpdateCompanyDto);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Empresa actualizada',
|
||||
message: 'Los cambios han sido guardados exitosamente.',
|
||||
});
|
||||
navigate(`/companies/${id}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Error al actualizar empresa';
|
||||
setSubmitError(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !company) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState
|
||||
title="Empresa no encontrada"
|
||||
description="No se pudo cargar la información de la empresa."
|
||||
onRetry={refresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Empresas', href: '/companies' },
|
||||
{ label: company.name, href: `/companies/${id}` },
|
||||
{ label: 'Editar' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(`/companies/${id}`)}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Editar empresa</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Modifica la información de {company.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información de la empresa</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{submitError && (
|
||||
<Alert
|
||||
variant="danger"
|
||||
title="Error"
|
||||
className="mb-4"
|
||||
onClose={() => setSubmitError(null)}
|
||||
>
|
||||
{submitError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<CompanyForm
|
||||
company={company}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => navigate(`/companies/${id}`)}
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CompanyEditPage;
|
||||
171
src/pages/dashboard/DashboardPage.tsx
Normal file
171
src/pages/dashboard/DashboardPage.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Users,
|
||||
ShoppingCart,
|
||||
DollarSign,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@components/molecules/Card';
|
||||
import { Badge } from '@components/atoms/Badge';
|
||||
import { useAuthStore } from '@stores/useAuthStore';
|
||||
import { formatCurrency } from '@utils/formatters';
|
||||
|
||||
const stats = [
|
||||
{
|
||||
name: 'Ventas del mes',
|
||||
value: 125000,
|
||||
change: 12.5,
|
||||
changeType: 'increase' as const,
|
||||
icon: DollarSign,
|
||||
format: 'currency',
|
||||
},
|
||||
{
|
||||
name: 'Pedidos',
|
||||
value: 48,
|
||||
change: 8.2,
|
||||
changeType: 'increase' as const,
|
||||
icon: ShoppingCart,
|
||||
format: 'number',
|
||||
},
|
||||
{
|
||||
name: 'Clientes activos',
|
||||
value: 324,
|
||||
change: -2.4,
|
||||
changeType: 'decrease' as const,
|
||||
icon: Users,
|
||||
format: 'number',
|
||||
},
|
||||
{
|
||||
name: 'Productos en stock',
|
||||
value: 1250,
|
||||
change: 4.1,
|
||||
changeType: 'increase' as const,
|
||||
icon: Package,
|
||||
format: 'number',
|
||||
},
|
||||
];
|
||||
|
||||
const recentOrders = [
|
||||
{ id: 'SO-001234', customer: 'Empresa ABC', amount: 15000, status: 'sale' },
|
||||
{ id: 'SO-001235', customer: 'Distribuidora XYZ', amount: 8500, status: 'draft' },
|
||||
{ id: 'SO-001236', customer: 'Comercial 123', amount: 22000, status: 'done' },
|
||||
{ id: 'SO-001237', customer: 'Industrias DEF', amount: 12500, status: 'sent' },
|
||||
{ id: 'SO-001238', customer: 'Servicios GHI', amount: 5800, status: 'cancelled' },
|
||||
];
|
||||
|
||||
const statusConfig: Record<string, { label: string; variant: 'default' | 'primary' | 'success' | 'warning' | 'danger' }> = {
|
||||
draft: { label: 'Borrador', variant: 'default' },
|
||||
sent: { label: 'Cotización Enviada', variant: 'primary' },
|
||||
sale: { label: 'Orden de Venta', variant: 'success' },
|
||||
done: { label: 'Completado', variant: 'success' },
|
||||
cancelled: { label: 'Cancelado', variant: 'danger' },
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuthStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Bienvenido, {user?.firstName}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Aquí tienes un resumen de tu negocio
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.name}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">{stat.name}</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-gray-900">
|
||||
{stat.format === 'currency'
|
||||
? formatCurrency(stat.value)
|
||||
: stat.value.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary-50">
|
||||
<stat.icon className="h-6 w-6 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center">
|
||||
{stat.changeType === 'increase' ? (
|
||||
<TrendingUp className="h-4 w-4 text-success-500" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-danger-500" />
|
||||
)}
|
||||
<span
|
||||
className={`ml-1 text-sm font-medium ${
|
||||
stat.changeType === 'increase'
|
||||
? 'text-success-600'
|
||||
: 'text-danger-600'
|
||||
}`}
|
||||
>
|
||||
{stat.changeType === 'increase' ? '+' : ''}
|
||||
{stat.change}%
|
||||
</span>
|
||||
<span className="ml-2 text-sm text-gray-500">vs mes anterior</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recent Orders */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pedidos recientes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Pedido
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Cliente
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Monto
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Estado
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{recentOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm font-medium text-primary-600">
|
||||
{order.id}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
{order.customer}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
||||
{formatCurrency(order.amount)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4">
|
||||
<Badge variant={statusConfig[order.status]?.variant}>
|
||||
{statusConfig[order.status]?.label}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
src/pages/partners/PartnerCreatePage.tsx
Normal file
91
src/pages/partners/PartnerCreatePage.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { Alert } from '@components/molecules/Alert';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { useToast } from '@components/organisms/Toast';
|
||||
import { PartnerForm } from '@features/partners/components/PartnerForm';
|
||||
import { partnersApi } from '@features/partners/api';
|
||||
import type { CreatePartnerDto, UpdatePartnerDto } from '@features/partners/types';
|
||||
|
||||
export function PartnerCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (data: CreatePartnerDto | UpdatePartnerDto) => {
|
||||
const createData = data as CreatePartnerDto;
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const partner = await partnersApi.create(createData);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Partner creado',
|
||||
message: `${partner.name} ha sido creado exitosamente.`,
|
||||
});
|
||||
navigate('/partners');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Error al crear partner';
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Partners', href: '/partners' },
|
||||
{ label: 'Nuevo partner' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/partners')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Nuevo partner</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Crea un nuevo cliente, proveedor o contacto
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información del partner</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<Alert
|
||||
variant="danger"
|
||||
title="Error"
|
||||
className="mb-4"
|
||||
onClose={() => setError(null)}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<PartnerForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => navigate('/partners')}
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PartnerCreatePage;
|
||||
344
src/pages/partners/PartnerDetailPage.tsx
Normal file
344
src/pages/partners/PartnerDetailPage.tsx
Normal file
@ -0,0 +1,344 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
Trash2,
|
||||
Mail,
|
||||
Phone,
|
||||
Globe,
|
||||
FileText,
|
||||
UserCheck,
|
||||
UserX,
|
||||
Building2,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Spinner } from '@components/atoms/Spinner';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { ConfirmModal } from '@components/organisms/Modal';
|
||||
import { useToast } from '@components/organisms/Toast';
|
||||
import { ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { usePartner } from '@features/partners/hooks';
|
||||
import { partnersApi } from '@features/partners/api';
|
||||
import { PartnerTypeBadge } from '@features/partners/components/PartnerTypeBadge';
|
||||
import { PartnerStatusBadge } from '@features/partners/components/PartnerStatusBadge';
|
||||
import { formatDate } from '@utils/formatters';
|
||||
|
||||
export function PartnerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { partner, isLoading, error, refresh } = usePartner(id);
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showToggleModal, setShowToggleModal] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await partnersApi.delete(id);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Partner eliminado',
|
||||
message: 'El partner ha sido eliminado exitosamente.',
|
||||
});
|
||||
navigate('/partners');
|
||||
} catch {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'No se pudo eliminar el partner.',
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setShowDeleteModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async () => {
|
||||
if (!id || !partner) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
if (partner.active) {
|
||||
await partnersApi.deactivate(id);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Partner desactivado',
|
||||
message: 'El partner ha sido desactivado.',
|
||||
});
|
||||
} else {
|
||||
await partnersApi.activate(id);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Partner activado',
|
||||
message: 'El partner ha sido activado.',
|
||||
});
|
||||
}
|
||||
refresh();
|
||||
} catch {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'No se pudo cambiar el estado del partner.',
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setShowToggleModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !partner) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState
|
||||
title="Partner no encontrado"
|
||||
description="No se pudo cargar la información del partner."
|
||||
onRetry={refresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Partners', href: '/partners' },
|
||||
{ label: partner.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/partners')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Detalle de partner</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => navigate(`/partners/${id}/edit`)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => setShowDeleteModal(true)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Eliminar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Partner info card */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-xl bg-primary-100">
|
||||
{partner.partnerType === 'company' ? (
|
||||
<Building2 className="h-10 w-10 text-primary-600" />
|
||||
) : (
|
||||
<User className="h-10 w-10 text-primary-600" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="mt-4 text-xl font-semibold text-gray-900">
|
||||
{partner.name}
|
||||
</h2>
|
||||
{partner.legalName && (
|
||||
<p className="text-sm text-gray-500">{partner.legalName}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap justify-center gap-2">
|
||||
<PartnerTypeBadge
|
||||
type={partner.partnerType}
|
||||
isCustomer={partner.isCustomer}
|
||||
isSupplier={partner.isSupplier}
|
||||
/>
|
||||
<PartnerStatusBadge active={partner.active} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 w-full space-y-3 text-left">
|
||||
{partner.email && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Mail className="h-4 w-4 text-gray-400" />
|
||||
<a href={`mailto:${partner.email}`} className="text-primary-600 hover:underline">
|
||||
{partner.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{partner.phone && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Phone className="h-4 w-4 text-gray-400" />
|
||||
<span>{partner.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{partner.website && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Globe className="h-4 w-4 text-gray-400" />
|
||||
<a href={partner.website} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">
|
||||
{partner.website}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 w-full space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowToggleModal(true)}
|
||||
>
|
||||
{partner.active ? (
|
||||
<>
|
||||
<UserX className="mr-2 h-4 w-4" />
|
||||
Desactivar
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserCheck className="mr-2 h-4 w-4" />
|
||||
Activar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Fiscal Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Información fiscal
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">RFC / Tax ID</dt>
|
||||
<dd className="font-medium text-gray-900">{partner.taxId || 'No especificado'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Idioma</dt>
|
||||
<dd className="font-medium text-gray-900">
|
||||
{partner.language === 'es' ? 'Español' : partner.language === 'en' ? 'English' : partner.language}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Moneda</dt>
|
||||
<dd className="font-medium text-gray-900">{partner.currencyCode || 'MXN'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Empresa asociada</dt>
|
||||
<dd className="font-medium text-gray-900">{partner.companyName || 'Ninguna'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
{(partner.notes || partner.internalNotes) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="space-y-4">
|
||||
{partner.notes && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Notas públicas</dt>
|
||||
<dd className="mt-1 whitespace-pre-wrap text-gray-900">{partner.notes}</dd>
|
||||
</div>
|
||||
)}
|
||||
{partner.internalNotes && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Notas internas</dt>
|
||||
<dd className="mt-1 whitespace-pre-wrap text-gray-900">{partner.internalNotes}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* System Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información del sistema</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">ID</dt>
|
||||
<dd className="font-mono text-sm text-gray-900">{partner.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Tenant ID</dt>
|
||||
<dd className="font-mono text-sm text-gray-900">{partner.tenantId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Creado</dt>
|
||||
<dd className="text-gray-900">{formatDate(partner.createdAt, 'full')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Actualizado</dt>
|
||||
<dd className="text-gray-900">
|
||||
{partner.updatedAt ? formatDate(partner.updatedAt, 'full') : '-'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="Eliminar partner"
|
||||
message={`¿Estás seguro de que deseas eliminar a ${partner.name}? Esta acción no se puede deshacer.`}
|
||||
variant="danger"
|
||||
confirmText="Eliminar"
|
||||
isLoading={isProcessing}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={showToggleModal}
|
||||
onClose={() => setShowToggleModal(false)}
|
||||
onConfirm={handleToggleStatus}
|
||||
title={partner.active ? 'Desactivar partner' : 'Activar partner'}
|
||||
message={
|
||||
partner.active
|
||||
? `¿Deseas desactivar a ${partner.name}?`
|
||||
: `¿Deseas activar a ${partner.name}?`
|
||||
}
|
||||
variant={partner.active ? 'warning' : 'success'}
|
||||
confirmText={partner.active ? 'Desactivar' : 'Activar'}
|
||||
isLoading={isProcessing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PartnerDetailPage;
|
||||
119
src/pages/partners/PartnerEditPage.tsx
Normal file
119
src/pages/partners/PartnerEditPage.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Spinner } from '@components/atoms/Spinner';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { Alert } from '@components/molecules/Alert';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { useToast } from '@components/organisms/Toast';
|
||||
import { ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { PartnerForm } from '@features/partners/components/PartnerForm';
|
||||
import { usePartner } from '@features/partners/hooks';
|
||||
import { partnersApi } from '@features/partners/api';
|
||||
import type { CreatePartnerDto, UpdatePartnerDto } from '@features/partners/types';
|
||||
|
||||
export function PartnerEditPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { partner, isLoading, error, refresh } = usePartner(id);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (data: CreatePartnerDto | UpdatePartnerDto) => {
|
||||
if (!id) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitError(null);
|
||||
|
||||
try {
|
||||
await partnersApi.update(id, data as UpdatePartnerDto);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Partner actualizado',
|
||||
message: 'Los cambios han sido guardados exitosamente.',
|
||||
});
|
||||
navigate(`/partners/${id}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Error al actualizar partner';
|
||||
setSubmitError(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !partner) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState
|
||||
title="Partner no encontrado"
|
||||
description="No se pudo cargar la información del partner."
|
||||
onRetry={refresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Partners', href: '/partners' },
|
||||
{ label: partner.name, href: `/partners/${id}` },
|
||||
{ label: 'Editar' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(`/partners/${id}`)}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Editar partner</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Modifica la información de {partner.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información del partner</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{submitError && (
|
||||
<Alert
|
||||
variant="danger"
|
||||
title="Error"
|
||||
className="mb-4"
|
||||
onClose={() => setSubmitError(null)}
|
||||
>
|
||||
{submitError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<PartnerForm
|
||||
partner={partner}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => navigate(`/partners/${id}`)}
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PartnerEditPage;
|
||||
287
src/pages/partners/PartnersListPage.tsx
Normal file
287
src/pages/partners/PartnersListPage.tsx
Normal file
@ -0,0 +1,287 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
UserCheck,
|
||||
UserX,
|
||||
Mail,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
|
||||
import { ConfirmModal } from '@components/organisms/Modal';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { usePartners } from '@features/partners/hooks';
|
||||
import { PartnerFiltersPanel } from '@features/partners/components/PartnerFiltersPanel';
|
||||
import { PartnerTypeBadge } from '@features/partners/components/PartnerTypeBadge';
|
||||
import { PartnerStatusBadge } from '@features/partners/components/PartnerStatusBadge';
|
||||
import type { Partner } from '@features/partners/types';
|
||||
import { formatDate } from '@utils/formatters';
|
||||
|
||||
export function PartnersListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [partnerToDelete, setPartnerToDelete] = useState<Partner | null>(null);
|
||||
const [partnerToToggle, setPartnerToToggle] = useState<{ partner: Partner; action: 'activate' | 'deactivate' } | null>(null);
|
||||
|
||||
const {
|
||||
partners,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
filters,
|
||||
setFilters,
|
||||
deletePartner,
|
||||
activatePartner,
|
||||
deactivatePartner,
|
||||
refresh,
|
||||
} = usePartners({ page: 1, limit: 10 });
|
||||
|
||||
const getActionsMenu = (partner: Partner): DropdownItem[] => {
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/partners/${partner.id}`),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Editar',
|
||||
icon: <Edit className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/partners/${partner.id}/edit`),
|
||||
},
|
||||
];
|
||||
|
||||
if (partner.email) {
|
||||
items.push({
|
||||
key: 'email',
|
||||
label: 'Enviar email',
|
||||
icon: <Mail className="h-4 w-4" />,
|
||||
onClick: () => window.location.href = `mailto:${partner.email}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (partner.active) {
|
||||
items.push({
|
||||
key: 'deactivate',
|
||||
label: 'Desactivar',
|
||||
icon: <UserX className="h-4 w-4" />,
|
||||
onClick: () => setPartnerToToggle({ partner, action: 'deactivate' }),
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
key: 'activate',
|
||||
label: 'Activar',
|
||||
icon: <UserCheck className="h-4 w-4" />,
|
||||
onClick: () => setPartnerToToggle({ partner, action: 'activate' }),
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: 'delete',
|
||||
label: 'Eliminar',
|
||||
icon: <Trash2 className="h-4 w-4" />,
|
||||
danger: true,
|
||||
onClick: () => setPartnerToDelete(partner),
|
||||
});
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const columns: Column<Partner>[] = [
|
||||
{
|
||||
key: 'partner',
|
||||
header: 'Partner',
|
||||
render: (partner) => (
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{partner.name}</div>
|
||||
<div className="text-sm text-gray-500">{partner.email || '-'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: 'Tipo',
|
||||
render: (partner) => (
|
||||
<PartnerTypeBadge
|
||||
type={partner.partnerType}
|
||||
isCustomer={partner.isCustomer}
|
||||
isSupplier={partner.isSupplier}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'taxId',
|
||||
header: 'RFC',
|
||||
render: (partner) => (
|
||||
<span className="text-sm text-gray-600">{partner.taxId || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'phone',
|
||||
header: 'Teléfono',
|
||||
render: (partner) => (
|
||||
<span className="text-sm text-gray-600">{partner.phone || partner.mobile || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Estado',
|
||||
render: (partner) => <PartnerStatusBadge active={partner.active} />,
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: 'Creado',
|
||||
sortable: true,
|
||||
render: (partner) => (
|
||||
<span className="text-sm text-gray-500">
|
||||
{formatDate(partner.createdAt, 'short')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (partner) => (
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="rounded p-1 hover:bg-gray-100">
|
||||
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
}
|
||||
items={getActionsMenu(partner)}
|
||||
align="right"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setFilters({ ...filters, page: newPage });
|
||||
};
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
const newOrder = filters.sortBy === key && filters.sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
setFilters({ ...filters, sortBy: key, sortOrder: newOrder });
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (partnerToDelete) {
|
||||
await deletePartner(partnerToDelete.id);
|
||||
setPartnerToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleConfirm = async () => {
|
||||
if (partnerToToggle) {
|
||||
if (partnerToToggle.action === 'activate') {
|
||||
await activatePartner(partnerToToggle.partner.id);
|
||||
} else {
|
||||
await deactivatePartner(partnerToToggle.partner.id);
|
||||
}
|
||||
setPartnerToToggle(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState onRetry={refresh} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs items={[{ label: 'Partners' }]} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Partners</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Gestiona clientes, proveedores y contactos
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate('/partners/new')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nuevo partner
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lista de partners</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<PartnerFiltersPanel
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
|
||||
{partners.length === 0 && !isLoading ? (
|
||||
<NoDataEmptyState
|
||||
entityName="partners"
|
||||
onCreateNew={() => navigate('/partners/new')}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
data={partners}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
pagination={{
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
limit: filters.limit || 10,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
sorting={{
|
||||
sortBy: filters.sortBy || null,
|
||||
sortOrder: (filters.sortOrder as 'asc' | 'desc') || 'asc',
|
||||
onSort: handleSort,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!partnerToDelete}
|
||||
onClose={() => setPartnerToDelete(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="Eliminar partner"
|
||||
message={`¿Estás seguro de que deseas eliminar a ${partnerToDelete?.name}? Esta acción no se puede deshacer.`}
|
||||
variant="danger"
|
||||
confirmText="Eliminar"
|
||||
/>
|
||||
|
||||
{/* Activate/Deactivate confirmation modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!partnerToToggle}
|
||||
onClose={() => setPartnerToToggle(null)}
|
||||
onConfirm={handleToggleConfirm}
|
||||
title={partnerToToggle?.action === 'activate' ? 'Activar partner' : 'Desactivar partner'}
|
||||
message={
|
||||
partnerToToggle?.action === 'activate'
|
||||
? `¿Deseas activar a ${partnerToToggle?.partner.name}?`
|
||||
: `¿Deseas desactivar a ${partnerToToggle?.partner.name}?`
|
||||
}
|
||||
variant={partnerToToggle?.action === 'activate' ? 'success' : 'warning'}
|
||||
confirmText={partnerToToggle?.action === 'activate' ? 'Activar' : 'Desactivar'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PartnersListPage;
|
||||
90
src/pages/users/UserCreatePage.tsx
Normal file
90
src/pages/users/UserCreatePage.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { Alert } from '@components/molecules/Alert';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { useToast } from '@components/organisms/Toast';
|
||||
import { UserForm } from '@features/users/components/UserForm';
|
||||
import { usersApi } from '@features/users/api';
|
||||
import type { CreateUserDto, UpdateUserDto } from '@features/users/types';
|
||||
|
||||
export function UserCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (data: CreateUserDto | UpdateUserDto) => {
|
||||
const createData = data as CreateUserDto;
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const user = await usersApi.create(createData);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Usuario creado',
|
||||
message: `${user.firstName} ${user.lastName} ha sido creado exitosamente.`,
|
||||
});
|
||||
navigate('/users');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Error al crear usuario';
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Usuarios', href: '/users' },
|
||||
{ label: 'Nuevo usuario' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/users')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Nuevo usuario</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Crea una nueva cuenta de usuario
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información del usuario</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<Alert
|
||||
variant="danger"
|
||||
title="Error"
|
||||
className="mb-4"
|
||||
onClose={() => setError(null)}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<UserForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => navigate('/users')}
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default UserCreatePage;
|
||||
346
src/pages/users/UserDetailPage.tsx
Normal file
346
src/pages/users/UserDetailPage.tsx
Normal file
@ -0,0 +1,346 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
Trash2,
|
||||
Mail,
|
||||
Phone,
|
||||
Calendar,
|
||||
Shield,
|
||||
UserCheck,
|
||||
UserX,
|
||||
Key,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Avatar } from '@components/atoms/Avatar';
|
||||
import { Spinner } from '@components/atoms/Spinner';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { ConfirmModal } from '@components/organisms/Modal';
|
||||
import { useToast } from '@components/organisms/Toast';
|
||||
import { ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { useUser } from '@features/users/hooks';
|
||||
import { usersApi } from '@features/users/api';
|
||||
import { UserStatusBadge } from '@features/users/components/UserStatusBadge';
|
||||
import { formatDate } from '@utils/formatters';
|
||||
|
||||
export function UserDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { user, isLoading, error, refresh } = useUser(id);
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showToggleModal, setShowToggleModal] = useState(false);
|
||||
const [showResetPasswordModal, setShowResetPasswordModal] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await usersApi.delete(id);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Usuario eliminado',
|
||||
message: 'El usuario ha sido eliminado exitosamente.',
|
||||
});
|
||||
navigate('/users');
|
||||
} catch {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'No se pudo eliminar el usuario.',
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setShowDeleteModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async () => {
|
||||
if (!id || !user) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
if (user.status === 'active') {
|
||||
await usersApi.deactivate(id);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Usuario desactivado',
|
||||
message: 'El usuario ha sido desactivado.',
|
||||
});
|
||||
} else {
|
||||
await usersApi.activate(id);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Usuario activado',
|
||||
message: 'El usuario ha sido activado.',
|
||||
});
|
||||
}
|
||||
refresh();
|
||||
} catch {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'No se pudo cambiar el estado del usuario.',
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setShowToggleModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!id) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await usersApi.resetPassword(id);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Contraseña restablecida',
|
||||
message: 'Se ha enviado un email con instrucciones para crear una nueva contraseña.',
|
||||
});
|
||||
} catch {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'No se pudo restablecer la contraseña.',
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setShowResetPasswordModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !user) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState
|
||||
title="Usuario no encontrado"
|
||||
description="No se pudo cargar la información del usuario."
|
||||
onRetry={refresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Usuarios', href: '/users' },
|
||||
{ label: `${user.firstName} ${user.lastName}` },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/users')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Detalle de usuario</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => navigate(`/users/${id}/edit`)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => setShowDeleteModal(true)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Eliminar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* User info card */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
fallback={`${user.firstName} ${user.lastName}`}
|
||||
size="2xl"
|
||||
status={user.status === 'active' ? 'online' : 'offline'}
|
||||
/>
|
||||
<h2 className="mt-4 text-xl font-semibold text-gray-900">
|
||||
{user.firstName} {user.lastName}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
<div className="mt-2">
|
||||
<UserStatusBadge status={user.status} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 w-full space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowToggleModal(true)}
|
||||
>
|
||||
{user.status === 'active' ? (
|
||||
<>
|
||||
<UserX className="mr-2 h-4 w-4" />
|
||||
Desactivar usuario
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserCheck className="mr-2 h-4 w-4" />
|
||||
Activar usuario
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowResetPasswordModal(true)}
|
||||
>
|
||||
<Key className="mr-2 h-4 w-4" />
|
||||
Restablecer contraseña
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información de contacto</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Email</dt>
|
||||
<dd className="font-medium text-gray-900">{user.email}</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Phone className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Teléfono</dt>
|
||||
<dd className="font-medium text-gray-900">
|
||||
{user.phone || 'No especificado'}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Permisos y acceso</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Rol</dt>
|
||||
<dd className="font-medium text-gray-900">
|
||||
{user.role?.name || 'Sin rol asignado'}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar className="h-5 w-5 text-gray-400" />
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Último acceso</dt>
|
||||
<dd className="font-medium text-gray-900">
|
||||
{user.lastLoginAt
|
||||
? formatDate(user.lastLoginAt, 'full')
|
||||
: 'Nunca'}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información del sistema</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">ID</dt>
|
||||
<dd className="font-mono text-sm text-gray-900">{user.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Tenant ID</dt>
|
||||
<dd className="font-mono text-sm text-gray-900">{user.tenantId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Creado</dt>
|
||||
<dd className="text-gray-900">{formatDate(user.createdAt, 'full')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Actualizado</dt>
|
||||
<dd className="text-gray-900">{formatDate(user.updatedAt, 'full')}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="Eliminar usuario"
|
||||
message={`¿Estás seguro de que deseas eliminar a ${user.firstName} ${user.lastName}? Esta acción no se puede deshacer.`}
|
||||
variant="danger"
|
||||
confirmText="Eliminar"
|
||||
isLoading={isProcessing}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={showToggleModal}
|
||||
onClose={() => setShowToggleModal(false)}
|
||||
onConfirm={handleToggleStatus}
|
||||
title={user.status === 'active' ? 'Desactivar usuario' : 'Activar usuario'}
|
||||
message={
|
||||
user.status === 'active'
|
||||
? `¿Deseas desactivar a ${user.firstName} ${user.lastName}? El usuario no podrá acceder al sistema.`
|
||||
: `¿Deseas activar a ${user.firstName} ${user.lastName}?`
|
||||
}
|
||||
variant={user.status === 'active' ? 'warning' : 'success'}
|
||||
confirmText={user.status === 'active' ? 'Desactivar' : 'Activar'}
|
||||
isLoading={isProcessing}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={showResetPasswordModal}
|
||||
onClose={() => setShowResetPasswordModal(false)}
|
||||
onConfirm={handleResetPassword}
|
||||
title="Restablecer contraseña"
|
||||
message={`Se enviará un email a ${user.email} con instrucciones para crear una nueva contraseña.`}
|
||||
variant="warning"
|
||||
confirmText="Enviar email"
|
||||
isLoading={isProcessing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default UserDetailPage;
|
||||
118
src/pages/users/UserEditPage.tsx
Normal file
118
src/pages/users/UserEditPage.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Spinner } from '@components/atoms/Spinner';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { Alert } from '@components/molecules/Alert';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { useToast } from '@components/organisms/Toast';
|
||||
import { ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { UserForm } from '@features/users/components/UserForm';
|
||||
import { useUser } from '@features/users/hooks';
|
||||
import { usersApi } from '@features/users/api';
|
||||
import type { UpdateUserDto } from '@features/users/types';
|
||||
|
||||
export function UserEditPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { user, isLoading, error: fetchError, refresh } = useUser(id);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (data: UpdateUserDto) => {
|
||||
if (!id) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await usersApi.update(id, data);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Usuario actualizado',
|
||||
message: 'Los cambios han sido guardados exitosamente.',
|
||||
});
|
||||
navigate('/users');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Error al actualizar usuario';
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchError || !user) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState
|
||||
title="Usuario no encontrado"
|
||||
description="No se pudo cargar la información del usuario."
|
||||
onRetry={refresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Usuarios', href: '/users' },
|
||||
{ label: `${user.firstName} ${user.lastName}`, href: `/users/${id}` },
|
||||
{ label: 'Editar' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(`/users/${id}`)}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Editar usuario</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Modifica la información de {user.firstName} {user.lastName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información del usuario</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<Alert
|
||||
variant="danger"
|
||||
title="Error"
|
||||
className="mb-4"
|
||||
onClose={() => setError(null)}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<UserForm
|
||||
user={user}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => navigate(`/users/${id}`)}
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default UserEditPage;
|
||||
287
src/pages/users/UsersListPage.tsx
Normal file
287
src/pages/users/UsersListPage.tsx
Normal file
@ -0,0 +1,287 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
UserCheck,
|
||||
UserX,
|
||||
Mail,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Avatar } from '@components/atoms/Avatar';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
|
||||
import { ConfirmModal } from '@components/organisms/Modal';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { useUsers } from '@features/users/hooks';
|
||||
import { UserStatusBadge } from '@features/users/components/UserStatusBadge';
|
||||
import { UserFiltersPanel } from '@features/users/components/UserFiltersPanel';
|
||||
import type { User } from '@features/users/types';
|
||||
import { formatDate } from '@utils/formatters';
|
||||
|
||||
export function UsersListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [userToDelete, setUserToDelete] = useState<User | null>(null);
|
||||
const [userToToggle, setUserToToggle] = useState<{ user: User; action: 'activate' | 'deactivate' } | null>(null);
|
||||
|
||||
const {
|
||||
users,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
filters,
|
||||
setFilters,
|
||||
deleteUser,
|
||||
activateUser,
|
||||
deactivateUser,
|
||||
refresh,
|
||||
} = useUsers({ page: 1, limit: 10 });
|
||||
|
||||
const getActionsMenu = (user: User): DropdownItem[] => {
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/users/${user.id}`),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: 'Editar',
|
||||
icon: <Edit className="h-4 w-4" />,
|
||||
onClick: () => navigate(`/users/${user.id}/edit`),
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Enviar email',
|
||||
icon: <Mail className="h-4 w-4" />,
|
||||
onClick: () => window.location.href = `mailto:${user.email}`,
|
||||
},
|
||||
];
|
||||
|
||||
if (user.status === 'active') {
|
||||
items.push({
|
||||
key: 'deactivate',
|
||||
label: 'Desactivar',
|
||||
icon: <UserX className="h-4 w-4" />,
|
||||
onClick: () => setUserToToggle({ user, action: 'deactivate' }),
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
key: 'activate',
|
||||
label: 'Activar',
|
||||
icon: <UserCheck className="h-4 w-4" />,
|
||||
onClick: () => setUserToToggle({ user, action: 'activate' }),
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: 'delete',
|
||||
label: 'Eliminar',
|
||||
icon: <Trash2 className="h-4 w-4" />,
|
||||
danger: true,
|
||||
onClick: () => setUserToDelete(user),
|
||||
});
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const columns: Column<User>[] = [
|
||||
{
|
||||
key: 'user',
|
||||
header: 'Usuario',
|
||||
render: (user) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
fallback={`${user.firstName} ${user.lastName}`}
|
||||
size="sm"
|
||||
status={user.status === 'active' ? 'online' : 'offline'}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{user.firstName} {user.lastName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
header: 'Rol',
|
||||
render: (user) => (
|
||||
<span className="text-sm text-gray-600">{user.role?.name || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Estado',
|
||||
render: (user) => <UserStatusBadge status={user.status} />,
|
||||
},
|
||||
{
|
||||
key: 'lastLoginAt',
|
||||
header: 'Último acceso',
|
||||
render: (user) => (
|
||||
<span className="text-sm text-gray-500">
|
||||
{user.lastLoginAt ? formatDate(user.lastLoginAt, 'relative') : 'Nunca'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: 'Creado',
|
||||
sortable: true,
|
||||
render: (user) => (
|
||||
<span className="text-sm text-gray-500">
|
||||
{formatDate(user.createdAt, 'short')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (user) => (
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="rounded p-1 hover:bg-gray-100">
|
||||
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
}
|
||||
items={getActionsMenu(user)}
|
||||
align="right"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setFilters({ ...filters, page: newPage });
|
||||
};
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
const newOrder = filters.sortBy === key && filters.sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
setFilters({ ...filters, sortBy: key, sortOrder: newOrder });
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (userToDelete) {
|
||||
await deleteUser(userToDelete.id);
|
||||
setUserToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleConfirm = async () => {
|
||||
if (userToToggle) {
|
||||
if (userToToggle.action === 'activate') {
|
||||
await activateUser(userToToggle.user.id);
|
||||
} else {
|
||||
await deactivateUser(userToToggle.user.id);
|
||||
}
|
||||
setUserToToggle(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState onRetry={refresh} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[{ label: 'Usuarios' }]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Usuarios</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Gestiona los usuarios del sistema
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate('/users/new')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nuevo usuario
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lista de usuarios</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<UserFiltersPanel
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
|
||||
{users.length === 0 && !isLoading ? (
|
||||
<NoDataEmptyState
|
||||
entityName="usuarios"
|
||||
onCreateNew={() => navigate('/users/new')}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
pagination={{
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
limit: filters.limit || 10,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
sorting={{
|
||||
sortBy: filters.sortBy || null,
|
||||
sortOrder: (filters.sortOrder as 'asc' | 'desc') || 'asc',
|
||||
onSort: handleSort,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!userToDelete}
|
||||
onClose={() => setUserToDelete(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="Eliminar usuario"
|
||||
message={`¿Estás seguro de que deseas eliminar a ${userToDelete?.firstName} ${userToDelete?.lastName}? Esta acción no se puede deshacer.`}
|
||||
variant="danger"
|
||||
confirmText="Eliminar"
|
||||
/>
|
||||
|
||||
{/* Activate/Deactivate confirmation modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!userToToggle}
|
||||
onClose={() => setUserToToggle(null)}
|
||||
onConfirm={handleToggleConfirm}
|
||||
title={userToToggle?.action === 'activate' ? 'Activar usuario' : 'Desactivar usuario'}
|
||||
message={
|
||||
userToToggle?.action === 'activate'
|
||||
? `¿Deseas activar a ${userToToggle?.user.firstName} ${userToToggle?.user.lastName}?`
|
||||
: `¿Deseas desactivar a ${userToToggle?.user.firstName} ${userToToggle?.user.lastName}? El usuario no podrá acceder al sistema.`
|
||||
}
|
||||
variant={userToToggle?.action === 'activate' ? 'success' : 'warning'}
|
||||
confirmText={userToToggle?.action === 'activate' ? 'Activar' : 'Desactivar'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UsersListPage;
|
||||
4
src/pages/users/index.ts
Normal file
4
src/pages/users/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { UsersListPage } from './UsersListPage';
|
||||
export { UserCreatePage } from './UserCreatePage';
|
||||
export { UserEditPage } from './UserEditPage';
|
||||
export { UserDetailPage } from './UserDetailPage';
|
||||
76
src/services/api/auth.api.ts
Normal file
76
src/services/api/auth.api.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import api from './axios-instance';
|
||||
import { API_ENDPOINTS } from '@constants/api-endpoints';
|
||||
import type {
|
||||
LoginCredentials,
|
||||
RegisterData,
|
||||
AuthResponse,
|
||||
User,
|
||||
} from '@shared/types/entities.types';
|
||||
import type { ApiResponse } from '@shared/types/api.types';
|
||||
|
||||
export const authApi = {
|
||||
login: async (credentials: LoginCredentials): Promise<AuthResponse> => {
|
||||
const response = await api.post<ApiResponse<AuthResponse>>(
|
||||
API_ENDPOINTS.AUTH.LOGIN,
|
||||
credentials
|
||||
);
|
||||
if (!response.data.success || !response.data.data) {
|
||||
throw new Error(response.data.error || 'Error al iniciar sesión');
|
||||
}
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
register: async (data: RegisterData): Promise<AuthResponse> => {
|
||||
const response = await api.post<ApiResponse<AuthResponse>>(
|
||||
API_ENDPOINTS.AUTH.REGISTER,
|
||||
data
|
||||
);
|
||||
if (!response.data.success || !response.data.data) {
|
||||
throw new Error(response.data.error || 'Error al registrarse');
|
||||
}
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
logout: async (): Promise<void> => {
|
||||
await api.post(API_ENDPOINTS.AUTH.LOGOUT);
|
||||
},
|
||||
|
||||
refresh: async (token: string): Promise<{ token: string }> => {
|
||||
const response = await api.post<ApiResponse<{ token: string }>>(
|
||||
API_ENDPOINTS.AUTH.REFRESH,
|
||||
{ token }
|
||||
);
|
||||
if (!response.data.success || !response.data.data) {
|
||||
throw new Error(response.data.error || 'Error al refrescar token');
|
||||
}
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
me: async (): Promise<User> => {
|
||||
const response = await api.get<ApiResponse<User>>(API_ENDPOINTS.AUTH.ME);
|
||||
if (!response.data.success || !response.data.data) {
|
||||
throw new Error(response.data.error || 'Error al obtener usuario');
|
||||
}
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
forgotPassword: async (email: string): Promise<void> => {
|
||||
const response = await api.post<ApiResponse>(
|
||||
API_ENDPOINTS.AUTH.FORGOT_PASSWORD,
|
||||
{ email }
|
||||
);
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || 'Error al enviar correo');
|
||||
}
|
||||
},
|
||||
|
||||
resetPassword: async (token: string, password: string): Promise<void> => {
|
||||
const response = await api.post<ApiResponse>(
|
||||
API_ENDPOINTS.AUTH.RESET_PASSWORD,
|
||||
{ token, password }
|
||||
);
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || 'Error al restablecer contraseña');
|
||||
}
|
||||
},
|
||||
};
|
||||
70
src/services/api/axios-instance.ts
Normal file
70
src/services/api/axios-instance.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import { useAuthStore } from '@stores/useAuthStore';
|
||||
import { useNotificationStore } from '@stores/useNotificationStore';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor - Add auth token
|
||||
api.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const { token } = useAuthStore.getState();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor - Handle errors and refresh token
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Handle 401 Unauthorized
|
||||
if (error.response?.status === 401 && originalRequest) {
|
||||
const { logout } = useAuthStore.getState();
|
||||
logout();
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
const message = getErrorMessage(error);
|
||||
|
||||
// Show notification for server errors
|
||||
if (error.response?.status && error.response.status >= 500) {
|
||||
useNotificationStore.getState().error('Error del servidor', message);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(message));
|
||||
}
|
||||
);
|
||||
|
||||
function getErrorMessage(error: AxiosError): string {
|
||||
if (error.response?.data) {
|
||||
const data = error.response.data as { error?: string; message?: string };
|
||||
return data.error || data.message || 'Error desconocido';
|
||||
}
|
||||
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
return 'Tiempo de espera agotado';
|
||||
}
|
||||
|
||||
if (!error.response) {
|
||||
return 'Error de conexión. Verifica tu conexión a internet.';
|
||||
}
|
||||
|
||||
return error.message || 'Error desconocido';
|
||||
}
|
||||
|
||||
export default api;
|
||||
3
src/services/api/index.ts
Normal file
3
src/services/api/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as api } from './axios-instance';
|
||||
export * from './auth.api';
|
||||
export * from './users.api';
|
||||
95
src/services/api/users.api.ts
Normal file
95
src/services/api/users.api.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import api from './axios-instance';
|
||||
import { API_ENDPOINTS } from '@constants/api-endpoints';
|
||||
import type { User } from '@shared/types/entities.types';
|
||||
import type { ApiResponse, PaginatedResponse, PaginationParams } from '@shared/types/api.types';
|
||||
|
||||
export interface CreateUserDto {
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateUserDto {
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export const usersApi = {
|
||||
getAll: async (params?: PaginationParams): Promise<PaginatedResponse<User>> => {
|
||||
const response = await api.get<PaginatedResponse<User>>(
|
||||
API_ENDPOINTS.USERS.BASE,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<User> => {
|
||||
const response = await api.get<ApiResponse<User>>(
|
||||
API_ENDPOINTS.USERS.BY_ID(id)
|
||||
);
|
||||
if (!response.data.success || !response.data.data) {
|
||||
throw new Error(response.data.error || 'Usuario no encontrado');
|
||||
}
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateUserDto): Promise<User> => {
|
||||
const response = await api.post<ApiResponse<User>>(
|
||||
API_ENDPOINTS.USERS.BASE,
|
||||
data
|
||||
);
|
||||
if (!response.data.success || !response.data.data) {
|
||||
throw new Error(response.data.error || 'Error al crear usuario');
|
||||
}
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateUserDto): Promise<User> => {
|
||||
const response = await api.put<ApiResponse<User>>(
|
||||
API_ENDPOINTS.USERS.BY_ID(id),
|
||||
data
|
||||
);
|
||||
if (!response.data.success || !response.data.data) {
|
||||
throw new Error(response.data.error || 'Error al actualizar usuario');
|
||||
}
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
const response = await api.delete<ApiResponse>(
|
||||
API_ENDPOINTS.USERS.BY_ID(id)
|
||||
);
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || 'Error al eliminar usuario');
|
||||
}
|
||||
},
|
||||
|
||||
updateRoles: async (id: string, roles: string[]): Promise<User> => {
|
||||
const response = await api.put<ApiResponse<User>>(
|
||||
API_ENDPOINTS.USERS.ROLES(id),
|
||||
{ roles }
|
||||
);
|
||||
if (!response.data.success || !response.data.data) {
|
||||
throw new Error(response.data.error || 'Error al actualizar roles');
|
||||
}
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
changePassword: async (
|
||||
id: string,
|
||||
currentPassword: string,
|
||||
newPassword: string
|
||||
): Promise<void> => {
|
||||
const response = await api.post<ApiResponse>(
|
||||
API_ENDPOINTS.USERS.CHANGE_PASSWORD(id),
|
||||
{ currentPassword, newPassword }
|
||||
);
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || 'Error al cambiar contraseña');
|
||||
}
|
||||
},
|
||||
};
|
||||
1
src/services/index.ts
Normal file
1
src/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './api';
|
||||
144
src/shared/components/atoms/Avatar/Avatar.tsx
Normal file
144
src/shared/components/atoms/Avatar/Avatar.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import { useState, type ImgHTMLAttributes } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@utils/cn';
|
||||
import { User } from 'lucide-react';
|
||||
|
||||
const avatarVariants = cva(
|
||||
'relative inline-flex items-center justify-center overflow-hidden rounded-full bg-gray-100',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'h-6 w-6 text-xs',
|
||||
sm: 'h-8 w-8 text-sm',
|
||||
md: 'h-10 w-10 text-base',
|
||||
lg: 'h-12 w-12 text-lg',
|
||||
xl: 'h-16 w-16 text-xl',
|
||||
'2xl': 'h-20 w-20 text-2xl',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface AvatarProps
|
||||
extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'size' | 'src'>,
|
||||
VariantProps<typeof avatarVariants> {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
fallback?: string;
|
||||
status?: 'online' | 'offline' | 'away' | 'busy';
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
online: 'bg-success-500',
|
||||
offline: 'bg-gray-400',
|
||||
away: 'bg-warning-500',
|
||||
busy: 'bg-danger-500',
|
||||
};
|
||||
|
||||
export function Avatar({
|
||||
src,
|
||||
alt = '',
|
||||
fallback,
|
||||
size,
|
||||
status,
|
||||
className,
|
||||
...props
|
||||
}: AvatarProps) {
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const getInitials = (name: 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();
|
||||
};
|
||||
|
||||
const showFallback = !src || hasError;
|
||||
|
||||
return (
|
||||
<div className={cn(avatarVariants({ size }), className)}>
|
||||
{showFallback ? (
|
||||
fallback ? (
|
||||
<span className="font-medium text-gray-600">{getInitials(fallback)}</span>
|
||||
) : (
|
||||
<User className="h-1/2 w-1/2 text-gray-400" />
|
||||
)
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
onError={() => setHasError(true)}
|
||||
className="h-full w-full object-cover"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
{status && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute bottom-0 right-0 block rounded-full ring-2 ring-white',
|
||||
statusColors[status],
|
||||
size === 'xs' && 'h-1.5 w-1.5',
|
||||
size === 'sm' && 'h-2 w-2',
|
||||
size === 'md' && 'h-2.5 w-2.5',
|
||||
size === 'lg' && 'h-3 w-3',
|
||||
(size === 'xl' || size === '2xl') && 'h-4 w-4'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Avatar Group
|
||||
export interface AvatarGroupProps {
|
||||
children: React.ReactNode;
|
||||
max?: number;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AvatarGroup({ children, max, size = 'md', className }: AvatarGroupProps) {
|
||||
const avatars = Array.isArray(children) ? children : [children];
|
||||
const visibleAvatars = max ? avatars.slice(0, max) : avatars;
|
||||
const extraCount = max ? Math.max(0, avatars.length - max) : 0;
|
||||
|
||||
const overlapClasses = {
|
||||
xs: '-ml-1.5',
|
||||
sm: '-ml-2',
|
||||
md: '-ml-2.5',
|
||||
lg: '-ml-3',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center', className)}>
|
||||
{visibleAvatars.map((avatar, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'relative ring-2 ring-white rounded-full',
|
||||
index > 0 && overlapClasses[size]
|
||||
)}
|
||||
style={{ zIndex: visibleAvatars.length - index }}
|
||||
>
|
||||
{avatar}
|
||||
</div>
|
||||
))}
|
||||
{extraCount > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
avatarVariants({ size }),
|
||||
overlapClasses[size],
|
||||
'bg-gray-200 font-medium text-gray-600 ring-2 ring-white'
|
||||
)}
|
||||
style={{ zIndex: 0 }}
|
||||
>
|
||||
+{extraCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/shared/components/atoms/Avatar/index.ts
Normal file
1
src/shared/components/atoms/Avatar/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Avatar';
|
||||
42
src/shared/components/atoms/Badge/Badge.tsx
Normal file
42
src/shared/components/atoms/Badge/Badge.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@utils/cn';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-gray-100 text-gray-800',
|
||||
primary: 'bg-primary-100 text-primary-800',
|
||||
success: 'bg-success-50 text-success-700',
|
||||
warning: 'bg-warning-50 text-warning-700',
|
||||
danger: 'bg-danger-50 text-danger-700',
|
||||
info: 'bg-blue-100 text-blue-800',
|
||||
},
|
||||
size: {
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-2.5 py-0.5 text-xs',
|
||||
lg: 'px-3 py-1 text-sm',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Badge({ children, variant, size, className, ...props }: BadgeProps) {
|
||||
return (
|
||||
<span className={cn(badgeVariants({ variant, size }), className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
1
src/shared/components/atoms/Badge/index.ts
Normal file
1
src/shared/components/atoms/Badge/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Badge';
|
||||
79
src/shared/components/atoms/Button/Button.tsx
Normal file
79
src/shared/components/atoms/Button/Button.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@utils/cn';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'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',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus-visible:ring-primary-500',
|
||||
secondary: 'bg-secondary-100 text-secondary-900 hover:bg-secondary-200 focus-visible:ring-secondary-500',
|
||||
outline: 'border border-gray-300 bg-white hover:bg-gray-50 focus-visible:ring-gray-500',
|
||||
ghost: 'hover:bg-gray-100 focus-visible:ring-gray-500',
|
||||
danger: 'bg-danger-600 text-white hover:bg-danger-700 focus-visible:ring-danger-500',
|
||||
link: 'text-primary-600 underline-offset-4 hover:underline focus-visible:ring-primary-500',
|
||||
},
|
||||
size: {
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
md: 'h-10 px-4',
|
||||
lg: 'h-12 px-6 text-lg',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
fullWidth: {
|
||||
true: 'w-full',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
children: ReactNode;
|
||||
isLoading?: boolean;
|
||||
leftIcon?: ReactNode;
|
||||
rightIcon?: ReactNode;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
fullWidth,
|
||||
isLoading,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
disabled,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant, size, fullWidth }), className)}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : leftIcon ? (
|
||||
<span className="mr-2">{leftIcon}</span>
|
||||
) : null}
|
||||
{children}
|
||||
{rightIcon && !isLoading && <span className="ml-2">{rightIcon}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
1
src/shared/components/atoms/Button/index.ts
Normal file
1
src/shared/components/atoms/Button/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Button';
|
||||
50
src/shared/components/atoms/Input/Input.tsx
Normal file
50
src/shared/components/atoms/Input/Input.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@utils/cn';
|
||||
|
||||
const inputVariants = cva(
|
||||
'block w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-gray-400 focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-gray-300 focus:border-primary-500 focus:ring-primary-500',
|
||||
error: 'border-danger-500 focus:border-danger-500 focus:ring-danger-500',
|
||||
},
|
||||
inputSize: {
|
||||
sm: 'h-8 text-sm',
|
||||
md: 'h-10',
|
||||
lg: 'h-12 text-lg',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
inputSize: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface InputProps
|
||||
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'>,
|
||||
VariantProps<typeof inputVariants> {
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ variant, inputSize, error, className, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
inputVariants({
|
||||
variant: error ? 'error' : variant,
|
||||
inputSize,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
1
src/shared/components/atoms/Input/index.ts
Normal file
1
src/shared/components/atoms/Input/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Input';
|
||||
23
src/shared/components/atoms/Label/Label.tsx
Normal file
23
src/shared/components/atoms/Label/Label.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { forwardRef, type LabelHTMLAttributes } from 'react';
|
||||
import { cn } from '@utils/cn';
|
||||
|
||||
export interface LabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const Label = forwardRef<HTMLLabelElement, LabelProps>(
|
||||
({ children, required, className, ...props }, ref) => {
|
||||
return (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn('block text-sm font-medium text-gray-700', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{required && <span className="ml-1 text-danger-500">*</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Label.displayName = 'Label';
|
||||
1
src/shared/components/atoms/Label/index.ts
Normal file
1
src/shared/components/atoms/Label/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Label';
|
||||
29
src/shared/components/atoms/Spinner/Spinner.tsx
Normal file
29
src/shared/components/atoms/Spinner/Spinner.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { cn } from '@utils/cn';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export interface SpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-6 w-6',
|
||||
lg: 'h-8 w-8',
|
||||
};
|
||||
|
||||
export function Spinner({ size = 'md', className }: SpinnerProps) {
|
||||
return (
|
||||
<Loader2
|
||||
className={cn('animate-spin text-primary-600', sizeClasses[size], className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FullPageSpinner() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/shared/components/atoms/Spinner/index.ts
Normal file
1
src/shared/components/atoms/Spinner/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Spinner';
|
||||
132
src/shared/components/atoms/Tooltip/Tooltip.tsx
Normal file
132
src/shared/components/atoms/Tooltip/Tooltip.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { useState, useRef, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { cn } from '@utils/cn';
|
||||
|
||||
export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
export interface TooltipProps {
|
||||
content: ReactNode;
|
||||
children: ReactNode;
|
||||
position?: TooltipPosition;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
content,
|
||||
children,
|
||||
position = 'top',
|
||||
delay = 200,
|
||||
className,
|
||||
disabled = false,
|
||||
}: TooltipProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const calculatePosition = () => {
|
||||
if (!triggerRef.current) return;
|
||||
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const gap = 8;
|
||||
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
|
||||
switch (position) {
|
||||
case 'top':
|
||||
top = rect.top - gap;
|
||||
left = rect.left + rect.width / 2;
|
||||
break;
|
||||
case 'bottom':
|
||||
top = rect.bottom + gap;
|
||||
left = rect.left + rect.width / 2;
|
||||
break;
|
||||
case 'left':
|
||||
top = rect.top + rect.height / 2;
|
||||
left = rect.left - gap;
|
||||
break;
|
||||
case 'right':
|
||||
top = rect.top + rect.height / 2;
|
||||
left = rect.right + gap;
|
||||
break;
|
||||
}
|
||||
|
||||
setCoords({ top, left });
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (disabled) return;
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
calculatePosition();
|
||||
setIsVisible(true);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
const positionClasses = {
|
||||
top: '-translate-x-1/2 -translate-y-full',
|
||||
bottom: '-translate-x-1/2',
|
||||
left: '-translate-x-full -translate-y-1/2',
|
||||
right: '-translate-y-1/2',
|
||||
};
|
||||
|
||||
const arrowClasses = {
|
||||
top: 'bottom-0 left-1/2 -translate-x-1/2 translate-y-full border-t-gray-900 border-l-transparent border-r-transparent border-b-transparent',
|
||||
bottom: 'top-0 left-1/2 -translate-x-1/2 -translate-y-full border-b-gray-900 border-l-transparent border-r-transparent border-t-transparent',
|
||||
left: 'right-0 top-1/2 translate-x-full -translate-y-1/2 border-l-gray-900 border-t-transparent border-b-transparent border-r-transparent',
|
||||
right: 'left-0 top-1/2 -translate-x-full -translate-y-1/2 border-r-gray-900 border-t-transparent border-b-transparent border-l-transparent',
|
||||
};
|
||||
|
||||
const tooltip = (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className={cn(
|
||||
'fixed z-50 rounded bg-gray-900 px-2 py-1 text-xs text-white shadow-lg',
|
||||
positionClasses[position],
|
||||
className
|
||||
)}
|
||||
style={{ top: coords.top, left: coords.left }}
|
||||
>
|
||||
{content}
|
||||
<span
|
||||
className={cn(
|
||||
'absolute border-4',
|
||||
arrowClasses[position]
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={triggerRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onFocus={handleMouseEnter}
|
||||
onBlur={handleMouseLeave}
|
||||
className="inline-block"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{createPortal(tooltip, document.body)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/shared/components/atoms/Tooltip/index.ts
Normal file
1
src/shared/components/atoms/Tooltip/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Tooltip';
|
||||
7
src/shared/components/atoms/index.ts
Normal file
7
src/shared/components/atoms/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export * from './Button';
|
||||
export * from './Input';
|
||||
export * from './Label';
|
||||
export * from './Badge';
|
||||
export * from './Spinner';
|
||||
export * from './Avatar';
|
||||
export * from './Tooltip';
|
||||
3
src/shared/components/index.ts
Normal file
3
src/shared/components/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './atoms';
|
||||
export * from './molecules';
|
||||
export * from './organisms';
|
||||
67
src/shared/components/molecules/Alert/Alert.tsx
Normal file
67
src/shared/components/molecules/Alert/Alert.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@utils/cn';
|
||||
import { AlertCircle, CheckCircle, Info, XCircle, X } from 'lucide-react';
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative rounded-lg border p-4',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-gray-50 border-gray-200 text-gray-800',
|
||||
success: 'bg-success-50 border-success-200 text-success-800',
|
||||
warning: 'bg-warning-50 border-warning-200 text-warning-800',
|
||||
danger: 'bg-danger-50 border-danger-200 text-danger-800',
|
||||
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const iconMap = {
|
||||
default: Info,
|
||||
success: CheckCircle,
|
||||
warning: AlertCircle,
|
||||
danger: XCircle,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
export interface AlertProps extends VariantProps<typeof alertVariants> {
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Alert({ title, children, variant = 'default', onClose, className }: AlertProps) {
|
||||
const Icon = iconMap[variant || 'default'];
|
||||
|
||||
return (
|
||||
<div className={cn(alertVariants({ variant }), className)} role="alert">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
{title && <h3 className="text-sm font-medium">{title}</h3>}
|
||||
<div className={cn('text-sm', title && 'mt-1')}>{children}</div>
|
||||
</div>
|
||||
{onClose && (
|
||||
<div className="ml-auto pl-3">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex rounded-md p-1.5 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||
onClick={onClose}
|
||||
>
|
||||
<span className="sr-only">Cerrar</span>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/shared/components/molecules/Alert/index.ts
Normal file
1
src/shared/components/molecules/Alert/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Alert';
|
||||
74
src/shared/components/molecules/Card/Card.tsx
Normal file
74
src/shared/components/molecules/Card/Card.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { cn } from '@utils/cn';
|
||||
|
||||
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Card({ children, className, ...props }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('rounded-lg border bg-white shadow-sm', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function CardHeader({ children, className, ...props }: CardHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('border-b px-6 py-4', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CardTitleProps extends HTMLAttributes<HTMLHeadingElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function CardTitle({ children, className, ...props }: CardTitleProps) {
|
||||
return (
|
||||
<h3
|
||||
className={cn('text-lg font-semibold text-gray-900', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CardContentProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function CardContent({ children, className, ...props }: CardContentProps) {
|
||||
return (
|
||||
<div className={cn('px-6 py-4', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CardFooterProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function CardFooter({ children, className, ...props }: CardFooterProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('border-t bg-gray-50 px-6 py-4', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/shared/components/molecules/Card/index.ts
Normal file
1
src/shared/components/molecules/Card/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Card';
|
||||
59
src/shared/components/molecules/FormField/FormField.tsx
Normal file
59
src/shared/components/molecules/FormField/FormField.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react';
|
||||
import { Label } from '@components/atoms/Label';
|
||||
import { Input } from '@components/atoms/Input';
|
||||
import { cn } from '@utils/cn';
|
||||
|
||||
export interface FormFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
leftIcon?: ReactNode;
|
||||
rightIcon?: ReactNode;
|
||||
}
|
||||
|
||||
export const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
|
||||
({ label, error, hint, required, leftIcon, rightIcon, className, id, ...props }, ref) => {
|
||||
const inputId = id || props.name;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1', className)}>
|
||||
{label && (
|
||||
<Label htmlFor={inputId} required={required}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{leftIcon && (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400">
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
error={!!error}
|
||||
className={cn(
|
||||
leftIcon && 'pl-10',
|
||||
rightIcon && 'pr-10'
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400">
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-danger-600">{error}</p>
|
||||
)}
|
||||
{hint && !error && (
|
||||
<p className="text-sm text-gray-500">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FormField.displayName = 'FormField';
|
||||
1
src/shared/components/molecules/FormField/index.ts
Normal file
1
src/shared/components/molecules/FormField/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './FormField';
|
||||
3
src/shared/components/molecules/index.ts
Normal file
3
src/shared/components/molecules/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './FormField';
|
||||
export * from './Alert';
|
||||
export * from './Card';
|
||||
106
src/shared/components/organisms/Breadcrumbs/Breadcrumbs.tsx
Normal file
106
src/shared/components/organisms/Breadcrumbs/Breadcrumbs.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChevronRight, Home } from 'lucide-react';
|
||||
import { cn } from '@utils/cn';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export interface BreadcrumbsProps {
|
||||
items: BreadcrumbItem[];
|
||||
separator?: ReactNode;
|
||||
showHome?: boolean;
|
||||
homeHref?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Breadcrumbs({
|
||||
items,
|
||||
separator,
|
||||
showHome = true,
|
||||
homeHref = '/dashboard',
|
||||
className,
|
||||
}: BreadcrumbsProps) {
|
||||
const separatorElement = separator || (
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
);
|
||||
|
||||
const allItems: BreadcrumbItem[] = showHome
|
||||
? [{ label: 'Inicio', href: homeHref, icon: <Home className="h-4 w-4" /> }, ...items]
|
||||
: items;
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className={className}>
|
||||
<ol className="flex items-center space-x-2">
|
||||
{allItems.map((item, index) => {
|
||||
const isLast = index === allItems.length - 1;
|
||||
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<li className="flex items-center">
|
||||
{item.href && !isLast ? (
|
||||
<Link
|
||||
to={item.href}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-sm',
|
||||
isLast ? 'font-medium text-gray-900' : 'text-gray-500'
|
||||
)}
|
||||
aria-current={isLast ? 'page' : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
{!isLast && (
|
||||
<li className="flex items-center" aria-hidden="true">
|
||||
{separatorElement}
|
||||
</li>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// Hook for dynamic breadcrumbs based on route
|
||||
import { useMatches } from 'react-router-dom';
|
||||
|
||||
export interface RouteHandle {
|
||||
breadcrumb?: string | ((params: Record<string, string>) => string);
|
||||
}
|
||||
|
||||
export function useBreadcrumbs(): BreadcrumbItem[] {
|
||||
const matches = useMatches();
|
||||
|
||||
// Build breadcrumbs from route matches
|
||||
return matches
|
||||
.filter((match) => {
|
||||
const handle = match.handle as RouteHandle | undefined;
|
||||
return handle?.breadcrumb;
|
||||
})
|
||||
.map((match) => {
|
||||
const handle = match.handle as RouteHandle;
|
||||
const label =
|
||||
typeof handle.breadcrumb === 'function'
|
||||
? handle.breadcrumb(match.params as Record<string, string>)
|
||||
: handle.breadcrumb!;
|
||||
|
||||
return {
|
||||
label,
|
||||
href: match.pathname,
|
||||
};
|
||||
});
|
||||
}
|
||||
1
src/shared/components/organisms/Breadcrumbs/index.ts
Normal file
1
src/shared/components/organisms/Breadcrumbs/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Breadcrumbs';
|
||||
299
src/shared/components/organisms/DataTable/DataTable.tsx
Normal file
299
src/shared/components/organisms/DataTable/DataTable.tsx
Normal file
@ -0,0 +1,299 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@utils/cn';
|
||||
import { Spinner } from '@components/atoms/Spinner';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
accessor?: keyof T | ((row: T) => ReactNode);
|
||||
sortable?: boolean;
|
||||
width?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
render?: (row: T) => ReactNode;
|
||||
}
|
||||
|
||||
export interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
isLoading?: boolean;
|
||||
emptyMessage?: string;
|
||||
// Pagination
|
||||
pagination?: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages?: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onLimitChange?: (limit: number) => void;
|
||||
};
|
||||
// Sorting
|
||||
sorting?: {
|
||||
sortBy: string | null;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
onSort: (key: string) => void;
|
||||
};
|
||||
// Selection
|
||||
selection?: {
|
||||
selectedIds: Set<string>;
|
||||
onSelect: (id: string) => void;
|
||||
onSelectAll: () => void;
|
||||
getRowId: (row: T) => string;
|
||||
};
|
||||
// Row actions
|
||||
onRowClick?: (row: T) => void;
|
||||
rowClassName?: (row: T) => string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const alignClasses = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
};
|
||||
|
||||
export function DataTable<T>({
|
||||
data,
|
||||
columns,
|
||||
isLoading = false,
|
||||
emptyMessage = 'No hay datos disponibles',
|
||||
pagination,
|
||||
sorting,
|
||||
selection,
|
||||
onRowClick,
|
||||
rowClassName,
|
||||
className,
|
||||
}: DataTableProps<T>) {
|
||||
const getCellValue = (row: T, column: Column<T>): ReactNode => {
|
||||
// If render function is provided, use it directly
|
||||
if (column.render) {
|
||||
return column.render(row);
|
||||
}
|
||||
|
||||
// Otherwise, use accessor
|
||||
const accessor = column.accessor;
|
||||
if (!accessor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof accessor === 'function') {
|
||||
return accessor(row);
|
||||
}
|
||||
|
||||
return row[accessor] as ReactNode;
|
||||
};
|
||||
|
||||
const renderSortIcon = (column: Column<T>) => {
|
||||
if (!column.sortable || !sorting) return null;
|
||||
|
||||
if (sorting.sortBy !== column.key) {
|
||||
return <ChevronsUpDown className="ml-1 h-4 w-4 text-gray-400" />;
|
||||
}
|
||||
|
||||
return sorting.sortOrder === 'asc' ? (
|
||||
<ChevronUp className="ml-1 h-4 w-4 text-primary-600" />
|
||||
) : (
|
||||
<ChevronDown className="ml-1 h-4 w-4 text-primary-600" />
|
||||
);
|
||||
};
|
||||
|
||||
const allSelected = useMemo(() => {
|
||||
if (!selection || data.length === 0) return false;
|
||||
return data.every((row) => selection.selectedIds.has(selection.getRowId(row)));
|
||||
}, [selection, data]);
|
||||
|
||||
const someSelected = useMemo(() => {
|
||||
if (!selection || data.length === 0) return false;
|
||||
const selectedCount = data.filter((row) =>
|
||||
selection.selectedIds.has(selection.getRowId(row))
|
||||
).length;
|
||||
return selectedCount > 0 && selectedCount < data.length;
|
||||
}, [selection, data]);
|
||||
|
||||
const totalPages = pagination
|
||||
? pagination.totalPages ?? Math.ceil(pagination.total / pagination.limit)
|
||||
: 1;
|
||||
|
||||
return (
|
||||
<div className={cn('overflow-hidden rounded-lg border bg-white', className)}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{/* Selection checkbox */}
|
||||
{selection && (
|
||||
<th className="w-12 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={selection.onSelectAll}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={cn(
|
||||
'px-4 py-3 text-xs font-medium uppercase tracking-wider text-gray-500',
|
||||
alignClasses[column.align || 'left'],
|
||||
column.sortable && sorting && 'cursor-pointer hover:bg-gray-100'
|
||||
)}
|
||||
style={{ width: column.width }}
|
||||
onClick={() => {
|
||||
if (column.sortable && sorting) {
|
||||
sorting.onSort(column.key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cn('flex items-center', column.align === 'right' && 'justify-end', column.align === 'center' && 'justify-center')}>
|
||||
{column.header}
|
||||
{renderSortIcon(column)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length + (selection ? 1 : 0)}
|
||||
className="px-4 py-12 text-center"
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length + (selection ? 1 : 0)}
|
||||
className="px-4 py-12 text-center text-gray-500"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, rowIndex) => {
|
||||
const rowId = selection ? selection.getRowId(row) : String(rowIndex);
|
||||
const isSelected = selection?.selectedIds.has(rowId);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={rowId}
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
onRowClick && 'cursor-pointer hover:bg-gray-50',
|
||||
isSelected && 'bg-primary-50',
|
||||
rowClassName?.(row)
|
||||
)}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{selection && (
|
||||
<td className="w-12 px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => selection.onSelect(rowId)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={cn(
|
||||
'whitespace-nowrap px-4 py-3 text-sm text-gray-900',
|
||||
alignClasses[column.align || 'left']
|
||||
)}
|
||||
>
|
||||
{getCellValue(row, column)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && (
|
||||
<div className="flex items-center justify-between border-t bg-gray-50 px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>
|
||||
Mostrando {Math.min((pagination.page - 1) * pagination.limit + 1, pagination.total)}{' '}
|
||||
- {Math.min(pagination.page * pagination.limit, pagination.total)} de{' '}
|
||||
{pagination.total}
|
||||
</span>
|
||||
{pagination.onLimitChange && (
|
||||
<select
|
||||
value={pagination.limit}
|
||||
onChange={(e) => pagination.onLimitChange?.(Number(e.target.value))}
|
||||
className="rounded border-gray-300 text-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => pagination.onPageChange(1)}
|
||||
disabled={pagination.page === 1}
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => pagination.onPageChange(pagination.page - 1)}
|
||||
disabled={pagination.page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="px-3 text-sm text-gray-600">
|
||||
{pagination.page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => pagination.onPageChange(pagination.page + 1)}
|
||||
disabled={pagination.page >= totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => pagination.onPageChange(totalPages)}
|
||||
disabled={pagination.page >= totalPages}
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/shared/components/organisms/DataTable/index.ts
Normal file
1
src/shared/components/organisms/DataTable/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './DataTable';
|
||||
305
src/shared/components/organisms/DatePicker/DatePicker.tsx
Normal file
305
src/shared/components/organisms/DatePicker/DatePicker.tsx
Normal file
@ -0,0 +1,305 @@
|
||||
import { useState, useRef, useEffect, forwardRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
format,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
addDays,
|
||||
addMonths,
|
||||
subMonths,
|
||||
isSameMonth,
|
||||
isSameDay,
|
||||
isToday,
|
||||
parse,
|
||||
isValid,
|
||||
} from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
import { Calendar, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||
import { cn } from '@utils/cn';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
|
||||
export interface DatePickerProps {
|
||||
value?: Date | null;
|
||||
onChange?: (date: Date | null) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
error?: boolean;
|
||||
clearable?: boolean;
|
||||
minDate?: Date;
|
||||
maxDate?: Date;
|
||||
dateFormat?: string;
|
||||
className?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['Lu', 'Ma', 'Mi', 'Ju', 'Vi', 'Sa', 'Do'];
|
||||
|
||||
export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Seleccionar fecha',
|
||||
disabled = false,
|
||||
error = false,
|
||||
clearable = true,
|
||||
minDate,
|
||||
maxDate,
|
||||
dateFormat = 'dd/MM/yyyy',
|
||||
className,
|
||||
name,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(value || new Date());
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setInputValue(format(value, dateFormat, { locale: es }));
|
||||
setCurrentMonth(value);
|
||||
} else {
|
||||
setInputValue('');
|
||||
}
|
||||
}, [value, dateFormat]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const calendarHeight = 320;
|
||||
|
||||
setPosition({
|
||||
top: spaceBelow > calendarHeight ? rect.bottom + 4 : rect.top - calendarHeight - 4,
|
||||
left: rect.left,
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (triggerRef.current && !triggerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setInputValue(val);
|
||||
|
||||
if (val === '') {
|
||||
onChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parse(val, dateFormat, new Date());
|
||||
if (isValid(parsed)) {
|
||||
const isInRange =
|
||||
(!minDate || parsed >= minDate) && (!maxDate || parsed <= maxDate);
|
||||
if (isInRange) {
|
||||
onChange?.(parsed);
|
||||
setCurrentMonth(parsed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateSelect = (date: Date) => {
|
||||
onChange?.(date);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onChange?.(null);
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
const isDateDisabled = (date: Date) => {
|
||||
if (minDate && date < minDate) return true;
|
||||
if (maxDate && date > maxDate) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const renderCalendar = () => {
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const startDate = startOfWeek(monthStart, { weekStartsOn: 1 });
|
||||
const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 });
|
||||
|
||||
const days: Date[] = [];
|
||||
let day = startDate;
|
||||
|
||||
while (day <= endDate) {
|
||||
days.push(day);
|
||||
day = addDays(day, 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((d, i) => {
|
||||
const isCurrentMonth = isSameMonth(d, currentMonth);
|
||||
const isSelected = value && isSameDay(d, value);
|
||||
const isTodayDate = isToday(d);
|
||||
const isDisabled = isDateDisabled(d);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
onClick={() => !isDisabled && handleDateSelect(d)}
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-full text-sm transition-colors',
|
||||
!isCurrentMonth && 'text-gray-300',
|
||||
isCurrentMonth && !isSelected && !isDisabled && 'hover:bg-gray-100',
|
||||
isSelected && 'bg-primary-600 text-white',
|
||||
isTodayDate && !isSelected && 'font-bold text-primary-600',
|
||||
isDisabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
{format(d, 'd')}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const calendar = (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed z-50 w-72 rounded-lg border bg-white p-4 shadow-lg"
|
||||
style={{ top: position.top, left: position.left }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-semibold capitalize">
|
||||
{format(currentMonth, 'MMMM yyyy', { locale: es })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Weekdays */}
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{WEEKDAYS.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="flex h-8 w-8 items-center justify-center text-xs font-medium text-gray-500"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Days */}
|
||||
{renderCalendar()}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-4 flex justify-between border-t pt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const today = new Date();
|
||||
if (!isDateDisabled(today)) {
|
||||
handleDateSelect(today);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Hoy
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsOpen(false)}>
|
||||
Cerrar
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={(node) => {
|
||||
(triggerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
if (typeof ref === 'function') ref(node);
|
||||
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
}}
|
||||
className={cn(
|
||||
'relative flex items-center rounded-md border transition-colors',
|
||||
disabled
|
||||
? 'cursor-not-allowed bg-gray-50'
|
||||
: 'bg-white hover:border-gray-400',
|
||||
error
|
||||
? 'border-danger-500 focus-within:border-danger-500 focus-within:ring-1 focus-within:ring-danger-500'
|
||||
: 'border-gray-300 focus-within:border-primary-500 focus-within:ring-1 focus-within:ring-primary-500',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name={name}
|
||||
value={value ? format(value, 'yyyy-MM-dd') : ''}
|
||||
/>
|
||||
<div className="pointer-events-none absolute left-3 text-gray-400">
|
||||
<Calendar className="h-4 w-4" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => !disabled && setIsOpen(true)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="h-10 w-full rounded-md bg-transparent py-2 pl-10 pr-8 text-sm outline-none placeholder:text-gray-400 disabled:cursor-not-allowed"
|
||||
/>
|
||||
{clearable && value && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-2 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{createPortal(calendar, document.body)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DatePicker.displayName = 'DatePicker';
|
||||
303
src/shared/components/organisms/DatePicker/DateRangePicker.tsx
Normal file
303
src/shared/components/organisms/DatePicker/DateRangePicker.tsx
Normal file
@ -0,0 +1,303 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
format,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
addDays,
|
||||
addMonths,
|
||||
subMonths,
|
||||
isSameMonth,
|
||||
isSameDay,
|
||||
isAfter,
|
||||
isBefore,
|
||||
isWithinInterval,
|
||||
} from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@utils/cn';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
|
||||
export interface DateRange {
|
||||
start: Date | null;
|
||||
end: Date | null;
|
||||
}
|
||||
|
||||
export interface DateRangePickerProps {
|
||||
value?: DateRange;
|
||||
onChange?: (range: DateRange) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
error?: boolean;
|
||||
minDate?: Date;
|
||||
maxDate?: Date;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['Lu', 'Ma', 'Mi', 'Ju', 'Vi', 'Sa', 'Do'];
|
||||
|
||||
export function DateRangePicker({
|
||||
value = { start: null, end: null },
|
||||
onChange,
|
||||
placeholder = 'Seleccionar rango',
|
||||
disabled = false,
|
||||
error = false,
|
||||
minDate,
|
||||
maxDate,
|
||||
className,
|
||||
}: DateRangePickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [leftMonth, setLeftMonth] = useState(value.start || new Date());
|
||||
const [rightMonth, setRightMonth] = useState(addMonths(value.start || new Date(), 1));
|
||||
const [hoverDate, setHoverDate] = useState<Date | null>(null);
|
||||
const [selecting, setSelecting] = useState<'start' | 'end'>('start');
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
top: rect.bottom + 4,
|
||||
left: Math.min(rect.left, window.innerWidth - 600),
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (triggerRef.current && !triggerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
if (selecting === 'start') {
|
||||
onChange?.({ start: date, end: null });
|
||||
setSelecting('end');
|
||||
} else {
|
||||
if (value.start && isBefore(date, value.start)) {
|
||||
onChange?.({ start: date, end: value.start });
|
||||
} else {
|
||||
onChange?.({ start: value.start, end: date });
|
||||
}
|
||||
setSelecting('start');
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isDateDisabled = (date: Date) => {
|
||||
if (minDate && isBefore(date, minDate)) return true;
|
||||
if (maxDate && isAfter(date, maxDate)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const isInRange = (date: Date) => {
|
||||
if (!value.start) return false;
|
||||
const endDate = value.end || hoverDate;
|
||||
if (!endDate) return false;
|
||||
|
||||
const start = isBefore(value.start, endDate) ? value.start : endDate;
|
||||
const end = isAfter(value.start, endDate) ? value.start : endDate;
|
||||
|
||||
return isWithinInterval(date, { start, end });
|
||||
};
|
||||
|
||||
const renderMonth = (month: Date) => {
|
||||
const monthStart = startOfMonth(month);
|
||||
const monthEnd = endOfMonth(month);
|
||||
const startDate = startOfWeek(monthStart, { weekStartsOn: 1 });
|
||||
const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 });
|
||||
|
||||
const days: Date[] = [];
|
||||
let day = startDate;
|
||||
|
||||
while (day <= endDate) {
|
||||
days.push(day);
|
||||
day = addDays(day, 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-64">
|
||||
<div className="mb-4 text-center text-sm font-semibold capitalize">
|
||||
{format(month, 'MMMM yyyy', { locale: es })}
|
||||
</div>
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{WEEKDAYS.map((d) => (
|
||||
<div
|
||||
key={d}
|
||||
className="flex h-8 w-8 items-center justify-center text-xs font-medium text-gray-500"
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((d, i) => {
|
||||
const isCurrentMonth = isSameMonth(d, month);
|
||||
const isStart = value.start && isSameDay(d, value.start);
|
||||
const isEnd = value.end && isSameDay(d, value.end);
|
||||
const inRange = isInRange(d);
|
||||
const isDisabled = isDateDisabled(d);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
disabled={isDisabled || !isCurrentMonth}
|
||||
onClick={() => handleDateClick(d)}
|
||||
onMouseEnter={() => selecting === 'end' && setHoverDate(d)}
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center text-sm transition-colors',
|
||||
!isCurrentMonth && 'invisible',
|
||||
isCurrentMonth && !isStart && !isEnd && !inRange && 'hover:bg-gray-100 rounded-full',
|
||||
(isStart || isEnd) && 'bg-primary-600 text-white rounded-full',
|
||||
inRange && !isStart && !isEnd && 'bg-primary-100',
|
||||
isDisabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
{format(d, 'd')}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const displayValue = () => {
|
||||
if (!value.start) return placeholder;
|
||||
const startStr = format(value.start, 'dd/MM/yyyy', { locale: es });
|
||||
if (!value.end) return `${startStr} - ...`;
|
||||
const endStr = format(value.end, 'dd/MM/yyyy', { locale: es });
|
||||
return `${startStr} - ${endStr}`;
|
||||
};
|
||||
|
||||
const calendar = (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed z-50 rounded-lg border bg-white p-4 shadow-lg"
|
||||
style={{ top: position.top, left: position.left }}
|
||||
>
|
||||
<div className="flex gap-8">
|
||||
{/* Left month */}
|
||||
<div>
|
||||
<div className="mb-2 flex justify-start">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setLeftMonth(subMonths(leftMonth, 1));
|
||||
setRightMonth(subMonths(rightMonth, 1));
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{renderMonth(leftMonth)}
|
||||
</div>
|
||||
|
||||
{/* Right month */}
|
||||
<div>
|
||||
<div className="mb-2 flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setLeftMonth(addMonths(leftMonth, 1));
|
||||
setRightMonth(addMonths(rightMonth, 1));
|
||||
}}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{renderMonth(rightMonth)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Presets */}
|
||||
<div className="mt-4 flex gap-2 border-t pt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const today = new Date();
|
||||
onChange?.({ start: today, end: today });
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Hoy
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const today = new Date();
|
||||
const weekAgo = addDays(today, -7);
|
||||
onChange?.({ start: weekAgo, end: today });
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Última semana
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const today = new Date();
|
||||
const monthAgo = addDays(today, -30);
|
||||
onChange?.({ start: monthAgo, end: today });
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Últimos 30 días
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={triggerRef}
|
||||
onClick={() => !disabled && setIsOpen(true)}
|
||||
className={cn(
|
||||
'flex h-10 cursor-pointer items-center gap-2 rounded-md border px-3 transition-colors',
|
||||
disabled
|
||||
? 'cursor-not-allowed bg-gray-50 text-gray-500'
|
||||
: 'bg-white hover:border-gray-400',
|
||||
error
|
||||
? 'border-danger-500'
|
||||
: 'border-gray-300',
|
||||
isOpen && 'border-primary-500 ring-1 ring-primary-500',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Calendar className="h-4 w-4 text-gray-400" />
|
||||
<span className={cn('flex-1 text-sm', !value.start && 'text-gray-400')}>
|
||||
{displayValue()}
|
||||
</span>
|
||||
</div>
|
||||
{createPortal(calendar, document.body)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
2
src/shared/components/organisms/DatePicker/index.ts
Normal file
2
src/shared/components/organisms/DatePicker/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './DatePicker';
|
||||
export * from './DateRangePicker';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user