Initial commit - erp-core-frontend-web

This commit is contained in:
rckrdmrd 2026-01-04 06:40:18 -06:00
commit d3fdc0b9c0
145 changed files with 18260 additions and 0 deletions

27
.eslintrc.cjs Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

53
package.json Normal file
View 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
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
public/vite.svg Normal file
View 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

View 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>
);
}

View 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
View File

@ -0,0 +1,2 @@
export * from './AuthLayout';
export * from './DashboardLayout';

View 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 />
</>
);
}

View 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
View 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
View 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>
),
},
]);

View 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;
},
};

View File

@ -0,0 +1 @@
export * from './companies.api';

View 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>
);
}

View 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>
);
}

View File

@ -0,0 +1,2 @@
export * from './CompanyForm';
export * from './CompanyFiltersPanel';

View File

@ -0,0 +1 @@
export * from './useCompanies';

View 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 };
}

View 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;
};
}

View File

@ -0,0 +1 @@
export * from './company.types';

View File

@ -0,0 +1 @@
export * from './partners.api';

View 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;
},
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -0,0 +1,4 @@
export * from './PartnerForm';
export * from './PartnerFiltersPanel';
export * from './PartnerTypeBadge';
export * from './PartnerStatusBadge';

View File

@ -0,0 +1 @@
export * from './usePartners';

View 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 });
}

View File

@ -0,0 +1 @@
export * from './partner.types';

View 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;
};
}

View File

@ -0,0 +1 @@
export * from './users.api';

View 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;
},
};

View 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>
);
}

View 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>
);
}

View 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>;
}

View File

@ -0,0 +1,3 @@
export * from './UserStatusBadge';
export * from './UserForm';
export * from './UserFiltersPanel';

View File

@ -0,0 +1 @@
export * from './useUsers';

View 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 };
}

View File

@ -0,0 +1 @@
export * from './user.types';

View 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
View 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
View 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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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;

View 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;

View 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;

View 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>
);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View File

@ -0,0 +1,4 @@
export { UsersListPage } from './UsersListPage';
export { UserCreatePage } from './UserCreatePage';
export { UserEditPage } from './UserEditPage';
export { UserDetailPage } from './UserDetailPage';

View 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');
}
},
};

View 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;

View File

@ -0,0 +1,3 @@
export { default as api } from './axios-instance';
export * from './auth.api';
export * from './users.api';

View 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
View File

@ -0,0 +1 @@
export * from './api';

View 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>
);
}

View File

@ -0,0 +1 @@
export * from './Avatar';

View 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>
);
}

View File

@ -0,0 +1 @@
export * from './Badge';

View 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';

View File

@ -0,0 +1 @@
export * from './Button';

View 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';

View File

@ -0,0 +1 @@
export * from './Input';

View 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';

View File

@ -0,0 +1 @@
export * from './Label';

View 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>
);
}

View File

@ -0,0 +1 @@
export * from './Spinner';

View 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)}
</>
);
}

View File

@ -0,0 +1 @@
export * from './Tooltip';

View 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';

View File

@ -0,0 +1,3 @@
export * from './atoms';
export * from './molecules';
export * from './organisms';

View 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>
);
}

View File

@ -0,0 +1 @@
export * from './Alert';

View 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>
);
}

View File

@ -0,0 +1 @@
export * from './Card';

View 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';

View File

@ -0,0 +1 @@
export * from './FormField';

View File

@ -0,0 +1,3 @@
export * from './FormField';
export * from './Alert';
export * from './Card';

View 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,
};
});
}

View File

@ -0,0 +1 @@
export * from './Breadcrumbs';

View 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>
);
}

View File

@ -0,0 +1 @@
export * from './DataTable';

View 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';

View 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)}
</>
);
}

View 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