feat: Implement Phase 1 & 2 - Full monorepo architecture
## Backend API (apps/api) - Express.js server with TypeScript - JWT authentication with access/refresh tokens - Multi-tenant middleware (schema per tenant) - Complete CRUD routes: auth, cfdis, transactions, contacts, categories, metrics, alerts - SAT integration: CFDI 4.0 XML parser, FIEL authentication - Metrics engine: 50+ financial metrics (Core, Startup, Enterprise) - Rate limiting, CORS, Helmet security ## Frontend Web (apps/web) - Next.js 14 with App Router - Authentication pages: login, register, forgot-password - Dashboard layout with Sidebar and Header - Dashboard pages: overview, cash-flow, revenue, expenses, metrics - Zustand stores for auth and UI state - Theme support with flash prevention ## Database Package (packages/database) - PostgreSQL migrations with multi-tenant architecture - Public schema: plans, tenants, users, sessions, subscriptions - Tenant schema: sat_credentials, cfdis, transactions, contacts, accounts, alerts - Tenant management functions - Seed data for plans and super admin ## Shared Package (packages/shared) - TypeScript types: auth, tenant, financial, metrics, reports - Zod validation schemas for all entities - Utility functions for formatting ## UI Package (packages/ui) - Chart components: LineChart, BarChart, AreaChart, PieChart - Data components: DataTable, MetricCard, KPICard, AlertBadge - PeriodSelector and Skeleton components ## Infrastructure - Docker Compose: PostgreSQL 15, Redis 7, MinIO, Mailhog - Makefile with 25+ development commands - Development scripts: dev-setup.sh, dev-down.sh - Complete .env.example template Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
40
apps/web/next.config.js
Normal file
40
apps/web/next.config.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@horux/shared', '@horux/ui'],
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '2mb',
|
||||
},
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.githubusercontent.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'avatars.githubusercontent.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
async redirects() {
|
||||
return [];
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ key: 'Access-Control-Allow-Methods', value: 'GET,DELETE,PATCH,POST,PUT' },
|
||||
{ key: 'Access-Control-Allow-Headers', value: 'Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, Authorization' },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
40
apps/web/package.json
Normal file
40
apps/web/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@horux/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Horux Strategy Frontend",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf .next node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@horux/shared": "workspace:*",
|
||||
"@horux/ui": "workspace:*",
|
||||
"next": "14.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"zustand": "^4.5.0",
|
||||
"recharts": "^2.10.4",
|
||||
"lucide-react": "^0.312.0",
|
||||
"clsx": "^2.1.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"date-fns": "^3.3.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "14.1.0",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
156
apps/web/src/app/(auth)/layout.tsx
Normal file
156
apps/web/src/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
|
||||
/**
|
||||
* Auth Layout
|
||||
*
|
||||
* Layout para las paginas de autenticacion (login, register).
|
||||
* Redirige a dashboard si el usuario ya esta autenticado.
|
||||
*/
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isInitialized, checkAuth } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized && isAuthenticated) {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [isAuthenticated, isInitialized, router]);
|
||||
|
||||
// Si esta autenticado, no renderizar nada mientras redirige
|
||||
if (isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Left Panel - Branding */}
|
||||
<div className="hidden lg:flex lg:w-1/2 xl:w-2/5 bg-horux-gradient-dark relative overflow-hidden">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex flex-col justify-between p-8 lg:p-12">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-white/10 backdrop-blur flex items-center justify-center">
|
||||
<span className="text-2xl font-bold text-white">H</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-white">Horux Strategy</span>
|
||||
</Link>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-4xl xl:text-5xl font-bold text-white leading-tight">
|
||||
Trading Algoritmico
|
||||
<br />
|
||||
<span className="text-primary-300">Inteligente</span>
|
||||
</h1>
|
||||
<p className="text-lg text-primary-100/80 max-w-md">
|
||||
Automatiza tus estrategias de inversion con inteligencia artificial
|
||||
y maximiza tus ganancias en el mercado de criptomonedas.
|
||||
</p>
|
||||
|
||||
{/* Features */}
|
||||
<div className="space-y-4 pt-4">
|
||||
<FeatureItem
|
||||
title="Estrategias Automatizadas"
|
||||
description="Ejecuta operaciones 24/7 sin intervencion manual"
|
||||
/>
|
||||
<FeatureItem
|
||||
title="Analisis en Tiempo Real"
|
||||
description="Monitorea el mercado y toma decisiones informadas"
|
||||
/>
|
||||
<FeatureItem
|
||||
title="Gestion de Riesgo"
|
||||
description="Protege tu capital con limites inteligentes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-primary-200/60 text-sm">
|
||||
© {new Date().getFullYear()} Horux Strategy. Todos los derechos reservados.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative Elements */}
|
||||
<div className="absolute -bottom-32 -right-32 w-96 h-96 rounded-full bg-primary-500/20 blur-3xl" />
|
||||
<div className="absolute -top-32 -left-32 w-96 h-96 rounded-full bg-primary-400/10 blur-3xl" />
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Auth Form */}
|
||||
<div className="flex-1 flex items-center justify-center p-4 sm:p-6 lg:p-8 bg-slate-50 dark:bg-slate-950">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Mobile Logo */}
|
||||
<div className="lg:hidden mb-8 text-center">
|
||||
<Link href="/" className="inline-flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-horux-gradient flex items-center justify-center">
|
||||
<span className="text-xl font-bold text-white">H</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-slate-900 dark:text-white">
|
||||
Horux Strategy
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature Item Component
|
||||
*/
|
||||
function FeatureItem({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary-400/20 flex items-center justify-center mt-0.5">
|
||||
<svg
|
||||
className="w-4 h-4 text-primary-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-medium">{title}</h3>
|
||||
<p className="text-primary-200/70 text-sm">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
apps/web/src/app/(auth)/login/page.tsx
Normal file
257
apps/web/src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Mail, Lock, AlertCircle } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Login Page
|
||||
*
|
||||
* Pagina de inicio de sesion con formulario de email y password.
|
||||
*/
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { login, isLoading, error, clearError } = useAuthStore();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
remember: false,
|
||||
});
|
||||
|
||||
const [validationErrors, setValidationErrors] = useState<{
|
||||
email?: string;
|
||||
password?: string;
|
||||
}>({});
|
||||
|
||||
// Handle input change
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
|
||||
// Clear validation error on change
|
||||
if (validationErrors[name as keyof typeof validationErrors]) {
|
||||
setValidationErrors((prev) => ({ ...prev, [name]: undefined }));
|
||||
}
|
||||
|
||||
// Clear API error on change
|
||||
if (error) {
|
||||
clearError();
|
||||
}
|
||||
};
|
||||
|
||||
// Validate form
|
||||
const validate = (): boolean => {
|
||||
const errors: typeof validationErrors = {};
|
||||
|
||||
if (!formData.email) {
|
||||
errors.email = 'El email es requerido';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
errors.email = 'Email invalido';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
errors.password = 'La contrasena es requerida';
|
||||
} else if (formData.password.length < 6) {
|
||||
errors.password = 'La contrasena debe tener al menos 6 caracteres';
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) return;
|
||||
|
||||
try {
|
||||
await login({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
remember: formData.remember,
|
||||
});
|
||||
|
||||
router.push('/dashboard');
|
||||
} catch {
|
||||
// Error is handled by the store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center lg:text-left">
|
||||
<h1 className="text-2xl lg:text-3xl font-bold text-slate-900 dark:text-white">
|
||||
Bienvenido de nuevo
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600 dark:text-slate-400">
|
||||
Ingresa tus credenciales para acceder a tu cuenta
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800">
|
||||
<AlertCircle className="h-5 w-5 text-error-600 dark:text-error-400 flex-shrink-0" />
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Email */}
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
label="Email"
|
||||
placeholder="tu@email.com"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
error={validationErrors.email}
|
||||
leftIcon={<Mail className="h-5 w-5" />}
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{/* Password */}
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
label="Contrasena"
|
||||
placeholder="Tu contrasena"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
error={validationErrors.password}
|
||||
leftIcon={<Lock className="h-5 w-5" />}
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{/* Remember & Forgot */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remember"
|
||||
checked={formData.remember}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-primary-600 focus:ring-primary-500"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Recordarme
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
Olvidaste tu contrasena?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Iniciar Sesion
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-slate-200 dark:border-slate-700" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-4 bg-slate-50 dark:bg-slate-950 text-slate-500">
|
||||
O continua con
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Login */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<GoogleIcon className="h-5 w-5" />
|
||||
Google
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<GithubIcon className="h-5 w-5" />
|
||||
GitHub
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Register Link */}
|
||||
<p className="text-center text-sm text-slate-600 dark:text-slate-400">
|
||||
No tienes una cuenta?{' '}
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-medium text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
Registrate gratis
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Icon
|
||||
*/
|
||||
function GoogleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Github Icon
|
||||
*/
|
||||
function GithubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
392
apps/web/src/app/(auth)/register/page.tsx
Normal file
392
apps/web/src/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Mail, Lock, User, AlertCircle, Check } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Register Page
|
||||
*
|
||||
* Pagina de registro con formulario de nombre, email y password.
|
||||
*/
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const { register, isLoading, error, clearError } = useAuthStore();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
acceptTerms: false,
|
||||
});
|
||||
|
||||
const [validationErrors, setValidationErrors] = useState<{
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
acceptTerms?: string;
|
||||
}>({});
|
||||
|
||||
// Password strength
|
||||
const getPasswordStrength = (password: string): {
|
||||
score: number;
|
||||
label: string;
|
||||
color: string;
|
||||
} => {
|
||||
let score = 0;
|
||||
if (password.length >= 8) score++;
|
||||
if (password.length >= 12) score++;
|
||||
if (/[A-Z]/.test(password)) score++;
|
||||
if (/[0-9]/.test(password)) score++;
|
||||
if (/[^A-Za-z0-9]/.test(password)) score++;
|
||||
|
||||
if (score <= 1) return { score, label: 'Muy debil', color: 'bg-error-500' };
|
||||
if (score === 2) return { score, label: 'Debil', color: 'bg-warning-500' };
|
||||
if (score === 3) return { score, label: 'Media', color: 'bg-warning-400' };
|
||||
if (score === 4) return { score, label: 'Fuerte', color: 'bg-success-400' };
|
||||
return { score, label: 'Muy fuerte', color: 'bg-success-500' };
|
||||
};
|
||||
|
||||
const passwordStrength = getPasswordStrength(formData.password);
|
||||
|
||||
// Handle input change
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
|
||||
// Clear validation error on change
|
||||
if (validationErrors[name as keyof typeof validationErrors]) {
|
||||
setValidationErrors((prev) => ({ ...prev, [name]: undefined }));
|
||||
}
|
||||
|
||||
// Clear API error on change
|
||||
if (error) {
|
||||
clearError();
|
||||
}
|
||||
};
|
||||
|
||||
// Validate form
|
||||
const validate = (): boolean => {
|
||||
const errors: typeof validationErrors = {};
|
||||
|
||||
if (!formData.name) {
|
||||
errors.name = 'El nombre es requerido';
|
||||
} else if (formData.name.length < 2) {
|
||||
errors.name = 'El nombre debe tener al menos 2 caracteres';
|
||||
}
|
||||
|
||||
if (!formData.email) {
|
||||
errors.email = 'El email es requerido';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
errors.email = 'Email invalido';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
errors.password = 'La contrasena es requerida';
|
||||
} else if (formData.password.length < 8) {
|
||||
errors.password = 'La contrasena debe tener al menos 8 caracteres';
|
||||
}
|
||||
|
||||
if (!formData.confirmPassword) {
|
||||
errors.confirmPassword = 'Confirma tu contrasena';
|
||||
} else if (formData.password !== formData.confirmPassword) {
|
||||
errors.confirmPassword = 'Las contrasenas no coinciden';
|
||||
}
|
||||
|
||||
if (!formData.acceptTerms) {
|
||||
errors.acceptTerms = 'Debes aceptar los terminos y condiciones';
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) return;
|
||||
|
||||
try {
|
||||
await register({
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
});
|
||||
|
||||
router.push('/dashboard');
|
||||
} catch {
|
||||
// Error is handled by the store
|
||||
}
|
||||
};
|
||||
|
||||
// Password requirements
|
||||
const requirements = [
|
||||
{ label: 'Al menos 8 caracteres', met: formData.password.length >= 8 },
|
||||
{ label: 'Una letra mayuscula', met: /[A-Z]/.test(formData.password) },
|
||||
{ label: 'Un numero', met: /[0-9]/.test(formData.password) },
|
||||
{ label: 'Un caracter especial', met: /[^A-Za-z0-9]/.test(formData.password) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center lg:text-left">
|
||||
<h1 className="text-2xl lg:text-3xl font-bold text-slate-900 dark:text-white">
|
||||
Crear cuenta
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600 dark:text-slate-400">
|
||||
Comienza tu viaje en el trading algoritmico
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800">
|
||||
<AlertCircle className="h-5 w-5 text-error-600 dark:text-error-400 flex-shrink-0" />
|
||||
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Name */}
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
label="Nombre completo"
|
||||
placeholder="Tu nombre"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
error={validationErrors.name}
|
||||
leftIcon={<User className="h-5 w-5" />}
|
||||
autoComplete="name"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{/* Email */}
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
label="Email"
|
||||
placeholder="tu@email.com"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
error={validationErrors.email}
|
||||
leftIcon={<Mail className="h-5 w-5" />}
|
||||
autoComplete="email"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{/* Password */}
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
label="Contrasena"
|
||||
placeholder="Crea una contrasena segura"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
error={validationErrors.password}
|
||||
leftIcon={<Lock className="h-5 w-5" />}
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{/* Password Strength */}
|
||||
{formData.password && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1 flex-1 rounded-full transition-all ${
|
||||
i <= passwordStrength.score
|
||||
? passwordStrength.color
|
||||
: 'bg-slate-200 dark:bg-slate-700'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Seguridad: <span className="font-medium">{passwordStrength.label}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Requirements */}
|
||||
{formData.password && (
|
||||
<ul className="grid grid-cols-2 gap-2">
|
||||
{requirements.map((req) => (
|
||||
<li
|
||||
key={req.label}
|
||||
className={`flex items-center gap-2 text-xs ${
|
||||
req.met
|
||||
? 'text-success-600 dark:text-success-400'
|
||||
: 'text-slate-400 dark:text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{req.met ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<div className="h-3 w-3 rounded-full border border-current" />
|
||||
)}
|
||||
{req.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<Input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
label="Confirmar contrasena"
|
||||
placeholder="Repite tu contrasena"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
error={validationErrors.confirmPassword}
|
||||
leftIcon={<Lock className="h-5 w-5" />}
|
||||
autoComplete="new-password"
|
||||
disabled={isLoading}
|
||||
success={
|
||||
formData.confirmPassword !== '' &&
|
||||
formData.password === formData.confirmPassword
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Terms */}
|
||||
<div>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="acceptTerms"
|
||||
checked={formData.acceptTerms}
|
||||
onChange={handleChange}
|
||||
className="mt-0.5 w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-primary-600 focus:ring-primary-500"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Acepto los{' '}
|
||||
<Link
|
||||
href="/terms"
|
||||
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
Terminos de Servicio
|
||||
</Link>{' '}
|
||||
y la{' '}
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
Politica de Privacidad
|
||||
</Link>
|
||||
</span>
|
||||
</label>
|
||||
{validationErrors.acceptTerms && (
|
||||
<p className="mt-1.5 text-sm text-error-600 dark:text-error-400">
|
||||
{validationErrors.acceptTerms}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button type="submit" fullWidth size="lg" isLoading={isLoading}>
|
||||
Crear cuenta
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-slate-200 dark:border-slate-700" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-4 bg-slate-50 dark:bg-slate-950 text-slate-500">
|
||||
O registrate con
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Register */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<GoogleIcon className="h-5 w-5" />
|
||||
Google
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<GithubIcon className="h-5 w-5" />
|
||||
GitHub
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Login Link */}
|
||||
<p className="text-center text-sm text-slate-600 dark:text-slate-400">
|
||||
Ya tienes una cuenta?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
Inicia sesion
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Icon
|
||||
*/
|
||||
function GoogleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Github Icon
|
||||
*/
|
||||
function GithubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
788
apps/web/src/app/(dashboard)/cfdis/page.tsx
Normal file
788
apps/web/src/app/(dashboard)/cfdis/page.tsx
Normal file
@@ -0,0 +1,788 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
Search,
|
||||
Filter,
|
||||
Download,
|
||||
RefreshCw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
X,
|
||||
ExternalLink,
|
||||
Building2,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
} from 'lucide-react';
|
||||
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/utils';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type CFDIType = 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago';
|
||||
type CFDIStatus = 'vigente' | 'cancelado' | 'pendiente_cancelacion';
|
||||
type PaymentStatus = 'pagado' | 'parcial' | 'pendiente' | 'vencido';
|
||||
type CFDITab = 'emitidos' | 'recibidos' | 'complementos';
|
||||
|
||||
interface CFDIConcept {
|
||||
claveProdServ: string;
|
||||
cantidad: number;
|
||||
claveUnidad: string;
|
||||
unidad: string;
|
||||
descripcion: string;
|
||||
valorUnitario: number;
|
||||
importe: number;
|
||||
descuento?: number;
|
||||
impuestos?: {
|
||||
traslados?: { impuesto: string; tasa: number; importe: number }[];
|
||||
retenciones?: { impuesto: string; tasa: number; importe: number }[];
|
||||
};
|
||||
}
|
||||
|
||||
interface CFDI {
|
||||
id: string;
|
||||
uuid: string;
|
||||
serie?: string;
|
||||
folio?: string;
|
||||
fecha: string;
|
||||
fechaTimbrado: string;
|
||||
tipo: CFDIType;
|
||||
tipoComprobante: string;
|
||||
metodoPago?: string;
|
||||
formaPago?: string;
|
||||
condicionesPago?: string;
|
||||
status: CFDIStatus;
|
||||
paymentStatus: PaymentStatus;
|
||||
emisor: {
|
||||
rfc: string;
|
||||
nombre: string;
|
||||
regimenFiscal: string;
|
||||
};
|
||||
receptor: {
|
||||
rfc: string;
|
||||
nombre: string;
|
||||
usoCFDI: string;
|
||||
};
|
||||
conceptos: CFDIConcept[];
|
||||
subtotal: number;
|
||||
descuento?: number;
|
||||
impuestos: {
|
||||
totalTraslados: number;
|
||||
totalRetenciones: number;
|
||||
};
|
||||
total: number;
|
||||
moneda: string;
|
||||
tipoCambio?: number;
|
||||
complementos?: string[];
|
||||
relacionados?: { tipoRelacion: string; uuid: string }[];
|
||||
pagosRelacionados?: { id: string; uuid: string; fecha: string; monto: number }[];
|
||||
montoPagado: number;
|
||||
montoPendiente: number;
|
||||
xmlUrl?: string;
|
||||
pdfUrl?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
search: string;
|
||||
tipo: CFDIType | 'all';
|
||||
status: CFDIStatus | 'all';
|
||||
paymentStatus: PaymentStatus | 'all';
|
||||
rfc: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data
|
||||
// ============================================================================
|
||||
|
||||
const generateMockCFDIs = (): CFDI[] => {
|
||||
const emisores = [
|
||||
{ rfc: 'EMP123456789', nombre: 'Mi Empresa S.A. de C.V.', regimenFiscal: '601' },
|
||||
];
|
||||
|
||||
const receptores = [
|
||||
{ rfc: 'CLI987654321', nombre: 'Cliente Uno S.A.', usoCFDI: 'G03' },
|
||||
{ rfc: 'PRO456789123', nombre: 'Proveedor Alpha', usoCFDI: 'G01' },
|
||||
{ rfc: 'SER789123456', nombre: 'Servicios Beta S.A.', usoCFDI: 'G03' },
|
||||
{ rfc: 'TEC321654987', nombre: 'Tech Solutions', usoCFDI: 'G01' },
|
||||
{ rfc: 'DIS654987321', nombre: 'Distribuidora Nacional', usoCFDI: 'G03' },
|
||||
];
|
||||
|
||||
const cfdis: CFDI[] = [];
|
||||
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const isEmitted = Math.random() > 0.4;
|
||||
const tipo: CFDIType = (['ingreso', 'egreso', 'pago'] as CFDIType[])[Math.floor(Math.random() * 3)];
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - Math.floor(Math.random() * 90));
|
||||
const total = Math.floor(Math.random() * 100000) + 1000;
|
||||
const pagado = Math.random() > 0.3 ? (Math.random() > 0.5 ? total : Math.floor(total * Math.random())) : 0;
|
||||
|
||||
const receptor = receptores[Math.floor(Math.random() * receptores.length)];
|
||||
|
||||
let paymentStatus: PaymentStatus = 'pendiente';
|
||||
if (pagado >= total) paymentStatus = 'pagado';
|
||||
else if (pagado > 0) paymentStatus = 'parcial';
|
||||
else if (date < new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)) paymentStatus = 'vencido';
|
||||
|
||||
cfdis.push({
|
||||
id: `cfdi-${i + 1}`,
|
||||
uuid: `${Math.random().toString(36).substr(2, 8)}-${Math.random().toString(36).substr(2, 4)}-${Math.random().toString(36).substr(2, 4)}-${Math.random().toString(36).substr(2, 4)}-${Math.random().toString(36).substr(2, 12)}`.toUpperCase(),
|
||||
serie: 'A',
|
||||
folio: String(1000 + i),
|
||||
fecha: date.toISOString(),
|
||||
fechaTimbrado: date.toISOString(),
|
||||
tipo,
|
||||
tipoComprobante: tipo === 'ingreso' ? 'I' : tipo === 'egreso' ? 'E' : 'P',
|
||||
metodoPago: ['PUE', 'PPD'][Math.floor(Math.random() * 2)],
|
||||
formaPago: ['01', '03', '04', '28'][Math.floor(Math.random() * 4)],
|
||||
status: Math.random() > 0.1 ? 'vigente' : 'cancelado',
|
||||
paymentStatus,
|
||||
emisor: isEmitted ? emisores[0] : { ...receptor, regimenFiscal: '601' },
|
||||
receptor: isEmitted ? receptor : emisores[0],
|
||||
conceptos: [
|
||||
{
|
||||
claveProdServ: '84111506',
|
||||
cantidad: 1,
|
||||
claveUnidad: 'E48',
|
||||
unidad: 'Servicio',
|
||||
descripcion: `Servicio profesional ${i + 1}`,
|
||||
valorUnitario: total / 1.16,
|
||||
importe: total / 1.16,
|
||||
impuestos: {
|
||||
traslados: [{ impuesto: '002', tasa: 0.16, importe: (total / 1.16) * 0.16 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
subtotal: total / 1.16,
|
||||
impuestos: {
|
||||
totalTraslados: (total / 1.16) * 0.16,
|
||||
totalRetenciones: 0,
|
||||
},
|
||||
total,
|
||||
moneda: 'MXN',
|
||||
montoPagado: pagado,
|
||||
montoPendiente: total - pagado,
|
||||
pagosRelacionados: pagado > 0 && tipo !== 'pago' ? [
|
||||
{ id: `pago-${i}`, uuid: `pago-uuid-${i}`, fecha: date.toISOString(), monto: pagado },
|
||||
] : undefined,
|
||||
xmlUrl: `/api/cfdis/${i}/xml`,
|
||||
pdfUrl: `/api/cfdis/${i}/pdf`,
|
||||
createdAt: date.toISOString(),
|
||||
updatedAt: date.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return cfdis.sort((a, b) => new Date(b.fecha).getTime() - new Date(a.fecha).getTime());
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Loading Skeleton
|
||||
// ============================================================================
|
||||
|
||||
function CFDISkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-28" />
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-64" />
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="px-4 py-4 border-b border-gray-100 dark:border-gray-700 flex gap-4">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded flex-1" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Payment Status Badge
|
||||
// ============================================================================
|
||||
|
||||
interface PaymentBadgeProps {
|
||||
status: PaymentStatus;
|
||||
montoPagado: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
function PaymentBadge({ status, montoPagado, total }: PaymentBadgeProps) {
|
||||
const configs: Record<PaymentStatus, { icon: React.ReactNode; label: string; classes: string }> = {
|
||||
pagado: {
|
||||
icon: <CheckCircle className="w-3.5 h-3.5" />,
|
||||
label: 'Pagado',
|
||||
classes: 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400',
|
||||
},
|
||||
parcial: {
|
||||
icon: <Clock className="w-3.5 h-3.5" />,
|
||||
label: `${Math.round((montoPagado / total) * 100)}% Pagado`,
|
||||
classes: 'bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-400',
|
||||
},
|
||||
pendiente: {
|
||||
icon: <Clock className="w-3.5 h-3.5" />,
|
||||
label: 'Pendiente',
|
||||
classes: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
},
|
||||
vencido: {
|
||||
icon: <AlertCircle className="w-3.5 h-3.5" />,
|
||||
label: 'Vencido',
|
||||
classes: 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400',
|
||||
},
|
||||
};
|
||||
|
||||
const config = configs[status];
|
||||
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full', config.classes)}>
|
||||
{config.icon}
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Filter Panel
|
||||
// ============================================================================
|
||||
|
||||
interface FilterPanelProps {
|
||||
filters: Filters;
|
||||
onChange: (filters: Filters) => void;
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
function FilterPanel({ filters, onChange, onClose, isOpen }: FilterPanelProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-4 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">Filtros</h3>
|
||||
<button onClick={onClose} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tipo</label>
|
||||
<select
|
||||
value={filters.tipo}
|
||||
onChange={(e) => onChange({ ...filters, tipo: e.target.value as Filters['tipo'] })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
<option value="ingreso">Ingreso</option>
|
||||
<option value="egreso">Egreso</option>
|
||||
<option value="pago">Pago</option>
|
||||
<option value="traslado">Traslado</option>
|
||||
<option value="nomina">Nomina</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estado CFDI</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => onChange({ ...filters, status: e.target.value as Filters['status'] })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
<option value="vigente">Vigente</option>
|
||||
<option value="cancelado">Cancelado</option>
|
||||
<option value="pendiente_cancelacion">Pendiente Cancelacion</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estado de Pago</label>
|
||||
<select
|
||||
value={filters.paymentStatus}
|
||||
onChange={(e) => onChange({ ...filters, paymentStatus: e.target.value as Filters['paymentStatus'] })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
<option value="pagado">Pagado</option>
|
||||
<option value="parcial">Parcial</option>
|
||||
<option value="pendiente">Pendiente</option>
|
||||
<option value="vencido">Vencido</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">RFC</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por RFC..."
|
||||
value={filters.rfc}
|
||||
onChange={(e) => onChange({ ...filters, rfc: e.target.value.toUpperCase() })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Desde</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateFrom}
|
||||
onChange={(e) => onChange({ ...filters, dateFrom: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hasta</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateTo}
|
||||
onChange={(e) => onChange({ ...filters, dateTo: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() =>
|
||||
onChange({
|
||||
search: '',
|
||||
tipo: 'all',
|
||||
status: 'all',
|
||||
paymentStatus: 'all',
|
||||
rfc: '',
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
})
|
||||
}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
Limpiar filtros
|
||||
</button>
|
||||
<button onClick={onClose} className="px-4 py-2 bg-primary-500 text-white rounded-lg text-sm hover:bg-primary-600">
|
||||
Aplicar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Page Component
|
||||
// ============================================================================
|
||||
|
||||
export default function CFDIsPage() {
|
||||
const [cfdis, setCfdis] = useState<CFDI[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<CFDITab>('emitidos');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
search: '',
|
||||
tipo: 'all',
|
||||
status: 'all',
|
||||
paymentStatus: 'all',
|
||||
rfc: '',
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
});
|
||||
|
||||
const limit = 20;
|
||||
const myRFC = 'EMP123456789';
|
||||
|
||||
const fetchCFDIs = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
setCfdis(generateMockCFDIs());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar CFDIs');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCFDIs();
|
||||
}, [fetchCFDIs]);
|
||||
|
||||
// Filter CFDIs by tab and filters
|
||||
const filteredCFDIs = useMemo(() => {
|
||||
return cfdis.filter((cfdi) => {
|
||||
// Tab filter
|
||||
if (activeTab === 'emitidos' && cfdi.emisor.rfc !== myRFC) return false;
|
||||
if (activeTab === 'recibidos' && cfdi.receptor.rfc !== myRFC) return false;
|
||||
if (activeTab === 'complementos' && cfdi.tipo !== 'pago') return false;
|
||||
|
||||
// Search filter
|
||||
if (filters.search) {
|
||||
const searchLower = filters.search.toLowerCase();
|
||||
if (
|
||||
!cfdi.uuid.toLowerCase().includes(searchLower) &&
|
||||
!cfdi.emisor.nombre.toLowerCase().includes(searchLower) &&
|
||||
!cfdi.receptor.nombre.toLowerCase().includes(searchLower) &&
|
||||
!cfdi.folio?.toLowerCase().includes(searchLower)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Other filters
|
||||
if (filters.tipo !== 'all' && cfdi.tipo !== filters.tipo) return false;
|
||||
if (filters.status !== 'all' && cfdi.status !== filters.status) return false;
|
||||
if (filters.paymentStatus !== 'all' && cfdi.paymentStatus !== filters.paymentStatus) return false;
|
||||
if (filters.rfc) {
|
||||
if (!cfdi.emisor.rfc.includes(filters.rfc) && !cfdi.receptor.rfc.includes(filters.rfc)) return false;
|
||||
}
|
||||
if (filters.dateFrom && cfdi.fecha < filters.dateFrom) return false;
|
||||
if (filters.dateTo && cfdi.fecha > filters.dateTo) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [cfdis, activeTab, filters, myRFC]);
|
||||
|
||||
const paginatedCFDIs = useMemo(() => {
|
||||
const start = (page - 1) * limit;
|
||||
return filteredCFDIs.slice(start, start + limit);
|
||||
}, [filteredCFDIs, page]);
|
||||
|
||||
const totalPages = Math.ceil(filteredCFDIs.length / limit);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const total = filteredCFDIs.reduce((sum, c) => sum + c.total, 0);
|
||||
const pagado = filteredCFDIs.reduce((sum, c) => sum + c.montoPagado, 0);
|
||||
const pendiente = filteredCFDIs.reduce((sum, c) => sum + c.montoPendiente, 0);
|
||||
return { total, pagado, pendiente, count: filteredCFDIs.length };
|
||||
}, [filteredCFDIs]);
|
||||
|
||||
const tipoLabels: Record<CFDIType, string> = {
|
||||
ingreso: 'Ingreso',
|
||||
egreso: 'Egreso',
|
||||
traslado: 'Traslado',
|
||||
nomina: 'Nomina',
|
||||
pago: 'Pago',
|
||||
};
|
||||
|
||||
const handleViewCFDI = (cfdi: CFDI) => {
|
||||
window.location.href = `/cfdis/${cfdi.id}`;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<CFDISkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800 rounded-lg p-6 text-center">
|
||||
<p className="text-error-700 dark:text-error-400 mb-4">{error}</p>
|
||||
<button onClick={fetchCFDIs} className="px-4 py-2 bg-error-600 text-white rounded-lg hover:bg-error-700">
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">CFDIs</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Gestion de comprobantes fiscales digitales
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={fetchCFDIs}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Sincronizar SAT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b border-gray-200 dark:border-gray-700">
|
||||
{([
|
||||
{ value: 'emitidos', label: 'Emitidos' },
|
||||
{ value: 'recibidos', label: 'Recibidos' },
|
||||
{ value: 'complementos', label: 'Complementos de Pago' },
|
||||
] as { value: CFDITab; label: string }[]).map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
activeTab === tab.value
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||
<FileText className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Total CFDIs</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">{formatNumber(summary.count)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
|
||||
<DollarSign className="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Monto Total</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">{formatCurrency(summary.total)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Pagado</p>
|
||||
<p className="text-xl font-bold text-success-600 dark:text-success-400">{formatCurrency(summary.pagado)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-warning-100 dark:bg-warning-900/30 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-warning-600 dark:text-warning-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Pendiente</p>
|
||||
<p className="text-xl font-bold text-warning-600 dark:text-warning-400">{formatCurrency(summary.pendiente)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por UUID, folio, nombre..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 border rounded-lg transition-colors',
|
||||
showFilters
|
||||
? 'bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-700 text-primary-700 dark:text-primary-400'
|
||||
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Filtros
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Panel */}
|
||||
<FilterPanel filters={filters} onChange={setFilters} onClose={() => setShowFilters(false)} isOpen={showFilters} />
|
||||
|
||||
{/* CFDIs Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Folio / UUID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Fecha
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{activeTab === 'emitidos' ? 'Receptor' : 'Emisor'}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Tipo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Total
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Estado CFDI
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Estado Pago
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{paginatedCFDIs.map((cfdi) => {
|
||||
const contacto = activeTab === 'emitidos' ? cfdi.receptor : cfdi.emisor;
|
||||
return (
|
||||
<tr
|
||||
key={cfdi.id}
|
||||
onClick={() => handleViewCFDI(cfdi)}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-900/50 cursor-pointer"
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{cfdi.serie}-{cfdi.folio}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||
{cfdi.uuid.slice(0, 8)}...
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{formatDate(cfdi.fecha)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-900 dark:text-white">{contacto.nombre}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{contacto.rfc}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex px-2 py-0.5 text-xs font-medium rounded-full',
|
||||
cfdi.tipo === 'ingreso' && 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400',
|
||||
cfdi.tipo === 'egreso' && 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400',
|
||||
cfdi.tipo === 'pago' && 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400',
|
||||
cfdi.tipo === 'traslado' && 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
cfdi.tipo === 'nomina' && 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
)}
|
||||
>
|
||||
{tipoLabels[cfdi.tipo]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-gray-900 dark:text-white">
|
||||
{formatCurrency(cfdi.total, cfdi.moneda)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full',
|
||||
cfdi.status === 'vigente' && 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400',
|
||||
cfdi.status === 'cancelado' && 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400',
|
||||
cfdi.status === 'pendiente_cancelacion' && 'bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-400'
|
||||
)}
|
||||
>
|
||||
{cfdi.status === 'vigente' && <CheckCircle className="w-3 h-3" />}
|
||||
{cfdi.status === 'cancelado' && <XCircle className="w-3 h-3" />}
|
||||
{cfdi.status === 'pendiente_cancelacion' && <Clock className="w-3 h-3" />}
|
||||
{cfdi.status === 'vigente' ? 'Vigente' : cfdi.status === 'cancelado' ? 'Cancelado' : 'Pend. Cancel.'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||
<PaymentBadge status={cfdi.paymentStatus} montoPagado={cfdi.montoPagado} total={cfdi.total} />
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewCFDI(cfdi);
|
||||
}}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
title="Ver detalle"
|
||||
>
|
||||
<Eye className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
<a
|
||||
href={cfdi.xmlUrl}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
title="Descargar XML"
|
||||
>
|
||||
<Download className="w-4 h-4 text-gray-500" />
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Mostrando {(page - 1) * limit + 1} a {Math.min(page * limit, filteredCFDIs.length)} de{' '}
|
||||
{filteredCFDIs.length} CFDIs
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Pagina {page} de {totalPages || 1}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages || totalPages === 0}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
441
apps/web/src/app/(dashboard)/dashboard/page.tsx
Normal file
441
apps/web/src/app/(dashboard)/dashboard/page.tsx
Normal file
@@ -0,0 +1,441 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardHeader, CardContent, StatsCard } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn, formatCurrency, formatPercentage, formatNumber } from '@/lib/utils';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Wallet,
|
||||
Activity,
|
||||
BarChart3,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Bot,
|
||||
Target,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Dashboard Page
|
||||
*
|
||||
* Pagina principal del dashboard con KPIs, grafico de portfolio,
|
||||
* estrategias activas y trades recientes.
|
||||
*/
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl lg:text-3xl font-bold text-slate-900 dark:text-white">
|
||||
Dashboard
|
||||
</h1>
|
||||
<p className="mt-1 text-slate-500 dark:text-slate-400">
|
||||
Bienvenido de nuevo. Aqui esta el resumen de tu portfolio.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" size="sm" leftIcon={<RefreshCw className="h-4 w-4" />}>
|
||||
Actualizar
|
||||
</Button>
|
||||
<Button size="sm" leftIcon={<Plus className="h-4 w-4" />}>
|
||||
Nueva Estrategia
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
|
||||
<StatsCard
|
||||
title="Balance Total"
|
||||
value={formatCurrency(125847.32)}
|
||||
change={{ value: 12.5, label: 'vs mes anterior' }}
|
||||
trend="up"
|
||||
icon={<Wallet className="h-6 w-6" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Ganancia Hoy"
|
||||
value={formatCurrency(2340.18)}
|
||||
change={{ value: 8.2, label: 'vs ayer' }}
|
||||
trend="up"
|
||||
icon={<TrendingUp className="h-6 w-6" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Trades Activos"
|
||||
value="12"
|
||||
change={{ value: -2, label: 'vs ayer' }}
|
||||
trend="down"
|
||||
icon={<Activity className="h-6 w-6" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Win Rate"
|
||||
value="68.5%"
|
||||
change={{ value: 3.2, label: 'vs semana anterior' }}
|
||||
trend="up"
|
||||
icon={<Target className="h-6 w-6" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{/* Portfolio Chart - Takes 2 columns */}
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader
|
||||
title="Rendimiento del Portfolio"
|
||||
subtitle="Ultimos 30 dias"
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-3 py-1 text-xs font-medium rounded-lg bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
||||
1M
|
||||
</button>
|
||||
<button className="px-3 py-1 text-xs font-medium rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
3M
|
||||
</button>
|
||||
<button className="px-3 py-1 text-xs font-medium rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
6M
|
||||
</button>
|
||||
<button className="px-3 py-1 text-xs font-medium rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
1A
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
{/* Chart Placeholder */}
|
||||
<div className="h-64 lg:h-80 flex items-center justify-center bg-slate-50 dark:bg-slate-800/50 rounded-lg border-2 border-dashed border-slate-200 dark:border-slate-700">
|
||||
<div className="text-center">
|
||||
<BarChart3 className="h-12 w-12 mx-auto text-slate-300 dark:text-slate-600" />
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
Grafico de rendimiento
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||
Conecta con Recharts para visualizacion
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart Stats */}
|
||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Maximo</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{formatCurrency(132450.00)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Minimo</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{formatCurrency(98320.00)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Promedio</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{formatCurrency(115385.00)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active Strategies */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Estrategias Activas"
|
||||
subtitle="3 de 5 ejecutando"
|
||||
action={
|
||||
<Button variant="ghost" size="xs">
|
||||
Ver todas
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{strategies.map((strategy) => (
|
||||
<StrategyItem key={strategy.id} strategy={strategy} />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Second Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Trades */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Trades Recientes"
|
||||
subtitle="Ultimas 24 horas"
|
||||
action={
|
||||
<Button variant="ghost" size="xs">
|
||||
Ver historial
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{recentTrades.map((trade) => (
|
||||
<TradeItem key={trade.id} trade={trade} />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Market Overview */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Resumen del Mercado"
|
||||
subtitle="Precios en tiempo real"
|
||||
action={
|
||||
<span className="flex items-center gap-1.5 text-xs text-success-600 dark:text-success-400">
|
||||
<span className="w-2 h-2 rounded-full bg-success-500 animate-pulse" />
|
||||
En vivo
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{marketData.map((market) => (
|
||||
<MarketItem key={market.symbol} market={market} />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Alerts Section */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Alertas y Notificaciones"
|
||||
action={
|
||||
<Button variant="ghost" size="xs">
|
||||
Configurar alertas
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{alerts.map((alert) => (
|
||||
<AlertItem key={alert.id} alert={alert} />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Mock Data
|
||||
// ============================================
|
||||
|
||||
interface Strategy {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
status: 'running' | 'paused' | 'stopped';
|
||||
profit: number;
|
||||
trades: number;
|
||||
}
|
||||
|
||||
const strategies: Strategy[] = [
|
||||
{ id: '1', name: 'Grid BTC/USDT', type: 'Grid Trading', status: 'running', profit: 12.5, trades: 45 },
|
||||
{ id: '2', name: 'DCA ETH', type: 'DCA', status: 'running', profit: 8.2, trades: 12 },
|
||||
{ id: '3', name: 'Scalping SOL', type: 'Scalping', status: 'paused', profit: -2.1, trades: 128 },
|
||||
];
|
||||
|
||||
interface Trade {
|
||||
id: string;
|
||||
pair: string;
|
||||
type: 'buy' | 'sell';
|
||||
amount: number;
|
||||
price: number;
|
||||
profit?: number;
|
||||
time: string;
|
||||
}
|
||||
|
||||
const recentTrades: Trade[] = [
|
||||
{ id: '1', pair: 'BTC/USDT', type: 'buy', amount: 0.05, price: 43250, time: '10:32' },
|
||||
{ id: '2', pair: 'ETH/USDT', type: 'sell', amount: 1.2, price: 2280, profit: 45.20, time: '10:15' },
|
||||
{ id: '3', pair: 'SOL/USDT', type: 'buy', amount: 10, price: 98.5, time: '09:58' },
|
||||
{ id: '4', pair: 'BTC/USDT', type: 'sell', amount: 0.08, price: 43180, profit: 120.50, time: '09:45' },
|
||||
];
|
||||
|
||||
interface Market {
|
||||
symbol: string;
|
||||
name: string;
|
||||
price: number;
|
||||
change: number;
|
||||
}
|
||||
|
||||
const marketData: Market[] = [
|
||||
{ symbol: 'BTC', name: 'Bitcoin', price: 43250.00, change: 2.34 },
|
||||
{ symbol: 'ETH', name: 'Ethereum', price: 2280.50, change: 1.82 },
|
||||
{ symbol: 'SOL', name: 'Solana', price: 98.45, change: -0.54 },
|
||||
{ symbol: 'BNB', name: 'BNB', price: 312.80, change: 0.92 },
|
||||
];
|
||||
|
||||
interface Alert {
|
||||
id: string;
|
||||
type: 'warning' | 'info' | 'success';
|
||||
title: string;
|
||||
message: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
const alerts: Alert[] = [
|
||||
{ id: '1', type: 'warning', title: 'Stop Loss cercano', message: 'BTC/USDT esta a 2% del stop loss', time: '5 min' },
|
||||
{ id: '2', type: 'success', title: 'Take Profit alcanzado', message: 'ETH/USDT cerro con +3.5%', time: '15 min' },
|
||||
{ id: '3', type: 'info', title: 'Nueva señal', message: 'SOL/USDT señal de compra detectada', time: '30 min' },
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// Sub-components
|
||||
// ============================================
|
||||
|
||||
function StrategyItem({ strategy }: { strategy: Strategy }) {
|
||||
const statusStyles = {
|
||||
running: 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400',
|
||||
paused: 'bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-400',
|
||||
stopped: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
|
||||
<Bot className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-white">{strategy.name}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{strategy.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={cn(
|
||||
'font-semibold',
|
||||
strategy.profit >= 0 ? 'text-success-600 dark:text-success-400' : 'text-error-600 dark:text-error-400'
|
||||
)}>
|
||||
{formatPercentage(strategy.profit)}
|
||||
</p>
|
||||
<span className={cn('text-xs px-2 py-0.5 rounded-full', statusStyles[strategy.status])}>
|
||||
{strategy.status === 'running' ? 'Activo' : strategy.status === 'paused' ? 'Pausado' : 'Detenido'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TradeItem({ trade }: { trade: Trade }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center',
|
||||
trade.type === 'buy'
|
||||
? 'bg-success-100 dark:bg-success-900/30'
|
||||
: 'bg-error-100 dark:bg-error-900/30'
|
||||
)}>
|
||||
{trade.type === 'buy' ? (
|
||||
<ArrowDownRight className="h-4 w-4 text-success-600 dark:text-success-400" />
|
||||
) : (
|
||||
<ArrowUpRight className="h-4 w-4 text-error-600 dark:text-error-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-white">{trade.pair}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{trade.type === 'buy' ? 'Compra' : 'Venta'} - {trade.amount} @ {formatCurrency(trade.price)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{trade.profit !== undefined ? (
|
||||
<p className={cn(
|
||||
'font-semibold',
|
||||
trade.profit >= 0 ? 'text-success-600 dark:text-success-400' : 'text-error-600 dark:text-error-400'
|
||||
)}>
|
||||
{trade.profit >= 0 ? '+' : ''}{formatCurrency(trade.profit)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Abierto</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">{trade.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarketItem({ market }: { market: Market }) {
|
||||
const isPositive = market.change >= 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center font-bold text-slate-700 dark:text-slate-300">
|
||||
{market.symbol.slice(0, 1)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-white">{market.symbol}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{market.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-slate-900 dark:text-white">
|
||||
{formatCurrency(market.price)}
|
||||
</p>
|
||||
<p className={cn(
|
||||
'text-sm flex items-center gap-1 justify-end',
|
||||
isPositive ? 'text-success-600 dark:text-success-400' : 'text-error-600 dark:text-error-400'
|
||||
)}>
|
||||
{isPositive ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||
{formatPercentage(market.change)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertItem({ alert }: { alert: Alert }) {
|
||||
const typeStyles = {
|
||||
warning: {
|
||||
bg: 'bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800',
|
||||
icon: 'text-warning-600 dark:text-warning-400',
|
||||
},
|
||||
success: {
|
||||
bg: 'bg-success-50 dark:bg-success-900/20 border-success-200 dark:border-success-800',
|
||||
icon: 'text-success-600 dark:text-success-400',
|
||||
},
|
||||
info: {
|
||||
bg: 'bg-primary-50 dark:bg-primary-900/20 border-primary-200 dark:border-primary-800',
|
||||
icon: 'text-primary-600 dark:text-primary-400',
|
||||
},
|
||||
};
|
||||
|
||||
const icons = {
|
||||
warning: <AlertTriangle className="h-5 w-5" />,
|
||||
success: <Target className="h-5 w-5" />,
|
||||
info: <Activity className="h-5 w-5" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('p-4 rounded-lg border', typeStyles[alert.type].bg)}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={typeStyles[alert.type].icon}>{icons[alert.type]}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-slate-900 dark:text-white">{alert.title}</p>
|
||||
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">{alert.message}</p>
|
||||
<p className="mt-2 text-xs text-slate-400 dark:text-slate-500 flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Hace {alert.time}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
apps/web/src/app/(dashboard)/layout.tsx
Normal file
99
apps/web/src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Dashboard Layout
|
||||
*
|
||||
* Layout principal para las paginas del dashboard.
|
||||
* Incluye sidebar, header y manejo de responsive.
|
||||
*/
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isInitialized, checkAuth, isLoading } = useAuthStore();
|
||||
const { sidebarCollapsed, isMobile, setIsMobile } = useUIStore();
|
||||
|
||||
// Check auth on mount
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
if (isInitialized && !isAuthenticated) {
|
||||
router.replace('/login');
|
||||
}
|
||||
}, [isAuthenticated, isInitialized, router]);
|
||||
|
||||
// Handle responsive
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 1024);
|
||||
};
|
||||
|
||||
// Check on mount
|
||||
checkMobile();
|
||||
|
||||
// Listen for resize
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, [setIsMobile]);
|
||||
|
||||
// Show loading while checking auth
|
||||
if (!isInitialized || isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-950">
|
||||
<div className="text-center">
|
||||
<div className="relative w-16 h-16 mx-auto mb-4">
|
||||
<div className="absolute inset-0 rounded-xl bg-horux-gradient animate-pulse" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-2xl font-bold text-white">H</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm animate-pulse">Cargando...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't render if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||
{/* Sidebar */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div
|
||||
className={cn(
|
||||
'transition-all duration-300',
|
||||
// Margin left based on sidebar state
|
||||
isMobile ? 'ml-0' : (sidebarCollapsed ? 'ml-20' : 'ml-64')
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<Header />
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="pt-16 min-h-screen">
|
||||
<div className="p-4 lg:p-6 xl:p-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
769
apps/web/src/app/(dashboard)/metricas/[code]/page.tsx
Normal file
769
apps/web/src/app/(dashboard)/metricas/[code]/page.tsx
Normal file
@@ -0,0 +1,769 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import {
|
||||
ArrowLeft,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Calendar,
|
||||
Download,
|
||||
RefreshCw,
|
||||
Target,
|
||||
Info,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { cn, formatCurrency, formatNumber, formatPercentage, formatDate } from '@/lib/utils';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface MetricValue {
|
||||
date: string;
|
||||
value: number;
|
||||
previousValue?: number;
|
||||
change?: number;
|
||||
changePercent?: number;
|
||||
}
|
||||
|
||||
interface MetricDetail {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
formula?: string;
|
||||
value: number;
|
||||
previousValue: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
format: 'currency' | 'number' | 'percentage';
|
||||
category: 'core' | 'startup' | 'enterprise';
|
||||
trend: 'up' | 'down' | 'neutral';
|
||||
target?: number;
|
||||
benchmark?: number;
|
||||
history: MetricValue[];
|
||||
periodComparison: {
|
||||
period: string;
|
||||
current: number;
|
||||
previous: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
type ChartType = 'line' | 'area' | 'bar';
|
||||
type PeriodType = '7d' | '30d' | '90d' | '12m' | 'ytd' | 'all';
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data
|
||||
// ============================================================================
|
||||
|
||||
const generateMockData = (code: string): MetricDetail => {
|
||||
const isRevenue = ['MRR', 'ARR', 'REVENUE', 'LTV', 'ARPU', 'NET_INCOME'].includes(code);
|
||||
const isCost = ['CAC', 'EXPENSES', 'BURN_RATE'].includes(code);
|
||||
const isPercentage = ['GROSS_MARGIN', 'CHURN', 'NRR', 'DAU_MAU'].includes(code);
|
||||
const isCount = ['ACTIVE_CUSTOMERS', 'NPS', 'SUPPORT_TICKETS', 'RESOLUTION_TIME', 'LTV_CAC'].includes(code);
|
||||
|
||||
const baseValue = isRevenue ? 125000 : isCost ? 2500 : isPercentage ? 72.5 : 342;
|
||||
const variance = baseValue * 0.1;
|
||||
|
||||
const history: MetricValue[] = Array.from({ length: 365 }, (_, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (364 - i));
|
||||
const trendFactor = i / 365;
|
||||
const seasonalFactor = Math.sin(i / 30) * 0.05;
|
||||
const randomFactor = (Math.random() - 0.5) * 0.1;
|
||||
const value = baseValue * (0.7 + trendFactor * 0.6 + seasonalFactor + randomFactor);
|
||||
|
||||
return {
|
||||
date: date.toISOString().split('T')[0],
|
||||
value: Math.round(value * 100) / 100,
|
||||
};
|
||||
});
|
||||
|
||||
// Add comparison data
|
||||
history.forEach((item, index) => {
|
||||
if (index >= 30) {
|
||||
item.previousValue = history[index - 30].value;
|
||||
item.change = item.value - item.previousValue;
|
||||
item.changePercent = (item.change / item.previousValue) * 100;
|
||||
}
|
||||
});
|
||||
|
||||
const metricNames: Record<string, { name: string; description: string; formula?: string }> = {
|
||||
MRR: {
|
||||
name: 'Monthly Recurring Revenue',
|
||||
description: 'Ingresos recurrentes mensuales provenientes de suscripciones activas',
|
||||
formula: 'Suma de todas las suscripciones activas mensuales',
|
||||
},
|
||||
ARR: {
|
||||
name: 'Annual Recurring Revenue',
|
||||
description: 'Ingresos recurrentes anuales (MRR x 12)',
|
||||
formula: 'MRR x 12',
|
||||
},
|
||||
REVENUE: {
|
||||
name: 'Ingresos Totales',
|
||||
description: 'Total de ingresos del periodo incluyendo one-time y recurrentes',
|
||||
},
|
||||
EXPENSES: {
|
||||
name: 'Gastos Totales',
|
||||
description: 'Total de gastos operativos del periodo',
|
||||
},
|
||||
GROSS_MARGIN: {
|
||||
name: 'Margen Bruto',
|
||||
description: 'Porcentaje de ingresos despues de costos directos',
|
||||
formula: '(Ingresos - Costos Directos) / Ingresos x 100',
|
||||
},
|
||||
NET_INCOME: {
|
||||
name: 'Utilidad Neta',
|
||||
description: 'Ingresos totales menos todos los gastos',
|
||||
formula: 'Ingresos Totales - Gastos Totales',
|
||||
},
|
||||
CAC: {
|
||||
name: 'Customer Acquisition Cost',
|
||||
description: 'Costo promedio para adquirir un nuevo cliente',
|
||||
formula: 'Gastos de Marketing y Ventas / Nuevos Clientes',
|
||||
},
|
||||
LTV: {
|
||||
name: 'Customer Lifetime Value',
|
||||
description: 'Valor total esperado de un cliente durante toda su relacion',
|
||||
formula: 'ARPU / Churn Rate',
|
||||
},
|
||||
LTV_CAC: {
|
||||
name: 'LTV/CAC Ratio',
|
||||
description: 'Ratio que indica el retorno de inversion en adquisicion',
|
||||
formula: 'LTV / CAC',
|
||||
},
|
||||
CHURN: {
|
||||
name: 'Churn Rate',
|
||||
description: 'Porcentaje mensual de clientes que cancelan',
|
||||
formula: 'Clientes Cancelados / Clientes Inicio de Periodo x 100',
|
||||
},
|
||||
NRR: {
|
||||
name: 'Net Revenue Retention',
|
||||
description: 'Retencion de ingresos incluyendo expansiones',
|
||||
formula: '(MRR Inicio + Expansion - Contraction - Churn) / MRR Inicio x 100',
|
||||
},
|
||||
BURN_RATE: {
|
||||
name: 'Burn Rate',
|
||||
description: 'Tasa mensual de consumo de capital',
|
||||
formula: 'Gastos Mensuales - Ingresos Mensuales',
|
||||
},
|
||||
ACTIVE_CUSTOMERS: {
|
||||
name: 'Clientes Activos',
|
||||
description: 'Numero de clientes con suscripcion activa',
|
||||
},
|
||||
ARPU: {
|
||||
name: 'Average Revenue Per User',
|
||||
description: 'Ingreso promedio mensual por cliente',
|
||||
formula: 'MRR / Clientes Activos',
|
||||
},
|
||||
NPS: {
|
||||
name: 'Net Promoter Score',
|
||||
description: 'Indice de satisfaccion y lealtad del cliente',
|
||||
formula: '% Promotores - % Detractores',
|
||||
},
|
||||
DAU_MAU: {
|
||||
name: 'DAU/MAU Ratio',
|
||||
description: 'Engagement de usuarios activos',
|
||||
formula: 'Usuarios Activos Diarios / Usuarios Activos Mensuales',
|
||||
},
|
||||
SUPPORT_TICKETS: {
|
||||
name: 'Tickets de Soporte',
|
||||
description: 'Cantidad de tickets abiertos en el periodo',
|
||||
},
|
||||
RESOLUTION_TIME: {
|
||||
name: 'Tiempo de Resolucion',
|
||||
description: 'Tiempo promedio para resolver un ticket (horas)',
|
||||
},
|
||||
};
|
||||
|
||||
const info = metricNames[code] || { name: code, description: '' };
|
||||
const currentValue = history[history.length - 1].value;
|
||||
const previousValue = history[history.length - 31]?.value || currentValue * 0.95;
|
||||
|
||||
return {
|
||||
code,
|
||||
name: info.name,
|
||||
description: info.description,
|
||||
formula: info.formula,
|
||||
value: currentValue,
|
||||
previousValue,
|
||||
change: currentValue - previousValue,
|
||||
changePercent: ((currentValue - previousValue) / previousValue) * 100,
|
||||
format: isPercentage ? 'percentage' : isCount ? 'number' : 'currency',
|
||||
category: ['MRR', 'ARR', 'REVENUE', 'EXPENSES', 'GROSS_MARGIN', 'NET_INCOME'].includes(code)
|
||||
? 'core'
|
||||
: ['CAC', 'LTV', 'LTV_CAC', 'CHURN', 'NRR', 'BURN_RATE'].includes(code)
|
||||
? 'startup'
|
||||
: 'enterprise',
|
||||
trend: currentValue > previousValue ? 'up' : 'down',
|
||||
target: currentValue * 1.2,
|
||||
benchmark: currentValue * 0.9,
|
||||
history,
|
||||
periodComparison: [
|
||||
{
|
||||
period: 'Esta semana',
|
||||
current: currentValue,
|
||||
previous: currentValue * 0.97,
|
||||
change: currentValue * 0.03,
|
||||
changePercent: 3.09,
|
||||
},
|
||||
{
|
||||
period: 'Este mes',
|
||||
current: currentValue,
|
||||
previous: previousValue,
|
||||
change: currentValue - previousValue,
|
||||
changePercent: ((currentValue - previousValue) / previousValue) * 100,
|
||||
},
|
||||
{
|
||||
period: 'Este trimestre',
|
||||
current: currentValue,
|
||||
previous: currentValue * 0.88,
|
||||
change: currentValue * 0.12,
|
||||
changePercent: 13.64,
|
||||
},
|
||||
{
|
||||
period: 'Este ano',
|
||||
current: currentValue,
|
||||
previous: currentValue * 0.65,
|
||||
change: currentValue * 0.35,
|
||||
changePercent: 53.85,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Loading Skeleton
|
||||
// ============================================================================
|
||||
|
||||
function MetricDetailSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
<div className="flex-1">
|
||||
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-2" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-72" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Value Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-36 mb-4" />
|
||||
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-24" />
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-4" />
|
||||
<div className="h-80 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
|
||||
export default function MetricDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const code = params.code as string;
|
||||
|
||||
const [metric, setMetric] = useState<MetricDetail | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [period, setPeriod] = useState<PeriodType>('30d');
|
||||
const [chartType, setChartType] = useState<ChartType>('area');
|
||||
const [showComparison, setShowComparison] = useState(true);
|
||||
|
||||
const fetchMetricDetail = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// In production, this would be an API call
|
||||
// const response = await api.get<MetricDetail>(`/metrics/${code}`, { params: { period } });
|
||||
// setMetric(response.data ?? null);
|
||||
|
||||
// Mock data for development
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
setMetric(generateMockData(code.toUpperCase()));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar metrica');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [code, period]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetricDetail();
|
||||
}, [fetchMetricDetail]);
|
||||
|
||||
const formatValue = (value: number) => {
|
||||
if (!metric) return value.toString();
|
||||
switch (metric.format) {
|
||||
case 'currency':
|
||||
return formatCurrency(value);
|
||||
case 'percentage':
|
||||
return `${value.toFixed(2)}%`;
|
||||
case 'number':
|
||||
default:
|
||||
return formatNumber(value, value % 1 === 0 ? 0 : 2);
|
||||
}
|
||||
};
|
||||
|
||||
const getFilteredHistory = () => {
|
||||
if (!metric) return [];
|
||||
const now = new Date();
|
||||
const history = metric.history;
|
||||
|
||||
switch (period) {
|
||||
case '7d':
|
||||
return history.slice(-7);
|
||||
case '30d':
|
||||
return history.slice(-30);
|
||||
case '90d':
|
||||
return history.slice(-90);
|
||||
case '12m':
|
||||
return history.slice(-365);
|
||||
case 'ytd':
|
||||
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
||||
return history.filter((h) => new Date(h.date) >= startOfYear);
|
||||
case 'all':
|
||||
default:
|
||||
return history;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (!metric) return;
|
||||
const data = getFilteredHistory();
|
||||
const csv = [
|
||||
['Fecha', 'Valor', 'Valor Anterior', 'Cambio', 'Cambio %'].join(','),
|
||||
...data.map((row) =>
|
||||
[row.date, row.value, row.previousValue || '', row.change || '', row.changePercent || ''].join(',')
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${metric.code}_${period}.csv`;
|
||||
a.click();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <MetricDetailSkeleton />;
|
||||
}
|
||||
|
||||
if (error || !metric) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800 rounded-lg p-6 text-center">
|
||||
<p className="text-error-700 dark:text-error-400 mb-4">{error || 'Metrica no encontrada'}</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Volver
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isInverseTrend = ['CAC', 'CHURN', 'BURN_RATE', 'EXPENSES', 'SUPPORT_TICKETS', 'RESOLUTION_TIME'].includes(
|
||||
metric.code
|
||||
);
|
||||
const isGoodTrend = isInverseTrend ? metric.change < 0 : metric.change > 0;
|
||||
const filteredHistory = getFilteredHistory();
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{metric.name}</h1>
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded">
|
||||
{metric.code}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">{metric.description}</p>
|
||||
{metric.formula && (
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500 mt-1">
|
||||
<span className="font-medium">Formula:</span> {metric.formula}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Exportar
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchMetricDetail}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Value Card */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div className="lg:col-span-2 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">Valor Actual</p>
|
||||
<p className="text-4xl font-bold text-gray-900 dark:text-white">{formatValue(metric.value)}</p>
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2 py-1 rounded-full text-sm font-medium',
|
||||
isGoodTrend
|
||||
? 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400'
|
||||
: 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400'
|
||||
)}
|
||||
>
|
||||
{isGoodTrend ? <ArrowUpRight className="w-4 h-4" /> : <ArrowDownRight className="w-4 h-4" />}
|
||||
{formatPercentage(Math.abs(metric.changePercent))}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">vs periodo anterior</span>
|
||||
</div>
|
||||
</div>
|
||||
{isGoodTrend ? (
|
||||
<TrendingUp className="w-12 h-12 text-success-500" />
|
||||
) : (
|
||||
<TrendingDown className="w-12 h-12 text-error-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Target Progress */}
|
||||
{metric.target && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Meta</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">{formatValue(metric.target)}</span>
|
||||
</div>
|
||||
<div className="h-3 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
metric.value >= metric.target
|
||||
? 'bg-success-500'
|
||||
: metric.value >= metric.target * 0.75
|
||||
? 'bg-primary-500'
|
||||
: metric.value >= metric.target * 0.5
|
||||
? 'bg-warning-500'
|
||||
: 'bg-error-500'
|
||||
)}
|
||||
style={{ width: `${Math.min(100, (metric.value / metric.target) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{((metric.value / metric.target) * 100).toFixed(1)}% completado
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Period Comparison */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-4">Comparativos</h3>
|
||||
<div className="space-y-4">
|
||||
{metric.periodComparison.map((comp) => (
|
||||
<div key={comp.period} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">{comp.period}</span>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{formatValue(comp.current)}</p>
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs',
|
||||
comp.changePercent >= 0 ? 'text-success-600 dark:text-success-400' : 'text-error-600 dark:text-error-400'
|
||||
)}
|
||||
>
|
||||
{formatPercentage(comp.changePercent)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart Controls */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{(['7d', '30d', '90d', '12m', 'ytd', 'all'] as PeriodType[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-sm font-medium rounded-md transition-all',
|
||||
period === p
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
{p === 'ytd' ? 'YTD' : p === 'all' ? 'Todo' : p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(['area', 'line', 'bar'] as ChartType[]).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setChartType(type)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-sm font-medium rounded-md capitalize transition-all',
|
||||
chartType === type
|
||||
? 'bg-gray-900 dark:bg-white text-white dark:text-gray-900'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Chart */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Historial</h3>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showComparison}
|
||||
onChange={(e) => setShowComparison(e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
Mostrar periodo anterior
|
||||
</label>
|
||||
</div>
|
||||
<div className="h-96">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{chartType === 'bar' ? (
|
||||
<BarChart data={filteredHistory}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.2} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#6b7280"
|
||||
tick={{ fill: '#6b7280', fontSize: 12 }}
|
||||
tickFormatter={(value) => formatDate(value, { day: 'numeric', month: 'short' })}
|
||||
/>
|
||||
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} tickFormatter={(v) => formatValue(v)} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1f2937',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
}}
|
||||
formatter={(value: number) => [formatValue(value), 'Valor']}
|
||||
labelFormatter={(label) => formatDate(label)}
|
||||
/>
|
||||
{showComparison && <Bar dataKey="previousValue" name="Periodo Anterior" fill="#6b7280" opacity={0.5} />}
|
||||
<Bar dataKey="value" name="Valor" fill="#0c8ce8" />
|
||||
{metric.target && <ReferenceLine y={metric.target} stroke="#10b981" strokeDasharray="5 5" label="Meta" />}
|
||||
</BarChart>
|
||||
) : chartType === 'line' ? (
|
||||
<LineChart data={filteredHistory}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.2} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#6b7280"
|
||||
tick={{ fill: '#6b7280', fontSize: 12 }}
|
||||
tickFormatter={(value) => formatDate(value, { day: 'numeric', month: 'short' })}
|
||||
/>
|
||||
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} tickFormatter={(v) => formatValue(v)} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1f2937',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
}}
|
||||
formatter={(value: number) => [formatValue(value), 'Valor']}
|
||||
labelFormatter={(label) => formatDate(label)}
|
||||
/>
|
||||
<Legend />
|
||||
{showComparison && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="previousValue"
|
||||
name="Periodo Anterior"
|
||||
stroke="#6b7280"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
/>
|
||||
)}
|
||||
<Line type="monotone" dataKey="value" name="Valor" stroke="#0c8ce8" strokeWidth={2} dot={false} activeDot={{ r: 4 }} />
|
||||
{metric.target && <ReferenceLine y={metric.target} stroke="#10b981" strokeDasharray="5 5" label="Meta" />}
|
||||
</LineChart>
|
||||
) : (
|
||||
<AreaChart data={filteredHistory}>
|
||||
<defs>
|
||||
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#0c8ce8" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#0c8ce8" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorPrevious" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#6b7280" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="#6b7280" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.2} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#6b7280"
|
||||
tick={{ fill: '#6b7280', fontSize: 12 }}
|
||||
tickFormatter={(value) => formatDate(value, { day: 'numeric', month: 'short' })}
|
||||
/>
|
||||
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} tickFormatter={(v) => formatValue(v)} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1f2937',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
}}
|
||||
formatter={(value: number) => [formatValue(value), 'Valor']}
|
||||
labelFormatter={(label) => formatDate(label)}
|
||||
/>
|
||||
<Legend />
|
||||
{showComparison && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="previousValue"
|
||||
name="Periodo Anterior"
|
||||
stroke="#6b7280"
|
||||
fill="url(#colorPrevious)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="5 5"
|
||||
/>
|
||||
)}
|
||||
<Area type="monotone" dataKey="value" name="Valor" stroke="#0c8ce8" fill="url(#colorValue)" strokeWidth={2} />
|
||||
{metric.target && <ReferenceLine y={metric.target} stroke="#10b981" strokeDasharray="5 5" label="Meta" />}
|
||||
</AreaChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Valores por Periodo</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Fecha
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Valor
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Anterior
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Cambio
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Cambio %
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredHistory
|
||||
.slice(-30)
|
||||
.reverse()
|
||||
.map((row) => (
|
||||
<tr key={row.date} className="hover:bg-gray-50 dark:hover:bg-gray-900/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{formatDate(row.date)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-right font-medium text-gray-900 dark:text-white">
|
||||
{formatValue(row.value)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-right text-gray-500 dark:text-gray-400">
|
||||
{row.previousValue ? formatValue(row.previousValue) : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-right">
|
||||
{row.change !== undefined ? (
|
||||
<span className={row.change >= 0 ? 'text-success-600' : 'text-error-600'}>
|
||||
{row.change >= 0 ? '+' : ''}
|
||||
{formatValue(row.change)}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-right">
|
||||
{row.changePercent !== undefined ? (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
row.changePercent >= 0
|
||||
? 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400'
|
||||
: 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400'
|
||||
)}
|
||||
>
|
||||
{formatPercentage(row.changePercent)}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
824
apps/web/src/app/(dashboard)/metricas/page.tsx
Normal file
824
apps/web/src/app/(dashboard)/metricas/page.tsx
Normal file
@@ -0,0 +1,824 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Users,
|
||||
DollarSign,
|
||||
Activity,
|
||||
Calendar,
|
||||
RefreshCw,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Target,
|
||||
Zap,
|
||||
Building2,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { cn, formatCurrency, formatNumber, formatPercentage } from '@/lib/utils';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface Metric {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
value: number;
|
||||
previousValue: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
format: 'currency' | 'number' | 'percentage';
|
||||
category: 'core' | 'startup' | 'enterprise';
|
||||
trend: 'up' | 'down' | 'neutral';
|
||||
target?: number;
|
||||
history: { date: string; value: number }[];
|
||||
}
|
||||
|
||||
interface MetricsResponse {
|
||||
metrics: Metric[];
|
||||
period: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
type MetricCategory = 'core' | 'startup' | 'enterprise';
|
||||
type PeriodType = '7d' | '30d' | '90d' | '12m' | 'ytd';
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data for Development
|
||||
// ============================================================================
|
||||
|
||||
const mockMetrics: Metric[] = [
|
||||
// Core Metrics
|
||||
{
|
||||
code: 'MRR',
|
||||
name: 'Monthly Recurring Revenue',
|
||||
description: 'Ingresos recurrentes mensuales',
|
||||
value: 125000,
|
||||
previousValue: 118000,
|
||||
change: 7000,
|
||||
changePercent: 5.93,
|
||||
format: 'currency',
|
||||
category: 'core',
|
||||
trend: 'up',
|
||||
target: 150000,
|
||||
history: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||
value: 80000 + i * 5000 + Math.random() * 3000,
|
||||
})),
|
||||
},
|
||||
{
|
||||
code: 'ARR',
|
||||
name: 'Annual Recurring Revenue',
|
||||
description: 'Ingresos recurrentes anuales',
|
||||
value: 1500000,
|
||||
previousValue: 1416000,
|
||||
change: 84000,
|
||||
changePercent: 5.93,
|
||||
format: 'currency',
|
||||
category: 'core',
|
||||
trend: 'up',
|
||||
target: 1800000,
|
||||
history: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||
value: 960000 + i * 60000 + Math.random() * 36000,
|
||||
})),
|
||||
},
|
||||
{
|
||||
code: 'REVENUE',
|
||||
name: 'Ingresos Totales',
|
||||
description: 'Ingresos totales del periodo',
|
||||
value: 245000,
|
||||
previousValue: 230000,
|
||||
change: 15000,
|
||||
changePercent: 6.52,
|
||||
format: 'currency',
|
||||
category: 'core',
|
||||
trend: 'up',
|
||||
history: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||
value: 180000 + i * 8000 + Math.random() * 10000,
|
||||
})),
|
||||
},
|
||||
{
|
||||
code: 'EXPENSES',
|
||||
name: 'Gastos Totales',
|
||||
description: 'Gastos totales del periodo',
|
||||
value: 180000,
|
||||
previousValue: 175000,
|
||||
change: 5000,
|
||||
changePercent: 2.86,
|
||||
format: 'currency',
|
||||
category: 'core',
|
||||
trend: 'down',
|
||||
history: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||
value: 150000 + i * 3000 + Math.random() * 5000,
|
||||
})),
|
||||
},
|
||||
{
|
||||
code: 'GROSS_MARGIN',
|
||||
name: 'Margen Bruto',
|
||||
description: 'Porcentaje de margen bruto',
|
||||
value: 72.5,
|
||||
previousValue: 70.2,
|
||||
change: 2.3,
|
||||
changePercent: 3.28,
|
||||
format: 'percentage',
|
||||
category: 'core',
|
||||
trend: 'up',
|
||||
target: 75,
|
||||
history: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||
value: 65 + i * 0.8 + Math.random() * 2,
|
||||
})),
|
||||
},
|
||||
{
|
||||
code: 'NET_INCOME',
|
||||
name: 'Utilidad Neta',
|
||||
description: 'Ingresos menos gastos',
|
||||
value: 65000,
|
||||
previousValue: 55000,
|
||||
change: 10000,
|
||||
changePercent: 18.18,
|
||||
format: 'currency',
|
||||
category: 'core',
|
||||
trend: 'up',
|
||||
history: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||
value: 30000 + i * 4000 + Math.random() * 5000,
|
||||
})),
|
||||
},
|
||||
// Startup Metrics
|
||||
{
|
||||
code: 'CAC',
|
||||
name: 'Customer Acquisition Cost',
|
||||
description: 'Costo de adquisicion por cliente',
|
||||
value: 2500,
|
||||
previousValue: 2800,
|
||||
change: -300,
|
||||
changePercent: -10.71,
|
||||
format: 'currency',
|
||||
category: 'startup',
|
||||
trend: 'up',
|
||||
target: 2000,
|
||||
history: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||
value: 3500 - i * 100 + Math.random() * 200,
|
||||
})),
|
||||
},
|
||||
{
|
||||
code: 'LTV',
|
||||
name: 'Customer Lifetime Value',
|
||||
description: 'Valor de vida del cliente',
|
||||
value: 15000,
|
||||
previousValue: 13500,
|
||||
change: 1500,
|
||||
changePercent: 11.11,
|
||||
format: 'currency',
|
||||
category: 'startup',
|
||||
trend: 'up',
|
||||
history: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||
value: 10000 + i * 500 + Math.random() * 500,
|
||||
})),
|
||||
},
|
||||
{
|
||||
code: 'LTV_CAC',
|
||||
name: 'LTV/CAC Ratio',
|
||||
description: 'Ratio de LTV sobre CAC',
|
||||
value: 6.0,
|
||||
previousValue: 4.8,
|
||||
change: 1.2,
|
||||
changePercent: 25,
|
||||
format: 'number',
|
||||
category: 'startup',
|
||||
trend: 'up',
|
||||
target: 5,
|
||||
history: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||
value: 3 + i * 0.3 + Math.random() * 0.5,
|
||||
})),
|
||||
},
|
||||
{
|
||||
code: 'CHURN',
|
||||
name: 'Churn Rate',
|
||||
description: 'Tasa de cancelacion mensual',
|
||||
value: 2.5,
|
||||
previousValue: 3.2,
|
||||
change: -0.7,
|
||||
changePercent: -21.88,
|
||||
format: 'percentage',
|
||||
category: 'startup',
|
||||
trend: 'up',
|
||||
target: 2,
|
||||
history: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||
value: 5 - i * 0.2 + Math.random() * 0.5,
|
||||
})),
|
||||
},
|
||||
{
|
||||
code: 'NRR',
|
||||
name: 'Net Revenue Retention',
|
||||
description: 'Retencion neta de ingresos',
|
||||
value: 115,
|
||||
previousValue: 110,
|
||||
change: 5,
|
||||
changePercent: 4.55,
|
||||
format: 'percentage',
|
||||
category: 'startup',
|
||||
trend: 'up',
|
||||
target: 120,
|
||||
history: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||
value: 100 + i * 1.5 + Math.random() * 3,
|
||||
})),
|
||||
},
|
||||
{
|
||||
code: 'BURN_RATE',
|
||||
name: 'Burn Rate',
|
||||
description: 'Tasa de quema mensual',
|
||||
value: 45000,
|
||||
previousValue: 52000,
|
||||
change: -7000,
|
||||
changePercent: -13.46,
|
||||
format: 'currency',
|
||||
category: 'startup',
|
||||
trend: 'up',
|
||||
history: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||
value: 70000 - i * 3000 + Math.random() * 5000,
|
||||
})),
|
||||
},
|
||||
// Enterprise Metrics
|
||||
{
|
||||
code: 'ACTIVE_CUSTOMERS',
|
||||
name: 'Clientes Activos',
|
||||
description: 'Numero de clientes activos',
|
||||
value: 342,
|
||||
previousValue: 315,
|
||||
change: 27,
|
||||
changePercent: 8.57,
|
||||
format: 'number',
|
||||
category: 'enterprise',
|
||||
trend: 'up',
|
||||
target: 400,
|
||||
history: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||
value: 250 + i * 10 + Math.floor(Math.random() * 10),
|
||||
})),
|
||||
},
|
||||
{
|
||||
code: 'ARPU',
|
||||
name: 'Average Revenue Per User',
|
||||
description: 'Ingreso promedio por usuario',
|
||||
value: 365,
|
||||
previousValue: 350,
|
||||
change: 15,
|
||||
changePercent: 4.29,
|
||||
format: 'currency',
|
||||
category: 'enterprise',
|
||||
trend: 'up',
|
||||
history: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||
value: 300 + i * 8 + Math.random() * 15,
|
||||
})),
|
||||
},
|
||||
{
|
||||
code: 'NPS',
|
||||
name: 'Net Promoter Score',
|
||||
description: 'Indice de promotores neto',
|
||||
value: 72,
|
||||
previousValue: 68,
|
||||
change: 4,
|
||||
changePercent: 5.88,
|
||||
format: 'number',
|
||||
category: 'enterprise',
|
||||
trend: 'up',
|
||||
target: 80,
|
||||
history: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||
value: 55 + i * 2 + Math.floor(Math.random() * 5),
|
||||
})),
|
||||
},
|
||||
{
|
||||
code: 'DAU_MAU',
|
||||
name: 'DAU/MAU Ratio',
|
||||
description: 'Ratio de usuarios activos diarios vs mensuales',
|
||||
value: 0.45,
|
||||
previousValue: 0.42,
|
||||
change: 0.03,
|
||||
changePercent: 7.14,
|
||||
format: 'percentage',
|
||||
category: 'enterprise',
|
||||
trend: 'up',
|
||||
target: 0.5,
|
||||
history: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||
value: 0.35 + i * 0.01 + Math.random() * 0.02,
|
||||
})),
|
||||
},
|
||||
{
|
||||
code: 'SUPPORT_TICKETS',
|
||||
name: 'Tickets de Soporte',
|
||||
description: 'Tickets abiertos este periodo',
|
||||
value: 89,
|
||||
previousValue: 120,
|
||||
change: -31,
|
||||
changePercent: -25.83,
|
||||
format: 'number',
|
||||
category: 'enterprise',
|
||||
trend: 'up',
|
||||
history: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||
value: 150 - i * 5 + Math.floor(Math.random() * 20),
|
||||
})),
|
||||
},
|
||||
{
|
||||
code: 'RESOLUTION_TIME',
|
||||
name: 'Tiempo de Resolucion',
|
||||
description: 'Tiempo promedio de resolucion en horas',
|
||||
value: 4.2,
|
||||
previousValue: 5.8,
|
||||
change: -1.6,
|
||||
changePercent: -27.59,
|
||||
format: 'number',
|
||||
category: 'enterprise',
|
||||
trend: 'up',
|
||||
target: 4,
|
||||
history: Array.from({ length: 12 }, (_, i) => ({
|
||||
date: `2024-${String(i + 1).padStart(2, '0')}`,
|
||||
value: 8 - i * 0.4 + Math.random() * 1,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Loading Skeleton
|
||||
// ============================================================================
|
||||
|
||||
function MetricsSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse space-y-6">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48" />
|
||||
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-36" />
|
||||
</div>
|
||||
|
||||
{/* Tabs skeleton */}
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-28" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cards skeleton */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6"
|
||||
>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2" />
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4" />
|
||||
<div className="h-20 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Metric Card Component
|
||||
// ============================================================================
|
||||
|
||||
interface MetricCardProps {
|
||||
metric: Metric;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function MetricCard({ metric, onClick }: MetricCardProps) {
|
||||
const formatValue = (value: number, format: Metric['format']) => {
|
||||
switch (format) {
|
||||
case 'currency':
|
||||
return formatCurrency(value);
|
||||
case 'percentage':
|
||||
return `${value.toFixed(1)}%`;
|
||||
case 'number':
|
||||
default:
|
||||
return formatNumber(value, value % 1 === 0 ? 0 : 2);
|
||||
}
|
||||
};
|
||||
|
||||
const isPositiveTrend = metric.trend === 'up';
|
||||
const TrendIcon = isPositiveTrend ? TrendingUp : TrendingDown;
|
||||
|
||||
// For metrics where lower is better (CAC, Churn, etc.)
|
||||
const isInverseTrend = ['CAC', 'CHURN', 'BURN_RATE', 'EXPENSES', 'SUPPORT_TICKETS', 'RESOLUTION_TIME'].includes(metric.code);
|
||||
const isGoodTrend = isInverseTrend ? metric.change < 0 : metric.change > 0;
|
||||
|
||||
const progressPercent = metric.target
|
||||
? Math.min(100, (metric.value / metric.target) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700',
|
||||
'p-6 hover:shadow-lg transition-all cursor-pointer hover:border-primary-300 dark:hover:border-primary-600',
|
||||
'group'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{metric.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
{metric.code}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400 group-hover:text-primary-500 transition-colors" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between mb-4">
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{formatValue(metric.value, metric.format)}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium',
|
||||
isGoodTrend
|
||||
? 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400'
|
||||
: 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400'
|
||||
)}
|
||||
>
|
||||
{isGoodTrend ? (
|
||||
<ArrowUpRight className="w-3 h-3" />
|
||||
) : (
|
||||
<ArrowDownRight className="w-3 h-3" />
|
||||
)}
|
||||
{formatPercentage(Math.abs(metric.changePercent))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini Chart */}
|
||||
<div className="h-16 mb-3">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={metric.history.slice(-7)}>
|
||||
<defs>
|
||||
<linearGradient id={`gradient-${metric.code}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={isGoodTrend ? '#10b981' : '#ef4444'}
|
||||
stopOpacity={0.3}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={isGoodTrend ? '#10b981' : '#ef4444'}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={isGoodTrend ? '#10b981' : '#ef4444'}
|
||||
strokeWidth={2}
|
||||
fill={`url(#gradient-${metric.code})`}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Target Progress */}
|
||||
{progressPercent !== null && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-gray-500 dark:text-gray-400">Meta</span>
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
{formatValue(metric.target!, metric.format)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
progressPercent >= 100
|
||||
? 'bg-success-500'
|
||||
: progressPercent >= 75
|
||||
? 'bg-primary-500'
|
||||
: progressPercent >= 50
|
||||
? 'bg-warning-500'
|
||||
: 'bg-error-500'
|
||||
)}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Trend Chart Component
|
||||
// ============================================================================
|
||||
|
||||
interface TrendChartProps {
|
||||
metrics: Metric[];
|
||||
selectedMetrics: string[];
|
||||
}
|
||||
|
||||
function TrendChart({ metrics, selectedMetrics }: TrendChartProps) {
|
||||
const selectedData = metrics.filter((m) => selectedMetrics.includes(m.code));
|
||||
|
||||
if (selectedData.length === 0) return null;
|
||||
|
||||
// Combine data for multiple metrics
|
||||
const chartData = selectedData[0].history.map((point, index) => {
|
||||
const dataPoint: Record<string, unknown> = { date: point.date };
|
||||
selectedData.forEach((metric) => {
|
||||
dataPoint[metric.code] = metric.history[index]?.value ?? 0;
|
||||
});
|
||||
return dataPoint;
|
||||
});
|
||||
|
||||
const colors = ['#0c8ce8', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Tendencias
|
||||
</h3>
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.2} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#6b7280"
|
||||
tick={{ fill: '#6b7280', fontSize: 12 }}
|
||||
/>
|
||||
<YAxis stroke="#6b7280" tick={{ fill: '#6b7280', fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1f2937',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
{selectedData.map((metric, index) => (
|
||||
<Line
|
||||
key={metric.code}
|
||||
type="monotone"
|
||||
dataKey={metric.code}
|
||||
name={metric.name}
|
||||
stroke={colors[index % colors.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Period Selector
|
||||
// ============================================================================
|
||||
|
||||
interface PeriodSelectorProps {
|
||||
value: PeriodType;
|
||||
onChange: (period: PeriodType) => void;
|
||||
}
|
||||
|
||||
function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
|
||||
const periods: { value: PeriodType; label: string }[] = [
|
||||
{ value: '7d', label: '7 dias' },
|
||||
{ value: '30d', label: '30 dias' },
|
||||
{ value: '90d', label: '90 dias' },
|
||||
{ value: '12m', label: '12 meses' },
|
||||
{ value: 'ytd', label: 'YTD' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 bg-gray-100 dark:bg-gray-800 p-1 rounded-lg">
|
||||
{periods.map((period) => (
|
||||
<button
|
||||
key={period.value}
|
||||
onClick={() => onChange(period.value)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-sm font-medium rounded-md transition-all',
|
||||
value === period.value
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
)}
|
||||
>
|
||||
{period.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Category Tab
|
||||
// ============================================================================
|
||||
|
||||
interface CategoryTabsProps {
|
||||
value: MetricCategory;
|
||||
onChange: (category: MetricCategory) => void;
|
||||
}
|
||||
|
||||
function CategoryTabs({ value, onChange }: CategoryTabsProps) {
|
||||
const categories: { value: MetricCategory; label: string; icon: React.ReactNode }[] = [
|
||||
{ value: 'core', label: 'Core', icon: <Activity className="w-4 h-4" /> },
|
||||
{ value: 'startup', label: 'Startup', icon: <Zap className="w-4 h-4" /> },
|
||||
{ value: 'enterprise', label: 'Enterprise', icon: <Building2 className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.value}
|
||||
onClick={() => onChange(category.value)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all',
|
||||
value === category.value
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
{category.icon}
|
||||
{category.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Page Component
|
||||
// ============================================================================
|
||||
|
||||
export default function MetricasPage() {
|
||||
const [metrics, setMetrics] = useState<Metric[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [category, setCategory] = useState<MetricCategory>('core');
|
||||
const [period, setPeriod] = useState<PeriodType>('30d');
|
||||
const [selectedForTrend, setSelectedForTrend] = useState<string[]>(['MRR', 'ARR']);
|
||||
|
||||
const fetchMetrics = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// In production, this would be an API call
|
||||
// const response = await api.get<MetricsResponse>('/metrics', { params: { period } });
|
||||
// setMetrics(response.data?.metrics ?? []);
|
||||
|
||||
// Mock data for development
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
setMetrics(mockMetrics);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar metricas');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [period]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetrics();
|
||||
}, [fetchMetrics]);
|
||||
|
||||
const filteredMetrics = metrics.filter((m) => m.category === category);
|
||||
|
||||
const handleMetricClick = (metric: Metric) => {
|
||||
// Navigate to detail page
|
||||
window.location.href = `/metricas/${metric.code}`;
|
||||
};
|
||||
|
||||
const toggleTrendSelection = (code: string) => {
|
||||
setSelectedForTrend((prev) =>
|
||||
prev.includes(code)
|
||||
? prev.filter((c) => c !== code)
|
||||
: prev.length < 4
|
||||
? [...prev, code]
|
||||
: prev
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<MetricsSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800 rounded-lg p-6 text-center">
|
||||
<p className="text-error-700 dark:text-error-400 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={fetchMetrics}
|
||||
className="px-4 py-2 bg-error-600 text-white rounded-lg hover:bg-error-700 transition-colors"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Metricas</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Analiza el rendimiento de tu negocio
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<PeriodSelector value={period} onChange={setPeriod} />
|
||||
<button
|
||||
onClick={fetchMetrics}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
title="Actualizar"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
<CategoryTabs value={category} onChange={setCategory} />
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredMetrics.map((metric) => (
|
||||
<MetricCard
|
||||
key={metric.code}
|
||||
metric={metric}
|
||||
onClick={() => handleMetricClick(metric)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Trend Selection */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Selecciona hasta 4 metricas para comparar en el grafico de tendencias:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{metrics.map((metric) => (
|
||||
<button
|
||||
key={metric.code}
|
||||
onClick={() => toggleTrendSelection(metric.code)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-sm font-medium rounded-full transition-all',
|
||||
selectedForTrend.includes(metric.code)
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:border-primary-300 dark:hover:border-primary-600'
|
||||
)}
|
||||
>
|
||||
{metric.code}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trend Chart */}
|
||||
<TrendChart metrics={metrics} selectedMetrics={selectedForTrend} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
912
apps/web/src/app/(dashboard)/transacciones/page.tsx
Normal file
912
apps/web/src/app/(dashboard)/transacciones/page.tsx
Normal file
@@ -0,0 +1,912 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Search,
|
||||
Filter,
|
||||
Download,
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Calendar,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
FileText,
|
||||
Building2,
|
||||
Tag,
|
||||
} from 'lucide-react';
|
||||
import { cn, formatCurrency, formatDate, formatNumber } from '@/lib/utils';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type TransactionType = 'income' | 'expense' | 'transfer';
|
||||
type TransactionStatus = 'pending' | 'completed' | 'cancelled' | 'reconciled';
|
||||
type PaymentMethod = 'cash' | 'bank_transfer' | 'credit_card' | 'debit_card' | 'check' | 'other';
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
date: string;
|
||||
type: TransactionType;
|
||||
category: string;
|
||||
subcategory?: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: TransactionStatus;
|
||||
paymentMethod: PaymentMethod;
|
||||
contact?: {
|
||||
id: string;
|
||||
name: string;
|
||||
rfc?: string;
|
||||
type: 'customer' | 'supplier';
|
||||
};
|
||||
cfdiId?: string;
|
||||
cfdiUuid?: string;
|
||||
bankAccountId?: string;
|
||||
bankAccountName?: string;
|
||||
reference?: string;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
attachments?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface TransactionsResponse {
|
||||
transactions: Transaction[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
summary: {
|
||||
totalIncome: number;
|
||||
totalExpenses: number;
|
||||
netAmount: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
search: string;
|
||||
type: TransactionType | 'all';
|
||||
status: TransactionStatus | 'all';
|
||||
category: string;
|
||||
contactId: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
paymentMethod: PaymentMethod | 'all';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data
|
||||
// ============================================================================
|
||||
|
||||
const categories = {
|
||||
income: ['Ventas', 'Servicios', 'Comisiones', 'Intereses', 'Otros Ingresos'],
|
||||
expense: ['Nomina', 'Servicios Profesionales', 'Renta', 'Servicios', 'Suministros', 'Marketing', 'Viajes', 'Otros Gastos'],
|
||||
};
|
||||
|
||||
const generateMockTransactions = (): Transaction[] => {
|
||||
const contacts = [
|
||||
{ id: '1', name: 'Empresa ABC S.A. de C.V.', rfc: 'EAB123456789', type: 'customer' as const },
|
||||
{ id: '2', name: 'Servicios XYZ S.A.', rfc: 'SXY987654321', type: 'supplier' as const },
|
||||
{ id: '3', name: 'Consultores Asociados', rfc: 'CON456789123', type: 'supplier' as const },
|
||||
{ id: '4', name: 'Tech Solutions MX', rfc: 'TSM789123456', type: 'customer' as const },
|
||||
{ id: '5', name: 'Distribuidora Nacional', rfc: 'DNA321654987', type: 'customer' as const },
|
||||
];
|
||||
|
||||
const transactions: Transaction[] = [];
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const type: TransactionType = Math.random() > 0.4 ? 'income' : 'expense';
|
||||
const categoryList = type === 'income' ? categories.income : categories.expense;
|
||||
const contact = contacts[Math.floor(Math.random() * contacts.length)];
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - Math.floor(Math.random() * 90));
|
||||
|
||||
transactions.push({
|
||||
id: `txn-${i + 1}`,
|
||||
date: date.toISOString().split('T')[0],
|
||||
type,
|
||||
category: categoryList[Math.floor(Math.random() * categoryList.length)],
|
||||
description: type === 'income' ? `Factura ${1000 + i}` : `Pago ${2000 + i}`,
|
||||
amount: Math.floor(Math.random() * 50000) + 1000,
|
||||
currency: 'MXN',
|
||||
status: (['pending', 'completed', 'reconciled'] as TransactionStatus[])[Math.floor(Math.random() * 3)],
|
||||
paymentMethod: (['bank_transfer', 'credit_card', 'cash', 'check'] as PaymentMethod[])[Math.floor(Math.random() * 4)],
|
||||
contact: Math.random() > 0.2 ? contact : undefined,
|
||||
cfdiId: Math.random() > 0.5 ? `cfdi-${i}` : undefined,
|
||||
cfdiUuid: Math.random() > 0.5 ? `${crypto.randomUUID?.() || `uuid-${i}`}` : undefined,
|
||||
bankAccountId: 'bank-1',
|
||||
bankAccountName: 'Cuenta Principal BBVA',
|
||||
reference: `REF-${1000 + i}`,
|
||||
notes: Math.random() > 0.7 ? 'Nota de la transaccion' : undefined,
|
||||
tags: Math.random() > 0.5 ? ['recurrente', 'prioritario'].slice(0, Math.floor(Math.random() * 2) + 1) : undefined,
|
||||
attachments: Math.floor(Math.random() * 3),
|
||||
createdAt: date.toISOString(),
|
||||
updatedAt: date.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return transactions.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Loading Skeleton
|
||||
// ============================================================================
|
||||
|
||||
function TransactionsSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2" />
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-32" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Table skeleton */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-64" />
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="px-4 py-4 border-b border-gray-100 dark:border-gray-700 flex gap-4">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded flex-1" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32" />
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Transaction Detail Modal
|
||||
// ============================================================================
|
||||
|
||||
interface TransactionModalProps {
|
||||
transaction: Transaction | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function TransactionModal({ transaction, isOpen, onClose }: TransactionModalProps) {
|
||||
if (!isOpen || !transaction) return null;
|
||||
|
||||
const typeLabels: Record<TransactionType, string> = {
|
||||
income: 'Ingreso',
|
||||
expense: 'Gasto',
|
||||
transfer: 'Transferencia',
|
||||
};
|
||||
|
||||
const statusLabels: Record<TransactionStatus, string> = {
|
||||
pending: 'Pendiente',
|
||||
completed: 'Completado',
|
||||
cancelled: 'Cancelado',
|
||||
reconciled: 'Conciliado',
|
||||
};
|
||||
|
||||
const paymentLabels: Record<PaymentMethod, string> = {
|
||||
cash: 'Efectivo',
|
||||
bank_transfer: 'Transferencia',
|
||||
credit_card: 'Tarjeta de Credito',
|
||||
debit_card: 'Tarjeta de Debito',
|
||||
check: 'Cheque',
|
||||
other: 'Otro',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'p-2 rounded-lg',
|
||||
transaction.type === 'income'
|
||||
? 'bg-success-100 dark:bg-success-900/30'
|
||||
: 'bg-error-100 dark:bg-error-900/30'
|
||||
)}
|
||||
>
|
||||
{transaction.type === 'income' ? (
|
||||
<ArrowDownRight className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||
) : (
|
||||
<ArrowUpRight className="w-5 h-5 text-error-600 dark:text-error-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{transaction.description}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{transaction.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Amount */}
|
||||
<div className="text-center py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-4xl font-bold',
|
||||
transaction.type === 'income' ? 'text-success-600 dark:text-success-400' : 'text-error-600 dark:text-error-400'
|
||||
)}
|
||||
>
|
||||
{transaction.type === 'income' ? '+' : '-'}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{formatDate(transaction.date)}</p>
|
||||
</div>
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tipo</label>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white mt-1">{typeLabels[transaction.type]}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Estado</label>
|
||||
<p className="mt-1">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex px-2 py-0.5 text-xs font-medium rounded-full',
|
||||
transaction.status === 'completed' && 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400',
|
||||
transaction.status === 'pending' && 'bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-400',
|
||||
transaction.status === 'cancelled' && 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400',
|
||||
transaction.status === 'reconciled' && 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
)}
|
||||
>
|
||||
{statusLabels[transaction.status]}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Categoria</label>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white mt-1">{transaction.category}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Metodo de Pago</label>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white mt-1">{paymentLabels[transaction.paymentMethod]}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
{transaction.contact && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Contacto</label>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<div className="p-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||
<Building2 className="w-5 h-5 text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{transaction.contact.name}</p>
|
||||
{transaction.contact.rfc && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{transaction.contact.rfc}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CFDI */}
|
||||
{transaction.cfdiUuid && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">CFDI Relacionado</label>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<div className="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
|
||||
<FileText className="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-mono text-gray-900 dark:text-white">{transaction.cfdiUuid}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`/cfdis/${transaction.cfdiId}`}
|
||||
className="text-sm text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
Ver CFDI
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bank Account */}
|
||||
{transaction.bankAccountName && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Cuenta Bancaria</label>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white mt-1">{transaction.bankAccountName}</p>
|
||||
{transaction.reference && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Ref: {transaction.reference}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{transaction.tags && transaction.tags.length > 0 && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Etiquetas</label>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{transaction.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-xs rounded-full"
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{transaction.notes && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<label className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Notas</label>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 mt-1">{transaction.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 px-6 py-4 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
<button className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600">
|
||||
<Edit className="w-4 h-4 inline mr-2" />
|
||||
Editar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Filter Panel
|
||||
// ============================================================================
|
||||
|
||||
interface FilterPanelProps {
|
||||
filters: Filters;
|
||||
onChange: (filters: Filters) => void;
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
function FilterPanel({ filters, onChange, onClose, isOpen }: FilterPanelProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const allCategories = [...categories.income, ...categories.expense];
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-4 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">Filtros</h3>
|
||||
<button onClick={onClose} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{/* Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tipo</label>
|
||||
<select
|
||||
value={filters.type}
|
||||
onChange={(e) => onChange({ ...filters, type: e.target.value as Filters['type'] })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
<option value="income">Ingresos</option>
|
||||
<option value="expense">Gastos</option>
|
||||
<option value="transfer">Transferencias</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estado</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => onChange({ ...filters, status: e.target.value as Filters['status'] })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
<option value="pending">Pendiente</option>
|
||||
<option value="completed">Completado</option>
|
||||
<option value="reconciled">Conciliado</option>
|
||||
<option value="cancelled">Cancelado</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Categoria</label>
|
||||
<select
|
||||
value={filters.category}
|
||||
onChange={(e) => onChange({ ...filters, category: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">Todas</option>
|
||||
{allCategories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Metodo de Pago</label>
|
||||
<select
|
||||
value={filters.paymentMethod}
|
||||
onChange={(e) => onChange({ ...filters, paymentMethod: e.target.value as Filters['paymentMethod'] })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
<option value="bank_transfer">Transferencia</option>
|
||||
<option value="cash">Efectivo</option>
|
||||
<option value="credit_card">Tarjeta de Credito</option>
|
||||
<option value="debit_card">Tarjeta de Debito</option>
|
||||
<option value="check">Cheque</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Date From */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Desde</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateFrom}
|
||||
onChange={(e) => onChange({ ...filters, dateFrom: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date To */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hasta</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateTo}
|
||||
onChange={(e) => onChange({ ...filters, dateTo: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() =>
|
||||
onChange({
|
||||
search: '',
|
||||
type: 'all',
|
||||
status: 'all',
|
||||
category: '',
|
||||
contactId: '',
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
paymentMethod: 'all',
|
||||
})
|
||||
}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
Limpiar filtros
|
||||
</button>
|
||||
<button onClick={onClose} className="px-4 py-2 bg-primary-500 text-white rounded-lg text-sm hover:bg-primary-600">
|
||||
Aplicar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Page Component
|
||||
// ============================================================================
|
||||
|
||||
export default function TransaccionesPage() {
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTransaction, setSelectedTransaction] = useState<Transaction | null>(null);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
search: '',
|
||||
type: 'all',
|
||||
status: 'all',
|
||||
category: '',
|
||||
contactId: '',
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
paymentMethod: 'all',
|
||||
});
|
||||
|
||||
const limit = 20;
|
||||
|
||||
const fetchTransactions = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// In production, this would be an API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
setTransactions(generateMockTransactions());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar transacciones');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTransactions();
|
||||
}, [fetchTransactions]);
|
||||
|
||||
// Filter and paginate transactions
|
||||
const filteredTransactions = useMemo(() => {
|
||||
return transactions.filter((t) => {
|
||||
if (filters.search) {
|
||||
const searchLower = filters.search.toLowerCase();
|
||||
if (
|
||||
!t.description.toLowerCase().includes(searchLower) &&
|
||||
!t.contact?.name.toLowerCase().includes(searchLower) &&
|
||||
!t.category.toLowerCase().includes(searchLower)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (filters.type !== 'all' && t.type !== filters.type) return false;
|
||||
if (filters.status !== 'all' && t.status !== filters.status) return false;
|
||||
if (filters.category && t.category !== filters.category) return false;
|
||||
if (filters.paymentMethod !== 'all' && t.paymentMethod !== filters.paymentMethod) return false;
|
||||
if (filters.dateFrom && t.date < filters.dateFrom) return false;
|
||||
if (filters.dateTo && t.date > filters.dateTo) return false;
|
||||
return true;
|
||||
});
|
||||
}, [transactions, filters]);
|
||||
|
||||
const paginatedTransactions = useMemo(() => {
|
||||
const start = (page - 1) * limit;
|
||||
return filteredTransactions.slice(start, start + limit);
|
||||
}, [filteredTransactions, page]);
|
||||
|
||||
const totalPages = Math.ceil(filteredTransactions.length / limit);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const income = filteredTransactions.filter((t) => t.type === 'income').reduce((sum, t) => sum + t.amount, 0);
|
||||
const expenses = filteredTransactions.filter((t) => t.type === 'expense').reduce((sum, t) => sum + t.amount, 0);
|
||||
return {
|
||||
income,
|
||||
expenses,
|
||||
net: income - expenses,
|
||||
};
|
||||
}, [filteredTransactions]);
|
||||
|
||||
const handleExport = () => {
|
||||
const csv = [
|
||||
['Fecha', 'Tipo', 'Descripcion', 'Categoria', 'Monto', 'Estado', 'Contacto'].join(','),
|
||||
...filteredTransactions.map((t) =>
|
||||
[t.date, t.type, t.description, t.category, t.amount, t.status, t.contact?.name || ''].join(',')
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `transacciones_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<TransactionsSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800 rounded-lg p-6 text-center">
|
||||
<p className="text-error-700 dark:text-error-400 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={fetchTransactions}
|
||||
className="px-4 py-2 bg-error-600 text-white rounded-lg hover:bg-error-700"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Transacciones</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{formatNumber(filteredTransactions.length)} transacciones
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Exportar
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600">
|
||||
<Plus className="w-4 h-4" />
|
||||
Nueva Transaccion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
|
||||
<ArrowDownRight className="w-5 h-5 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Ingresos</p>
|
||||
<p className="text-xl font-bold text-success-600 dark:text-success-400">{formatCurrency(summary.income)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-error-100 dark:bg-error-900/30 rounded-lg">
|
||||
<ArrowUpRight className="w-5 h-5 text-error-600 dark:text-error-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Gastos</p>
|
||||
<p className="text-xl font-bold text-error-600 dark:text-error-400">{formatCurrency(summary.expenses)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn('p-2 rounded-lg', summary.net >= 0 ? 'bg-primary-100 dark:bg-primary-900/30' : 'bg-error-100 dark:bg-error-900/30')}>
|
||||
<Activity className="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Neto</p>
|
||||
<p className={cn('text-xl font-bold', summary.net >= 0 ? 'text-primary-600 dark:text-primary-400' : 'text-error-600 dark:text-error-400')}>
|
||||
{formatCurrency(summary.net)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar transacciones..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 border rounded-lg transition-colors',
|
||||
showFilters
|
||||
? 'bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-700 text-primary-700 dark:text-primary-400'
|
||||
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Filtros
|
||||
{Object.values(filters).filter((v) => v && v !== 'all').length > 1 && (
|
||||
<span className="ml-1 px-1.5 py-0.5 bg-primary-500 text-white text-xs rounded-full">
|
||||
{Object.values(filters).filter((v) => v && v !== 'all').length - 1}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Panel */}
|
||||
<FilterPanel filters={filters} onChange={setFilters} onClose={() => setShowFilters(false)} isOpen={showFilters} />
|
||||
|
||||
{/* Transactions Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Fecha
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Descripcion
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Categoria
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Contacto
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Monto
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{paginatedTransactions.map((transaction) => (
|
||||
<tr
|
||||
key={transaction.id}
|
||||
onClick={() => setSelectedTransaction(transaction)}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-900/50 cursor-pointer"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
||||
{formatDate(transaction.date)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'p-1.5 rounded-lg',
|
||||
transaction.type === 'income'
|
||||
? 'bg-success-100 dark:bg-success-900/30'
|
||||
: 'bg-error-100 dark:bg-error-900/30'
|
||||
)}
|
||||
>
|
||||
{transaction.type === 'income' ? (
|
||||
<ArrowDownRight className="w-4 h-4 text-success-600 dark:text-success-400" />
|
||||
) : (
|
||||
<ArrowUpRight className="w-4 h-4 text-error-600 dark:text-error-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{transaction.description}</p>
|
||||
{transaction.cfdiUuid && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||
CFDI: {transaction.cfdiUuid.slice(0, 8)}...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||
{transaction.category}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{transaction.contact ? (
|
||||
<div>
|
||||
<p className="text-sm text-gray-900 dark:text-white">{transaction.contact.name}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{transaction.contact.rfc}</p>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
transaction.type === 'income'
|
||||
? 'text-success-600 dark:text-success-400'
|
||||
: 'text-error-600 dark:text-error-400'
|
||||
)}
|
||||
>
|
||||
{transaction.type === 'income' ? '+' : '-'}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex px-2 py-0.5 text-xs font-medium rounded-full',
|
||||
transaction.status === 'completed' && 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400',
|
||||
transaction.status === 'pending' && 'bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-400',
|
||||
transaction.status === 'cancelled' && 'bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-400',
|
||||
transaction.status === 'reconciled' && 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
)}
|
||||
>
|
||||
{transaction.status === 'completed' && 'Completado'}
|
||||
{transaction.status === 'pending' && 'Pendiente'}
|
||||
{transaction.status === 'cancelled' && 'Cancelado'}
|
||||
{transaction.status === 'reconciled' && 'Conciliado'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedTransaction(transaction);
|
||||
}}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<Eye className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Mostrando {(page - 1) * limit + 1} a {Math.min(page * limit, filteredTransactions.length)} de{' '}
|
||||
{filteredTransactions.length} transacciones
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Pagina {page} de {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transaction Detail Modal */}
|
||||
<TransactionModal
|
||||
transaction={selectedTransaction}
|
||||
isOpen={!!selectedTransaction}
|
||||
onClose={() => setSelectedTransaction(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Need to import Activity for the summary card
|
||||
import { Activity } from 'lucide-react';
|
||||
374
apps/web/src/app/globals.css
Normal file
374
apps/web/src/app/globals.css
Normal file
@@ -0,0 +1,374 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ============================================
|
||||
Base Styles
|
||||
============================================ */
|
||||
|
||||
@layer base {
|
||||
/* Root variables */
|
||||
:root {
|
||||
--background: 255 255 255;
|
||||
--foreground: 15 23 42;
|
||||
--primary: 12 140 232;
|
||||
--primary-foreground: 255 255 255;
|
||||
--muted: 241 245 249;
|
||||
--muted-foreground: 100 116 139;
|
||||
--accent: 241 245 249;
|
||||
--accent-foreground: 15 23 42;
|
||||
--border: 226 232 240;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 10 15 26;
|
||||
--foreground: 241 245 249;
|
||||
--primary: 54 167 247;
|
||||
--primary-foreground: 255 255 255;
|
||||
--muted: 30 41 59;
|
||||
--muted-foreground: 148 163 184;
|
||||
--accent: 30 41 59;
|
||||
--accent-foreground: 241 245 249;
|
||||
--border: 51 65 85;
|
||||
}
|
||||
|
||||
/* Base HTML styles */
|
||||
html {
|
||||
@apply antialiased;
|
||||
scroll-behavior: smooth;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100;
|
||||
@apply min-h-screen;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-semibold tracking-tight;
|
||||
}
|
||||
|
||||
h1 { @apply text-4xl lg:text-5xl; }
|
||||
h2 { @apply text-3xl lg:text-4xl; }
|
||||
h3 { @apply text-2xl lg:text-3xl; }
|
||||
h4 { @apply text-xl lg:text-2xl; }
|
||||
h5 { @apply text-lg lg:text-xl; }
|
||||
h6 { @apply text-base lg:text-lg; }
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
@apply transition-colors duration-200;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
*:focus-visible {
|
||||
@apply outline-none ring-2 ring-primary-500 ring-offset-2 ring-offset-white dark:ring-offset-slate-900;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
@apply bg-primary-500/20 text-primary-900 dark:text-primary-100;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
@apply w-2 h-2;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-slate-300 dark:bg-slate-700 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-slate-400 dark:bg-slate-600;
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(148 163 184) transparent;
|
||||
}
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: rgb(51 65 85) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Component Styles
|
||||
============================================ */
|
||||
|
||||
@layer components {
|
||||
/* Container */
|
||||
.container-app {
|
||||
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
/* Page wrapper */
|
||||
.page-wrapper {
|
||||
@apply min-h-screen flex flex-col;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
@apply bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply card transition-all duration-200 hover:border-primary-300 hover:shadow-md dark:hover:border-primary-700;
|
||||
}
|
||||
|
||||
/* Form styles */
|
||||
.form-group {
|
||||
@apply space-y-1.5;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
@apply block text-sm font-medium text-slate-700 dark:text-slate-200;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
@apply w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-600;
|
||||
@apply bg-white dark:bg-slate-800 text-slate-900 dark:text-white;
|
||||
@apply placeholder:text-slate-400 dark:placeholder:text-slate-500;
|
||||
@apply focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20;
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
@apply text-sm text-error-600 dark:text-error-400;
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
@apply badge bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply badge bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-300;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply badge bg-warning-100 text-warning-700 dark:bg-warning-900/30 dark:text-warning-300;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
@apply badge bg-error-100 text-error-700 dark:bg-error-900/30 dark:text-error-300;
|
||||
}
|
||||
|
||||
/* Status dot */
|
||||
.status-dot {
|
||||
@apply w-2 h-2 rounded-full;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
@apply status-dot bg-success-500 animate-pulse;
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
@apply status-dot bg-slate-400;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
@apply status-dot bg-warning-500;
|
||||
}
|
||||
|
||||
/* Gradient text */
|
||||
.gradient-text {
|
||||
@apply bg-gradient-to-r from-primary-600 to-primary-400 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
/* Glass effect */
|
||||
.glass {
|
||||
@apply bg-white/70 dark:bg-slate-900/70 backdrop-blur-lg;
|
||||
}
|
||||
|
||||
/* Skeleton loading */
|
||||
.skeleton {
|
||||
@apply bg-slate-200 dark:bg-slate-700 animate-pulse rounded;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
@apply border-t border-slate-200 dark:border-slate-700;
|
||||
}
|
||||
|
||||
/* Avatar */
|
||||
.avatar {
|
||||
@apply rounded-full bg-primary-100 dark:bg-primary-900/50 flex items-center justify-center;
|
||||
@apply text-primary-700 dark:text-primary-300 font-semibold;
|
||||
}
|
||||
|
||||
.avatar-sm { @apply w-8 h-8 text-sm; }
|
||||
.avatar-md { @apply w-10 h-10 text-base; }
|
||||
.avatar-lg { @apply w-12 h-12 text-lg; }
|
||||
.avatar-xl { @apply w-16 h-16 text-xl; }
|
||||
|
||||
/* Trading specific */
|
||||
.price-up {
|
||||
@apply text-success-600 dark:text-success-400;
|
||||
}
|
||||
|
||||
.price-down {
|
||||
@apply text-error-600 dark:text-error-400;
|
||||
}
|
||||
|
||||
.price-neutral {
|
||||
@apply text-slate-600 dark:text-slate-400;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Utility Styles
|
||||
============================================ */
|
||||
|
||||
@layer utilities {
|
||||
/* Hide scrollbar */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Truncate multiline */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Safe area padding */
|
||||
.safe-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* Animation delays */
|
||||
.animation-delay-100 { animation-delay: 100ms; }
|
||||
.animation-delay-200 { animation-delay: 200ms; }
|
||||
.animation-delay-300 { animation-delay: 300ms; }
|
||||
.animation-delay-400 { animation-delay: 400ms; }
|
||||
.animation-delay-500 { animation-delay: 500ms; }
|
||||
|
||||
/* Glow effects */
|
||||
.glow-primary {
|
||||
box-shadow: 0 0 20px rgba(12, 140, 232, 0.3);
|
||||
}
|
||||
|
||||
.glow-success {
|
||||
box-shadow: 0 0 20px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.glow-error {
|
||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* Text gradient */
|
||||
.text-gradient-primary {
|
||||
@apply bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
/* Backdrop blur variants */
|
||||
.backdrop-blur-xs {
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Animation Keyframes
|
||||
============================================ */
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradient {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0.2) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient 4s ease infinite;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Print Styles
|
||||
============================================ */
|
||||
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white text-black;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply border border-slate-300;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
144
apps/web/src/app/layout.tsx
Normal file
144
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Inter, JetBrains_Mono } from 'next/font/google';
|
||||
import './globals.css';
|
||||
|
||||
// Fonts
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-inter',
|
||||
});
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-mono',
|
||||
});
|
||||
|
||||
// Metadata
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'Horux Strategy - Trading Algoritmico',
|
||||
template: '%s | Horux Strategy',
|
||||
},
|
||||
description:
|
||||
'Plataforma de trading algoritmico para automatizar tus estrategias de inversion con inteligencia artificial.',
|
||||
keywords: [
|
||||
'trading',
|
||||
'algoritmico',
|
||||
'criptomonedas',
|
||||
'bitcoin',
|
||||
'estrategias',
|
||||
'automatizacion',
|
||||
'inversion',
|
||||
],
|
||||
authors: [{ name: 'Horux Team' }],
|
||||
creator: 'Horux',
|
||||
publisher: 'Horux',
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'es_ES',
|
||||
url: 'https://horux.io',
|
||||
siteName: 'Horux Strategy',
|
||||
title: 'Horux Strategy - Trading Algoritmico',
|
||||
description:
|
||||
'Plataforma de trading algoritmico para automatizar tus estrategias de inversion.',
|
||||
images: [
|
||||
{
|
||||
url: '/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Horux Strategy',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Horux Strategy',
|
||||
description: 'Plataforma de trading algoritmico',
|
||||
images: ['/og-image.png'],
|
||||
},
|
||||
icons: {
|
||||
icon: '/favicon.ico',
|
||||
shortcut: '/favicon-16x16.png',
|
||||
apple: '/apple-touch-icon.png',
|
||||
},
|
||||
manifest: '/manifest.json',
|
||||
};
|
||||
|
||||
// Viewport
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#0a0f1a' },
|
||||
],
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Root Layout
|
||||
*
|
||||
* Layout principal de la aplicacion que envuelve todas las paginas.
|
||||
* Incluye providers globales, fonts y meta tags.
|
||||
*/
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="es"
|
||||
className={`${inter.variable} ${jetbrainsMono.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
{/* Preconnect to external resources */}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://fonts.gstatic.com"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</head>
|
||||
<body className="font-sans antialiased">
|
||||
{/* Theme Script - Prevent flash */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
try {
|
||||
var stored = localStorage.getItem('horux-ui-storage');
|
||||
var theme = 'dark';
|
||||
if (stored) {
|
||||
var parsed = JSON.parse(stored);
|
||||
theme = parsed.state?.theme || 'dark';
|
||||
}
|
||||
if (theme === 'system') {
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
document.documentElement.classList.add(theme);
|
||||
} catch (e) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="min-h-screen">{children}</main>
|
||||
|
||||
{/* Portal containers for modals/toasts */}
|
||||
<div id="modal-root" />
|
||||
<div id="toast-root" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
51
apps/web/src/app/page.tsx
Normal file
51
apps/web/src/app/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
|
||||
/**
|
||||
* Landing Page
|
||||
*
|
||||
* Pagina principal que redirige a login o dashboard
|
||||
* dependiendo del estado de autenticacion.
|
||||
*/
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isInitialized, checkAuth } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
await checkAuth();
|
||||
};
|
||||
init();
|
||||
}, [checkAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized) {
|
||||
if (isAuthenticated) {
|
||||
router.replace('/dashboard');
|
||||
} else {
|
||||
router.replace('/login');
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, isInitialized, router]);
|
||||
|
||||
// Loading state mientras se verifica la autenticacion
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-950">
|
||||
<div className="text-center">
|
||||
{/* Logo animado */}
|
||||
<div className="relative w-20 h-20 mx-auto mb-6">
|
||||
<div className="absolute inset-0 rounded-2xl bg-horux-gradient animate-pulse" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-4xl font-bold text-white">H</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading text */}
|
||||
<p className="text-slate-400 text-sm animate-pulse">Cargando...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
345
apps/web/src/components/layout/Header.tsx
Normal file
345
apps/web/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { cn, getInitials } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useUIStore, useTheme } from '@/stores/ui.store';
|
||||
import {
|
||||
Menu,
|
||||
Bell,
|
||||
Sun,
|
||||
Moon,
|
||||
Search,
|
||||
User,
|
||||
Settings,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
/**
|
||||
* Notificacion mock
|
||||
*/
|
||||
interface Notification {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'info' | 'success' | 'warning' | 'error';
|
||||
timestamp: Date;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notificaciones de ejemplo
|
||||
*/
|
||||
const mockNotifications: Notification[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Trade ejecutado',
|
||||
message: 'BTC/USDT compra a $43,250',
|
||||
type: 'success',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 5),
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Stop Loss activado',
|
||||
message: 'ETH/USDT posicion cerrada',
|
||||
type: 'warning',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 30),
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Nueva estrategia disponible',
|
||||
message: 'Grid Trading actualizado',
|
||||
type: 'info',
|
||||
timestamp: new Date(Date.now() - 1000 * 60 * 60 * 2),
|
||||
read: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Componente Header
|
||||
*
|
||||
* Header principal con búsqueda, notificaciones, tema y menú de usuario.
|
||||
*/
|
||||
export const Header: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { sidebarCollapsed, isMobile, setSidebarOpen } = useUIStore();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const [notifications] = useState<Notification[]>(mockNotifications);
|
||||
|
||||
const notificationRef = useRef<HTMLDivElement>(null);
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Cerrar menus al hacer click fuera
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (notificationRef.current && !notificationRef.current.contains(event.target as Node)) {
|
||||
setShowNotifications(false);
|
||||
}
|
||||
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
|
||||
setShowUserMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Handle logout
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
// Unread count
|
||||
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'fixed top-0 right-0 z-40',
|
||||
'h-16 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md',
|
||||
'border-b border-slate-200 dark:border-slate-700',
|
||||
'transition-all duration-300',
|
||||
// Ajustar ancho segun sidebar
|
||||
isMobile ? 'left-0' : (sidebarCollapsed ? 'left-20' : 'left-64')
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between h-full px-4 lg:px-6">
|
||||
{/* Left Section */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Mobile menu button */}
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 rounded-lg text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
|
||||
aria-label="Abrir menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
{showSearch ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar..."
|
||||
className="w-64 px-4 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowSearch(false)}
|
||||
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowSearch(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Buscar</span>
|
||||
<kbd className="hidden lg:inline-flex items-center gap-1 px-2 py-0.5 text-xs font-mono bg-slate-100 dark:bg-slate-700 rounded">
|
||||
<span className="text-xs">Ctrl</span>K
|
||||
</kbd>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Section */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors"
|
||||
aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
|
||||
>
|
||||
{isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
{/* Notifications */}
|
||||
<div ref={notificationRef} className="relative">
|
||||
<button
|
||||
onClick={() => setShowNotifications(!showNotifications)}
|
||||
className="relative p-2 rounded-lg text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors"
|
||||
aria-label="Notificaciones"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 w-4 h-4 flex items-center justify-center text-xs font-bold text-white bg-error-500 rounded-full">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Notifications Dropdown */}
|
||||
{showNotifications && (
|
||||
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden z-50">
|
||||
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">
|
||||
Notificaciones
|
||||
</h3>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{notifications.length > 0 ? (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={cn(
|
||||
'px-4 py-3 border-b border-slate-100 dark:border-slate-700 last:border-0',
|
||||
'hover:bg-slate-50 dark:hover:bg-slate-700/50 cursor-pointer transition-colors',
|
||||
!notification.read && 'bg-primary-50/50 dark:bg-primary-900/20'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 mt-2 rounded-full flex-shrink-0',
|
||||
notification.type === 'success' && 'bg-success-500',
|
||||
notification.type === 'error' && 'bg-error-500',
|
||||
notification.type === 'warning' && 'bg-warning-500',
|
||||
notification.type === 'info' && 'bg-primary-500'
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-white">
|
||||
{notification.title}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 truncate">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500">
|
||||
{formatTimeAgo(notification.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-8 text-center text-slate-500 dark:text-slate-400">
|
||||
No hay notificaciones
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 border-t border-slate-200 dark:border-slate-700">
|
||||
<Link
|
||||
href="/notifications"
|
||||
className="block text-center text-sm text-primary-600 dark:text-primary-400 hover:underline"
|
||||
onClick={() => setShowNotifications(false)}
|
||||
>
|
||||
Ver todas
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Menu */}
|
||||
<div ref={userMenuRef} className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center gap-3 p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900/50 flex items-center justify-center text-primary-700 dark:text-primary-300 font-semibold text-sm">
|
||||
{user?.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
getInitials(user?.name || 'Usuario')
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden md:block text-left">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-white">
|
||||
{user?.name || 'Usuario'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{user?.role || 'trader'}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown className="hidden md:block h-4 w-4 text-slate-400" />
|
||||
</button>
|
||||
|
||||
{/* User Dropdown */}
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-slate-800 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden z-50">
|
||||
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-white">
|
||||
{user?.name || 'Usuario'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 truncate">
|
||||
{user?.email || 'usuario@ejemplo.com'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<Link
|
||||
href="/profile"
|
||||
className="flex items-center gap-3 px-4 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700/50"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Mi Perfil
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
className="flex items-center gap-3 px-4 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700/50"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Configuracion
|
||||
</Link>
|
||||
</div>
|
||||
<div className="py-2 border-t border-slate-200 dark:border-slate-700">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 w-full px-4 py-2 text-sm text-error-600 dark:text-error-400 hover:bg-error-50 dark:hover:bg-error-900/20"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Cerrar sesion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formatea tiempo relativo
|
||||
*/
|
||||
function formatTimeAgo(date: Date): string {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (minutes < 1) return 'Ahora';
|
||||
if (minutes < 60) return `Hace ${minutes} min`;
|
||||
if (hours < 24) return `Hace ${hours}h`;
|
||||
return `Hace ${days}d`;
|
||||
}
|
||||
|
||||
export default Header;
|
||||
306
apps/web/src/components/layout/Sidebar.tsx
Normal file
306
apps/web/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
LineChart,
|
||||
Wallet,
|
||||
History,
|
||||
Settings,
|
||||
HelpCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
TrendingUp,
|
||||
Bot,
|
||||
Shield,
|
||||
Bell,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Item de navegación
|
||||
*/
|
||||
interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
badge?: string | number;
|
||||
children?: NavItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Grupos de navegación
|
||||
*/
|
||||
interface NavGroup {
|
||||
label?: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Navegación principal
|
||||
*/
|
||||
const navigation: NavGroup[] = [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
href: '/dashboard',
|
||||
icon: <LayoutDashboard className="h-5 w-5" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Trading',
|
||||
items: [
|
||||
{
|
||||
label: 'Estrategias',
|
||||
href: '/strategies',
|
||||
icon: <Bot className="h-5 w-5" />,
|
||||
badge: 3,
|
||||
},
|
||||
{
|
||||
label: 'Portfolio',
|
||||
href: '/portfolio',
|
||||
icon: <Wallet className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
label: 'Mercados',
|
||||
href: '/markets',
|
||||
icon: <TrendingUp className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
label: 'Historial',
|
||||
href: '/history',
|
||||
icon: <History className="h-5 w-5" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Analisis',
|
||||
items: [
|
||||
{
|
||||
label: 'Performance',
|
||||
href: '/analytics',
|
||||
icon: <LineChart className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
label: 'Riesgo',
|
||||
href: '/risk',
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Sistema',
|
||||
items: [
|
||||
{
|
||||
label: 'Notificaciones',
|
||||
href: '/notifications',
|
||||
icon: <Bell className="h-5 w-5" />,
|
||||
badge: 5,
|
||||
},
|
||||
{
|
||||
label: 'Configuracion',
|
||||
href: '/settings',
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
label: 'Ayuda',
|
||||
href: '/help',
|
||||
icon: <HelpCircle className="h-5 w-5" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Componente NavLink
|
||||
*/
|
||||
interface NavLinkProps {
|
||||
item: NavItem;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
const NavLink: React.FC<NavLinkProps> = ({ item, collapsed }) => {
|
||||
const pathname = usePathname();
|
||||
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg',
|
||||
'text-sm font-medium transition-all duration-200',
|
||||
'group relative',
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'
|
||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-700/50 dark:hover:text-white',
|
||||
collapsed && 'justify-center px-2'
|
||||
)}
|
||||
>
|
||||
{/* Active Indicator */}
|
||||
{isActive && (
|
||||
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-primary-600 dark:bg-primary-400 rounded-r-full" />
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<span
|
||||
className={cn(
|
||||
'flex-shrink-0 transition-colors',
|
||||
isActive
|
||||
? 'text-primary-600 dark:text-primary-400'
|
||||
: 'text-slate-400 group-hover:text-slate-600 dark:text-slate-500 dark:group-hover:text-slate-300'
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
|
||||
{/* Label */}
|
||||
{!collapsed && (
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
)}
|
||||
|
||||
{/* Badge */}
|
||||
{item.badge && !collapsed && (
|
||||
<span className="flex-shrink-0 px-2 py-0.5 text-xs font-semibold rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/50 dark:text-primary-300">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Tooltip when collapsed */}
|
||||
{collapsed && (
|
||||
<span className="absolute left-full ml-2 px-2 py-1 text-sm font-medium text-white bg-slate-900 dark:bg-slate-700 rounded opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
|
||||
{item.label}
|
||||
{item.badge && (
|
||||
<span className="ml-2 px-1.5 py-0.5 text-xs rounded-full bg-primary-500 text-white">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Componente Sidebar
|
||||
*
|
||||
* Barra lateral de navegación con soporte para colapsado y grupos de navegación.
|
||||
*/
|
||||
export const Sidebar: React.FC = () => {
|
||||
const { sidebarOpen, sidebarCollapsed, toggleSidebarCollapsed, isMobile, setSidebarOpen } = useUIStore();
|
||||
|
||||
// En mobile, cerrar al hacer click en overlay
|
||||
const handleOverlayClick = () => {
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay mobile */}
|
||||
{isMobile && sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||
onClick={handleOverlayClick}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed top-0 left-0 z-50 h-full',
|
||||
'bg-white dark:bg-slate-900',
|
||||
'border-r border-slate-200 dark:border-slate-700',
|
||||
'flex flex-col',
|
||||
'transition-all duration-300 ease-in-out',
|
||||
// Width
|
||||
sidebarCollapsed ? 'w-20' : 'w-64',
|
||||
// Mobile
|
||||
isMobile && !sidebarOpen && '-translate-x-full',
|
||||
isMobile && sidebarOpen && 'translate-x-0',
|
||||
// Desktop
|
||||
!isMobile && 'translate-x-0'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={cn(
|
||||
'flex items-center h-16 px-4',
|
||||
'border-b border-slate-200 dark:border-slate-700',
|
||||
sidebarCollapsed && 'justify-center'
|
||||
)}>
|
||||
{/* Logo */}
|
||||
<Link href="/dashboard" className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-horux-gradient flex items-center justify-center">
|
||||
<span className="text-white font-bold text-xl">H</span>
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="text-xl font-bold text-slate-900 dark:text-white">
|
||||
Horux
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-6">
|
||||
{navigation.map((group, idx) => (
|
||||
<div key={idx}>
|
||||
{/* Group Label */}
|
||||
{group.label && !sidebarCollapsed && (
|
||||
<h3 className="px-3 mb-2 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
|
||||
{group.label}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{/* Separator when collapsed */}
|
||||
{group.label && sidebarCollapsed && (
|
||||
<div className="my-2 border-t border-slate-200 dark:border-slate-700" />
|
||||
)}
|
||||
|
||||
{/* Items */}
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item) => (
|
||||
<NavLink key={item.href} item={item} collapsed={sidebarCollapsed} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer - Collapse Button */}
|
||||
{!isMobile && (
|
||||
<div className="p-3 border-t border-slate-200 dark:border-slate-700">
|
||||
<button
|
||||
onClick={toggleSidebarCollapsed}
|
||||
className={cn(
|
||||
'flex items-center gap-3 w-full px-3 py-2.5 rounded-lg',
|
||||
'text-sm font-medium text-slate-600 dark:text-slate-400',
|
||||
'hover:bg-slate-100 dark:hover:bg-slate-700/50',
|
||||
'transition-all duration-200',
|
||||
sidebarCollapsed && 'justify-center'
|
||||
)}
|
||||
aria-label={sidebarCollapsed ? 'Expandir sidebar' : 'Colapsar sidebar'}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
) : (
|
||||
<>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
<span>Colapsar</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
9
apps/web/src/components/layout/index.ts
Normal file
9
apps/web/src/components/layout/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Layout Components Barrel Export
|
||||
*
|
||||
* Re-exporta todos los componentes de layout.
|
||||
* Ejemplo: import { Sidebar, Header } from '@/components/layout';
|
||||
*/
|
||||
|
||||
export { Sidebar } from './Sidebar';
|
||||
export { Header } from './Header';
|
||||
215
apps/web/src/components/ui/Button.tsx
Normal file
215
apps/web/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import React, { forwardRef, ButtonHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Variantes del botón
|
||||
*/
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success';
|
||||
|
||||
/**
|
||||
* Tamaños del botón
|
||||
*/
|
||||
type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
/**
|
||||
* Props del componente Button
|
||||
*/
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
isLoading?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estilos base del botón
|
||||
*/
|
||||
const baseStyles = `
|
||||
inline-flex items-center justify-center
|
||||
font-medium rounded-lg
|
||||
transition-all duration-200 ease-in-out
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none
|
||||
active:scale-[0.98]
|
||||
`;
|
||||
|
||||
/**
|
||||
* Estilos por variante
|
||||
*/
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary: `
|
||||
bg-primary-600 text-white
|
||||
hover:bg-primary-700
|
||||
focus:ring-primary-500
|
||||
dark:bg-primary-500 dark:hover:bg-primary-600
|
||||
`,
|
||||
secondary: `
|
||||
bg-slate-100 text-slate-900
|
||||
hover:bg-slate-200
|
||||
focus:ring-slate-500
|
||||
dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-600
|
||||
`,
|
||||
outline: `
|
||||
border-2 border-primary-600 text-primary-600
|
||||
hover:bg-primary-50
|
||||
focus:ring-primary-500
|
||||
dark:border-primary-400 dark:text-primary-400 dark:hover:bg-primary-950
|
||||
`,
|
||||
ghost: `
|
||||
text-slate-700
|
||||
hover:bg-slate-100
|
||||
focus:ring-slate-500
|
||||
dark:text-slate-300 dark:hover:bg-slate-800
|
||||
`,
|
||||
danger: `
|
||||
bg-error-600 text-white
|
||||
hover:bg-error-700
|
||||
focus:ring-error-500
|
||||
dark:bg-error-500 dark:hover:bg-error-600
|
||||
`,
|
||||
success: `
|
||||
bg-success-600 text-white
|
||||
hover:bg-success-700
|
||||
focus:ring-success-500
|
||||
dark:bg-success-500 dark:hover:bg-success-600
|
||||
`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Estilos por tamaño
|
||||
*/
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
xs: 'text-xs px-2.5 py-1.5 gap-1',
|
||||
sm: 'text-sm px-3 py-2 gap-1.5',
|
||||
md: 'text-sm px-4 py-2.5 gap-2',
|
||||
lg: 'text-base px-5 py-3 gap-2',
|
||||
xl: 'text-lg px-6 py-3.5 gap-2.5',
|
||||
};
|
||||
|
||||
/**
|
||||
* Componente Spinner para loading
|
||||
*/
|
||||
const Spinner: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={cn('animate-spin', className)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Componente Button
|
||||
*
|
||||
* Botón reutilizable con múltiples variantes y tamaños.
|
||||
* Soporta estados de loading, iconos y ancho completo.
|
||||
*/
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
fullWidth = false,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// Determinar tamaño del spinner
|
||||
const spinnerSize = {
|
||||
xs: 'h-3 w-3',
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-5 w-5',
|
||||
xl: 'h-5 w-5',
|
||||
}[size];
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
baseStyles,
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
fullWidth && 'w-full',
|
||||
className
|
||||
)}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner className={spinnerSize} />
|
||||
<span>Cargando...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{leftIcon && <span className="flex-shrink-0">{leftIcon}</span>}
|
||||
{children}
|
||||
{rightIcon && <span className="flex-shrink-0">{rightIcon}</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
/**
|
||||
* Botón de icono (solo icono, sin texto)
|
||||
*/
|
||||
interface IconButtonProps extends Omit<ButtonProps, 'leftIcon' | 'rightIcon' | 'children'> {
|
||||
icon: React.ReactNode;
|
||||
'aria-label': string;
|
||||
}
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
({ className, size = 'md', icon, ...props }, ref) => {
|
||||
const iconSizeStyles: Record<ButtonSize, string> = {
|
||||
xs: 'p-1.5',
|
||||
sm: 'p-2',
|
||||
md: 'p-2.5',
|
||||
lg: 'p-3',
|
||||
xl: 'p-3.5',
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
className={cn(iconSizeStyles[size], className)}
|
||||
size={size}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
IconButton.displayName = 'IconButton';
|
||||
|
||||
export default Button;
|
||||
256
apps/web/src/components/ui/Card.tsx
Normal file
256
apps/web/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
'use client';
|
||||
|
||||
import React, { forwardRef, HTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Variantes del Card
|
||||
*/
|
||||
type CardVariant = 'default' | 'bordered' | 'elevated' | 'gradient';
|
||||
|
||||
/**
|
||||
* Props del componente Card
|
||||
*/
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
variant?: CardVariant;
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
hoverable?: boolean;
|
||||
clickable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estilos base del card
|
||||
*/
|
||||
const baseStyles = `
|
||||
rounded-xl bg-white
|
||||
dark:bg-slate-800
|
||||
transition-all duration-200
|
||||
`;
|
||||
|
||||
/**
|
||||
* Estilos por variante
|
||||
*/
|
||||
const variantStyles: Record<CardVariant, string> = {
|
||||
default: `
|
||||
border border-slate-200
|
||||
dark:border-slate-700
|
||||
`,
|
||||
bordered: `
|
||||
border-2 border-slate-300
|
||||
dark:border-slate-600
|
||||
`,
|
||||
elevated: `
|
||||
shadow-lg shadow-slate-200/50
|
||||
dark:shadow-slate-900/50
|
||||
border border-slate-100
|
||||
dark:border-slate-700
|
||||
`,
|
||||
gradient: `
|
||||
border border-transparent
|
||||
bg-gradient-to-br from-white to-slate-50
|
||||
dark:from-slate-800 dark:to-slate-900
|
||||
shadow-lg shadow-slate-200/50
|
||||
dark:shadow-slate-900/50
|
||||
`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Estilos de padding
|
||||
*/
|
||||
const paddingStyles: Record<string, string> = {
|
||||
none: '',
|
||||
sm: 'p-4',
|
||||
md: 'p-6',
|
||||
lg: 'p-8',
|
||||
};
|
||||
|
||||
/**
|
||||
* Componente Card
|
||||
*
|
||||
* Contenedor reutilizable con múltiples variantes y estados.
|
||||
*/
|
||||
export const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant = 'default',
|
||||
padding = 'md',
|
||||
hoverable = false,
|
||||
clickable = false,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
baseStyles,
|
||||
variantStyles[variant],
|
||||
paddingStyles[padding],
|
||||
hoverable && 'hover:border-primary-300 hover:shadow-md dark:hover:border-primary-600',
|
||||
clickable && 'cursor-pointer active:scale-[0.99]',
|
||||
className
|
||||
)}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Card.displayName = 'Card';
|
||||
|
||||
/**
|
||||
* Card Header
|
||||
*/
|
||||
interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
|
||||
({ className, title, subtitle, action, children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-start justify-between gap-4 mb-4', className)}
|
||||
{...props}
|
||||
>
|
||||
{(title || subtitle) ? (
|
||||
<div className="flex-1 min-w-0">
|
||||
{title && (
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white truncate">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{action && <div className="flex-shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
/**
|
||||
* Card Content
|
||||
*/
|
||||
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className={cn('', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
/**
|
||||
* Card Footer
|
||||
*/
|
||||
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-4 pt-4 border-t border-slate-200 dark:border-slate-700',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
/**
|
||||
* Stats Card - Card especializado para mostrar estadísticas
|
||||
*/
|
||||
interface StatsCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title: string;
|
||||
value: string | number;
|
||||
change?: {
|
||||
value: number;
|
||||
label?: string;
|
||||
};
|
||||
icon?: React.ReactNode;
|
||||
trend?: 'up' | 'down' | 'neutral';
|
||||
}
|
||||
|
||||
export const StatsCard = forwardRef<HTMLDivElement, StatsCardProps>(
|
||||
({ className, title, value, change, icon, trend, ...props }, ref) => {
|
||||
const trendColors = {
|
||||
up: 'text-success-600 dark:text-success-400',
|
||||
down: 'text-error-600 dark:text-error-400',
|
||||
neutral: 'text-slate-500 dark:text-slate-400',
|
||||
};
|
||||
|
||||
const trendBgColors = {
|
||||
up: 'bg-success-50 dark:bg-success-900/30',
|
||||
down: 'bg-error-50 dark:bg-error-900/30',
|
||||
neutral: 'bg-slate-100 dark:bg-slate-700',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card ref={ref} className={cn('', className)} {...props}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">
|
||||
{title}
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">
|
||||
{value}
|
||||
</p>
|
||||
{change && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
|
||||
trendBgColors[trend || 'neutral'],
|
||||
trendColors[trend || 'neutral']
|
||||
)}
|
||||
>
|
||||
{change.value >= 0 ? '+' : ''}{change.value}%
|
||||
</span>
|
||||
{change.label && (
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{change.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div className="flex-shrink-0 p-3 rounded-lg bg-primary-50 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
StatsCard.displayName = 'StatsCard';
|
||||
|
||||
export default Card;
|
||||
266
apps/web/src/components/ui/Input.tsx
Normal file
266
apps/web/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
'use client';
|
||||
|
||||
import React, { forwardRef, InputHTMLAttributes, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Eye, EyeOff, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Tamaños del input
|
||||
*/
|
||||
type InputSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* Props del componente Input
|
||||
*/
|
||||
interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
success?: boolean;
|
||||
size?: InputSize;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estilos base del input
|
||||
*/
|
||||
const baseStyles = `
|
||||
w-full rounded-lg border
|
||||
transition-all duration-200 ease-in-out
|
||||
focus:outline-none focus:ring-2
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-slate-50
|
||||
dark:disabled:bg-slate-900
|
||||
placeholder:text-slate-400 dark:placeholder:text-slate-500
|
||||
`;
|
||||
|
||||
/**
|
||||
* Estilos por estado
|
||||
*/
|
||||
const stateStyles = {
|
||||
default: `
|
||||
border-slate-300 bg-white text-slate-900
|
||||
hover:border-slate-400
|
||||
focus:border-primary-500 focus:ring-primary-500/20
|
||||
dark:border-slate-600 dark:bg-slate-800 dark:text-white
|
||||
dark:hover:border-slate-500
|
||||
dark:focus:border-primary-400 dark:focus:ring-primary-400/20
|
||||
`,
|
||||
error: `
|
||||
border-error-500 bg-white text-slate-900
|
||||
hover:border-error-600
|
||||
focus:border-error-500 focus:ring-error-500/20
|
||||
dark:border-error-400 dark:bg-slate-800 dark:text-white
|
||||
dark:hover:border-error-300
|
||||
dark:focus:border-error-400 dark:focus:ring-error-400/20
|
||||
`,
|
||||
success: `
|
||||
border-success-500 bg-white text-slate-900
|
||||
hover:border-success-600
|
||||
focus:border-success-500 focus:ring-success-500/20
|
||||
dark:border-success-400 dark:bg-slate-800 dark:text-white
|
||||
dark:hover:border-success-300
|
||||
dark:focus:border-success-400 dark:focus:ring-success-400/20
|
||||
`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Estilos por tamaño
|
||||
*/
|
||||
const sizeStyles: Record<InputSize, string> = {
|
||||
sm: 'px-3 py-2 text-sm',
|
||||
md: 'px-4 py-2.5 text-sm',
|
||||
lg: 'px-4 py-3 text-base',
|
||||
};
|
||||
|
||||
/**
|
||||
* Estilos del label
|
||||
*/
|
||||
const labelStyles = `
|
||||
block text-sm font-medium text-slate-700
|
||||
dark:text-slate-200 mb-1.5
|
||||
`;
|
||||
|
||||
/**
|
||||
* Componente Input
|
||||
*
|
||||
* Input reutilizable con soporte para label, error, hint,
|
||||
* iconos y diferentes tamaños.
|
||||
*/
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
success,
|
||||
size = 'md',
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
fullWidth = true,
|
||||
type = 'text',
|
||||
id,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Determinar el estado actual
|
||||
const state = error ? 'error' : success ? 'success' : 'default';
|
||||
|
||||
// Determinar si es password y toggle visibility
|
||||
const isPassword = type === 'password';
|
||||
const inputType = isPassword && showPassword ? 'text' : type;
|
||||
|
||||
// Calcular padding extra para iconos
|
||||
const paddingLeft = leftIcon ? 'pl-10' : '';
|
||||
const paddingRight = rightIcon || isPassword ? 'pr-10' : '';
|
||||
|
||||
return (
|
||||
<div className={cn(!fullWidth && 'inline-block', fullWidth && 'w-full')}>
|
||||
{/* Label */}
|
||||
{label && (
|
||||
<label htmlFor={inputId} className={labelStyles}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Input Container */}
|
||||
<div className="relative">
|
||||
{/* Left Icon */}
|
||||
{leftIcon && (
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500">
|
||||
{leftIcon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
type={inputType}
|
||||
className={cn(
|
||||
baseStyles,
|
||||
stateStyles[state],
|
||||
sizeStyles[size],
|
||||
paddingLeft,
|
||||
paddingRight,
|
||||
className
|
||||
)}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{/* Right Icon or Password Toggle or State Icon */}
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||
{isPassword ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300 transition-colors"
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : error ? (
|
||||
<AlertCircle className="h-4 w-4 text-error-500" />
|
||||
) : success ? (
|
||||
<CheckCircle className="h-4 w-4 text-success-500" />
|
||||
) : rightIcon ? (
|
||||
<span className="text-slate-400 dark:text-slate-500">{rightIcon}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<p
|
||||
id={`${inputId}-error`}
|
||||
className="mt-1.5 text-sm text-error-600 dark:text-error-400"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Hint */}
|
||||
{hint && !error && (
|
||||
<p
|
||||
id={`${inputId}-hint`}
|
||||
className="mt-1.5 text-sm text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
/**
|
||||
* Componente TextArea
|
||||
*/
|
||||
interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
({ className, label, error, hint, fullWidth = true, id, ...props }, ref) => {
|
||||
const inputId = id || `textarea-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const state = error ? 'error' : 'default';
|
||||
|
||||
return (
|
||||
<div className={cn(!fullWidth && 'inline-block', fullWidth && 'w-full')}>
|
||||
{label && (
|
||||
<label htmlFor={inputId} className={labelStyles}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={cn(
|
||||
baseStyles,
|
||||
stateStyles[state],
|
||||
'px-4 py-2.5 text-sm min-h-[100px] resize-y',
|
||||
className
|
||||
)}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-error-600 dark:text-error-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hint && !error && (
|
||||
<p id={`${inputId}-hint`} className="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TextArea.displayName = 'TextArea';
|
||||
|
||||
export default Input;
|
||||
15
apps/web/src/components/ui/index.ts
Normal file
15
apps/web/src/components/ui/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* UI Components Barrel Export
|
||||
*
|
||||
* Re-exporta todos los componentes UI para facilitar imports.
|
||||
* Ejemplo: import { Button, Input, Card } from '@/components/ui';
|
||||
*/
|
||||
|
||||
export { Button, IconButton } from './Button';
|
||||
export type { } from './Button';
|
||||
|
||||
export { Input, TextArea } from './Input';
|
||||
export type { } from './Input';
|
||||
|
||||
export { Card, CardHeader, CardContent, CardFooter, StatsCard } from './Card';
|
||||
export type { } from './Card';
|
||||
212
apps/web/src/lib/api.ts
Normal file
212
apps/web/src/lib/api.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Cliente API para comunicación con el backend
|
||||
*/
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
interface ApiResponse<T = unknown> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
interface RequestOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown;
|
||||
params?: Record<string, string | number | boolean | undefined>;
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
status: number;
|
||||
data?: unknown;
|
||||
|
||||
constructor(message: string, status: number, data?: unknown) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el token de autenticación del storage
|
||||
*/
|
||||
function getAuthToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem('horux-auth-storage');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed?.state?.token || null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye la URL con query params
|
||||
*/
|
||||
function buildUrl(endpoint: string, params?: Record<string, string | number | boolean | undefined>): string {
|
||||
const url = new URL(endpoint.startsWith('http') ? endpoint : `${API_BASE_URL}${endpoint}`);
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cliente fetch base con manejo de errores y auth
|
||||
*/
|
||||
async function fetchApi<T>(endpoint: string, options: RequestOptions = {}): Promise<ApiResponse<T>> {
|
||||
const { body, params, headers: customHeaders, ...restOptions } = options;
|
||||
|
||||
const token = getAuthToken();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...customHeaders,
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const config: RequestInit = {
|
||||
...restOptions,
|
||||
headers,
|
||||
};
|
||||
|
||||
if (body) {
|
||||
config.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
const url = buildUrl(endpoint, params);
|
||||
const response = await fetch(url, config);
|
||||
|
||||
let data: T | undefined;
|
||||
const contentType = response.headers.get('content-type');
|
||||
|
||||
if (contentType?.includes('application/json')) {
|
||||
data = await response.json();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = (data as { message?: string })?.message ||
|
||||
(data as { error?: string })?.error ||
|
||||
`Error ${response.status}`;
|
||||
throw new ApiError(errorMessage, response.status, data);
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
status: response.status,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new ApiError(
|
||||
error instanceof Error ? error.message : 'Error de conexión',
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API Client con métodos HTTP
|
||||
*/
|
||||
export const api = {
|
||||
get<T>(endpoint: string, options?: RequestOptions): Promise<ApiResponse<T>> {
|
||||
return fetchApi<T>(endpoint, { ...options, method: 'GET' });
|
||||
},
|
||||
|
||||
post<T>(endpoint: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>> {
|
||||
return fetchApi<T>(endpoint, { ...options, method: 'POST', body });
|
||||
},
|
||||
|
||||
put<T>(endpoint: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>> {
|
||||
return fetchApi<T>(endpoint, { ...options, method: 'PUT', body });
|
||||
},
|
||||
|
||||
patch<T>(endpoint: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>> {
|
||||
return fetchApi<T>(endpoint, { ...options, method: 'PATCH', body });
|
||||
},
|
||||
|
||||
delete<T>(endpoint: string, options?: RequestOptions): Promise<ApiResponse<T>> {
|
||||
return fetchApi<T>(endpoint, { ...options, method: 'DELETE' });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Endpoints tipados para la API
|
||||
*/
|
||||
export const endpoints = {
|
||||
// Auth
|
||||
auth: {
|
||||
login: '/auth/login',
|
||||
register: '/auth/register',
|
||||
logout: '/auth/logout',
|
||||
me: '/auth/me',
|
||||
refresh: '/auth/refresh',
|
||||
},
|
||||
|
||||
// Users
|
||||
users: {
|
||||
base: '/users',
|
||||
byId: (id: string) => `/users/${id}`,
|
||||
profile: '/users/profile',
|
||||
},
|
||||
|
||||
// Strategies
|
||||
strategies: {
|
||||
base: '/strategies',
|
||||
byId: (id: string) => `/strategies/${id}`,
|
||||
activate: (id: string) => `/strategies/${id}/activate`,
|
||||
deactivate: (id: string) => `/strategies/${id}/deactivate`,
|
||||
backtest: (id: string) => `/strategies/${id}/backtest`,
|
||||
},
|
||||
|
||||
// Trades
|
||||
trades: {
|
||||
base: '/trades',
|
||||
byId: (id: string) => `/trades/${id}`,
|
||||
active: '/trades/active',
|
||||
history: '/trades/history',
|
||||
},
|
||||
|
||||
// Portfolio
|
||||
portfolio: {
|
||||
base: '/portfolio',
|
||||
balance: '/portfolio/balance',
|
||||
positions: '/portfolio/positions',
|
||||
performance: '/portfolio/performance',
|
||||
},
|
||||
|
||||
// Market Data
|
||||
market: {
|
||||
prices: '/market/prices',
|
||||
ticker: (symbol: string) => `/market/ticker/${symbol}`,
|
||||
candles: (symbol: string) => `/market/candles/${symbol}`,
|
||||
},
|
||||
|
||||
// Analytics
|
||||
analytics: {
|
||||
dashboard: '/analytics/dashboard',
|
||||
performance: '/analytics/performance',
|
||||
risk: '/analytics/risk',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export { ApiError };
|
||||
export type { ApiResponse, RequestOptions };
|
||||
125
apps/web/src/lib/utils.ts
Normal file
125
apps/web/src/lib/utils.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Combina clases de Tailwind de manera inteligente
|
||||
* Usa clsx para condicionales y twMerge para resolver conflictos
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea una fecha a string legible
|
||||
*/
|
||||
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('es-ES', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea un número como moneda
|
||||
*/
|
||||
export function formatCurrency(amount: number, currency: string = 'USD'): string {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea un número con separadores de miles
|
||||
*/
|
||||
export function formatNumber(num: number, decimals: number = 0): string {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea un porcentaje
|
||||
*/
|
||||
export function formatPercentage(value: number, decimals: number = 2): string {
|
||||
return `${value >= 0 ? '+' : ''}${value.toFixed(decimals)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un ID único
|
||||
*/
|
||||
export function generateId(): string {
|
||||
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function
|
||||
*/
|
||||
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Trunca un texto con ellipsis
|
||||
*/
|
||||
export function truncate(str: string, length: number): string {
|
||||
if (str.length <= length) return str;
|
||||
return str.slice(0, length) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitaliza la primera letra
|
||||
*/
|
||||
export function capitalize(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene las iniciales de un nombre
|
||||
*/
|
||||
export function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si estamos en el cliente
|
||||
*/
|
||||
export const isClient = typeof window !== 'undefined';
|
||||
|
||||
/**
|
||||
* Verifica si estamos en producción
|
||||
*/
|
||||
export const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
/**
|
||||
* Sleep helper para async/await
|
||||
*/
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp un número entre min y max
|
||||
*/
|
||||
export function clamp(num: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(num, min), max);
|
||||
}
|
||||
287
apps/web/src/stores/auth.store.ts
Normal file
287
apps/web/src/stores/auth.store.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { api, endpoints, ApiError } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* Tipos para el usuario
|
||||
*/
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
role: 'admin' | 'trader' | 'viewer';
|
||||
createdAt: string;
|
||||
lastLoginAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credenciales de login
|
||||
*/
|
||||
interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
remember?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datos de registro
|
||||
*/
|
||||
interface RegisterData {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Respuesta de autenticación
|
||||
*/
|
||||
interface AuthResponse {
|
||||
user: User;
|
||||
token: string;
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estado del store de autenticación
|
||||
*/
|
||||
interface AuthState {
|
||||
// Estado
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
isInitialized: boolean;
|
||||
|
||||
// Acciones
|
||||
login: (credentials: LoginCredentials) => Promise<void>;
|
||||
register: (data: RegisterData) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshSession: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
updateUser: (data: Partial<User>) => void;
|
||||
clearError: () => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store de autenticación con persistencia
|
||||
*/
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Estado inicial
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isInitialized: false,
|
||||
|
||||
/**
|
||||
* Iniciar sesión
|
||||
*/
|
||||
login: async (credentials: LoginCredentials) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const { data } = await api.post<AuthResponse>(endpoints.auth.login, credentials);
|
||||
|
||||
if (data) {
|
||||
set({
|
||||
user: data.user,
|
||||
token: data.token,
|
||||
refreshToken: data.refreshToken || null,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof ApiError
|
||||
? error.message
|
||||
: 'Error al iniciar sesión';
|
||||
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: message,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Registrar nuevo usuario
|
||||
*/
|
||||
register: async (data: RegisterData) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const { data: authData } = await api.post<AuthResponse>(endpoints.auth.register, data);
|
||||
|
||||
if (authData) {
|
||||
set({
|
||||
user: authData.user,
|
||||
token: authData.token,
|
||||
refreshToken: authData.refreshToken || null,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof ApiError
|
||||
? error.message
|
||||
: 'Error al registrar usuario';
|
||||
|
||||
set({
|
||||
isLoading: false,
|
||||
error: message,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Cerrar sesión
|
||||
*/
|
||||
logout: async () => {
|
||||
const { token } = get();
|
||||
|
||||
try {
|
||||
if (token) {
|
||||
await api.post(endpoints.auth.logout);
|
||||
}
|
||||
} catch {
|
||||
// Ignorar errores de logout
|
||||
} finally {
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Refrescar sesión con refresh token
|
||||
*/
|
||||
refreshSession: async () => {
|
||||
const { refreshToken } = get();
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await api.post<AuthResponse>(endpoints.auth.refresh, {
|
||||
refreshToken,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
set({
|
||||
token: data.token,
|
||||
refreshToken: data.refreshToken || refreshToken,
|
||||
user: data.user,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Si falla el refresh, cerrar sesión
|
||||
get().logout();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Verificar autenticación actual
|
||||
*/
|
||||
checkAuth: async () => {
|
||||
const { token } = get();
|
||||
|
||||
if (!token) {
|
||||
set({ isInitialized: true });
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isLoading: true });
|
||||
|
||||
try {
|
||||
const { data } = await api.get<User>(endpoints.auth.me);
|
||||
|
||||
if (data) {
|
||||
set({
|
||||
user: data,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
isInitialized: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Token inválido, limpiar estado
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitialized: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Actualizar datos del usuario
|
||||
*/
|
||||
updateUser: (data: Partial<User>) => {
|
||||
const { user } = get();
|
||||
if (user) {
|
||||
set({ user: { ...user, ...data } });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Limpiar error
|
||||
*/
|
||||
clearError: () => set({ error: null }),
|
||||
|
||||
/**
|
||||
* Establecer loading
|
||||
*/
|
||||
setLoading: (loading: boolean) => set({ isLoading: loading }),
|
||||
}),
|
||||
{
|
||||
name: 'horux-auth-storage',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
token: state.token,
|
||||
refreshToken: state.refreshToken,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Selector para verificar si el usuario es admin
|
||||
*/
|
||||
export const selectIsAdmin = (state: AuthState): boolean =>
|
||||
state.user?.role === 'admin';
|
||||
|
||||
/**
|
||||
* Selector para verificar si el usuario puede hacer trading
|
||||
*/
|
||||
export const selectCanTrade = (state: AuthState): boolean =>
|
||||
state.user?.role === 'admin' || state.user?.role === 'trader';
|
||||
297
apps/web/src/stores/ui.store.ts
Normal file
297
apps/web/src/stores/ui.store.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
/**
|
||||
* Tipos de tema
|
||||
*/
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
/**
|
||||
* Tipo de notificación
|
||||
*/
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
title: string;
|
||||
message?: string;
|
||||
duration?: number;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal state
|
||||
*/
|
||||
interface ModalState {
|
||||
isOpen: boolean;
|
||||
type: string | null;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estado del store de UI
|
||||
*/
|
||||
interface UIState {
|
||||
// Sidebar
|
||||
sidebarOpen: boolean;
|
||||
sidebarCollapsed: boolean;
|
||||
|
||||
// Theme
|
||||
theme: Theme;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
|
||||
// Notifications
|
||||
notifications: Notification[];
|
||||
|
||||
// Modal
|
||||
modal: ModalState;
|
||||
|
||||
// Loading states
|
||||
globalLoading: boolean;
|
||||
loadingMessage: string | null;
|
||||
|
||||
// Mobile
|
||||
isMobile: boolean;
|
||||
|
||||
// Acciones Sidebar
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
toggleSidebarCollapsed: () => void;
|
||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||
|
||||
// Acciones Theme
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
|
||||
// Acciones Notifications
|
||||
addNotification: (notification: Omit<Notification, 'id'>) => void;
|
||||
removeNotification: (id: string) => void;
|
||||
clearNotifications: () => void;
|
||||
|
||||
// Acciones Modal
|
||||
openModal: (type: string, data?: unknown) => void;
|
||||
closeModal: () => void;
|
||||
|
||||
// Acciones Loading
|
||||
setGlobalLoading: (loading: boolean, message?: string) => void;
|
||||
|
||||
// Acciones Mobile
|
||||
setIsMobile: (isMobile: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un ID único para notificaciones
|
||||
*/
|
||||
function generateNotificationId(): string {
|
||||
return `notification-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta el tema del sistema
|
||||
*/
|
||||
function getSystemTheme(): 'light' | 'dark' {
|
||||
if (typeof window === 'undefined') return 'dark';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
/**
|
||||
* Store de UI con persistencia parcial
|
||||
*/
|
||||
export const useUIStore = create<UIState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Estado inicial
|
||||
sidebarOpen: true,
|
||||
sidebarCollapsed: false,
|
||||
theme: 'dark',
|
||||
resolvedTheme: 'dark',
|
||||
notifications: [],
|
||||
modal: {
|
||||
isOpen: false,
|
||||
type: null,
|
||||
data: undefined,
|
||||
},
|
||||
globalLoading: false,
|
||||
loadingMessage: null,
|
||||
isMobile: false,
|
||||
|
||||
// Sidebar
|
||||
toggleSidebar: () => {
|
||||
set((state) => ({ sidebarOpen: !state.sidebarOpen }));
|
||||
},
|
||||
|
||||
setSidebarOpen: (open: boolean) => {
|
||||
set({ sidebarOpen: open });
|
||||
},
|
||||
|
||||
toggleSidebarCollapsed: () => {
|
||||
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed }));
|
||||
},
|
||||
|
||||
setSidebarCollapsed: (collapsed: boolean) => {
|
||||
set({ sidebarCollapsed: collapsed });
|
||||
},
|
||||
|
||||
// Theme
|
||||
setTheme: (theme: Theme) => {
|
||||
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
||||
|
||||
// Aplicar clase al documento
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
document.documentElement.classList.add(resolvedTheme);
|
||||
}
|
||||
|
||||
set({ theme, resolvedTheme });
|
||||
},
|
||||
|
||||
toggleTheme: () => {
|
||||
const { theme } = get();
|
||||
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
||||
get().setTheme(newTheme);
|
||||
},
|
||||
|
||||
// Notifications
|
||||
addNotification: (notification) => {
|
||||
const id = generateNotificationId();
|
||||
const newNotification: Notification = {
|
||||
...notification,
|
||||
id,
|
||||
duration: notification.duration ?? 5000,
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
notifications: [...state.notifications, newNotification],
|
||||
}));
|
||||
|
||||
// Auto-remove after duration
|
||||
if (newNotification.duration && newNotification.duration > 0) {
|
||||
setTimeout(() => {
|
||||
get().removeNotification(id);
|
||||
}, newNotification.duration);
|
||||
}
|
||||
},
|
||||
|
||||
removeNotification: (id: string) => {
|
||||
set((state) => ({
|
||||
notifications: state.notifications.filter((n) => n.id !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
clearNotifications: () => {
|
||||
set({ notifications: [] });
|
||||
},
|
||||
|
||||
// Modal
|
||||
openModal: (type: string, data?: unknown) => {
|
||||
set({
|
||||
modal: {
|
||||
isOpen: true,
|
||||
type,
|
||||
data,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
closeModal: () => {
|
||||
set({
|
||||
modal: {
|
||||
isOpen: false,
|
||||
type: null,
|
||||
data: undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Loading
|
||||
setGlobalLoading: (loading: boolean, message?: string) => {
|
||||
set({
|
||||
globalLoading: loading,
|
||||
loadingMessage: loading ? (message || null) : null,
|
||||
});
|
||||
},
|
||||
|
||||
// Mobile
|
||||
setIsMobile: (isMobile: boolean) => {
|
||||
set({ isMobile });
|
||||
|
||||
// Cerrar sidebar en mobile
|
||||
if (isMobile) {
|
||||
set({ sidebarOpen: false });
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'horux-ui-storage',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
theme: state.theme,
|
||||
sidebarCollapsed: state.sidebarCollapsed,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
// Aplicar tema al rehidratar
|
||||
if (state) {
|
||||
const resolvedTheme = state.theme === 'system' ? getSystemTheme() : state.theme;
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
document.documentElement.classList.add(resolvedTheme);
|
||||
}
|
||||
state.resolvedTheme = resolvedTheme;
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook helper para notificaciones
|
||||
*/
|
||||
export const useNotifications = () => {
|
||||
const { addNotification, removeNotification, clearNotifications, notifications } = useUIStore();
|
||||
|
||||
return {
|
||||
notifications,
|
||||
notify: addNotification,
|
||||
remove: removeNotification,
|
||||
clear: clearNotifications,
|
||||
success: (title: string, message?: string) =>
|
||||
addNotification({ type: 'success', title, message }),
|
||||
error: (title: string, message?: string) =>
|
||||
addNotification({ type: 'error', title, message }),
|
||||
warning: (title: string, message?: string) =>
|
||||
addNotification({ type: 'warning', title, message }),
|
||||
info: (title: string, message?: string) =>
|
||||
addNotification({ type: 'info', title, message }),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook helper para modales
|
||||
*/
|
||||
export const useModal = () => {
|
||||
const { modal, openModal, closeModal } = useUIStore();
|
||||
|
||||
return {
|
||||
...modal,
|
||||
open: openModal,
|
||||
close: closeModal,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook helper para el tema
|
||||
*/
|
||||
export const useTheme = () => {
|
||||
const { theme, resolvedTheme, setTheme, toggleTheme } = useUIStore();
|
||||
|
||||
return {
|
||||
theme,
|
||||
resolvedTheme,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
isDark: resolvedTheme === 'dark',
|
||||
isLight: resolvedTheme === 'light',
|
||||
};
|
||||
};
|
||||
152
apps/web/tailwind.config.js
Normal file
152
apps/web/tailwind.config.js
Normal file
@@ -0,0 +1,152 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Horux Brand Colors
|
||||
horux: {
|
||||
50: '#f0f7ff',
|
||||
100: '#e0effe',
|
||||
200: '#b9dffd',
|
||||
300: '#7cc5fb',
|
||||
400: '#36a7f7',
|
||||
500: '#0c8ce8',
|
||||
600: '#006fc6',
|
||||
700: '#0159a1',
|
||||
800: '#064c85',
|
||||
900: '#0b406e',
|
||||
950: '#072849',
|
||||
},
|
||||
// Primary (usando horux como primario)
|
||||
primary: {
|
||||
50: '#f0f7ff',
|
||||
100: '#e0effe',
|
||||
200: '#b9dffd',
|
||||
300: '#7cc5fb',
|
||||
400: '#36a7f7',
|
||||
500: '#0c8ce8',
|
||||
600: '#006fc6',
|
||||
700: '#0159a1',
|
||||
800: '#064c85',
|
||||
900: '#0b406e',
|
||||
950: '#072849',
|
||||
},
|
||||
// Grises personalizados
|
||||
slate: {
|
||||
850: '#172033',
|
||||
950: '#0a0f1a',
|
||||
},
|
||||
// Estados
|
||||
success: {
|
||||
50: '#ecfdf5',
|
||||
100: '#d1fae5',
|
||||
200: '#a7f3d0',
|
||||
300: '#6ee7b7',
|
||||
400: '#34d399',
|
||||
500: '#10b981',
|
||||
600: '#059669',
|
||||
700: '#047857',
|
||||
800: '#065f46',
|
||||
900: '#064e3b',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
error: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||
},
|
||||
fontSize: {
|
||||
'2xs': ['0.625rem', { lineHeight: '0.875rem' }],
|
||||
},
|
||||
boxShadow: {
|
||||
'glow': '0 0 20px rgba(12, 140, 232, 0.3)',
|
||||
'glow-lg': '0 0 40px rgba(12, 140, 232, 0.4)',
|
||||
'inner-glow': 'inset 0 0 20px rgba(12, 140, 232, 0.1)',
|
||||
},
|
||||
backgroundImage: {
|
||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||
'horux-gradient': 'linear-gradient(135deg, #0c8ce8 0%, #006fc6 50%, #0159a1 100%)',
|
||||
'horux-gradient-dark': 'linear-gradient(135deg, #072849 0%, #0b406e 50%, #064c85 100%)',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.3s ease-in-out',
|
||||
'fade-out': 'fadeOut 0.3s ease-in-out',
|
||||
'slide-in': 'slideIn 0.3s ease-out',
|
||||
'slide-out': 'slideOut 0.3s ease-in',
|
||||
'scale-in': 'scaleIn 0.2s ease-out',
|
||||
'spin-slow': 'spin 2s linear infinite',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'bounce-slow': 'bounce 2s infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
fadeOut: {
|
||||
'0%': { opacity: '1' },
|
||||
'100%': { opacity: '0' },
|
||||
},
|
||||
slideIn: {
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(0)' },
|
||||
},
|
||||
slideOut: {
|
||||
'0%': { transform: 'translateX(0)' },
|
||||
'100%': { transform: 'translateX(-100%)' },
|
||||
},
|
||||
scaleIn: {
|
||||
'0%': { transform: 'scale(0.95)', opacity: '0' },
|
||||
'100%': { transform: 'scale(1)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
'4xl': '2rem',
|
||||
},
|
||||
spacing: {
|
||||
'18': '4.5rem',
|
||||
'88': '22rem',
|
||||
'112': '28rem',
|
||||
'128': '32rem',
|
||||
},
|
||||
zIndex: {
|
||||
'60': '60',
|
||||
'70': '70',
|
||||
'80': '80',
|
||||
'90': '90',
|
||||
'100': '100',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
39
apps/web/tsconfig.json
Normal file
39
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/components/*": ["./src/components/*"],
|
||||
"@/lib/*": ["./src/lib/*"],
|
||||
"@/stores/*": ["./src/stores/*"],
|
||||
"@/hooks/*": ["./src/hooks/*"],
|
||||
"@/types/*": ["./src/types/*"]
|
||||
},
|
||||
"target": "ES2017",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user