diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 25bd8b1..ba09213 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -12,6 +12,7 @@ import { alertasRoutes } from './routes/alertas.routes.js'; import { calendarioRoutes } from './routes/calendario.routes.js'; import { reportesRoutes } from './routes/reportes.routes.js'; import { usuariosRoutes } from './routes/usuarios.routes.js'; +import { tenantsRoutes } from './routes/tenants.routes.js'; const app = express(); @@ -41,6 +42,7 @@ app.use('/api/alertas', alertasRoutes); app.use('/api/calendario', calendarioRoutes); app.use('/api/reportes', reportesRoutes); app.use('/api/usuarios', usuariosRoutes); +app.use('/api/tenants', tenantsRoutes); // Error handling app.use(errorMiddleware); diff --git a/apps/api/src/controllers/tenants.controller.ts b/apps/api/src/controllers/tenants.controller.ts new file mode 100644 index 0000000..e62a394 --- /dev/null +++ b/apps/api/src/controllers/tenants.controller.ts @@ -0,0 +1,60 @@ +import { Request, Response, NextFunction } from 'express'; +import * as tenantsService from '../services/tenants.service.js'; +import { AppError } from '../utils/errors.js'; + +export async function getAllTenants(req: Request, res: Response, next: NextFunction) { + try { + // Only admin can list all tenants + if (req.user!.role !== 'admin') { + throw new AppError(403, 'Solo administradores pueden ver todos los clientes'); + } + + const tenants = await tenantsService.getAllTenants(); + res.json(tenants); + } catch (error) { + next(error); + } +} + +export async function getTenant(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'admin') { + throw new AppError(403, 'Solo administradores pueden ver detalles de clientes'); + } + + const tenant = await tenantsService.getTenantById(req.params.id); + if (!tenant) { + throw new AppError(404, 'Cliente no encontrado'); + } + + res.json(tenant); + } catch (error) { + next(error); + } +} + +export async function createTenant(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'admin') { + throw new AppError(403, 'Solo administradores pueden crear clientes'); + } + + const { nombre, rfc, plan, cfdiLimit, usersLimit } = req.body; + + if (!nombre || !rfc) { + throw new AppError(400, 'Nombre y RFC son requeridos'); + } + + const tenant = await tenantsService.createTenant({ + nombre, + rfc, + plan, + cfdiLimit, + usersLimit, + }); + + res.status(201).json(tenant); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/middlewares/tenant.middleware.ts b/apps/api/src/middlewares/tenant.middleware.ts index 0d81c55..b7674c5 100644 --- a/apps/api/src/middlewares/tenant.middleware.ts +++ b/apps/api/src/middlewares/tenant.middleware.ts @@ -6,6 +6,7 @@ declare global { namespace Express { interface Request { tenantSchema?: string; + viewingTenantId?: string; } } } @@ -16,8 +17,18 @@ export async function tenantMiddleware(req: Request, res: Response, next: NextFu } try { + // Check if admin is viewing a different tenant + const viewTenantId = req.headers['x-view-tenant'] as string | undefined; + let tenantId = req.user.tenantId; + + // Only admins can view other tenants + if (viewTenantId && req.user.role === 'admin') { + tenantId = viewTenantId; + req.viewingTenantId = viewTenantId; + } + const tenant = await prisma.tenant.findUnique({ - where: { id: req.user.tenantId }, + where: { id: tenantId }, select: { schemaName: true, active: true }, }); diff --git a/apps/api/src/routes/tenants.routes.ts b/apps/api/src/routes/tenants.routes.ts new file mode 100644 index 0000000..94a5b23 --- /dev/null +++ b/apps/api/src/routes/tenants.routes.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import * as tenantsController from '../controllers/tenants.controller.js'; + +const router = Router(); + +router.use(authenticate); + +router.get('/', tenantsController.getAllTenants); +router.get('/:id', tenantsController.getTenant); +router.post('/', tenantsController.createTenant); + +export { router as tenantsRoutes }; diff --git a/apps/api/src/services/tenants.service.ts b/apps/api/src/services/tenants.service.ts new file mode 100644 index 0000000..aadbfa4 --- /dev/null +++ b/apps/api/src/services/tenants.service.ts @@ -0,0 +1,141 @@ +import { prisma } from '../config/database.js'; + +export async function getAllTenants() { + return prisma.tenant.findMany({ + where: { active: true }, + select: { + id: true, + nombre: true, + rfc: true, + plan: true, + schemaName: true, + createdAt: true, + _count: { + select: { users: true } + } + }, + orderBy: { nombre: 'asc' } + }); +} + +export async function getTenantById(id: string) { + return prisma.tenant.findUnique({ + where: { id }, + select: { + id: true, + nombre: true, + rfc: true, + plan: true, + schemaName: true, + cfdiLimit: true, + usersLimit: true, + createdAt: true, + } + }); +} + +export async function createTenant(data: { + nombre: string; + rfc: string; + plan?: 'starter' | 'business' | 'professional' | 'enterprise'; + cfdiLimit?: number; + usersLimit?: number; +}) { + const schemaName = `tenant_${data.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`; + + // Create tenant record + const tenant = await prisma.tenant.create({ + data: { + nombre: data.nombre, + rfc: data.rfc.toUpperCase(), + plan: data.plan || 'starter', + schemaName, + cfdiLimit: data.cfdiLimit || 500, + usersLimit: data.usersLimit || 3, + } + }); + + // Create schema and tables for the tenant + await prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`); + + // Create CFDIs table + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS "${schemaName}"."cfdis" ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + uuid_fiscal VARCHAR(36) UNIQUE NOT NULL, + tipo VARCHAR(20) NOT NULL, + serie VARCHAR(25), + folio VARCHAR(40), + fecha_emision TIMESTAMP NOT NULL, + fecha_timbrado TIMESTAMP NOT NULL, + rfc_emisor VARCHAR(13) NOT NULL, + nombre_emisor VARCHAR(300) NOT NULL, + rfc_receptor VARCHAR(13) NOT NULL, + nombre_receptor VARCHAR(300) NOT NULL, + subtotal DECIMAL(18,2) NOT NULL, + descuento DECIMAL(18,2) DEFAULT 0, + iva DECIMAL(18,2) DEFAULT 0, + isr_retenido DECIMAL(18,2) DEFAULT 0, + iva_retenido DECIMAL(18,2) DEFAULT 0, + total DECIMAL(18,2) NOT NULL, + moneda VARCHAR(3) DEFAULT 'MXN', + tipo_cambio DECIMAL(10,4) DEFAULT 1, + metodo_pago VARCHAR(3), + forma_pago VARCHAR(2), + uso_cfdi VARCHAR(4), + estado VARCHAR(20) DEFAULT 'vigente', + xml_url TEXT, + pdf_url TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Create IVA monthly table + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS "${schemaName}"."iva_mensual" ( + id SERIAL PRIMARY KEY, + año INT NOT NULL, + mes INT NOT NULL, + iva_trasladado DECIMAL(18,2) NOT NULL, + iva_acreditable DECIMAL(18,2) NOT NULL, + iva_retenido DECIMAL(18,2) DEFAULT 0, + resultado DECIMAL(18,2) NOT NULL, + acumulado DECIMAL(18,2) NOT NULL, + estado VARCHAR(20) DEFAULT 'pendiente', + fecha_declaracion TIMESTAMP, + UNIQUE(año, mes) + ) + `); + + // Create alerts table + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS "${schemaName}"."alertas" ( + id SERIAL PRIMARY KEY, + tipo VARCHAR(50) NOT NULL, + titulo VARCHAR(200) NOT NULL, + mensaje TEXT NOT NULL, + prioridad VARCHAR(20) DEFAULT 'media', + fecha_vencimiento TIMESTAMP, + leida BOOLEAN DEFAULT FALSE, + resuelta BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Create calendario_fiscal table + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS "${schemaName}"."calendario_fiscal" ( + id SERIAL PRIMARY KEY, + titulo VARCHAR(200) NOT NULL, + descripcion TEXT, + tipo VARCHAR(20) NOT NULL, + fecha_limite TIMESTAMP NOT NULL, + recurrencia VARCHAR(20) DEFAULT 'mensual', + completado BOOLEAN DEFAULT FALSE, + notas TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + return tenant; +} diff --git a/apps/web/app/(dashboard)/clientes/page.tsx b/apps/web/app/(dashboard)/clientes/page.tsx new file mode 100644 index 0000000..88273ce --- /dev/null +++ b/apps/web/app/(dashboard)/clientes/page.tsx @@ -0,0 +1,259 @@ +'use client'; + +import { useState } from 'react'; +import { Header } from '@/components/layouts/header'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useTenants, useCreateTenant } from '@/lib/hooks/use-tenants'; +import { useTenantViewStore } from '@/stores/tenant-view-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { Building, Plus, Users, Eye, Calendar } from 'lucide-react'; + +export default function ClientesPage() { + const { user } = useAuthStore(); + const { data: tenants, isLoading } = useTenants(); + const createTenant = useCreateTenant(); + const { setViewingTenant } = useTenantViewStore(); + + const [showForm, setShowForm] = useState(false); + const [formData, setFormData] = useState({ + nombre: '', + rfc: '', + plan: 'starter' as const, + }); + + // Only admins can access this page + if (user?.role !== 'admin') { + return ( + <> +
+
+ + +

+ No tienes permisos para ver esta página. +

+
+
+
+ + ); + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + await createTenant.mutateAsync(formData); + setFormData({ nombre: '', rfc: '', plan: 'starter' }); + setShowForm(false); + } catch (error) { + console.error('Error creating tenant:', error); + } + }; + + const handleViewClient = (tenantId: string, tenantName: string) => { + setViewingTenant(tenantId, tenantName); + window.location.href = '/dashboard'; + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('es-MX', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + const planLabels: Record = { + starter: 'Starter', + business: 'Business', + professional: 'Professional', + enterprise: 'Enterprise', + }; + + const planColors: Record = { + starter: 'bg-muted text-muted-foreground', + business: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100', + professional: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100', + enterprise: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100', + }; + + return ( + <> +
+
+ {/* Stats */} +
+ + +
+
+ +
+
+

{tenants?.length || 0}

+

Total Clientes

+
+
+
+
+ + +
+
+ +
+
+

+ {tenants?.reduce((acc, t) => acc + (t._count?.users || 0), 0) || 0} +

+

Total Usuarios

+
+
+
+
+ + + + + +
+ + {/* Add Client Form */} + {showForm && ( + + + Nuevo Cliente + + Registra un nuevo cliente para gestionar su facturación + + + +
+
+
+ + setFormData({ ...formData, nombre: e.target.value })} + placeholder="Empresa SA de CV" + required + /> +
+
+ + setFormData({ ...formData, rfc: e.target.value.toUpperCase() })} + placeholder="XAXX010101000" + maxLength={13} + required + /> +
+
+
+ + +
+
+ + +
+
+
+
+ )} + + {/* Clients List */} + + + Lista de Clientes + + + {isLoading ? ( +
Cargando...
+ ) : tenants && tenants.length > 0 ? ( +
+ {tenants.map((tenant) => ( +
+
+
+ + {tenant.nombre.substring(0, 2).toUpperCase()} + +
+
+

{tenant.nombre}

+
+ {tenant.rfc} + + {planLabels[tenant.plan]} + +
+
+
+
+
+
+ + {tenant._count?.users || 0} usuarios +
+
+ + {formatDate(tenant.createdAt)} +
+
+ +
+
+ ))} +
+ ) : ( +
+ No hay clientes registrados +
+ )} +
+
+
+ + ); +} diff --git a/apps/web/components/layouts/header.tsx b/apps/web/components/layouts/header.tsx index 31690ea..8c2fe1c 100644 --- a/apps/web/components/layouts/header.tsx +++ b/apps/web/components/layouts/header.tsx @@ -3,6 +3,7 @@ import { useThemeStore } from '@/stores/theme-store'; import { themes, type ThemeName } from '@/themes'; import { Button } from '@/components/ui/button'; +import { TenantSelector } from '@/components/tenant-selector'; import { Sun, Moon, Palette } from 'lucide-react'; const themeIcons: Record = { @@ -27,7 +28,8 @@ export function Header({ title }: { title: string }) {

{title}

-
+
+ + )} + + + + {open && ( +
+
+

Seleccionar cliente

+
+
+ {isLoading ? ( +
Cargando...
+ ) : tenants && tenants.length > 0 ? ( + <> + {/* Option to go back to own tenant */} + + +
+ + {/* Other tenants */} + {tenants + .filter(t => t.id !== user?.tenantId) + .map((tenant) => ( + + ))} + + ) : ( +
+ No hay otros clientes +
+ )} +
+
+ )} +
+ ); +} diff --git a/apps/web/lib/api/client.ts b/apps/web/lib/api/client.ts index f2ffea0..4b9532a 100644 --- a/apps/web/lib/api/client.ts +++ b/apps/web/lib/api/client.ts @@ -13,6 +13,19 @@ apiClient.interceptors.request.use((config) => { if (token) { config.headers.Authorization = `Bearer ${token}`; } + + // Add viewing tenant header for admin users + const tenantViewStore = localStorage.getItem('horux-tenant-view'); + if (tenantViewStore) { + try { + const { state } = JSON.parse(tenantViewStore); + if (state?.viewingTenantId) { + config.headers['X-View-Tenant'] = state.viewingTenantId; + } + } catch { + // Ignore parse errors + } + } } return config; }); diff --git a/apps/web/lib/api/tenants.ts b/apps/web/lib/api/tenants.ts new file mode 100644 index 0000000..f485de1 --- /dev/null +++ b/apps/web/lib/api/tenants.ts @@ -0,0 +1,36 @@ +import { apiClient } from './client'; + +export interface Tenant { + id: string; + nombre: string; + rfc: string; + plan: string; + schemaName: string; + createdAt: string; + _count?: { + users: number; + }; +} + +export interface CreateTenantData { + nombre: string; + rfc: string; + plan?: 'starter' | 'business' | 'professional' | 'enterprise'; + cfdiLimit?: number; + usersLimit?: number; +} + +export async function getTenants(): Promise { + const response = await apiClient.get('/tenants'); + return response.data; +} + +export async function getTenant(id: string): Promise { + const response = await apiClient.get(`/tenants/${id}`); + return response.data; +} + +export async function createTenant(data: CreateTenantData): Promise { + const response = await apiClient.post('/tenants', data); + return response.data; +} diff --git a/apps/web/lib/hooks/use-tenants.ts b/apps/web/lib/hooks/use-tenants.ts new file mode 100644 index 0000000..4bc0106 --- /dev/null +++ b/apps/web/lib/hooks/use-tenants.ts @@ -0,0 +1,20 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { getTenants, createTenant, type CreateTenantData } from '@/lib/api/tenants'; + +export function useTenants() { + return useQuery({ + queryKey: ['tenants'], + queryFn: getTenants, + }); +} + +export function useCreateTenant() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: CreateTenantData) => createTenant(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tenants'] }); + }, + }); +} diff --git a/apps/web/stores/tenant-view-store.ts b/apps/web/stores/tenant-view-store.ts new file mode 100644 index 0000000..71972a9 --- /dev/null +++ b/apps/web/stores/tenant-view-store.ts @@ -0,0 +1,23 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface TenantViewState { + viewingTenantId: string | null; + viewingTenantName: string | null; + setViewingTenant: (id: string | null, name: string | null) => void; + clearViewingTenant: () => void; +} + +export const useTenantViewStore = create()( + persist( + (set) => ({ + viewingTenantId: null, + viewingTenantName: null, + setViewingTenant: (id, name) => set({ viewingTenantId: id, viewingTenantName: name }), + clearViewingTenant: () => set({ viewingTenantId: null, viewingTenantName: null }), + }), + { + name: 'horux-tenant-view', + } + ) +);