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