Detailed step-by-step plan covering: - Monorepo setup with Turborepo + pnpm - Shared package with types and constants - Express API with JWT authentication - Prisma with PostgreSQL multi-tenant (schema per tenant) - Next.js 14 frontend - 4-theme system (Light, Vibrant, Corporate, Dark) - Login/Register pages with auth store - Demo data seed - Docker Compose configuration Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
72 KiB
Fase 1: Fundación - Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Establecer la base del proyecto Horux360 con monorepo, autenticación JWT, sistema multi-tenant, y sistema de 4 temas visuales.
Architecture: Monorepo con Turborepo conteniendo frontend Next.js 14 y backend Express. PostgreSQL con Prisma como ORM. Multi-tenant implementado con schemas separados por empresa. Sistema de temas con 4 layouts diferentes.
Tech Stack: Next.js 14, Express, TypeScript, Tailwind CSS, Prisma, PostgreSQL, JWT, Zustand, Radix UI
Task 1: Inicializar Monorepo con Turborepo
Files:
- Create:
package.json - Create:
turbo.json - Create:
pnpm-workspace.yaml - Create:
.nvmrc
Step 1: Crear package.json raíz
{
"name": "horux360",
"private": true,
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint",
"test": "turbo run test",
"db:generate": "turbo run db:generate",
"db:push": "turbo run db:push",
"db:migrate": "turbo run db:migrate",
"db:seed": "turbo run db:seed"
},
"devDependencies": {
"turbo": "^2.3.0",
"typescript": "^5.3.0"
},
"packageManager": "pnpm@9.0.0",
"engines": {
"node": ">=20.0.0"
}
}
Step 2: Crear turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {},
"test": {},
"db:generate": {
"cache": false
},
"db:push": {
"cache": false
},
"db:migrate": {
"cache": false
},
"db:seed": {
"cache": false
}
}
}
Step 3: Crear pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
Step 4: Crear .nvmrc
20
Step 5: Instalar dependencias
Run: pnpm install
Expected: Dependencies installed successfully
Step 6: Commit
git add package.json turbo.json pnpm-workspace.yaml .nvmrc pnpm-lock.yaml
git commit -m "chore: initialize monorepo with Turborepo and pnpm"
Task 2: Crear Package Shared (Tipos compartidos)
Files:
- Create:
packages/shared/package.json - Create:
packages/shared/tsconfig.json - Create:
packages/shared/src/index.ts - Create:
packages/shared/src/types/auth.ts - Create:
packages/shared/src/types/tenant.ts - Create:
packages/shared/src/types/user.ts - Create:
packages/shared/src/constants/plans.ts - Create:
packages/shared/src/constants/roles.ts
Step 1: Crear estructura de carpetas
mkdir -p packages/shared/src/types packages/shared/src/constants
Step 2: Crear packages/shared/package.json
{
"name": "@horux/shared",
"version": "0.0.1",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src/",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.3.0"
}
}
Step 3: Crear packages/shared/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Step 4: Crear packages/shared/src/types/auth.ts
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
accessToken: string;
refreshToken: string;
user: UserInfo;
}
export interface RegisterRequest {
empresa: {
nombre: string;
rfc: string;
};
usuario: {
nombre: string;
email: string;
password: string;
};
}
export interface UserInfo {
id: string;
email: string;
nombre: string;
role: Role;
tenantId: string;
tenantName: string;
}
export interface JWTPayload {
userId: string;
email: string;
role: Role;
tenantId: string;
schemaName: string;
iat?: number;
exp?: number;
}
export type Role = 'admin' | 'contador' | 'visor';
Step 5: Crear packages/shared/src/types/tenant.ts
import type { Plan } from '../constants/plans';
export interface Tenant {
id: string;
nombre: string;
rfc: string;
plan: Plan;
schemaName: string;
cfdiLimit: number;
usersLimit: number;
active: boolean;
createdAt: Date;
expiresAt: Date | null;
}
export interface TenantUsage {
cfdiCount: number;
cfdiLimit: number;
usersCount: number;
usersLimit: number;
plan: Plan;
}
Step 6: Crear packages/shared/src/types/user.ts
import type { Role } from './auth';
export interface User {
id: string;
tenantId: string;
email: string;
nombre: string;
role: Role;
active: boolean;
lastLogin: Date | null;
createdAt: Date;
}
export interface CreateUserRequest {
email: string;
nombre: string;
role: Role;
password: string;
}
export interface UpdateUserRequest {
nombre?: string;
role?: Role;
active?: boolean;
}
Step 7: Crear packages/shared/src/constants/plans.ts
export const PLANS = {
starter: {
name: 'Starter',
cfdiLimit: 100,
usersLimit: 1,
features: ['dashboard', 'cfdi_basic', 'iva_isr'],
},
business: {
name: 'Business',
cfdiLimit: 500,
usersLimit: 3,
features: ['dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas', 'calendario'],
},
professional: {
name: 'Professional',
cfdiLimit: 2000,
usersLimit: 10,
features: ['dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas', 'calendario', 'conciliacion', 'forecasting', 'xml_sat'],
},
enterprise: {
name: 'Enterprise',
cfdiLimit: -1, // unlimited
usersLimit: -1, // unlimited
features: ['dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas', 'calendario', 'conciliacion', 'forecasting', 'xml_sat', 'api', 'multi_empresa'],
},
} as const;
export type Plan = keyof typeof PLANS;
export function getPlanLimits(plan: Plan) {
return PLANS[plan];
}
export function hasFeature(plan: Plan, feature: string): boolean {
return PLANS[plan].features.includes(feature);
}
Step 8: Crear packages/shared/src/constants/roles.ts
export const ROLES = {
admin: {
name: 'Administrador',
permissions: ['read', 'write', 'delete', 'manage_users', 'manage_settings'],
},
contador: {
name: 'Contador',
permissions: ['read', 'write'],
},
visor: {
name: 'Visor',
permissions: ['read'],
},
} as const;
export type Role = keyof typeof ROLES;
export function hasPermission(role: Role, permission: string): boolean {
return ROLES[role].permissions.includes(permission as any);
}
Step 9: Crear packages/shared/src/index.ts
// Types
export * from './types/auth';
export * from './types/tenant';
export * from './types/user';
// Constants
export * from './constants/plans';
export * from './constants/roles';
Step 10: Commit
git add packages/shared
git commit -m "feat: add shared package with types and constants"
Task 3: Crear Backend Express Base
Files:
- Create:
apps/api/package.json - Create:
apps/api/tsconfig.json - Create:
apps/api/src/index.ts - Create:
apps/api/src/app.ts - Create:
apps/api/src/config/env.ts - Create:
apps/api/.env.example
Step 1: Crear estructura de carpetas
mkdir -p apps/api/src/config apps/api/src/routes apps/api/src/controllers apps/api/src/services apps/api/src/middlewares apps/api/src/utils
Step 2: Crear apps/api/package.json
{
"name": "@horux/api",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@horux/shared": "workspace:*",
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"express": "^4.21.0",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.0.0",
"prisma": "^5.22.0",
"tsx": "^4.19.0",
"typescript": "^5.3.0"
}
}
Step 3: Crear apps/api/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Step 4: Crear apps/api/src/config/env.ts
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.string().default('4000'),
DATABASE_URL: z.string(),
JWT_SECRET: z.string().min(32),
JWT_EXPIRES_IN: z.string().default('15m'),
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
CORS_ORIGIN: z.string().default('http://localhost:3000'),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('❌ Invalid environment variables:', parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
Step 5: Crear apps/api/src/app.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { env } from './config/env.js';
import { errorMiddleware } from './middlewares/error.middleware.js';
const app = express();
// Security
app.use(helmet());
app.use(cors({
origin: env.CORS_ORIGIN,
credentials: true,
}));
// Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API Routes (to be added)
// app.use('/api/auth', authRoutes);
// app.use('/api/tenant', tenantRoutes);
// Error handling
app.use(errorMiddleware);
export { app };
Step 6: Crear apps/api/src/index.ts
import { app } from './app.js';
import { env } from './config/env.js';
const PORT = parseInt(env.PORT, 10);
app.listen(PORT, () => {
console.log(`🚀 API Server running on http://localhost:${PORT}`);
console.log(`📊 Environment: ${env.NODE_ENV}`);
});
Step 7: Crear apps/api/src/middlewares/error.middleware.ts
import type { Request, Response, NextFunction } from 'express';
export class AppError extends Error {
constructor(
public statusCode: number,
public message: string,
public isOperational = true
) {
super(message);
Object.setPrototypeOf(this, AppError.prototype);
}
}
export function errorMiddleware(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
status: 'error',
message: err.message,
});
}
console.error('Unhandled error:', err);
return res.status(500).json({
status: 'error',
message: 'Internal server error',
});
}
Step 8: Crear apps/api/.env.example
NODE_ENV=development
PORT=4000
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/horux360?schema=public"
JWT_SECRET=your-super-secret-jwt-key-min-32-chars-long
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
CORS_ORIGIN=http://localhost:3000
Step 9: Commit
git add apps/api
git commit -m "feat: add Express API base structure"
Task 4: Configurar Prisma con Schema Multi-tenant
Files:
- Create:
apps/api/prisma/schema.prisma - Create:
apps/api/src/config/database.ts - Create:
apps/api/src/utils/schema-manager.ts
Step 1: Crear apps/api/prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============================================
// PUBLIC SCHEMA - Shared across all tenants
// ============================================
model Tenant {
id String @id @default(uuid())
nombre String
rfc String @unique
plan Plan @default(starter)
schemaName String @unique @map("schema_name")
cfdiLimit Int @map("cfdi_limit")
usersLimit Int @map("users_limit")
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime? @map("expires_at")
users User[]
@@map("tenants")
}
model User {
id String @id @default(uuid())
tenantId String @map("tenant_id")
email String @unique
passwordHash String @map("password_hash")
nombre String
role Role @default(visor)
active Boolean @default(true)
lastLogin DateTime? @map("last_login")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
@@map("users")
}
model RefreshToken {
id String @id @default(uuid())
userId String @map("user_id")
token String @unique
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("refresh_tokens")
}
enum Plan {
starter
business
professional
enterprise
}
enum Role {
admin
contador
visor
}
Step 2: Crear apps/api/src/config/database.ts
import { PrismaClient } from '@prisma/client';
declare global {
var prisma: PrismaClient | undefined;
}
export const prisma = globalThis.prisma || new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalThis.prisma = prisma;
}
Step 3: Crear apps/api/src/utils/schema-manager.ts
import { prisma } from '../config/database.js';
export async function createTenantSchema(schemaName: string): Promise<void> {
// Create the schema
await prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
// Create tables in the tenant schema
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
)
`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."iva_mensual" (
id SERIAL PRIMARY KEY,
año INTEGER NOT NULL,
mes INTEGER 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)
)
`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."isr_mensual" (
id SERIAL PRIMARY KEY,
año INTEGER NOT NULL,
mes INTEGER NOT NULL,
ingresos_acumulados DECIMAL(18,2) NOT NULL,
deducciones DECIMAL(18,2) NOT NULL,
base_gravable DECIMAL(18,2) NOT NULL,
isr_causado DECIMAL(18,2) NOT NULL,
isr_retenido DECIMAL(18,2) NOT NULL,
isr_a_pagar DECIMAL(18,2) NOT NULL,
estado VARCHAR(20) DEFAULT 'pendiente',
fecha_declaracion TIMESTAMP,
UNIQUE(año, mes)
)
`);
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
)
`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."calendario_fiscal" (
id SERIAL PRIMARY KEY,
titulo VARCHAR(200) NOT NULL,
descripcion TEXT,
tipo VARCHAR(50) NOT NULL,
fecha_limite TIMESTAMP NOT NULL,
recurrencia VARCHAR(20) DEFAULT 'unica',
completado BOOLEAN DEFAULT FALSE,
notas TEXT
)
`);
}
export async function setTenantSchema(schemaName: string): Promise<void> {
await prisma.$executeRawUnsafe(`SET search_path TO "${schemaName}"`);
}
export async function deleteTenantSchema(schemaName: string): Promise<void> {
await prisma.$executeRawUnsafe(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
}
Step 4: Commit
git add apps/api/prisma apps/api/src/config/database.ts apps/api/src/utils/schema-manager.ts
git commit -m "feat: add Prisma schema with multi-tenant support"
Task 5: Implementar Autenticación JWT
Files:
- Create:
apps/api/src/utils/password.ts - Create:
apps/api/src/utils/token.ts - Create:
apps/api/src/services/auth.service.ts - Create:
apps/api/src/controllers/auth.controller.ts - Create:
apps/api/src/routes/auth.routes.ts - Create:
apps/api/src/middlewares/auth.middleware.ts
Step 1: Crear apps/api/src/utils/password.ts
import bcrypt from 'bcryptjs';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
Step 2: Crear apps/api/src/utils/token.ts
import jwt from 'jsonwebtoken';
import type { JWTPayload } from '@horux/shared';
import { env } from '../config/env.js';
export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
return jwt.sign(payload, env.JWT_SECRET, {
expiresIn: env.JWT_EXPIRES_IN,
});
}
export function generateRefreshToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
return jwt.sign(payload, env.JWT_SECRET, {
expiresIn: env.JWT_REFRESH_EXPIRES_IN,
});
}
export function verifyToken(token: string): JWTPayload {
return jwt.verify(token, env.JWT_SECRET) as JWTPayload;
}
export function decodeToken(token: string): JWTPayload | null {
try {
return jwt.decode(token) as JWTPayload;
} catch {
return null;
}
}
Step 3: Crear apps/api/src/services/auth.service.ts
import { prisma } from '../config/database.js';
import { hashPassword, verifyPassword } from '../utils/password.js';
import { generateAccessToken, generateRefreshToken, verifyToken } from '../utils/token.js';
import { createTenantSchema } from '../utils/schema-manager.js';
import { AppError } from '../middlewares/error.middleware.js';
import { PLANS } from '@horux/shared';
import type { LoginRequest, RegisterRequest, LoginResponse } from '@horux/shared';
export async function register(data: RegisterRequest): Promise<LoginResponse> {
// Check if email already exists
const existingUser = await prisma.user.findUnique({
where: { email: data.usuario.email },
});
if (existingUser) {
throw new AppError(400, 'El email ya está registrado');
}
// Check if RFC already exists
const existingTenant = await prisma.tenant.findUnique({
where: { rfc: data.empresa.rfc },
});
if (existingTenant) {
throw new AppError(400, 'El RFC ya está registrado');
}
// Generate schema name from RFC
const schemaName = `tenant_${data.empresa.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
// Create tenant
const tenant = await prisma.tenant.create({
data: {
nombre: data.empresa.nombre,
rfc: data.empresa.rfc.toUpperCase(),
plan: 'starter',
schemaName,
cfdiLimit: PLANS.starter.cfdiLimit,
usersLimit: PLANS.starter.usersLimit,
},
});
// Create tenant schema
await createTenantSchema(schemaName);
// Create admin user
const passwordHash = await hashPassword(data.usuario.password);
const user = await prisma.user.create({
data: {
tenantId: tenant.id,
email: data.usuario.email.toLowerCase(),
passwordHash,
nombre: data.usuario.nombre,
role: 'admin',
},
});
// Generate tokens
const tokenPayload = {
userId: user.id,
email: user.email,
role: user.role,
tenantId: tenant.id,
schemaName: tenant.schemaName,
};
const accessToken = generateAccessToken(tokenPayload);
const refreshToken = generateRefreshToken(tokenPayload);
// Store refresh token
await prisma.refreshToken.create({
data: {
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
return {
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
nombre: user.nombre,
role: user.role,
tenantId: tenant.id,
tenantName: tenant.nombre,
},
};
}
export async function login(data: LoginRequest): Promise<LoginResponse> {
const user = await prisma.user.findUnique({
where: { email: data.email.toLowerCase() },
include: { tenant: true },
});
if (!user) {
throw new AppError(401, 'Credenciales inválidas');
}
if (!user.active) {
throw new AppError(401, 'Usuario desactivado');
}
if (!user.tenant.active) {
throw new AppError(401, 'Empresa desactivada');
}
const isValidPassword = await verifyPassword(data.password, user.passwordHash);
if (!isValidPassword) {
throw new AppError(401, 'Credenciales inválidas');
}
// Update last login
await prisma.user.update({
where: { id: user.id },
data: { lastLogin: new Date() },
});
// Generate tokens
const tokenPayload = {
userId: user.id,
email: user.email,
role: user.role,
tenantId: user.tenantId,
schemaName: user.tenant.schemaName,
};
const accessToken = generateAccessToken(tokenPayload);
const refreshToken = generateRefreshToken(tokenPayload);
// Store refresh token
await prisma.refreshToken.create({
data: {
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return {
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
nombre: user.nombre,
role: user.role,
tenantId: user.tenantId,
tenantName: user.tenant.nombre,
},
};
}
export async function refreshTokens(token: string): Promise<{ accessToken: string; refreshToken: string }> {
const storedToken = await prisma.refreshToken.findUnique({
where: { token },
});
if (!storedToken) {
throw new AppError(401, 'Token inválido');
}
if (storedToken.expiresAt < new Date()) {
await prisma.refreshToken.delete({ where: { id: storedToken.id } });
throw new AppError(401, 'Token expirado');
}
// Verify and decode token
const payload = verifyToken(token);
const user = await prisma.user.findUnique({
where: { id: payload.userId },
include: { tenant: true },
});
if (!user || !user.active) {
throw new AppError(401, 'Usuario no encontrado o desactivado');
}
// Delete old token
await prisma.refreshToken.delete({ where: { id: storedToken.id } });
// Generate new tokens
const newTokenPayload = {
userId: user.id,
email: user.email,
role: user.role,
tenantId: user.tenantId,
schemaName: user.tenant.schemaName,
};
const accessToken = generateAccessToken(newTokenPayload);
const refreshToken = generateRefreshToken(newTokenPayload);
// Store new refresh token
await prisma.refreshToken.create({
data: {
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return { accessToken, refreshToken };
}
export async function logout(token: string): Promise<void> {
await prisma.refreshToken.deleteMany({
where: { token },
});
}
Step 4: Crear apps/api/src/controllers/auth.controller.ts
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as authService from '../services/auth.service.js';
import { AppError } from '../middlewares/error.middleware.js';
const registerSchema = z.object({
empresa: z.object({
nombre: z.string().min(2, 'Nombre de empresa requerido'),
rfc: z.string().min(12).max(13, 'RFC inválido'),
}),
usuario: z.object({
nombre: z.string().min(2, 'Nombre requerido'),
email: z.string().email('Email inválido'),
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
}),
});
const loginSchema = z.object({
email: z.string().email('Email inválido'),
password: z.string().min(1, 'Contraseña requerida'),
});
export async function register(req: Request, res: Response, next: NextFunction) {
try {
const data = registerSchema.parse(req.body);
const result = await authService.register(data);
res.status(201).json(result);
} catch (error) {
if (error instanceof z.ZodError) {
return next(new AppError(400, error.errors[0].message));
}
next(error);
}
}
export async function login(req: Request, res: Response, next: NextFunction) {
try {
const data = loginSchema.parse(req.body);
const result = await authService.login(data);
res.json(result);
} catch (error) {
if (error instanceof z.ZodError) {
return next(new AppError(400, error.errors[0].message));
}
next(error);
}
}
export async function refresh(req: Request, res: Response, next: NextFunction) {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
throw new AppError(400, 'Refresh token requerido');
}
const tokens = await authService.refreshTokens(refreshToken);
res.json(tokens);
} catch (error) {
next(error);
}
}
export async function logout(req: Request, res: Response, next: NextFunction) {
try {
const { refreshToken } = req.body;
if (refreshToken) {
await authService.logout(refreshToken);
}
res.json({ message: 'Sesión cerrada exitosamente' });
} catch (error) {
next(error);
}
}
export async function me(req: Request, res: Response, next: NextFunction) {
try {
res.json({ user: req.user });
} catch (error) {
next(error);
}
}
Step 5: Crear apps/api/src/middlewares/auth.middleware.ts
import type { Request, Response, NextFunction } from 'express';
import { verifyToken } from '../utils/token.js';
import { AppError } from './error.middleware.js';
import type { JWTPayload, Role } from '@horux/shared';
declare global {
namespace Express {
interface Request {
user?: JWTPayload;
}
}
}
export function authenticate(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return next(new AppError(401, 'Token no proporcionado'));
}
const token = authHeader.split(' ')[1];
try {
const payload = verifyToken(token);
req.user = payload;
next();
} catch (error) {
next(new AppError(401, 'Token inválido o expirado'));
}
}
export function authorize(...roles: Role[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return next(new AppError(401, 'No autenticado'));
}
if (roles.length > 0 && !roles.includes(req.user.role)) {
return next(new AppError(403, 'No autorizado'));
}
next();
};
}
Step 6: Crear apps/api/src/routes/auth.routes.ts
import { Router } from 'express';
import * as authController from '../controllers/auth.controller.js';
import { authenticate } from '../middlewares/auth.middleware.js';
const router = Router();
router.post('/register', authController.register);
router.post('/login', authController.login);
router.post('/refresh', authController.refresh);
router.post('/logout', authController.logout);
router.get('/me', authenticate, authController.me);
export { router as authRoutes };
Step 7: Actualizar apps/api/src/app.ts para incluir rutas
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { env } from './config/env.js';
import { errorMiddleware } from './middlewares/error.middleware.js';
import { authRoutes } from './routes/auth.routes.js';
const app = express();
// Security
app.use(helmet());
app.use(cors({
origin: env.CORS_ORIGIN,
credentials: true,
}));
// Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API Routes
app.use('/api/auth', authRoutes);
// Error handling
app.use(errorMiddleware);
export { app };
Step 8: Commit
git add apps/api/src
git commit -m "feat: implement JWT authentication system"
Task 6: Crear Frontend Next.js Base
Files:
- Create:
apps/web/package.json - Create:
apps/web/tsconfig.json - Create:
apps/web/tailwind.config.ts - Create:
apps/web/postcss.config.js - Create:
apps/web/next.config.js - Create:
apps/web/app/layout.tsx - Create:
apps/web/app/globals.css - Create:
apps/web/app/page.tsx
Step 1: Crear estructura de carpetas
mkdir -p apps/web/app apps/web/components/ui apps/web/lib apps/web/stores apps/web/themes
Step 2: Crear apps/web/package.json
{
"name": "@horux/web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@horux/shared": "workspace:*",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.0",
"@radix-ui/react-tooltip": "^1.1.0",
"@tanstack/react-query": "^5.60.0",
"axios": "^1.7.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.460.0",
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hook-form": "^7.53.0",
"tailwind-merge": "^2.5.0",
"zod": "^3.23.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.0"
}
}
Step 3: Crear apps/web/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"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": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
Step 4: Crear apps/web/tailwind.config.ts
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: ['class'],
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
success: {
DEFAULT: 'hsl(var(--success))',
foreground: 'hsl(var(--success-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
};
export default config;
Step 5: Crear apps/web/postcss.config.js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Step 6: Crear apps/web/next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ['@horux/shared'],
experimental: {
typedRoutes: true,
},
};
module.exports = nextConfig;
Step 7: Crear apps/web/app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--success: 142.1 76.2% 36.3%;
--success-foreground: 355.7 100% 97.3%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--primary: 187.2 85.7% 53.3%;
--primary-foreground: 222.2 84% 4.9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--success: 142.1 70.6% 45.3%;
--success-foreground: 144.9 80.4% 10%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 187.2 85.7% 53.3%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
Step 8: Crear apps/web/app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Horux360 - Análisis Financiero',
description: 'Plataforma de análisis financiero y gestión fiscal para empresas mexicanas',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="es">
<body className={inter.className}>{children}</body>
</html>
);
}
Step 9: Crear apps/web/app/page.tsx
import Link from 'next/link';
export default function Home() {
return (
<main className="min-h-screen bg-gradient-to-b from-background to-muted">
<nav className="container mx-auto px-4 py-6 flex justify-between items-center">
<div className="text-2xl font-bold text-primary">Horux360</div>
<div className="flex gap-4">
<Link
href="/login"
className="px-4 py-2 text-foreground hover:text-primary transition"
>
Iniciar Sesión
</Link>
<Link
href="/register"
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition"
>
Comenzar Gratis
</Link>
</div>
</nav>
<section className="container mx-auto px-4 py-20 text-center">
<h1 className="text-5xl font-bold mb-6">
Controla tus finanzas con{' '}
<span className="text-primary">inteligencia</span>
</h1>
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto">
La plataforma de análisis financiero para empresas mexicanas más moderna.
Dashboard en tiempo real, control de IVA/ISR, y mucho más.
</p>
<Link
href="/register"
className="inline-block px-8 py-4 bg-primary text-primary-foreground rounded-lg text-lg font-semibold hover:opacity-90 transition"
>
Prueba gratis 14 días
</Link>
</section>
<section className="container mx-auto px-4 py-16">
<div className="grid md:grid-cols-4 gap-8">
{[
{ title: 'Dashboard', desc: 'KPIs en tiempo real' },
{ title: 'Control IVA/ISR', desc: 'Cálculo automático' },
{ title: 'Conciliación', desc: 'Bancaria inteligente' },
{ title: 'Alertas', desc: 'Fiscales proactivas' },
].map((feature) => (
<div
key={feature.title}
className="p-6 bg-card rounded-xl border shadow-sm"
>
<h3 className="text-lg font-semibold mb-2">{feature.title}</h3>
<p className="text-muted-foreground">{feature.desc}</p>
</div>
))}
</div>
</section>
</main>
);
}
Step 10: Commit
git add apps/web
git commit -m "feat: add Next.js frontend base structure"
Task 7: Implementar Sistema de Temas
Files:
- Create:
apps/web/themes/index.ts - Create:
apps/web/themes/light.ts - Create:
apps/web/themes/vibrant.ts - Create:
apps/web/themes/corporate.ts - Create:
apps/web/themes/dark.ts - Create:
apps/web/stores/theme-store.ts - Create:
apps/web/components/providers/theme-provider.tsx - Create:
apps/web/lib/utils.ts
Step 1: Crear apps/web/lib/utils.ts
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Step 2: Crear apps/web/themes/light.ts
export const lightTheme = {
name: 'light' as const,
label: 'Light',
layout: 'sidebar-fixed',
cssVars: {
'--background': '0 0% 100%',
'--foreground': '222.2 84% 4.9%',
'--card': '0 0% 100%',
'--card-foreground': '222.2 84% 4.9%',
'--primary': '221.2 83.2% 53.3%',
'--primary-foreground': '210 40% 98%',
'--secondary': '210 40% 96.1%',
'--secondary-foreground': '222.2 47.4% 11.2%',
'--muted': '210 40% 96.1%',
'--muted-foreground': '215.4 16.3% 46.9%',
'--accent': '210 40% 96.1%',
'--accent-foreground': '222.2 47.4% 11.2%',
'--destructive': '0 84.2% 60.2%',
'--destructive-foreground': '210 40% 98%',
'--success': '142.1 76.2% 36.3%',
'--success-foreground': '355.7 100% 97.3%',
'--border': '214.3 31.8% 91.4%',
'--input': '214.3 31.8% 91.4%',
'--ring': '221.2 83.2% 53.3%',
'--radius': '0.5rem',
},
sidebar: {
width: '240px',
collapsible: false,
},
};
Step 3: Crear apps/web/themes/vibrant.ts
export const vibrantTheme = {
name: 'vibrant' as const,
label: 'Vibrant',
layout: 'sidebar-collapsible',
cssVars: {
'--background': '270 50% 98%',
'--foreground': '263.4 84% 6.7%',
'--card': '0 0% 100%',
'--card-foreground': '263.4 84% 6.7%',
'--primary': '262.1 83.3% 57.8%',
'--primary-foreground': '210 40% 98%',
'--secondary': '187 85.7% 53.3%',
'--secondary-foreground': '222.2 47.4% 11.2%',
'--muted': '270 30% 94%',
'--muted-foreground': '263.4 25% 40%',
'--accent': '24.6 95% 53.1%',
'--accent-foreground': '0 0% 100%',
'--destructive': '0 84.2% 60.2%',
'--destructive-foreground': '210 40% 98%',
'--success': '142.1 76.2% 36.3%',
'--success-foreground': '355.7 100% 97.3%',
'--border': '270 30% 88%',
'--input': '270 30% 88%',
'--ring': '262.1 83.3% 57.8%',
'--radius': '1rem',
},
sidebar: {
width: '280px',
collapsible: true,
},
};
Step 4: Crear apps/web/themes/corporate.ts
export const corporateTheme = {
name: 'corporate' as const,
label: 'Corporate',
layout: 'multi-panel',
cssVars: {
'--background': '210 20% 96%',
'--foreground': '210 50% 10%',
'--card': '0 0% 100%',
'--card-foreground': '210 50% 10%',
'--primary': '210 100% 25%',
'--primary-foreground': '0 0% 100%',
'--secondary': '210 20% 92%',
'--secondary-foreground': '210 50% 10%',
'--muted': '210 20% 92%',
'--muted-foreground': '210 15% 45%',
'--accent': '43 96% 56%',
'--accent-foreground': '210 50% 10%',
'--destructive': '0 84.2% 60.2%',
'--destructive-foreground': '210 40% 98%',
'--success': '142.1 76.2% 36.3%',
'--success-foreground': '355.7 100% 97.3%',
'--border': '210 20% 85%',
'--input': '210 20% 85%',
'--ring': '210 100% 25%',
'--radius': '0.25rem',
},
sidebar: {
width: '200px',
collapsible: false,
},
density: 'compact',
};
Step 5: Crear apps/web/themes/dark.ts
export const darkTheme = {
name: 'dark' as const,
label: 'Dark',
layout: 'minimal-floating',
cssVars: {
'--background': '0 0% 3.9%',
'--foreground': '0 0% 98%',
'--card': '0 0% 6%',
'--card-foreground': '0 0% 98%',
'--primary': '187.2 85.7% 53.3%',
'--primary-foreground': '0 0% 3.9%',
'--secondary': '0 0% 12%',
'--secondary-foreground': '0 0% 98%',
'--muted': '0 0% 12%',
'--muted-foreground': '0 0% 63.9%',
'--accent': '142.1 70.6% 45.3%',
'--accent-foreground': '0 0% 3.9%',
'--destructive': '0 62.8% 30.6%',
'--destructive-foreground': '0 0% 98%',
'--success': '142.1 70.6% 45.3%',
'--success-foreground': '144.9 80.4% 10%',
'--border': '0 0% 14.9%',
'--input': '0 0% 14.9%',
'--ring': '187.2 85.7% 53.3%',
'--radius': '0.75rem',
},
sidebar: {
width: '64px',
collapsible: false,
iconsOnly: true,
},
effects: {
blur: '10px',
glow: '0 0 20px rgba(34,211,238,0.3)',
},
};
Step 6: Crear apps/web/themes/index.ts
import { lightTheme } from './light';
import { vibrantTheme } from './vibrant';
import { corporateTheme } from './corporate';
import { darkTheme } from './dark';
export const themes = {
light: lightTheme,
vibrant: vibrantTheme,
corporate: corporateTheme,
dark: darkTheme,
} as const;
export type ThemeName = keyof typeof themes;
export type Theme = (typeof themes)[ThemeName];
export { lightTheme, vibrantTheme, corporateTheme, darkTheme };
Step 7: Crear apps/web/stores/theme-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { ThemeName } from '@/themes';
interface ThemeState {
theme: ThemeName;
setTheme: (theme: ThemeName) => void;
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}),
{
name: 'horux-theme',
}
)
);
Step 8: Crear carpeta providers
mkdir -p apps/web/components/providers
Step 9: Crear apps/web/components/providers/theme-provider.tsx
'use client';
import { useEffect } from 'react';
import { useThemeStore } from '@/stores/theme-store';
import { themes } from '@/themes';
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const { theme } = useThemeStore();
useEffect(() => {
const selectedTheme = themes[theme];
const root = document.documentElement;
// Apply CSS variables
Object.entries(selectedTheme.cssVars).forEach(([key, value]) => {
root.style.setProperty(key, value);
});
// Apply dark class for dark theme
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}, [theme]);
return <>{children}</>;
}
Step 10: Actualizar apps/web/app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { ThemeProvider } from '@/components/providers/theme-provider';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Horux360 - Análisis Financiero',
description: 'Plataforma de análisis financiero y gestión fiscal para empresas mexicanas',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="es" suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
Step 11: Commit
git add apps/web/themes apps/web/stores apps/web/components/providers apps/web/lib apps/web/app/layout.tsx
git commit -m "feat: implement 4-theme system with Zustand persistence"
Task 8: Crear Componentes UI Base
Files:
- Create:
apps/web/components/ui/button.tsx - Create:
apps/web/components/ui/input.tsx - Create:
apps/web/components/ui/card.tsx - Create:
apps/web/components/ui/label.tsx
Step 1: Crear apps/web/components/ui/button.tsx
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
success: 'bg-success text-success-foreground hover:bg-success/90',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };
Step 2: Crear apps/web/components/ui/input.tsx
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };
Step 3: Crear apps/web/components/ui/card.tsx
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
Step 4: Crear apps/web/components/ui/label.tsx
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
Step 5: Commit
git add apps/web/components/ui
git commit -m "feat: add base UI components (Button, Input, Card, Label)"
Task 9: Crear Páginas de Login y Registro
Files:
- Create:
apps/web/app/(auth)/layout.tsx - Create:
apps/web/app/(auth)/login/page.tsx - Create:
apps/web/app/(auth)/register/page.tsx - Create:
apps/web/lib/api/client.ts - Create:
apps/web/lib/api/auth.ts - Create:
apps/web/stores/auth-store.ts
Step 1: Crear estructura de carpetas
mkdir -p "apps/web/app/(auth)/login" "apps/web/app/(auth)/register" apps/web/lib/api
Step 2: Crear apps/web/lib/api/client.ts
import axios from 'axios';
export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api',
headers: {
'Content-Type': 'application/json',
},
});
apiClient.interceptors.request.use((config) => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
});
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
const response = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api'}/auth/refresh`,
{ refreshToken }
);
const { accessToken, refreshToken: newRefreshToken } = response.data;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', newRefreshToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return apiClient(originalRequest);
}
} catch {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
Step 3: Crear apps/web/lib/api/auth.ts
import { apiClient } from './client';
import type { LoginRequest, RegisterRequest, LoginResponse } from '@horux/shared';
export async function login(data: LoginRequest): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/auth/login', data);
return response.data;
}
export async function register(data: RegisterRequest): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/auth/register', data);
return response.data;
}
export async function logout(): Promise<void> {
const refreshToken = localStorage.getItem('refreshToken');
await apiClient.post('/auth/logout', { refreshToken });
}
export async function getMe(): Promise<LoginResponse['user']> {
const response = await apiClient.get('/auth/me');
return response.data.user;
}
Step 4: Crear apps/web/stores/auth-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { UserInfo } from '@horux/shared';
interface AuthState {
user: UserInfo | null;
isAuthenticated: boolean;
setUser: (user: UserInfo | null) => void;
setTokens: (accessToken: string, refreshToken: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
isAuthenticated: false,
setUser: (user) => set({ user, isAuthenticated: !!user }),
setTokens: (accessToken, refreshToken) => {
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
},
logout: () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
set({ user: null, isAuthenticated: false });
},
}),
{
name: 'horux-auth',
partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
}
)
);
Step 5: Crear apps/web/app/(auth)/layout.tsx
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-background to-muted p-4">
<div className="w-full max-w-md">{children}</div>
</div>
);
}
Step 6: Crear apps/web/app/(auth)/login/page.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { login } from '@/lib/api/auth';
import { useAuthStore } from '@/stores/auth-store';
export default function LoginPage() {
const router = useRouter();
const { setUser, setTokens } = useAuthStore();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsLoading(true);
setError('');
const formData = new FormData(e.currentTarget);
const email = formData.get('email') as string;
const password = formData.get('password') as string;
try {
const response = await login({ email, password });
setTokens(response.accessToken, response.refreshToken);
setUser(response.user);
router.push('/dashboard');
} catch (err: any) {
setError(err.response?.data?.message || 'Error al iniciar sesión');
} finally {
setIsLoading(false);
}
}
return (
<Card>
<CardHeader className="text-center">
<CardTitle className="text-2xl">Iniciar Sesión</CardTitle>
<CardDescription>
Ingresa tus credenciales para acceder a tu cuenta
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="tu@email.com"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Contraseña</Label>
<Input
id="password"
name="password"
type="password"
placeholder="••••••••"
required
/>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Iniciando sesión...' : 'Iniciar Sesión'}
</Button>
<p className="text-sm text-muted-foreground text-center">
¿No tienes cuenta?{' '}
<Link href="/register" className="text-primary hover:underline">
Regístrate
</Link>
</p>
</CardFooter>
</form>
</Card>
);
}
Step 7: Crear apps/web/app/(auth)/register/page.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { register } from '@/lib/api/auth';
import { useAuthStore } from '@/stores/auth-store';
export default function RegisterPage() {
const router = useRouter();
const { setUser, setTokens } = useAuthStore();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsLoading(true);
setError('');
const formData = new FormData(e.currentTarget);
try {
const response = await register({
empresa: {
nombre: formData.get('empresaNombre') as string,
rfc: formData.get('empresaRfc') as string,
},
usuario: {
nombre: formData.get('nombre') as string,
email: formData.get('email') as string,
password: formData.get('password') as string,
},
});
setTokens(response.accessToken, response.refreshToken);
setUser(response.user);
router.push('/dashboard');
} catch (err: any) {
setError(err.response?.data?.message || 'Error al registrarse');
} finally {
setIsLoading(false);
}
}
return (
<Card>
<CardHeader className="text-center">
<CardTitle className="text-2xl">Crear Cuenta</CardTitle>
<CardDescription>
Registra tu empresa y comienza tu prueba gratuita
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
Datos de la Empresa
</Label>
<Input
name="empresaNombre"
placeholder="Nombre de la empresa"
required
/>
<Input
name="empresaRfc"
placeholder="RFC (ej: ABC123456XY1)"
maxLength={13}
required
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
Datos del Administrador
</Label>
<Input
name="nombre"
placeholder="Tu nombre completo"
required
/>
<Input
name="email"
type="email"
placeholder="tu@email.com"
required
/>
<Input
name="password"
type="password"
placeholder="Contraseña (mín. 8 caracteres)"
minLength={8}
required
/>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Creando cuenta...' : 'Crear Cuenta Gratis'}
</Button>
<p className="text-sm text-muted-foreground text-center">
¿Ya tienes cuenta?{' '}
<Link href="/login" className="text-primary hover:underline">
Inicia sesión
</Link>
</p>
</CardFooter>
</form>
</Card>
);
}
Step 8: Crear apps/web/.env.example
NEXT_PUBLIC_API_URL=http://localhost:4000/api
Step 9: Commit
git add apps/web/app apps/web/lib apps/web/stores apps/web/.env.example
git commit -m "feat: add login and register pages with auth store"
Task 10: Crear Seed de Datos Demo y Docker Compose
Files:
- Create:
apps/api/prisma/seed.ts - Create:
docker-compose.yml - Update:
apps/api/.env.example
Step 1: Crear apps/api/prisma/seed.ts
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Seeding database...');
// Create demo tenant
const schemaName = 'tenant_ede123456ab1';
const tenant = await prisma.tenant.upsert({
where: { rfc: 'EDE123456AB1' },
update: {},
create: {
nombre: 'Empresa Demo SA de CV',
rfc: 'EDE123456AB1',
plan: 'professional',
schemaName,
cfdiLimit: 2000,
usersLimit: 10,
},
});
console.log('✅ Tenant created:', tenant.nombre);
// Create demo users
const passwordHash = await bcrypt.hash('demo123', 12);
const users = [
{ email: 'admin@demo.com', nombre: 'Admin Demo', role: 'admin' as const },
{ email: 'contador@demo.com', nombre: 'Contador Demo', role: 'contador' as const },
{ email: 'visor@demo.com', nombre: 'Visor Demo', role: 'visor' as const },
];
for (const userData of users) {
const user = await prisma.user.upsert({
where: { email: userData.email },
update: {},
create: {
tenantId: tenant.id,
email: userData.email,
passwordHash,
nombre: userData.nombre,
role: userData.role,
},
});
console.log(`✅ User created: ${user.email} (${user.role})`);
}
// Create tenant schema
await prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
// Create tables in tenant schema
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
)
`);
// Insert demo CFDIs
const cfdiTypes = ['ingreso', 'egreso'];
const rfcs = ['XAXX010101000', 'MEXX020202000', 'AAXX030303000', 'BBXX040404000'];
const nombres = ['Cliente Demo SA', 'Proveedor ABC', 'Servicios XYZ', 'Materiales 123'];
for (let i = 0; i < 50; i++) {
const tipo = cfdiTypes[i % 2];
const rfcIndex = i % 4;
const subtotal = Math.floor(Math.random() * 50000) + 1000;
const iva = subtotal * 0.16;
const total = subtotal + iva;
await prisma.$executeRawUnsafe(`
INSERT INTO "${schemaName}"."cfdis"
(uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
subtotal, iva, total, estado)
VALUES (
'${crypto.randomUUID()}',
'${tipo}',
'A',
'${1000 + i}',
NOW() - INTERVAL '${Math.floor(Math.random() * 180)} days',
NOW() - INTERVAL '${Math.floor(Math.random() * 180)} days',
'${tipo === 'ingreso' ? 'EDE123456AB1' : rfcs[rfcIndex]}',
'${tipo === 'ingreso' ? 'Empresa Demo SA de CV' : nombres[rfcIndex]}',
'${tipo === 'egreso' ? 'EDE123456AB1' : rfcs[rfcIndex]}',
'${tipo === 'egreso' ? 'Empresa Demo SA de CV' : nombres[rfcIndex]}',
${subtotal},
${iva},
${total},
'vigente'
)
ON CONFLICT (uuid_fiscal) DO NOTHING
`);
}
console.log('✅ Demo CFDIs created');
// Create IVA monthly records
const months = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio'];
let acumulado = 0;
for (let mes = 1; mes <= 6; mes++) {
const trasladado = Math.floor(Math.random() * 100000) + 150000;
const acreditable = Math.floor(Math.random() * 80000) + 120000;
const resultado = trasladado - acreditable;
acumulado += resultado;
await prisma.$executeRawUnsafe(`
INSERT INTO "${schemaName}"."iva_mensual"
(año, mes, iva_trasladado, iva_acreditable, resultado, acumulado, estado)
VALUES (2024, ${mes}, ${trasladado}, ${acreditable}, ${resultado}, ${acumulado},
'${mes <= 4 ? 'declarado' : 'pendiente'}')
ON CONFLICT (año, mes) DO NOTHING
`);
}
console.log('✅ IVA monthly records created');
// Create alerts
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
)
`);
await prisma.$executeRawUnsafe(`
INSERT INTO "${schemaName}"."alertas" (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
VALUES
('iva_favor', 'IVA a Favor Disponible', 'Tienes $43,582.40 de IVA a favor acumulado', 'media', NULL),
('declaracion', 'Declaración Mensual', 'La declaración mensual de IVA/ISR vence el 17 de febrero', 'alta', NOW() + INTERVAL '5 days'),
('discrepancia', 'CFDI con Discrepancia', 'Se encontraron 12 facturas con discrepancias', 'alta', NULL)
ON CONFLICT DO NOTHING
`);
console.log('✅ Alerts created');
console.log('🎉 Seed completed successfully!');
console.log('\n📝 Demo credentials:');
console.log(' Admin: admin@demo.com / demo123');
console.log(' Contador: contador@demo.com / demo123');
console.log(' Visor: visor@demo.com / demo123');
}
main()
.catch((e) => {
console.error('Error seeding database:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
Step 2: Crear docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: horux360-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: horux360
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
api:
build:
context: .
dockerfile: apps/api/Dockerfile
container_name: horux360-api
environment:
NODE_ENV: development
PORT: 4000
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/horux360?schema=public
JWT_SECRET: your-super-secret-jwt-key-min-32-chars-long-for-development
JWT_EXPIRES_IN: 15m
JWT_REFRESH_EXPIRES_IN: 7d
CORS_ORIGIN: http://localhost:3000
ports:
- "4000:4000"
depends_on:
postgres:
condition: service_healthy
web:
build:
context: .
dockerfile: apps/web/Dockerfile
container_name: horux360-web
environment:
NEXT_PUBLIC_API_URL: http://localhost:4000/api
ports:
- "3000:3000"
depends_on:
- api
volumes:
postgres_data:
Step 3: Actualizar apps/api/.env.example
NODE_ENV=development
PORT=4000
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/horux360?schema=public"
JWT_SECRET=your-super-secret-jwt-key-min-32-chars-long-for-development
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
CORS_ORIGIN=http://localhost:3000
Step 4: Commit
git add apps/api/prisma/seed.ts docker-compose.yml apps/api/.env.example
git commit -m "feat: add database seed with demo data and Docker Compose setup"
Task 11: Final - Instalar Dependencias y Verificar
Step 1: Instalar todas las dependencias
Run: pnpm install
Expected: All dependencies installed
Step 2: Generar cliente Prisma
Run: cd apps/api && pnpm db:generate
Expected: Prisma client generated
Step 3: Iniciar base de datos con Docker
Run: docker-compose up -d postgres
Expected: PostgreSQL running on port 5432
Step 4: Ejecutar migraciones
Run: cd apps/api && pnpm db:push
Expected: Database schema created
Step 5: Ejecutar seed
Run: cd apps/api && pnpm db:seed
Expected: Demo data inserted
Step 6: Iniciar desarrollo
Run: pnpm dev
Expected: API on http://localhost:4000, Web on http://localhost:3000
Step 7: Commit final
git add -A
git commit -m "chore: complete Phase 1 - foundation setup"
Step 8: Push al repositorio
git push origin main
Summary
Phase 1 establishes:
- Monorepo with Turborepo + pnpm
- Shared package with types and constants
- Express API with JWT auth
- Prisma with PostgreSQL multi-tenant
- Next.js 14 frontend
- 4-theme system (Light, Vibrant, Corporate, Dark)
- Login/Register pages
- Demo data seed
- Docker Compose for development
Next Phase (2): Dashboard with KPIs, CFDI management, IVA/ISR control.