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>
2863 lines
72 KiB
Markdown
2863 lines
72 KiB
Markdown
# 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**
|
|
|
|
```json
|
|
{
|
|
"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**
|
|
|
|
```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**
|
|
|
|
```yaml
|
|
packages:
|
|
- "apps/*"
|
|
- "packages/*"
|
|
```
|
|
|
|
**Step 4: Crear .nvmrc**
|
|
|
|
```
|
|
20
|
|
```
|
|
|
|
**Step 5: Instalar dependencias**
|
|
|
|
Run: `pnpm install`
|
|
Expected: Dependencies installed successfully
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
mkdir -p packages/shared/src/types packages/shared/src/constants
|
|
```
|
|
|
|
**Step 2: Crear packages/shared/package.json**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
// 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```env
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```javascript
|
|
module.exports = {
|
|
plugins: {
|
|
tailwindcss: {},
|
|
autoprefixer: {},
|
|
},
|
|
};
|
|
```
|
|
|
|
**Step 6: Crear apps/web/next.config.js**
|
|
|
|
```javascript
|
|
/** @type {import('next').NextConfig} */
|
|
const nextConfig = {
|
|
transpilePackages: ['@horux/shared'],
|
|
experimental: {
|
|
typedRoutes: true,
|
|
},
|
|
};
|
|
|
|
module.exports = nextConfig;
|
|
```
|
|
|
|
**Step 7: Crear apps/web/app/globals.css**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
mkdir -p apps/web/components/providers
|
|
```
|
|
|
|
**Step 9: Crear apps/web/components/providers/theme-provider.tsx**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```env
|
|
NEXT_PUBLIC_API_URL=http://localhost:4000/api
|
|
```
|
|
|
|
**Step 9: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```yaml
|
|
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**
|
|
|
|
```env
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "chore: complete Phase 1 - foundation setup"
|
|
```
|
|
|
|
**Step 8: Push al repositorio**
|
|
|
|
```bash
|
|
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.
|