feat: Implement Phase 1 & 2 - Full monorepo architecture

## Backend API (apps/api)
- Express.js server with TypeScript
- JWT authentication with access/refresh tokens
- Multi-tenant middleware (schema per tenant)
- Complete CRUD routes: auth, cfdis, transactions, contacts, categories, metrics, alerts
- SAT integration: CFDI 4.0 XML parser, FIEL authentication
- Metrics engine: 50+ financial metrics (Core, Startup, Enterprise)
- Rate limiting, CORS, Helmet security

## Frontend Web (apps/web)
- Next.js 14 with App Router
- Authentication pages: login, register, forgot-password
- Dashboard layout with Sidebar and Header
- Dashboard pages: overview, cash-flow, revenue, expenses, metrics
- Zustand stores for auth and UI state
- Theme support with flash prevention

## Database Package (packages/database)
- PostgreSQL migrations with multi-tenant architecture
- Public schema: plans, tenants, users, sessions, subscriptions
- Tenant schema: sat_credentials, cfdis, transactions, contacts, accounts, alerts
- Tenant management functions
- Seed data for plans and super admin

## Shared Package (packages/shared)
- TypeScript types: auth, tenant, financial, metrics, reports
- Zod validation schemas for all entities
- Utility functions for formatting

## UI Package (packages/ui)
- Chart components: LineChart, BarChart, AreaChart, PieChart
- Data components: DataTable, MetricCard, KPICard, AlertBadge
- PeriodSelector and Skeleton components

## Infrastructure
- Docker Compose: PostgreSQL 15, Redis 7, MinIO, Mailhog
- Makefile with 25+ development commands
- Development scripts: dev-setup.sh, dev-down.sh
- Complete .env.example template

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 11:05:24 +00:00
parent c1321c3f0c
commit a9b1994c48
110 changed files with 40788 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
{
"name": "@horux/database",
"version": "0.1.0",
"private": true,
"description": "Database schemas and migrations for Horux Strategy",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"migrate": "node dist/migrate.js",
"migrate:dev": "tsx src/migrate.ts",
"seed": "tsx src/seed.ts",
"studio": "echo 'DB Studio not configured'",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist node_modules"
},
"dependencies": {
"@horux/shared": "workspace:*",
"pg": "^8.11.3",
"dotenv": "^16.4.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/pg": "^8.10.9",
"eslint": "^8.56.0",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}

View File

@@ -0,0 +1,406 @@
/**
* PostgreSQL Connection Pool with Multi-Tenant Support
*
* Implements a connection pool manager that supports schema-based multi-tenancy.
* Each tenant has their own schema, and connections can be scoped to a specific tenant.
*/
import { Pool, PoolClient, PoolConfig, QueryResult, QueryResultRow } from 'pg';
import { EventEmitter } from 'events';
// Database configuration interface
export interface DatabaseConfig {
host: string;
port: number;
database: string;
user: string;
password: string;
ssl?: boolean | { rejectUnauthorized: boolean };
max?: number;
idleTimeoutMillis?: number;
connectionTimeoutMillis?: number;
}
// Tenant context for queries
export interface TenantContext {
tenantId: string;
schemaName: string;
userId?: string;
}
// Query options
export interface QueryOptions {
tenant?: TenantContext;
timeout?: number;
}
// Connection pool events
export interface PoolEvents {
connect: (client: PoolClient) => void;
acquire: (client: PoolClient) => void;
release: (client: PoolClient) => void;
error: (error: Error, client: PoolClient) => void;
remove: (client: PoolClient) => void;
}
// Singleton pool instance
let globalPool: Pool | null = null;
// Pool statistics
interface PoolStats {
totalConnections: number;
idleConnections: number;
waitingRequests: number;
}
/**
* Database connection manager with multi-tenant support
*/
export class DatabaseConnection extends EventEmitter {
private pool: Pool;
private config: DatabaseConfig;
private isConnected: boolean = false;
constructor(config?: DatabaseConfig) {
super();
this.config = config || this.getConfigFromEnv();
const poolConfig: PoolConfig = {
host: this.config.host,
port: this.config.port,
database: this.config.database,
user: this.config.user,
password: this.config.password,
ssl: this.config.ssl,
max: this.config.max || 20,
idleTimeoutMillis: this.config.idleTimeoutMillis || 30000,
connectionTimeoutMillis: this.config.connectionTimeoutMillis || 10000,
};
this.pool = new Pool(poolConfig);
this.setupPoolEvents();
}
/**
* Get configuration from environment variables
*/
private getConfigFromEnv(): DatabaseConfig {
return {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
database: process.env.DB_NAME || 'horux_strategy',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || '',
ssl: process.env.DB_SSL === 'true'
? { rejectUnauthorized: process.env.DB_SSL_REJECT_UNAUTHORIZED !== 'false' }
: false,
max: parseInt(process.env.DB_POOL_MAX || '20', 10),
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
connectionTimeoutMillis: parseInt(process.env.DB_CONNECTION_TIMEOUT || '10000', 10),
};
}
/**
* Setup pool event handlers
*/
private setupPoolEvents(): void {
this.pool.on('connect', (client) => {
this.isConnected = true;
this.emit('connect', client);
});
this.pool.on('acquire', (client) => {
this.emit('acquire', client);
});
this.pool.on('release', (client) => {
this.emit('release', client);
});
this.pool.on('error', (err, client) => {
console.error('Unexpected pool error:', err);
this.emit('error', err, client);
});
this.pool.on('remove', (client) => {
this.emit('remove', client);
});
}
/**
* Test database connection
*/
async connect(): Promise<boolean> {
try {
const client = await this.pool.connect();
await client.query('SELECT 1');
client.release();
this.isConnected = true;
console.log('Database connection established successfully');
return true;
} catch (error) {
console.error('Failed to connect to database:', error);
this.isConnected = false;
throw error;
}
}
/**
* Close all connections in the pool
*/
async disconnect(): Promise<void> {
await this.pool.end();
this.isConnected = false;
console.log('Database connection pool closed');
}
/**
* Get pool statistics
*/
getStats(): PoolStats {
return {
totalConnections: this.pool.totalCount,
idleConnections: this.pool.idleCount,
waitingRequests: this.pool.waitingCount,
};
}
/**
* Check if connected
*/
isPoolConnected(): boolean {
return this.isConnected;
}
/**
* Execute a query with optional tenant context
*/
async query<T extends QueryResultRow = QueryResultRow>(
text: string,
params?: unknown[],
options?: QueryOptions
): Promise<QueryResult<T>> {
const client = await this.pool.connect();
try {
// Set search path for tenant if provided
if (options?.tenant) {
await client.query(`SET search_path TO ${this.escapeIdentifier(options.tenant.schemaName)}, public`);
}
// Set query timeout if provided
if (options?.timeout) {
await client.query(`SET statement_timeout = ${options.timeout}`);
}
const result = await client.query<T>(text, params);
return result;
} finally {
// Reset search path and timeout before releasing
if (options?.tenant || options?.timeout) {
await client.query('RESET search_path; RESET statement_timeout;').catch(() => {});
}
client.release();
}
}
/**
* Execute a query in the public schema
*/
async queryPublic<T extends QueryResultRow = QueryResultRow>(
text: string,
params?: unknown[]
): Promise<QueryResult<T>> {
return this.query<T>(text, params);
}
/**
* Execute a query in a tenant schema
*/
async queryTenant<T extends QueryResultRow = QueryResultRow>(
tenant: TenantContext,
text: string,
params?: unknown[]
): Promise<QueryResult<T>> {
return this.query<T>(text, params, { tenant });
}
/**
* Get a client for transaction handling
*/
async getClient(): Promise<PoolClient> {
return this.pool.connect();
}
/**
* Execute a function within a transaction
*/
async transaction<T>(
fn: (client: PoolClient) => Promise<T>,
options?: QueryOptions
): Promise<T> {
const client = await this.pool.connect();
try {
// Set search path for tenant if provided
if (options?.tenant) {
await client.query(`SET search_path TO ${this.escapeIdentifier(options.tenant.schemaName)}, public`);
}
await client.query('BEGIN');
try {
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
} finally {
if (options?.tenant) {
await client.query('RESET search_path').catch(() => {});
}
client.release();
}
}
/**
* Execute a function within a tenant transaction
*/
async tenantTransaction<T>(
tenant: TenantContext,
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
return this.transaction(fn, { tenant });
}
/**
* Escape an identifier (schema name, table name, etc.)
*/
private escapeIdentifier(identifier: string): string {
// Validate identifier format (alphanumeric and underscores only)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(identifier)) {
throw new Error(`Invalid identifier: ${identifier}`);
}
return `"${identifier}"`;
}
/**
* Check if a schema exists
*/
async schemaExists(schemaName: string): Promise<boolean> {
const result = await this.query<{ exists: boolean }>(
`SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = $1) as exists`,
[schemaName]
);
return result.rows[0]?.exists ?? false;
}
/**
* Get list of all tenant schemas
*/
async getTenantSchemas(): Promise<string[]> {
const result = await this.query<{ schema_name: string }>(
`SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'tenant_%'
ORDER BY schema_name`
);
return result.rows.map(row => row.schema_name);
}
/**
* Get the underlying pool (use with caution)
*/
getPool(): Pool {
return this.pool;
}
}
/**
* Get or create the global database connection
*/
export function getDatabase(config?: DatabaseConfig): DatabaseConnection {
if (!globalPool) {
const connection = new DatabaseConnection(config);
globalPool = connection.getPool();
return connection;
}
return new DatabaseConnection(config);
}
/**
* Create a new database connection (non-singleton)
*/
export function createDatabase(config?: DatabaseConfig): DatabaseConnection {
return new DatabaseConnection(config);
}
/**
* Tenant-scoped database client
* Provides a simplified interface for tenant-specific operations
*/
export class TenantDatabase {
private db: DatabaseConnection;
private tenant: TenantContext;
constructor(db: DatabaseConnection, tenant: TenantContext) {
this.db = db;
this.tenant = tenant;
}
/**
* Execute a query in the tenant schema
*/
async query<T extends QueryResultRow = QueryResultRow>(
text: string,
params?: unknown[]
): Promise<QueryResult<T>> {
return this.db.queryTenant<T>(this.tenant, text, params);
}
/**
* Execute a transaction in the tenant schema
*/
async transaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
return this.db.tenantTransaction(this.tenant, fn);
}
/**
* Get tenant context
*/
getContext(): TenantContext {
return this.tenant;
}
/**
* Query the public schema (for cross-tenant operations)
*/
async queryPublic<T extends QueryResultRow = QueryResultRow>(
text: string,
params?: unknown[]
): Promise<QueryResult<T>> {
return this.db.queryPublic<T>(text, params);
}
}
/**
* Create a tenant-scoped database client
*/
export function createTenantDatabase(
db: DatabaseConnection,
tenantId: string,
userId?: string
): TenantDatabase {
const tenant: TenantContext = {
tenantId,
schemaName: `tenant_${tenantId}`,
userId,
};
return new TenantDatabase(db, tenant);
}
// Export types
export type { Pool, PoolClient, QueryResult, QueryResultRow };

View File

@@ -0,0 +1,676 @@
/**
* @horux/database
*
* Database package for Horux Strategy - CFO Digital para Empresas Mexicanas
*
* Provides:
* - PostgreSQL connection pool with multi-tenant support
* - Tenant schema management (create, delete, suspend)
* - Migration utilities
* - Type definitions for database entities
*/
// Connection management
export {
DatabaseConnection,
TenantDatabase,
getDatabase,
createDatabase,
createTenantDatabase,
type DatabaseConfig,
type TenantContext,
type QueryOptions,
type Pool,
type PoolClient,
type QueryResult,
type QueryResultRow,
} from './connection.js';
// Tenant management
export {
createTenantSchema,
deleteTenantSchema,
suspendTenant,
reactivateTenant,
getTenant,
getTenantBySlug,
listTenants,
updateTenantSettings,
validateTenantAccess,
getSchemaName,
createTenantContext,
type CreateTenantOptions,
type TenantSettings,
type TenantInfo,
type TenantStatus,
} from './tenant.js';
// Migration utilities (for programmatic use)
export {
runMigrations,
printStatus as getMigrationStatus,
rollbackLast as rollbackMigration,
ensureDatabase,
loadMigrationFiles,
getExecutedMigrations,
ensureMigrationsTable,
type MigrationFile,
type MigrationRecord,
} from './migrate.js';
// Seed data exports
export {
PLANS,
SYSTEM_SETTINGS,
} from './seed.js';
// ============================================================================
// Type Definitions for Database Entities
// ============================================================================
// User roles
export type UserRole = 'super_admin' | 'owner' | 'admin' | 'manager' | 'analyst' | 'viewer';
// Subscription status
export type SubscriptionStatus = 'active' | 'trial' | 'past_due' | 'cancelled' | 'suspended' | 'expired';
// Job status
export type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
// Transaction types
export type TransactionType = 'income' | 'expense' | 'transfer' | 'adjustment';
export type TransactionStatus = 'pending' | 'confirmed' | 'reconciled' | 'voided';
// CFDI types
export type CfdiStatus = 'active' | 'cancelled' | 'pending_cancellation';
export type CfdiType = 'I' | 'E' | 'T' | 'N' | 'P'; // Ingreso, Egreso, Traslado, Nomina, Pago
// Contact types
export type ContactType = 'customer' | 'supplier' | 'both' | 'employee';
// Category types
export type CategoryType = 'income' | 'expense' | 'cost' | 'other';
// Account types
export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense';
// Alert severity
export type AlertSeverity = 'info' | 'warning' | 'critical';
// Report status
export type ReportStatus = 'draft' | 'generating' | 'completed' | 'failed' | 'archived';
// ============================================================================
// Entity Interfaces
// ============================================================================
/**
* Plan entity
*/
export interface Plan {
id: string;
name: string;
description: string | null;
priceMonthly: number;
priceYearly: number;
maxUsers: number;
maxCfdisMonthly: number;
maxStorageMb: number;
maxApiCallsDaily: number;
maxReportsMonthly: number;
features: Record<string, boolean>;
hasSatSync: boolean;
hasBankSync: boolean;
hasAiInsights: boolean;
hasCustomReports: boolean;
hasApiAccess: boolean;
hasWhiteLabel: boolean;
hasPrioritySupport: boolean;
hasDedicatedAccountManager: boolean;
dataRetentionMonths: number;
displayOrder: number;
isActive: boolean;
isPopular: boolean;
createdAt: Date;
updatedAt: Date;
}
/**
* User entity
*/
export interface User {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
phone: string | null;
avatarUrl: string | null;
defaultRole: UserRole;
isActive: boolean;
isEmailVerified: boolean;
emailVerifiedAt: Date | null;
twoFactorEnabled: boolean;
preferences: Record<string, unknown>;
timezone: string;
locale: string;
lastLoginAt: Date | null;
lastLoginIp: string | null;
createdAt: Date;
updatedAt: Date;
}
/**
* Tenant entity
*/
export interface Tenant {
id: string;
name: string;
slug: string;
schemaName: string;
rfc: string | null;
razonSocial: string | null;
email: string | null;
phone: string | null;
ownerId: string;
planId: string;
status: TenantStatus;
settings: TenantSettings;
createdAt: Date;
updatedAt: Date;
}
/**
* Subscription entity
*/
export interface Subscription {
id: string;
tenantId: string;
planId: string;
status: SubscriptionStatus;
billingCycle: 'monthly' | 'yearly';
trialEndsAt: Date | null;
currentPeriodStart: Date;
currentPeriodEnd: Date;
cancelledAt: Date | null;
cancelAtPeriodEnd: boolean;
paymentProcessor: string | null;
externalSubscriptionId: string | null;
externalCustomerId: string | null;
priceCents: number;
currency: string;
usageCfdisCurrent: number;
usageStorageMbCurrent: number;
usageApiCallsCurrent: number;
usageResetAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
/**
* User session entity
*/
export interface UserSession {
id: string;
userId: string;
tenantId: string | null;
userAgent: string | null;
ipAddress: string | null;
deviceType: string | null;
deviceName: string | null;
locationCity: string | null;
locationCountry: string | null;
isActive: boolean;
expiresAt: Date;
refreshExpiresAt: Date | null;
lastActivityAt: Date;
createdAt: Date;
revokedAt: Date | null;
}
/**
* Background job entity
*/
export interface BackgroundJob {
id: string;
tenantId: string | null;
userId: string | null;
jobType: string;
jobName: string | null;
queue: string;
priority: number;
payload: Record<string, unknown>;
status: JobStatus;
progress: number;
result: Record<string, unknown> | null;
errorMessage: string | null;
errorStack: string | null;
attempts: number;
maxAttempts: number;
scheduledAt: Date;
startedAt: Date | null;
completedAt: Date | null;
timeoutSeconds: number;
createdAt: Date;
updatedAt: Date;
}
/**
* API key entity
*/
export interface ApiKey {
id: string;
tenantId: string;
name: string;
description: string | null;
keyPrefix: string;
scopes: string[];
allowedIps: string[] | null;
allowedOrigins: string[] | null;
rateLimitPerMinute: number;
rateLimitPerDay: number;
lastUsedAt: Date | null;
usageCount: number;
isActive: boolean;
expiresAt: Date | null;
createdBy: string | null;
createdAt: Date;
updatedAt: Date;
revokedAt: Date | null;
revokedBy: string | null;
}
/**
* Audit log entry entity
*/
export interface AuditLogEntry {
id: string;
tenantId: string | null;
userId: string | null;
action: string;
entityType: string;
entityId: string | null;
oldValues: Record<string, unknown> | null;
newValues: Record<string, unknown> | null;
details: Record<string, unknown> | null;
ipAddress: string | null;
userAgent: string | null;
requestId: string | null;
createdAt: Date;
}
// ============================================================================
// Tenant Schema Entity Interfaces
// ============================================================================
/**
* SAT credentials entity (tenant schema)
*/
export interface SatCredentials {
id: string;
rfc: string;
cerSerialNumber: string | null;
cerIssuedAt: Date | null;
cerExpiresAt: Date | null;
cerIssuer: string | null;
isActive: boolean;
isValid: boolean;
lastValidatedAt: Date | null;
validationError: string | null;
syncEnabled: boolean;
syncFrequencyHours: number;
lastSyncAt: Date | null;
lastSyncStatus: string | null;
lastSyncError: string | null;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
/**
* CFDI entity (tenant schema)
*/
export interface Cfdi {
id: string;
uuidFiscal: string;
serie: string | null;
folio: string | null;
tipoComprobante: CfdiType;
status: CfdiStatus;
fechaEmision: Date;
fechaTimbrado: Date | null;
fechaCancelacion: Date | null;
emisorRfc: string;
emisorNombre: string;
emisorRegimenFiscal: string;
receptorRfc: string;
receptorNombre: string;
receptorRegimenFiscal: string | null;
receptorDomicilioFiscal: string | null;
receptorUsoCfdi: string;
subtotal: number;
descuento: number;
total: number;
totalImpuestosTrasladados: number;
totalImpuestosRetenidos: number;
iva16: number;
iva8: number;
iva0: number;
ivaExento: number;
isrRetenido: number;
ivaRetenido: number;
moneda: string;
tipoCambio: number;
formaPago: string | null;
metodoPago: string | null;
condicionesPago: string | null;
cfdiRelacionados: Record<string, unknown> | null;
tipoRelacion: string | null;
conceptos: Record<string, unknown>[];
isEmitted: boolean;
categoryId: string | null;
contactId: string | null;
isReconciled: boolean;
reconciledAt: Date | null;
reconciledBy: string | null;
aiCategorySuggestion: string | null;
aiConfidenceScore: number | null;
source: string;
createdAt: Date;
updatedAt: Date;
}
/**
* Transaction entity (tenant schema)
*/
export interface Transaction {
id: string;
type: TransactionType;
status: TransactionStatus;
amount: number;
currency: string;
exchangeRate: number;
amountMxn: number;
transactionDate: Date;
valueDate: Date | null;
recordedAt: Date;
description: string | null;
reference: string | null;
notes: string | null;
categoryId: string | null;
accountId: string | null;
contactId: string | null;
cfdiId: string | null;
bankTransactionId: string | null;
bankAccountId: string | null;
bankDescription: string | null;
isRecurring: boolean;
recurringPattern: Record<string, unknown> | null;
parentTransactionId: string | null;
attachments: Record<string, unknown>[] | null;
tags: string[] | null;
isReconciled: boolean;
reconciledAt: Date | null;
reconciledBy: string | null;
requiresApproval: boolean;
approvedAt: Date | null;
approvedBy: string | null;
aiCategoryId: string | null;
aiConfidence: number | null;
aiNotes: string | null;
createdBy: string | null;
createdAt: Date;
updatedAt: Date;
voidedAt: Date | null;
voidedBy: string | null;
voidReason: string | null;
}
/**
* Contact entity (tenant schema)
*/
export interface Contact {
id: string;
type: ContactType;
name: string;
tradeName: string | null;
rfc: string | null;
regimenFiscal: string | null;
usoCfdiDefault: string | null;
email: string | null;
phone: string | null;
mobile: string | null;
website: string | null;
addressStreet: string | null;
addressInterior: string | null;
addressExterior: string | null;
addressNeighborhood: string | null;
addressCity: string | null;
addressMunicipality: string | null;
addressState: string | null;
addressZip: string | null;
addressCountry: string;
bankName: string | null;
bankAccount: string | null;
bankClabe: string | null;
creditDays: number;
creditLimit: number;
balanceReceivable: number;
balancePayable: number;
category: string | null;
tags: string[] | null;
isActive: boolean;
notes: string | null;
createdBy: string | null;
createdAt: Date;
updatedAt: Date;
}
/**
* Category entity (tenant schema)
*/
export interface Category {
id: string;
code: string;
name: string;
description: string | null;
type: CategoryType;
parentId: string | null;
level: number;
path: string | null;
satKey: string | null;
budgetMonthly: number | null;
budgetYearly: number | null;
color: string | null;
icon: string | null;
displayOrder: number;
isActive: boolean;
isSystem: boolean;
createdAt: Date;
updatedAt: Date;
}
/**
* Account entity (tenant schema)
*/
export interface Account {
id: string;
code: string;
name: string;
description: string | null;
type: AccountType;
parentId: string | null;
level: number;
path: string | null;
satCode: string | null;
satNature: 'D' | 'A' | null;
balanceDebit: number;
balanceCredit: number;
balanceCurrent: number;
isActive: boolean;
isSystem: boolean;
allowsMovements: boolean;
displayOrder: number;
createdAt: Date;
updatedAt: Date;
}
/**
* Alert entity (tenant schema)
*/
export interface Alert {
id: string;
type: string;
title: string;
message: string;
severity: AlertSeverity;
entityType: string | null;
entityId: string | null;
thresholdType: string | null;
thresholdValue: number | null;
currentValue: number | null;
actionUrl: string | null;
actionLabel: string | null;
actionData: Record<string, unknown> | null;
isRead: boolean;
isDismissed: boolean;
readAt: Date | null;
dismissedAt: Date | null;
dismissedBy: string | null;
isRecurring: boolean;
lastTriggeredAt: Date | null;
triggerCount: number;
autoResolved: boolean;
resolvedAt: Date | null;
resolvedBy: string | null;
resolutionNotes: string | null;
createdAt: Date;
expiresAt: Date | null;
}
/**
* Report entity (tenant schema)
*/
export interface Report {
id: string;
type: string;
name: string;
description: string | null;
periodStart: Date;
periodEnd: Date;
comparisonPeriodStart: Date | null;
comparisonPeriodEnd: Date | null;
status: ReportStatus;
parameters: Record<string, unknown> | null;
data: Record<string, unknown> | null;
fileUrl: string | null;
fileFormat: string | null;
isScheduled: boolean;
scheduleCron: string | null;
nextScheduledAt: Date | null;
lastGeneratedAt: Date | null;
isShared: boolean;
sharedWith: string[] | null;
shareToken: string | null;
shareExpiresAt: Date | null;
generatedBy: string | null;
createdAt: Date;
updatedAt: Date;
}
/**
* Metric cache entry entity (tenant schema)
*/
export interface MetricCache {
id: string;
metricKey: string;
periodType: 'daily' | 'weekly' | 'monthly' | 'yearly';
periodStart: Date;
periodEnd: Date;
dimensionType: string | null;
dimensionId: string | null;
valueNumeric: number | null;
valueJson: Record<string, unknown> | null;
previousValue: number | null;
changePercent: number | null;
changeAbsolute: number | null;
computedAt: Date;
validUntil: Date | null;
isStale: boolean;
}
/**
* Setting entity (tenant schema)
*/
export interface Setting {
key: string;
value: string;
valueType: 'string' | 'integer' | 'boolean' | 'json';
category: string;
label: string | null;
description: string | null;
isSensitive: boolean;
createdAt: Date;
updatedAt: Date;
}
/**
* Bank account entity (tenant schema)
*/
export interface BankAccount {
id: string;
bankName: string;
bankCode: string | null;
accountNumber: string | null;
clabe: string | null;
accountType: string | null;
alias: string | null;
currency: string;
balanceAvailable: number | null;
balanceCurrent: number | null;
balanceUpdatedAt: Date | null;
connectionProvider: string | null;
connectionId: string | null;
connectionStatus: string | null;
lastSyncAt: Date | null;
lastSyncError: string | null;
accountId: string | null;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
/**
* Budget item entity (tenant schema)
*/
export interface BudgetItem {
id: string;
year: number;
month: number;
categoryId: string | null;
accountId: string | null;
amountBudgeted: number;
amountActual: number;
amountVariance: number;
notes: string | null;
isLocked: boolean;
createdBy: string | null;
createdAt: Date;
updatedAt: Date;
}
/**
* Attachment entity (tenant schema)
*/
export interface Attachment {
id: string;
entityType: string;
entityId: string;
fileName: string;
fileType: string | null;
fileSize: number | null;
fileUrl: string;
storageProvider: string;
storagePath: string | null;
uploadedBy: string | null;
createdAt: Date;
}

View File

@@ -0,0 +1,462 @@
/**
* Database Migration Script
*
* Executes SQL migrations against the database.
* Supports both public schema and tenant schema migrations.
*/
import { readFileSync, readdirSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { Pool, PoolClient } from 'pg';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config();
// Get directory path for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Migration tracking table
const MIGRATIONS_TABLE = '_migrations';
// Database configuration
interface DatabaseConfig {
host: string;
port: number;
database: string;
user: string;
password: string;
ssl?: boolean | { rejectUnauthorized: boolean };
}
// Migration record
interface MigrationRecord {
id: number;
name: string;
executed_at: Date;
checksum: string;
}
// Migration file
interface MigrationFile {
name: string;
path: string;
content: string;
checksum: string;
}
/**
* Get database configuration from environment
*/
function getConfig(): DatabaseConfig {
return {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
database: process.env.DB_NAME || 'horux_strategy',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || '',
ssl: process.env.DB_SSL === 'true'
? { rejectUnauthorized: process.env.DB_SSL_REJECT_UNAUTHORIZED !== 'false' }
: false,
};
}
/**
* Calculate simple checksum for migration content
*/
function calculateChecksum(content: string): string {
let hash = 0;
for (let i = 0; i < content.length; i++) {
const char = content.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(16).padStart(8, '0');
}
/**
* Create migrations tracking table if it doesn't exist
*/
async function ensureMigrationsTable(client: PoolClient): Promise<void> {
await client.query(`
CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
executed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
checksum VARCHAR(20) NOT NULL,
execution_time_ms INTEGER
)
`);
}
/**
* Get list of executed migrations
*/
async function getExecutedMigrations(client: PoolClient): Promise<MigrationRecord[]> {
const result = await client.query<MigrationRecord>(
`SELECT id, name, executed_at, checksum FROM ${MIGRATIONS_TABLE} ORDER BY id`
);
return result.rows;
}
/**
* Load migration files from directory
*/
function loadMigrationFiles(migrationsDir: string): MigrationFile[] {
if (!existsSync(migrationsDir)) {
console.log(`Migrations directory not found: ${migrationsDir}`);
return [];
}
const files = readdirSync(migrationsDir)
.filter(f => f.endsWith('.sql'))
.sort();
return files.map(file => {
const filePath = join(migrationsDir, file);
const content = readFileSync(filePath, 'utf-8');
return {
name: file,
path: filePath,
content,
checksum: calculateChecksum(content),
};
});
}
/**
* Execute a single migration
*/
async function executeMigration(
client: PoolClient,
migration: MigrationFile
): Promise<number> {
const startTime = Date.now();
console.log(` Executing: ${migration.name}`);
// Execute the migration SQL
await client.query(migration.content);
// Record the migration
await client.query(
`INSERT INTO ${MIGRATIONS_TABLE} (name, checksum, execution_time_ms)
VALUES ($1, $2, $3)`,
[migration.name, migration.checksum, Date.now() - startTime]
);
const executionTime = Date.now() - startTime;
console.log(` Completed: ${migration.name} (${executionTime}ms)`);
return executionTime;
}
/**
* Run pending migrations
*/
async function runMigrations(
pool: Pool,
migrationsDir: string,
options: { force?: boolean; dryRun?: boolean } = {}
): Promise<{ executed: string[]; skipped: string[]; errors: string[] }> {
const result = {
executed: [] as string[],
skipped: [] as string[],
errors: [] as string[],
};
const client = await pool.connect();
try {
// Ensure migrations table exists
await ensureMigrationsTable(client);
// Get executed migrations
const executed = await getExecutedMigrations(client);
const executedNames = new Set(executed.map(m => m.name));
const executedChecksums = new Map(executed.map(m => [m.name, m.checksum]));
// Load migration files
const migrations = loadMigrationFiles(migrationsDir);
if (migrations.length === 0) {
console.log('No migration files found.');
return result;
}
console.log(`Found ${migrations.length} migration file(s)`);
// Check for checksum mismatches
for (const migration of migrations) {
if (executedNames.has(migration.name)) {
const previousChecksum = executedChecksums.get(migration.name);
if (previousChecksum !== migration.checksum && !options.force) {
const errorMsg = `Checksum mismatch for ${migration.name}. Migration was modified after execution.`;
console.error(`ERROR: ${errorMsg}`);
result.errors.push(errorMsg);
return result;
}
}
}
// Execute pending migrations
await client.query('BEGIN');
try {
for (const migration of migrations) {
if (executedNames.has(migration.name)) {
console.log(` Skipping: ${migration.name} (already executed)`);
result.skipped.push(migration.name);
continue;
}
if (options.dryRun) {
console.log(` Would execute: ${migration.name}`);
result.executed.push(migration.name);
continue;
}
await executeMigration(client, migration);
result.executed.push(migration.name);
}
if (!options.dryRun) {
await client.query('COMMIT');
} else {
await client.query('ROLLBACK');
console.log('\nDry run complete. No changes were made.');
}
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
return result;
} finally {
client.release();
}
}
/**
* Print migration status
*/
async function printStatus(pool: Pool, migrationsDir: string): Promise<void> {
const client = await pool.connect();
try {
await ensureMigrationsTable(client);
const executed = await getExecutedMigrations(client);
const executedNames = new Set(executed.map(m => m.name));
const migrations = loadMigrationFiles(migrationsDir);
console.log('\nMigration Status:');
console.log('=================\n');
if (migrations.length === 0) {
console.log('No migration files found.');
return;
}
for (const migration of migrations) {
const status = executedNames.has(migration.name) ? '[x]' : '[ ]';
const executedRecord = executed.find(e => e.name === migration.name);
const date = executedRecord
? executedRecord.executed_at.toISOString()
: 'pending';
console.log(`${status} ${migration.name} - ${date}`);
}
const pending = migrations.filter(m => !executedNames.has(m.name));
console.log(`\nTotal: ${migrations.length}, Executed: ${executed.length}, Pending: ${pending.length}`);
} finally {
client.release();
}
}
/**
* Rollback last migration
*/
async function rollbackLast(pool: Pool): Promise<void> {
const client = await pool.connect();
try {
await ensureMigrationsTable(client);
const result = await client.query<MigrationRecord>(
`SELECT id, name FROM ${MIGRATIONS_TABLE} ORDER BY id DESC LIMIT 1`
);
if (result.rows.length === 0) {
console.log('No migrations to rollback.');
return;
}
const lastMigration = result.rows[0];
await client.query('BEGIN');
try {
// Delete migration record
await client.query(`DELETE FROM ${MIGRATIONS_TABLE} WHERE id = $1`, [lastMigration.id]);
await client.query('COMMIT');
console.log(`Rolled back: ${lastMigration.name}`);
console.log('\nNote: The SQL changes were NOT reversed. You need to manually undo the schema changes.');
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
} finally {
client.release();
}
}
/**
* Create database if it doesn't exist
*/
async function ensureDatabase(config: DatabaseConfig): Promise<void> {
// Connect to postgres database to create the target database
const adminPool = new Pool({
...config,
database: 'postgres',
});
try {
const client = await adminPool.connect();
try {
// Check if database exists
const result = await client.query(
'SELECT 1 FROM pg_database WHERE datname = $1',
[config.database]
);
if (result.rows.length === 0) {
// Create database
console.log(`Creating database: ${config.database}`);
await client.query(`CREATE DATABASE "${config.database}"`);
console.log(`Database created: ${config.database}`);
}
} finally {
client.release();
}
} finally {
await adminPool.end();
}
}
/**
* Main CLI
*/
async function main(): Promise<void> {
const args = process.argv.slice(2);
const command = args[0] || 'up';
const config = getConfig();
const migrationsDir = join(__dirname, 'migrations');
console.log(`\nHorux Strategy - Database Migration Tool`);
console.log(`========================================`);
console.log(`Database: ${config.database}@${config.host}:${config.port}`);
console.log(`Migrations: ${migrationsDir}\n`);
// Ensure database exists
if (command !== 'status') {
try {
await ensureDatabase(config);
} catch (error) {
console.error('Error ensuring database exists:', error);
// Continue anyway, might not have permission to create DB
}
}
const pool = new Pool(config);
try {
switch (command) {
case 'up':
case 'migrate': {
const dryRun = args.includes('--dry-run');
const force = args.includes('--force');
console.log('Running migrations...');
if (dryRun) console.log('(Dry run mode)\n');
const result = await runMigrations(pool, migrationsDir, { dryRun, force });
console.log('\nSummary:');
console.log(` Executed: ${result.executed.length}`);
console.log(` Skipped: ${result.skipped.length}`);
console.log(` Errors: ${result.errors.length}`);
if (result.errors.length > 0) {
process.exit(1);
}
break;
}
case 'status': {
await printStatus(pool, migrationsDir);
break;
}
case 'rollback': {
console.log('Rolling back last migration...\n');
await rollbackLast(pool);
break;
}
case 'help':
default: {
console.log(`
Usage: migrate [command] [options]
Commands:
up, migrate Run pending migrations (default)
status Show migration status
rollback Rollback the last migration record
help Show this help message
Options:
--dry-run Show what would be executed without making changes
--force Ignore checksum mismatches
Environment Variables:
DB_HOST Database host (default: localhost)
DB_PORT Database port (default: 5432)
DB_NAME Database name (default: horux_strategy)
DB_USER Database user (default: postgres)
DB_PASSWORD Database password
DB_SSL Enable SSL (default: false)
`);
break;
}
}
} catch (error) {
console.error('\nMigration failed:', error);
process.exit(1);
} finally {
await pool.end();
}
}
// Run if called directly
main().catch(console.error);
// Export for programmatic use
export {
runMigrations,
printStatus,
rollbackLast,
ensureDatabase,
loadMigrationFiles,
getExecutedMigrations,
ensureMigrationsTable,
MigrationFile,
MigrationRecord,
};

View File

@@ -0,0 +1,658 @@
-- ============================================================================
-- Horux Strategy - Public Schema Migration
-- Version: 001
-- Description: Core tables for multi-tenant SaaS platform
-- ============================================================================
-- Enable required extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- For text search
-- ============================================================================
-- ENUM TYPES
-- ============================================================================
-- User roles enum
CREATE TYPE user_role AS ENUM (
'super_admin', -- Platform administrator (Horux team)
'owner', -- Tenant owner (company owner)
'admin', -- Tenant administrator
'manager', -- Department manager
'analyst', -- Financial analyst (read + limited write)
'viewer' -- Read-only access
);
-- Subscription status enum
CREATE TYPE subscription_status AS ENUM (
'active',
'trial',
'past_due',
'cancelled',
'suspended',
'expired'
);
-- Tenant status enum
CREATE TYPE tenant_status AS ENUM (
'active',
'suspended',
'pending',
'deleted'
);
-- Job status enum
CREATE TYPE job_status AS ENUM (
'pending',
'running',
'completed',
'failed',
'cancelled'
);
-- ============================================================================
-- PLANS TABLE
-- Subscription plans available in the platform
-- ============================================================================
CREATE TABLE plans (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
-- Pricing (in MXN cents to avoid floating point issues)
price_monthly_cents INTEGER NOT NULL DEFAULT 0,
price_yearly_cents INTEGER NOT NULL DEFAULT 0,
-- Limits
max_users INTEGER NOT NULL DEFAULT 1,
max_cfdis_monthly INTEGER NOT NULL DEFAULT 100,
max_storage_mb INTEGER NOT NULL DEFAULT 1024,
max_api_calls_daily INTEGER NOT NULL DEFAULT 1000,
max_reports_monthly INTEGER NOT NULL DEFAULT 10,
-- Features (JSON for flexibility)
features JSONB NOT NULL DEFAULT '{}',
-- Feature flags
has_sat_sync BOOLEAN NOT NULL DEFAULT false,
has_bank_sync BOOLEAN NOT NULL DEFAULT false,
has_ai_insights BOOLEAN NOT NULL DEFAULT false,
has_custom_reports BOOLEAN NOT NULL DEFAULT false,
has_api_access BOOLEAN NOT NULL DEFAULT false,
has_white_label BOOLEAN NOT NULL DEFAULT false,
has_priority_support BOOLEAN NOT NULL DEFAULT false,
has_dedicated_account_manager BOOLEAN NOT NULL DEFAULT false,
-- Retention
data_retention_months INTEGER NOT NULL DEFAULT 12,
-- Display
display_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
is_popular BOOLEAN NOT NULL DEFAULT false,
-- Metadata
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Create index for active plans
CREATE INDEX idx_plans_active ON plans(is_active, display_order);
-- ============================================================================
-- TENANTS TABLE
-- Companies/organizations using the platform
-- ============================================================================
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Basic info
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
schema_name VARCHAR(100) NOT NULL UNIQUE,
-- Tax info (Mexican RFC)
rfc VARCHAR(13),
razon_social VARCHAR(500),
-- Contact
email VARCHAR(255),
phone VARCHAR(50),
-- Address
address_street VARCHAR(500),
address_city VARCHAR(100),
address_state VARCHAR(100),
address_zip VARCHAR(10),
address_country VARCHAR(2) DEFAULT 'MX',
-- Branding
logo_url VARCHAR(500),
primary_color VARCHAR(7),
-- Owner and plan
owner_id UUID NOT NULL,
plan_id VARCHAR(50) NOT NULL REFERENCES plans(id),
-- Status
status tenant_status NOT NULL DEFAULT 'pending',
-- Settings (JSON for flexibility)
settings JSONB NOT NULL DEFAULT '{}',
-- Metadata
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE
);
-- Indexes for tenants
CREATE INDEX idx_tenants_slug ON tenants(slug);
CREATE INDEX idx_tenants_rfc ON tenants(rfc) WHERE rfc IS NOT NULL;
CREATE INDEX idx_tenants_owner ON tenants(owner_id);
CREATE INDEX idx_tenants_plan ON tenants(plan_id);
CREATE INDEX idx_tenants_status ON tenants(status);
CREATE INDEX idx_tenants_created ON tenants(created_at);
-- ============================================================================
-- USERS TABLE
-- Platform users (can belong to multiple tenants via user_tenants)
-- ============================================================================
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Authentication
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255),
-- Profile
first_name VARCHAR(100),
last_name VARCHAR(100),
phone VARCHAR(50),
avatar_url VARCHAR(500),
-- Default role (for super_admin only)
default_role user_role NOT NULL DEFAULT 'viewer',
-- Status
is_active BOOLEAN NOT NULL DEFAULT true,
is_email_verified BOOLEAN NOT NULL DEFAULT false,
email_verified_at TIMESTAMP WITH TIME ZONE,
-- Security
failed_login_attempts INTEGER NOT NULL DEFAULT 0,
locked_until TIMESTAMP WITH TIME ZONE,
password_changed_at TIMESTAMP WITH TIME ZONE,
must_change_password BOOLEAN NOT NULL DEFAULT false,
-- Two-factor authentication
two_factor_enabled BOOLEAN NOT NULL DEFAULT false,
two_factor_secret VARCHAR(255),
two_factor_recovery_codes TEXT[],
-- Preferences
preferences JSONB NOT NULL DEFAULT '{}',
timezone VARCHAR(50) DEFAULT 'America/Mexico_City',
locale VARCHAR(10) DEFAULT 'es-MX',
-- Metadata
last_login_at TIMESTAMP WITH TIME ZONE,
last_login_ip INET,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for users
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_active ON users(is_active);
CREATE INDEX idx_users_default_role ON users(default_role) WHERE default_role = 'super_admin';
CREATE INDEX idx_users_last_login ON users(last_login_at);
-- ============================================================================
-- USER_TENANTS TABLE
-- Association between users and tenants with role
-- ============================================================================
CREATE TABLE user_tenants (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
-- Role within this tenant
role user_role NOT NULL DEFAULT 'viewer',
-- Status
is_active BOOLEAN NOT NULL DEFAULT true,
-- Invitation
invited_by UUID REFERENCES users(id),
invited_at TIMESTAMP WITH TIME ZONE,
accepted_at TIMESTAMP WITH TIME ZONE,
-- Metadata
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, tenant_id)
);
-- Indexes for user_tenants
CREATE INDEX idx_user_tenants_tenant ON user_tenants(tenant_id);
CREATE INDEX idx_user_tenants_user ON user_tenants(user_id);
CREATE INDEX idx_user_tenants_role ON user_tenants(role);
-- ============================================================================
-- USER_SESSIONS TABLE
-- Active user sessions for authentication
-- ============================================================================
CREATE TABLE user_sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
-- Session token (hashed)
token_hash VARCHAR(255) NOT NULL UNIQUE,
refresh_token_hash VARCHAR(255),
-- Device info
user_agent TEXT,
ip_address INET,
device_type VARCHAR(50),
device_name VARCHAR(255),
-- Location (approximate)
location_city VARCHAR(100),
location_country VARCHAR(2),
-- Status
is_active BOOLEAN NOT NULL DEFAULT true,
-- Timestamps
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
refresh_expires_at TIMESTAMP WITH TIME ZONE,
last_activity_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMP WITH TIME ZONE
);
-- Indexes for user_sessions
CREATE INDEX idx_user_sessions_user ON user_sessions(user_id);
CREATE INDEX idx_user_sessions_tenant ON user_sessions(tenant_id);
CREATE INDEX idx_user_sessions_token ON user_sessions(token_hash);
CREATE INDEX idx_user_sessions_expires ON user_sessions(expires_at) WHERE is_active = true;
CREATE INDEX idx_user_sessions_active ON user_sessions(user_id, is_active) WHERE is_active = true;
-- ============================================================================
-- SUBSCRIPTIONS TABLE
-- Tenant subscription management
-- ============================================================================
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
plan_id VARCHAR(50) NOT NULL REFERENCES plans(id),
-- Status
status subscription_status NOT NULL DEFAULT 'trial',
-- Billing cycle
billing_cycle VARCHAR(20) NOT NULL DEFAULT 'monthly', -- monthly, yearly
-- Dates
trial_ends_at TIMESTAMP WITH TIME ZONE,
current_period_start TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
current_period_end TIMESTAMP WITH TIME ZONE NOT NULL,
cancelled_at TIMESTAMP WITH TIME ZONE,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
-- Payment info (Stripe or other processor)
payment_processor VARCHAR(50),
external_subscription_id VARCHAR(255),
external_customer_id VARCHAR(255),
-- Pricing at subscription time
price_cents INTEGER NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'MXN',
-- Usage tracking
usage_cfdis_current INTEGER NOT NULL DEFAULT 0,
usage_storage_mb_current DECIMAL(10,2) NOT NULL DEFAULT 0,
usage_api_calls_current INTEGER NOT NULL DEFAULT 0,
usage_reset_at TIMESTAMP WITH TIME ZONE,
-- Metadata
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for subscriptions
CREATE INDEX idx_subscriptions_tenant ON subscriptions(tenant_id);
CREATE INDEX idx_subscriptions_plan ON subscriptions(plan_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
CREATE INDEX idx_subscriptions_period_end ON subscriptions(current_period_end);
CREATE INDEX idx_subscriptions_external ON subscriptions(external_subscription_id) WHERE external_subscription_id IS NOT NULL;
-- ============================================================================
-- AUDIT_LOG TABLE
-- Comprehensive audit trail for compliance
-- ============================================================================
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Context
tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
-- Action info
action VARCHAR(100) NOT NULL,
entity_type VARCHAR(100) NOT NULL,
entity_id VARCHAR(255),
-- Change tracking
old_values JSONB,
new_values JSONB,
details JSONB,
-- Request context
ip_address INET,
user_agent TEXT,
request_id VARCHAR(100),
-- Metadata
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for audit_log (optimized for queries)
CREATE INDEX idx_audit_log_tenant ON audit_log(tenant_id, created_at DESC);
CREATE INDEX idx_audit_log_user ON audit_log(user_id, created_at DESC);
CREATE INDEX idx_audit_log_action ON audit_log(action, created_at DESC);
CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id, created_at DESC);
CREATE INDEX idx_audit_log_created ON audit_log(created_at DESC);
-- Partition audit_log by month for better performance (optional, can be enabled later)
-- This is a placeholder comment - actual partitioning would require more setup
-- ============================================================================
-- BACKGROUND_JOBS TABLE
-- Async job queue for long-running tasks
-- ============================================================================
CREATE TABLE background_jobs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Context
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
-- Job info
job_type VARCHAR(100) NOT NULL,
job_name VARCHAR(255),
queue VARCHAR(50) NOT NULL DEFAULT 'default',
priority INTEGER NOT NULL DEFAULT 0,
-- Payload
payload JSONB NOT NULL DEFAULT '{}',
-- Status
status job_status NOT NULL DEFAULT 'pending',
progress INTEGER DEFAULT 0, -- 0-100
-- Results
result JSONB,
error_message TEXT,
error_stack TEXT,
-- Retry logic
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 3,
-- Scheduling
scheduled_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
-- Timeout
timeout_seconds INTEGER DEFAULT 3600, -- 1 hour default
-- Metadata
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for background_jobs
CREATE INDEX idx_background_jobs_tenant ON background_jobs(tenant_id);
CREATE INDEX idx_background_jobs_status ON background_jobs(status, scheduled_at) WHERE status IN ('pending', 'running');
CREATE INDEX idx_background_jobs_queue ON background_jobs(queue, priority DESC, scheduled_at) WHERE status = 'pending';
CREATE INDEX idx_background_jobs_type ON background_jobs(job_type, status);
CREATE INDEX idx_background_jobs_scheduled ON background_jobs(scheduled_at) WHERE status = 'pending';
-- ============================================================================
-- API_KEYS TABLE
-- API keys for external integrations
-- ============================================================================
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
-- Key info
name VARCHAR(255) NOT NULL,
description TEXT,
-- The key (only prefix stored for display, full hash for verification)
key_prefix VARCHAR(10) NOT NULL, -- First 8 chars for identification
key_hash VARCHAR(255) NOT NULL UNIQUE, -- SHA-256 hash of full key
-- Permissions (scopes)
scopes TEXT[] NOT NULL DEFAULT '{}',
-- Restrictions
allowed_ips INET[],
allowed_origins TEXT[],
-- Rate limiting
rate_limit_per_minute INTEGER DEFAULT 60,
rate_limit_per_day INTEGER DEFAULT 10000,
-- Usage tracking
last_used_at TIMESTAMP WITH TIME ZONE,
usage_count BIGINT NOT NULL DEFAULT 0,
-- Status
is_active BOOLEAN NOT NULL DEFAULT true,
-- Expiration
expires_at TIMESTAMP WITH TIME ZONE,
-- Metadata
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMP WITH TIME ZONE,
revoked_by UUID REFERENCES users(id) ON DELETE SET NULL
);
-- Indexes for api_keys
CREATE INDEX idx_api_keys_tenant ON api_keys(tenant_id);
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash) WHERE is_active = true;
CREATE INDEX idx_api_keys_prefix ON api_keys(key_prefix);
CREATE INDEX idx_api_keys_active ON api_keys(tenant_id, is_active) WHERE is_active = true;
-- ============================================================================
-- PASSWORD_RESET_TOKENS TABLE
-- Tokens for password reset functionality
-- ============================================================================
CREATE TABLE password_reset_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Index for password_reset_tokens
CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens(user_id);
CREATE INDEX idx_password_reset_tokens_token ON password_reset_tokens(token_hash) WHERE used_at IS NULL;
-- ============================================================================
-- EMAIL_VERIFICATION_TOKENS TABLE
-- Tokens for email verification
-- ============================================================================
CREATE TABLE email_verification_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
token_hash VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
verified_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Index for email_verification_tokens
CREATE INDEX idx_email_verification_tokens_user ON email_verification_tokens(user_id);
CREATE INDEX idx_email_verification_tokens_token ON email_verification_tokens(token_hash) WHERE verified_at IS NULL;
-- ============================================================================
-- INVITATION_TOKENS TABLE
-- Tokens for user invitations to tenants
-- ============================================================================
CREATE TABLE invitation_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
role user_role NOT NULL DEFAULT 'viewer',
token_hash VARCHAR(255) NOT NULL UNIQUE,
invited_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
accepted_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Index for invitation_tokens
CREATE INDEX idx_invitation_tokens_tenant ON invitation_tokens(tenant_id);
CREATE INDEX idx_invitation_tokens_email ON invitation_tokens(email);
CREATE INDEX idx_invitation_tokens_token ON invitation_tokens(token_hash) WHERE accepted_at IS NULL;
-- ============================================================================
-- SYSTEM_SETTINGS TABLE
-- Global platform settings
-- ============================================================================
CREATE TABLE system_settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL,
value_type VARCHAR(20) NOT NULL DEFAULT 'string', -- string, integer, boolean, json
category VARCHAR(50) NOT NULL DEFAULT 'general',
description TEXT,
is_sensitive BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- ============================================================================
-- NOTIFICATIONS TABLE
-- System and user notifications
-- ============================================================================
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
-- Notification content
type VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
message TEXT,
data JSONB,
-- Action
action_url VARCHAR(500),
action_label VARCHAR(100),
-- Status
is_read BOOLEAN NOT NULL DEFAULT false,
read_at TIMESTAMP WITH TIME ZONE,
-- Delivery
channels TEXT[] NOT NULL DEFAULT '{"in_app"}', -- in_app, email, push
email_sent_at TIMESTAMP WITH TIME ZONE,
push_sent_at TIMESTAMP WITH TIME ZONE,
-- Metadata
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE
);
-- Indexes for notifications
CREATE INDEX idx_notifications_user ON notifications(user_id, created_at DESC);
CREATE INDEX idx_notifications_unread ON notifications(user_id, is_read, created_at DESC) WHERE is_read = false;
CREATE INDEX idx_notifications_tenant ON notifications(tenant_id, created_at DESC);
-- ============================================================================
-- FUNCTIONS AND TRIGGERS
-- ============================================================================
-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Apply update_updated_at trigger to all relevant tables
CREATE TRIGGER update_plans_updated_at BEFORE UPDATE ON plans
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_tenants_updated_at BEFORE UPDATE ON tenants
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_user_tenants_updated_at BEFORE UPDATE ON user_tenants
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_subscriptions_updated_at BEFORE UPDATE ON subscriptions
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_background_jobs_updated_at BEFORE UPDATE ON background_jobs
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_api_keys_updated_at BEFORE UPDATE ON api_keys
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_system_settings_updated_at BEFORE UPDATE ON system_settings
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================================
-- ROW LEVEL SECURITY (RLS) POLICIES
-- Enable RLS for multi-tenant security
-- ============================================================================
-- Note: RLS policies would be configured here in production
-- For now, security is handled at the application layer with schema-based isolation
-- ============================================================================
-- COMMENTS FOR DOCUMENTATION
-- ============================================================================
COMMENT ON TABLE plans IS 'Subscription plans available in the platform';
COMMENT ON TABLE tenants IS 'Companies/organizations using the platform (each gets their own schema)';
COMMENT ON TABLE users IS 'Platform users (authentication and profile)';
COMMENT ON TABLE user_tenants IS 'Many-to-many relationship between users and tenants with role';
COMMENT ON TABLE user_sessions IS 'Active user sessions for JWT-based authentication';
COMMENT ON TABLE subscriptions IS 'Tenant subscription and billing information';
COMMENT ON TABLE audit_log IS 'Comprehensive audit trail for security and compliance';
COMMENT ON TABLE background_jobs IS 'Async job queue for long-running tasks (SAT sync, reports, etc.)';
COMMENT ON TABLE api_keys IS 'API keys for external integrations and third-party access';
COMMENT ON TABLE notifications IS 'In-app and push notifications for users';

View File

@@ -0,0 +1,889 @@
-- ============================================================================
-- Horux Strategy - Tenant Schema Template
-- Version: 002
-- Description: Tables created for each tenant in their own schema
-- Note: ${SCHEMA_NAME} will be replaced with the actual schema name
-- ============================================================================
-- ============================================================================
-- ENUM TYPES (tenant-specific)
-- ============================================================================
-- Transaction type
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'transaction_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
CREATE TYPE transaction_type AS ENUM ('income', 'expense', 'transfer', 'adjustment');
END IF;
END$$;
-- Transaction status
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'transaction_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
CREATE TYPE transaction_status AS ENUM ('pending', 'confirmed', 'reconciled', 'voided');
END IF;
END$$;
-- CFDI status
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'cfdi_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
CREATE TYPE cfdi_status AS ENUM ('active', 'cancelled', 'pending_cancellation');
END IF;
END$$;
-- CFDI type (comprobante)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'cfdi_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
CREATE TYPE cfdi_type AS ENUM ('I', 'E', 'T', 'N', 'P'); -- Ingreso, Egreso, Traslado, Nomina, Pago
END IF;
END$$;
-- Contact type
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'contact_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
CREATE TYPE contact_type AS ENUM ('customer', 'supplier', 'both', 'employee');
END IF;
END$$;
-- Category type
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'category_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
CREATE TYPE category_type AS ENUM ('income', 'expense', 'cost', 'other');
END IF;
END$$;
-- Account type
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'account_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
CREATE TYPE account_type AS ENUM ('asset', 'liability', 'equity', 'revenue', 'expense');
END IF;
END$$;
-- Alert severity
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'alert_severity' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
CREATE TYPE alert_severity AS ENUM ('info', 'warning', 'critical');
END IF;
END$$;
-- Report status
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'report_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema())) THEN
CREATE TYPE report_status AS ENUM ('draft', 'generating', 'completed', 'failed', 'archived');
END IF;
END$$;
-- ============================================================================
-- SAT_CREDENTIALS TABLE
-- Encrypted FIEL (e.firma) credentials for SAT integration
-- ============================================================================
CREATE TABLE sat_credentials (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- RFC associated with credentials
rfc VARCHAR(13) NOT NULL UNIQUE,
-- FIEL Components (encrypted with AES-256)
-- The actual encryption key is stored securely in environment variables
cer_file_encrypted BYTEA NOT NULL, -- .cer file content (encrypted)
key_file_encrypted BYTEA NOT NULL, -- .key file content (encrypted)
password_encrypted BYTEA NOT NULL, -- FIEL password (encrypted)
-- Certificate metadata
cer_serial_number VARCHAR(50),
cer_issued_at TIMESTAMP WITH TIME ZONE,
cer_expires_at TIMESTAMP WITH TIME ZONE,
cer_issuer VARCHAR(255),
-- CIEC credentials (optional, for portal access)
ciec_password_encrypted BYTEA,
-- Status
is_active BOOLEAN NOT NULL DEFAULT true,
is_valid BOOLEAN NOT NULL DEFAULT false, -- Set after validation
last_validated_at TIMESTAMP WITH TIME ZONE,
validation_error TEXT,
-- SAT sync settings
sync_enabled BOOLEAN NOT NULL DEFAULT false,
sync_frequency_hours INTEGER DEFAULT 24,
last_sync_at TIMESTAMP WITH TIME ZONE,
last_sync_status VARCHAR(50),
last_sync_error TEXT,
-- Encryption metadata
encryption_version INTEGER NOT NULL DEFAULT 1,
encryption_iv BYTEA, -- Initialization vector
-- Metadata
created_by UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for sat_credentials
CREATE INDEX idx_sat_credentials_rfc ON sat_credentials(rfc);
CREATE INDEX idx_sat_credentials_active ON sat_credentials(is_active, is_valid);
CREATE INDEX idx_sat_credentials_sync ON sat_credentials(sync_enabled, last_sync_at) WHERE sync_enabled = true;
-- ============================================================================
-- CFDIS TABLE
-- CFDI 4.0 compliant invoice storage
-- ============================================================================
CREATE TABLE cfdis (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- CFDI Identifiers
uuid_fiscal UUID NOT NULL UNIQUE, -- UUID from SAT (timbre fiscal)
serie VARCHAR(25),
folio VARCHAR(40),
-- Type and status
tipo_comprobante cfdi_type NOT NULL, -- I, E, T, N, P
status cfdi_status NOT NULL DEFAULT 'active',
-- Dates
fecha_emision TIMESTAMP WITH TIME ZONE NOT NULL,
fecha_timbrado TIMESTAMP WITH TIME ZONE,
fecha_cancelacion TIMESTAMP WITH TIME ZONE,
-- Emisor (seller)
emisor_rfc VARCHAR(13) NOT NULL,
emisor_nombre VARCHAR(500) NOT NULL,
emisor_regimen_fiscal VARCHAR(3) NOT NULL, -- SAT catalog code
-- Receptor (buyer)
receptor_rfc VARCHAR(13) NOT NULL,
receptor_nombre VARCHAR(500) NOT NULL,
receptor_regimen_fiscal VARCHAR(3),
receptor_domicilio_fiscal VARCHAR(5), -- CP
receptor_uso_cfdi VARCHAR(4) NOT NULL, -- SAT catalog code
-- Amounts
subtotal DECIMAL(18,2) NOT NULL,
descuento DECIMAL(18,2) DEFAULT 0,
total DECIMAL(18,2) NOT NULL,
-- Tax breakdown
total_impuestos_trasladados DECIMAL(18,2) DEFAULT 0,
total_impuestos_retenidos DECIMAL(18,2) DEFAULT 0,
-- IVA specific
iva_16 DECIMAL(18,2) DEFAULT 0, -- IVA 16%
iva_8 DECIMAL(18,2) DEFAULT 0, -- IVA 8% (frontera)
iva_0 DECIMAL(18,2) DEFAULT 0, -- IVA 0%
iva_exento DECIMAL(18,2) DEFAULT 0, -- IVA exento
-- ISR retention
isr_retenido DECIMAL(18,2) DEFAULT 0,
iva_retenido DECIMAL(18,2) DEFAULT 0,
-- Currency
moneda VARCHAR(3) NOT NULL DEFAULT 'MXN',
tipo_cambio DECIMAL(18,6) DEFAULT 1,
-- Payment info
forma_pago VARCHAR(2), -- SAT catalog
metodo_pago VARCHAR(3), -- PUE, PPD
condiciones_pago VARCHAR(255),
-- Related documents
cfdi_relacionados JSONB, -- Array of related CFDI UUIDs
tipo_relacion VARCHAR(2), -- SAT catalog
-- Concepts/items (denormalized for quick access)
conceptos JSONB NOT NULL, -- Array of line items
-- Full XML storage
xml_content TEXT, -- Original XML
xml_hash VARCHAR(64), -- SHA-256 of XML
-- Digital stamps
sello_cfdi TEXT, -- Digital signature
sello_sat TEXT, -- SAT signature
certificado_sat VARCHAR(50),
cadena_original_tfd TEXT,
-- Direction (for the tenant)
is_emitted BOOLEAN NOT NULL, -- true = we issued it, false = we received it
-- Categorization
category_id UUID,
contact_id UUID,
-- Reconciliation
is_reconciled BOOLEAN NOT NULL DEFAULT false,
reconciled_at TIMESTAMP WITH TIME ZONE,
reconciled_by UUID,
-- AI-generated insights
ai_category_suggestion VARCHAR(100),
ai_confidence_score DECIMAL(5,4),
-- Source
source VARCHAR(50) NOT NULL DEFAULT 'sat_sync', -- sat_sync, manual, api
-- Metadata
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for cfdis (optimized for reporting queries)
CREATE INDEX idx_cfdis_uuid_fiscal ON cfdis(uuid_fiscal);
CREATE INDEX idx_cfdis_fecha_emision ON cfdis(fecha_emision DESC);
CREATE INDEX idx_cfdis_emisor_rfc ON cfdis(emisor_rfc);
CREATE INDEX idx_cfdis_receptor_rfc ON cfdis(receptor_rfc);
CREATE INDEX idx_cfdis_tipo ON cfdis(tipo_comprobante);
CREATE INDEX idx_cfdis_status ON cfdis(status);
CREATE INDEX idx_cfdis_is_emitted ON cfdis(is_emitted);
CREATE INDEX idx_cfdis_category ON cfdis(category_id) WHERE category_id IS NOT NULL;
CREATE INDEX idx_cfdis_contact ON cfdis(contact_id) WHERE contact_id IS NOT NULL;
CREATE INDEX idx_cfdis_reconciled ON cfdis(is_reconciled, fecha_emision DESC) WHERE is_reconciled = false;
-- Composite indexes for common queries
CREATE INDEX idx_cfdis_emitted_date ON cfdis(is_emitted, fecha_emision DESC);
CREATE INDEX idx_cfdis_type_date ON cfdis(tipo_comprobante, fecha_emision DESC);
CREATE INDEX idx_cfdis_month_report ON cfdis(DATE_TRUNC('month', fecha_emision), tipo_comprobante, is_emitted);
-- Full-text search index
CREATE INDEX idx_cfdis_search ON cfdis USING gin(to_tsvector('spanish', emisor_nombre || ' ' || receptor_nombre || ' ' || COALESCE(serie, '') || ' ' || COALESCE(folio, '')));
-- ============================================================================
-- TRANSACTIONS TABLE
-- Unified financial transaction model
-- ============================================================================
CREATE TABLE transactions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Transaction info
type transaction_type NOT NULL,
status transaction_status NOT NULL DEFAULT 'pending',
-- Amount
amount DECIMAL(18,2) NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'MXN',
exchange_rate DECIMAL(18,6) DEFAULT 1,
amount_mxn DECIMAL(18,2) NOT NULL, -- Always in MXN for reporting
-- Dates
transaction_date DATE NOT NULL,
value_date DATE, -- Settlement date
recorded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
-- Description
description TEXT,
reference VARCHAR(255),
notes TEXT,
-- Categorization
category_id UUID,
account_id UUID,
contact_id UUID,
-- Related documents
cfdi_id UUID REFERENCES cfdis(id) ON DELETE SET NULL,
-- Bank reconciliation
bank_transaction_id VARCHAR(255),
bank_account_id VARCHAR(100),
bank_description TEXT,
-- Recurring
is_recurring BOOLEAN NOT NULL DEFAULT false,
recurring_pattern JSONB, -- Frequency, end date, etc.
parent_transaction_id UUID REFERENCES transactions(id) ON DELETE SET NULL,
-- Attachments
attachments JSONB, -- Array of file references
-- Tags for custom classification
tags TEXT[],
-- Reconciliation status
is_reconciled BOOLEAN NOT NULL DEFAULT false,
reconciled_at TIMESTAMP WITH TIME ZONE,
reconciled_by UUID,
-- Approval workflow (for larger amounts)
requires_approval BOOLEAN NOT NULL DEFAULT false,
approved_at TIMESTAMP WITH TIME ZONE,
approved_by UUID,
-- AI categorization
ai_category_id UUID,
ai_confidence DECIMAL(5,4),
ai_notes TEXT,
-- Metadata
created_by UUID,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
voided_at TIMESTAMP WITH TIME ZONE,
voided_by UUID,
void_reason TEXT
);
-- Indexes for transactions
CREATE INDEX idx_transactions_date ON transactions(transaction_date DESC);
CREATE INDEX idx_transactions_type ON transactions(type);
CREATE INDEX idx_transactions_status ON transactions(status);
CREATE INDEX idx_transactions_category ON transactions(category_id);
CREATE INDEX idx_transactions_account ON transactions(account_id);
CREATE INDEX idx_transactions_contact ON transactions(contact_id);
CREATE INDEX idx_transactions_cfdi ON transactions(cfdi_id) WHERE cfdi_id IS NOT NULL;
CREATE INDEX idx_transactions_reconciled ON transactions(is_reconciled, transaction_date DESC) WHERE is_reconciled = false;
-- Composite indexes for reporting
CREATE INDEX idx_transactions_monthly ON transactions(DATE_TRUNC('month', transaction_date), type, status);
CREATE INDEX idx_transactions_category_date ON transactions(category_id, transaction_date DESC) WHERE category_id IS NOT NULL;
-- ============================================================================
-- CONTACTS TABLE
-- Customers and suppliers
-- ============================================================================
CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Type
type contact_type NOT NULL,
-- Basic info
name VARCHAR(500) NOT NULL,
trade_name VARCHAR(500), -- Nombre comercial
-- Tax info (RFC)
rfc VARCHAR(13),
regimen_fiscal VARCHAR(3), -- SAT catalog
uso_cfdi_default VARCHAR(4), -- Default uso CFDI
-- Contact info
email VARCHAR(255),
phone VARCHAR(50),
mobile VARCHAR(50),
website VARCHAR(255),
-- Address
address_street VARCHAR(500),
address_interior VARCHAR(50),
address_exterior VARCHAR(50),
address_neighborhood VARCHAR(200), -- Colonia
address_city VARCHAR(100),
address_municipality VARCHAR(100), -- Municipio/Delegacion
address_state VARCHAR(100),
address_zip VARCHAR(5),
address_country VARCHAR(2) DEFAULT 'MX',
-- Bank info
bank_name VARCHAR(100),
bank_account VARCHAR(20),
bank_clabe VARCHAR(18),
-- Credit terms
credit_days INTEGER DEFAULT 0,
credit_limit DECIMAL(18,2) DEFAULT 0,
-- Balances (denormalized for performance)
balance_receivable DECIMAL(18,2) DEFAULT 0,
balance_payable DECIMAL(18,2) DEFAULT 0,
-- Classification
category VARCHAR(100),
tags TEXT[],
-- Status
is_active BOOLEAN NOT NULL DEFAULT true,
-- Notes
notes TEXT,
-- Metadata
created_by UUID,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for contacts
CREATE INDEX idx_contacts_type ON contacts(type);
CREATE INDEX idx_contacts_rfc ON contacts(rfc) WHERE rfc IS NOT NULL;
CREATE INDEX idx_contacts_name ON contacts(name);
CREATE INDEX idx_contacts_active ON contacts(is_active);
CREATE INDEX idx_contacts_search ON contacts USING gin(to_tsvector('spanish', name || ' ' || COALESCE(trade_name, '') || ' ' || COALESCE(rfc, '')));
-- ============================================================================
-- CATEGORIES TABLE
-- Transaction/expense categories
-- ============================================================================
CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Identification
code VARCHAR(20) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
description TEXT,
-- Type
type category_type NOT NULL,
-- Hierarchy
parent_id UUID REFERENCES categories(id) ON DELETE SET NULL,
level INTEGER NOT NULL DEFAULT 0,
path TEXT, -- Materialized path for hierarchy
-- SAT mapping
sat_key VARCHAR(10), -- Clave producto/servicio SAT
-- Budget
budget_monthly DECIMAL(18,2),
budget_yearly DECIMAL(18,2),
-- Display
color VARCHAR(7), -- Hex color
icon VARCHAR(50),
display_order INTEGER DEFAULT 0,
-- Status
is_active BOOLEAN NOT NULL DEFAULT true,
is_system BOOLEAN NOT NULL DEFAULT false, -- Prevent deletion of system categories
-- Metadata
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for categories
CREATE INDEX idx_categories_code ON categories(code);
CREATE INDEX idx_categories_type ON categories(type);
CREATE INDEX idx_categories_parent ON categories(parent_id);
CREATE INDEX idx_categories_active ON categories(is_active);
-- ============================================================================
-- ACCOUNTS TABLE
-- Chart of accounts (catalogo de cuentas)
-- ============================================================================
CREATE TABLE accounts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Identification
code VARCHAR(20) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
description TEXT,
-- Type
type account_type NOT NULL,
-- Hierarchy
parent_id UUID REFERENCES accounts(id) ON DELETE SET NULL,
level INTEGER NOT NULL DEFAULT 0,
path TEXT, -- Materialized path
-- SAT mapping (for Contabilidad Electronica)
sat_code VARCHAR(20), -- Codigo agrupador SAT
sat_nature VARCHAR(1), -- D = Deudora, A = Acreedora
-- Balances (denormalized)
balance_debit DECIMAL(18,2) DEFAULT 0,
balance_credit DECIMAL(18,2) DEFAULT 0,
balance_current DECIMAL(18,2) DEFAULT 0,
-- Status
is_active BOOLEAN NOT NULL DEFAULT true,
is_system BOOLEAN NOT NULL DEFAULT false,
allows_movements BOOLEAN NOT NULL DEFAULT true, -- Can have direct transactions
-- Display
display_order INTEGER DEFAULT 0,
-- Metadata
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for accounts
CREATE INDEX idx_accounts_code ON accounts(code);
CREATE INDEX idx_accounts_type ON accounts(type);
CREATE INDEX idx_accounts_parent ON accounts(parent_id);
CREATE INDEX idx_accounts_active ON accounts(is_active);
-- ============================================================================
-- METRICS_CACHE TABLE
-- Pre-computed metrics for dashboard performance
-- ============================================================================
CREATE TABLE metrics_cache (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Metric identification
metric_key VARCHAR(100) NOT NULL,
period_type VARCHAR(20) NOT NULL, -- daily, weekly, monthly, yearly
period_start DATE NOT NULL,
period_end DATE NOT NULL,
-- Dimension (optional filtering)
dimension_type VARCHAR(50), -- category, contact, account, etc.
dimension_id UUID,
-- Values
value_numeric DECIMAL(18,4),
value_json JSONB,
-- Comparison
previous_value DECIMAL(18,4),
change_percent DECIMAL(8,4),
change_absolute DECIMAL(18,4),
-- Validity
computed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
valid_until TIMESTAMP WITH TIME ZONE,
is_stale BOOLEAN NOT NULL DEFAULT false,
-- Unique constraint on metric + period + dimension
UNIQUE(metric_key, period_type, period_start, dimension_type, dimension_id)
);
-- Indexes for metrics_cache
CREATE INDEX idx_metrics_cache_key ON metrics_cache(metric_key);
CREATE INDEX idx_metrics_cache_period ON metrics_cache(period_type, period_start DESC);
CREATE INDEX idx_metrics_cache_dimension ON metrics_cache(dimension_type, dimension_id) WHERE dimension_type IS NOT NULL;
CREATE INDEX idx_metrics_cache_stale ON metrics_cache(is_stale) WHERE is_stale = true;
-- ============================================================================
-- ALERTS TABLE
-- Financial alerts and notifications
-- ============================================================================
CREATE TABLE alerts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Alert info
type VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
severity alert_severity NOT NULL DEFAULT 'info',
-- Related entity
entity_type VARCHAR(50),
entity_id UUID,
-- Threshold that triggered the alert
threshold_type VARCHAR(50),
threshold_value DECIMAL(18,4),
current_value DECIMAL(18,4),
-- Actions
action_url VARCHAR(500),
action_label VARCHAR(100),
action_data JSONB,
-- Status
is_read BOOLEAN NOT NULL DEFAULT false,
is_dismissed BOOLEAN NOT NULL DEFAULT false,
read_at TIMESTAMP WITH TIME ZONE,
dismissed_at TIMESTAMP WITH TIME ZONE,
dismissed_by UUID,
-- Recurrence
is_recurring BOOLEAN NOT NULL DEFAULT false,
last_triggered_at TIMESTAMP WITH TIME ZONE,
trigger_count INTEGER DEFAULT 1,
-- Auto-resolve
auto_resolved BOOLEAN NOT NULL DEFAULT false,
resolved_at TIMESTAMP WITH TIME ZONE,
resolved_by UUID,
resolution_notes TEXT,
-- Metadata
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE
);
-- Indexes for alerts
CREATE INDEX idx_alerts_type ON alerts(type);
CREATE INDEX idx_alerts_severity ON alerts(severity);
CREATE INDEX idx_alerts_unread ON alerts(is_read, created_at DESC) WHERE is_read = false;
CREATE INDEX idx_alerts_entity ON alerts(entity_type, entity_id) WHERE entity_type IS NOT NULL;
CREATE INDEX idx_alerts_active ON alerts(is_dismissed, created_at DESC) WHERE is_dismissed = false;
-- ============================================================================
-- REPORTS TABLE
-- Generated reports
-- ============================================================================
CREATE TABLE reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Report info
type VARCHAR(100) NOT NULL, -- balance_general, estado_resultados, flujo_efectivo, etc.
name VARCHAR(255) NOT NULL,
description TEXT,
-- Period
period_start DATE NOT NULL,
period_end DATE NOT NULL,
comparison_period_start DATE,
comparison_period_end DATE,
-- Status
status report_status NOT NULL DEFAULT 'draft',
-- Parameters used to generate
parameters JSONB,
-- Output
data JSONB, -- Report data
file_url VARCHAR(500), -- PDF/Excel URL
file_format VARCHAR(10), -- pdf, xlsx, csv
-- Scheduling
is_scheduled BOOLEAN NOT NULL DEFAULT false,
schedule_cron VARCHAR(50),
next_scheduled_at TIMESTAMP WITH TIME ZONE,
last_generated_at TIMESTAMP WITH TIME ZONE,
-- Sharing
is_shared BOOLEAN NOT NULL DEFAULT false,
shared_with UUID[],
share_token VARCHAR(100),
share_expires_at TIMESTAMP WITH TIME ZONE,
-- Metadata
generated_by UUID,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for reports
CREATE INDEX idx_reports_type ON reports(type);
CREATE INDEX idx_reports_status ON reports(status);
CREATE INDEX idx_reports_period ON reports(period_start, period_end);
CREATE INDEX idx_reports_scheduled ON reports(is_scheduled, next_scheduled_at) WHERE is_scheduled = true;
-- ============================================================================
-- SETTINGS TABLE
-- Tenant-specific settings
-- ============================================================================
CREATE TABLE settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL,
value_type VARCHAR(20) NOT NULL DEFAULT 'string', -- string, integer, boolean, json
category VARCHAR(50) NOT NULL DEFAULT 'general',
label VARCHAR(200),
description TEXT,
is_sensitive BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- ============================================================================
-- BANK_ACCOUNTS TABLE
-- Connected bank accounts
-- ============================================================================
CREATE TABLE bank_accounts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Bank info
bank_name VARCHAR(100) NOT NULL,
bank_code VARCHAR(10), -- SPEI bank code
-- Account info
account_number VARCHAR(20),
clabe VARCHAR(18),
account_type VARCHAR(50), -- checking, savings, credit
-- Display
alias VARCHAR(100),
currency VARCHAR(3) DEFAULT 'MXN',
-- Balance (cached from bank sync)
balance_available DECIMAL(18,2),
balance_current DECIMAL(18,2),
balance_updated_at TIMESTAMP WITH TIME ZONE,
-- Connection
connection_provider VARCHAR(50), -- belvo, finerio, manual
connection_id VARCHAR(255),
connection_status VARCHAR(50),
last_sync_at TIMESTAMP WITH TIME ZONE,
last_sync_error TEXT,
-- Categorization
account_id UUID REFERENCES accounts(id), -- Link to chart of accounts
-- Status
is_active BOOLEAN NOT NULL DEFAULT true,
-- Metadata
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for bank_accounts
CREATE INDEX idx_bank_accounts_active ON bank_accounts(is_active);
CREATE INDEX idx_bank_accounts_connection ON bank_accounts(connection_provider, connection_id) WHERE connection_id IS NOT NULL;
-- ============================================================================
-- BUDGET_ITEMS TABLE
-- Budget planning
-- ============================================================================
CREATE TABLE budget_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Period
year INTEGER NOT NULL,
month INTEGER NOT NULL, -- 1-12, or 0 for yearly
-- Category
category_id UUID REFERENCES categories(id) ON DELETE CASCADE,
account_id UUID REFERENCES accounts(id) ON DELETE CASCADE,
-- Amounts
amount_budgeted DECIMAL(18,2) NOT NULL,
amount_actual DECIMAL(18,2) DEFAULT 0,
amount_variance DECIMAL(18,2) GENERATED ALWAYS AS (amount_actual - amount_budgeted) STORED,
-- Notes
notes TEXT,
-- Status
is_locked BOOLEAN NOT NULL DEFAULT false,
-- Metadata
created_by UUID,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
-- One budget per category/account per period
UNIQUE(year, month, category_id),
UNIQUE(year, month, account_id)
);
-- Indexes for budget_items
CREATE INDEX idx_budget_items_period ON budget_items(year, month);
CREATE INDEX idx_budget_items_category ON budget_items(category_id);
CREATE INDEX idx_budget_items_account ON budget_items(account_id);
-- ============================================================================
-- ATTACHMENTS TABLE
-- File attachments for various entities
-- ============================================================================
CREATE TABLE attachments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Related entity
entity_type VARCHAR(50) NOT NULL, -- cfdi, transaction, contact, etc.
entity_id UUID NOT NULL,
-- File info
file_name VARCHAR(255) NOT NULL,
file_type VARCHAR(100),
file_size INTEGER,
file_url VARCHAR(500) NOT NULL,
-- Storage
storage_provider VARCHAR(50) DEFAULT 'local', -- local, s3, gcs
storage_path VARCHAR(500),
-- Metadata
uploaded_by UUID,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for attachments
CREATE INDEX idx_attachments_entity ON attachments(entity_type, entity_id);
-- ============================================================================
-- TRIGGERS FOR TENANT SCHEMA
-- ============================================================================
-- Update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Apply triggers
CREATE TRIGGER update_sat_credentials_updated_at BEFORE UPDATE ON sat_credentials
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_cfdis_updated_at BEFORE UPDATE ON cfdis
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_transactions_updated_at BEFORE UPDATE ON transactions
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_contacts_updated_at BEFORE UPDATE ON contacts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_categories_updated_at BEFORE UPDATE ON categories
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_accounts_updated_at BEFORE UPDATE ON accounts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_reports_updated_at BEFORE UPDATE ON reports
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_settings_updated_at BEFORE UPDATE ON settings
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_bank_accounts_updated_at BEFORE UPDATE ON bank_accounts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_budget_items_updated_at BEFORE UPDATE ON budget_items
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================================
-- FOREIGN KEY CONSTRAINTS
-- ============================================================================
-- Add foreign keys after all tables are created
ALTER TABLE cfdis ADD CONSTRAINT fk_cfdis_category
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL;
ALTER TABLE cfdis ADD CONSTRAINT fk_cfdis_contact
FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE SET NULL;
ALTER TABLE transactions ADD CONSTRAINT fk_transactions_category
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL;
ALTER TABLE transactions ADD CONSTRAINT fk_transactions_account
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE SET NULL;
ALTER TABLE transactions ADD CONSTRAINT fk_transactions_contact
FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE SET NULL;
-- ============================================================================
-- COMMENTS
-- ============================================================================
COMMENT ON TABLE sat_credentials IS 'Encrypted SAT FIEL credentials for each tenant';
COMMENT ON TABLE cfdis IS 'CFDI 4.0 invoices (emitted and received)';
COMMENT ON TABLE transactions IS 'Unified financial transactions';
COMMENT ON TABLE contacts IS 'Customers and suppliers';
COMMENT ON TABLE categories IS 'Transaction categorization';
COMMENT ON TABLE accounts IS 'Chart of accounts (catalogo de cuentas)';
COMMENT ON TABLE metrics_cache IS 'Pre-computed metrics for dashboard';
COMMENT ON TABLE alerts IS 'Financial alerts and notifications';
COMMENT ON TABLE reports IS 'Generated financial reports';
COMMENT ON TABLE settings IS 'Tenant-specific configuration';

View File

@@ -0,0 +1,521 @@
/**
* Database Seed Script
*
* Populates the database with initial data:
* - Subscription plans
* - System settings
* - Default super admin user
*/
import { Pool, PoolClient } from 'pg';
import { randomBytes, createHash } from 'crypto';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config();
// Database configuration
interface DatabaseConfig {
host: string;
port: number;
database: string;
user: string;
password: string;
ssl?: boolean | { rejectUnauthorized: boolean };
}
/**
* Get database configuration from environment
*/
function getConfig(): DatabaseConfig {
return {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
database: process.env.DB_NAME || 'horux_strategy',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || '',
ssl: process.env.DB_SSL === 'true'
? { rejectUnauthorized: process.env.DB_SSL_REJECT_UNAUTHORIZED !== 'false' }
: false,
};
}
/**
* Hash password using SHA-256 (in production, use bcrypt)
* This is a placeholder - actual implementation should use bcrypt
*/
function hashPassword(password: string): string {
// In production, use bcrypt with proper salt rounds
// This is just for seeding purposes
const salt = randomBytes(16).toString('hex');
const hash = createHash('sha256').update(password + salt).digest('hex');
return `sha256:${salt}:${hash}`;
}
/**
* Subscription plans data
*/
const PLANS = [
{
id: 'startup',
name: 'Startup',
description: 'Para emprendedores y negocios en crecimiento',
price_monthly_cents: 49900, // $499 MXN
price_yearly_cents: 479900, // $4,799 MXN (20% off)
max_users: 2,
max_cfdis_monthly: 100,
max_storage_mb: 1024, // 1 GB
max_api_calls_daily: 500,
max_reports_monthly: 5,
features: {
dashboard: true,
cfdi_management: true,
basic_reports: true,
email_support: true,
},
has_sat_sync: true,
has_bank_sync: false,
has_ai_insights: false,
has_custom_reports: false,
has_api_access: false,
has_white_label: false,
has_priority_support: false,
has_dedicated_account_manager: false,
data_retention_months: 12,
display_order: 1,
is_popular: false,
},
{
id: 'pyme',
name: 'PyME',
description: 'Para pequenas y medianas empresas',
price_monthly_cents: 99900, // $999 MXN
price_yearly_cents: 959900, // $9,599 MXN (20% off)
max_users: 5,
max_cfdis_monthly: 500,
max_storage_mb: 5120, // 5 GB
max_api_calls_daily: 2000,
max_reports_monthly: 20,
features: {
dashboard: true,
cfdi_management: true,
basic_reports: true,
advanced_reports: true,
email_support: true,
chat_support: true,
bank_reconciliation: true,
},
has_sat_sync: true,
has_bank_sync: true,
has_ai_insights: true,
has_custom_reports: false,
has_api_access: false,
has_white_label: false,
has_priority_support: false,
has_dedicated_account_manager: false,
data_retention_months: 24,
display_order: 2,
is_popular: true,
},
{
id: 'enterprise',
name: 'Enterprise',
description: 'Para empresas en expansion con necesidades avanzadas',
price_monthly_cents: 249900, // $2,499 MXN
price_yearly_cents: 2399900, // $23,999 MXN (20% off)
max_users: 15,
max_cfdis_monthly: 2000,
max_storage_mb: 20480, // 20 GB
max_api_calls_daily: 10000,
max_reports_monthly: 100,
features: {
dashboard: true,
cfdi_management: true,
basic_reports: true,
advanced_reports: true,
custom_reports: true,
email_support: true,
chat_support: true,
phone_support: true,
bank_reconciliation: true,
multi_currency: true,
budget_planning: true,
api_access: true,
},
has_sat_sync: true,
has_bank_sync: true,
has_ai_insights: true,
has_custom_reports: true,
has_api_access: true,
has_white_label: false,
has_priority_support: true,
has_dedicated_account_manager: false,
data_retention_months: 60,
display_order: 3,
is_popular: false,
},
{
id: 'corporativo',
name: 'Corporativo',
description: 'Solucion completa para grandes corporaciones',
price_monthly_cents: 499900, // $4,999 MXN
price_yearly_cents: 4799900, // $47,999 MXN (20% off)
max_users: 50,
max_cfdis_monthly: 10000,
max_storage_mb: 102400, // 100 GB
max_api_calls_daily: 50000,
max_reports_monthly: -1, // Unlimited
features: {
dashboard: true,
cfdi_management: true,
basic_reports: true,
advanced_reports: true,
custom_reports: true,
email_support: true,
chat_support: true,
phone_support: true,
bank_reconciliation: true,
multi_currency: true,
budget_planning: true,
api_access: true,
white_label: true,
sso: true,
audit_logs: true,
custom_integrations: true,
dedicated_infrastructure: true,
},
has_sat_sync: true,
has_bank_sync: true,
has_ai_insights: true,
has_custom_reports: true,
has_api_access: true,
has_white_label: true,
has_priority_support: true,
has_dedicated_account_manager: true,
data_retention_months: 120, // 10 years
display_order: 4,
is_popular: false,
},
];
/**
* System settings data
*/
const SYSTEM_SETTINGS = [
// General
{ key: 'app_name', value: 'Horux Strategy', category: 'general', value_type: 'string' },
{ key: 'app_version', value: '0.1.0', category: 'general', value_type: 'string' },
{ key: 'app_environment', value: 'development', category: 'general', value_type: 'string' },
{ key: 'default_timezone', value: 'America/Mexico_City', category: 'general', value_type: 'string' },
{ key: 'default_locale', value: 'es-MX', category: 'general', value_type: 'string' },
{ key: 'default_currency', value: 'MXN', category: 'general', value_type: 'string' },
// Authentication
{ key: 'session_duration_hours', value: '24', category: 'auth', value_type: 'integer' },
{ key: 'refresh_token_duration_days', value: '30', category: 'auth', value_type: 'integer' },
{ key: 'max_login_attempts', value: '5', category: 'auth', value_type: 'integer' },
{ key: 'lockout_duration_minutes', value: '30', category: 'auth', value_type: 'integer' },
{ key: 'password_min_length', value: '8', category: 'auth', value_type: 'integer' },
{ key: 'require_2fa_for_admin', value: 'true', category: 'auth', value_type: 'boolean' },
// Email
{ key: 'email_from_name', value: 'Horux Strategy', category: 'email', value_type: 'string' },
{ key: 'email_from_address', value: 'noreply@horuxstrategy.com', category: 'email', value_type: 'string' },
// SAT Integration
{ key: 'sat_api_base_url', value: 'https://cfdidescargamasiva.clouda.sat.gob.mx', category: 'sat', value_type: 'string' },
{ key: 'sat_sync_batch_size', value: '100', category: 'sat', value_type: 'integer' },
{ key: 'sat_sync_max_retries', value: '3', category: 'sat', value_type: 'integer' },
// Jobs
{ key: 'job_cleanup_days', value: '30', category: 'jobs', value_type: 'integer' },
{ key: 'job_max_concurrent', value: '5', category: 'jobs', value_type: 'integer' },
// Maintenance
{ key: 'maintenance_mode', value: 'false', category: 'maintenance', value_type: 'boolean' },
{ key: 'maintenance_message', value: '', category: 'maintenance', value_type: 'string' },
];
/**
* Seed plans
*/
async function seedPlans(client: PoolClient): Promise<void> {
console.log('Seeding subscription plans...');
for (const plan of PLANS) {
await client.query(
`INSERT INTO plans (
id, name, description,
price_monthly_cents, price_yearly_cents,
max_users, max_cfdis_monthly, max_storage_mb, max_api_calls_daily, max_reports_monthly,
features,
has_sat_sync, has_bank_sync, has_ai_insights, has_custom_reports,
has_api_access, has_white_label, has_priority_support, has_dedicated_account_manager,
data_retention_months, display_order, is_popular
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22
)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
price_monthly_cents = EXCLUDED.price_monthly_cents,
price_yearly_cents = EXCLUDED.price_yearly_cents,
max_users = EXCLUDED.max_users,
max_cfdis_monthly = EXCLUDED.max_cfdis_monthly,
max_storage_mb = EXCLUDED.max_storage_mb,
max_api_calls_daily = EXCLUDED.max_api_calls_daily,
max_reports_monthly = EXCLUDED.max_reports_monthly,
features = EXCLUDED.features,
has_sat_sync = EXCLUDED.has_sat_sync,
has_bank_sync = EXCLUDED.has_bank_sync,
has_ai_insights = EXCLUDED.has_ai_insights,
has_custom_reports = EXCLUDED.has_custom_reports,
has_api_access = EXCLUDED.has_api_access,
has_white_label = EXCLUDED.has_white_label,
has_priority_support = EXCLUDED.has_priority_support,
has_dedicated_account_manager = EXCLUDED.has_dedicated_account_manager,
data_retention_months = EXCLUDED.data_retention_months,
display_order = EXCLUDED.display_order,
is_popular = EXCLUDED.is_popular,
updated_at = NOW()`,
[
plan.id,
plan.name,
plan.description,
plan.price_monthly_cents,
plan.price_yearly_cents,
plan.max_users,
plan.max_cfdis_monthly,
plan.max_storage_mb,
plan.max_api_calls_daily,
plan.max_reports_monthly,
JSON.stringify(plan.features),
plan.has_sat_sync,
plan.has_bank_sync,
plan.has_ai_insights,
plan.has_custom_reports,
plan.has_api_access,
plan.has_white_label,
plan.has_priority_support,
plan.has_dedicated_account_manager,
plan.data_retention_months,
plan.display_order,
plan.is_popular,
]
);
console.log(` - Plan: ${plan.name} ($${(plan.price_monthly_cents / 100).toFixed(2)} MXN/mes)`);
}
console.log(` Total: ${PLANS.length} plans seeded\n`);
}
/**
* Seed system settings
*/
async function seedSystemSettings(client: PoolClient): Promise<void> {
console.log('Seeding system settings...');
for (const setting of SYSTEM_SETTINGS) {
await client.query(
`INSERT INTO system_settings (key, value, value_type, category)
VALUES ($1, $2, $3, $4)
ON CONFLICT (key) DO UPDATE SET
value = EXCLUDED.value,
value_type = EXCLUDED.value_type,
category = EXCLUDED.category,
updated_at = NOW()`,
[setting.key, setting.value, setting.value_type, setting.category]
);
}
console.log(` Total: ${SYSTEM_SETTINGS.length} settings seeded\n`);
}
/**
* Seed default super admin user
*/
async function seedSuperAdmin(client: PoolClient): Promise<void> {
console.log('Seeding super admin user...');
const email = process.env.SUPER_ADMIN_EMAIL || 'admin@horuxstrategy.com';
const password = process.env.SUPER_ADMIN_PASSWORD || 'HoruxAdmin2024!';
// Check if user already exists
const existing = await client.query(
'SELECT id FROM users WHERE email = $1',
[email]
);
if (existing.rows.length > 0) {
console.log(` - Super admin already exists: ${email}\n`);
return;
}
// Create super admin
const passwordHash = hashPassword(password);
await client.query(
`INSERT INTO users (
email, password_hash, first_name, last_name,
default_role, is_active, is_email_verified, email_verified_at
) VALUES (
$1, $2, 'Super', 'Admin',
'super_admin', true, true, NOW()
)`,
[email, passwordHash]
);
console.log(` - Created super admin: ${email}`);
console.log(` - Default password: ${password}`);
console.log(' - IMPORTANT: Change the password immediately!\n');
}
/**
* Seed demo tenant (optional)
*/
async function seedDemoTenant(client: PoolClient): Promise<void> {
if (process.env.SEED_DEMO_TENANT !== 'true') {
console.log('Skipping demo tenant (set SEED_DEMO_TENANT=true to enable)\n');
return;
}
console.log('Seeding demo tenant...');
// Get super admin user
const adminResult = await client.query<{ id: string }>(
"SELECT id FROM users WHERE default_role = 'super_admin' LIMIT 1"
);
if (adminResult.rows.length === 0) {
console.log(' - No super admin found, skipping demo tenant\n');
return;
}
const adminId = adminResult.rows[0].id;
// Check if demo tenant exists
const existingTenant = await client.query(
"SELECT id FROM tenants WHERE slug = 'demo-company'"
);
if (existingTenant.rows.length > 0) {
console.log(' - Demo tenant already exists\n');
return;
}
// Create demo tenant
const tenantResult = await client.query<{ id: string }>(
`INSERT INTO tenants (
name, slug, schema_name, rfc, razon_social,
email, phone, owner_id, plan_id, status, settings
) VALUES (
'Empresa Demo S.A. de C.V.',
'demo-company',
'tenant_demo',
'XAXX010101000',
'Empresa Demo S.A. de C.V.',
'demo@horuxstrategy.com',
'+52 55 1234 5678',
$1,
'pyme',
'active',
$2
) RETURNING id`,
[
adminId,
JSON.stringify({
timezone: 'America/Mexico_City',
currency: 'MXN',
language: 'es-MX',
fiscalYearStart: 1,
}),
]
);
const tenantId = tenantResult.rows[0].id;
// Add admin to tenant
await client.query(
`INSERT INTO user_tenants (user_id, tenant_id, role, is_active, accepted_at)
VALUES ($1, $2, 'owner', true, NOW())`,
[adminId, tenantId]
);
// Create subscription
const trialEnds = new Date();
trialEnds.setDate(trialEnds.getDate() + 14); // 14-day trial
const periodEnd = new Date();
periodEnd.setMonth(periodEnd.getMonth() + 1);
await client.query(
`INSERT INTO subscriptions (
tenant_id, plan_id, status, billing_cycle,
trial_ends_at, current_period_end, price_cents
) VALUES (
$1, 'pyme', 'trial', 'monthly',
$2, $3, 99900
)`,
[tenantId, trialEnds, periodEnd]
);
console.log(` - Created demo tenant: Empresa Demo S.A. de C.V.`);
console.log(` - Tenant ID: ${tenantId}`);
console.log(` - Schema: tenant_demo`);
console.log(' - Note: Tenant schema needs to be created separately using createTenantSchema()\n');
}
/**
* Main seed function
*/
async function seed(): Promise<void> {
const config = getConfig();
console.log('\nHorux Strategy - Database Seed');
console.log('==============================');
console.log(`Database: ${config.database}@${config.host}:${config.port}\n`);
const pool = new Pool(config);
try {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Run seeders
await seedPlans(client);
await seedSystemSettings(client);
await seedSuperAdmin(client);
await seedDemoTenant(client);
await client.query('COMMIT');
console.log('Seed completed successfully!\n');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
console.error('Seed failed:', error);
process.exit(1);
} finally {
await pool.end();
}
}
// Run if called directly
seed().catch(console.error);
// Export for programmatic use
export {
seed,
seedPlans,
seedSystemSettings,
seedSuperAdmin,
seedDemoTenant,
PLANS,
SYSTEM_SETTINGS,
};

View File

@@ -0,0 +1,619 @@
/**
* Tenant Schema Management
*
* Functions for creating, deleting, and managing tenant schemas.
* Each tenant gets their own PostgreSQL schema with isolated data.
*/
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { DatabaseConnection, TenantContext } from './connection.js';
import { PoolClient } from 'pg';
// Get directory path for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Tenant creation options
export interface CreateTenantOptions {
tenantId: string;
companyName: string;
ownerId: string;
planId?: string;
settings?: TenantSettings;
}
// Tenant settings
export interface TenantSettings {
timezone?: string;
currency?: string;
language?: string;
fiscalYearStart?: number; // Month (1-12)
dateFormat?: string;
numberFormat?: string;
}
// Default tenant settings for Mexican companies
const DEFAULT_TENANT_SETTINGS: TenantSettings = {
timezone: 'America/Mexico_City',
currency: 'MXN',
language: 'es-MX',
fiscalYearStart: 1, // January
dateFormat: 'DD/MM/YYYY',
numberFormat: 'es-MX',
};
// Tenant info
export interface TenantInfo {
id: string;
name: string;
slug: string;
schemaName: string;
status: TenantStatus;
planId: string;
createdAt: Date;
updatedAt: Date;
}
// Tenant status
export type TenantStatus = 'active' | 'suspended' | 'pending' | 'deleted';
/**
* Generate schema name from tenant ID
*/
export function getSchemaName(tenantId: string): string {
// Validate tenant ID format (UUID or alphanumeric)
if (!/^[a-zA-Z0-9-_]+$/.test(tenantId)) {
throw new Error(`Invalid tenant ID format: ${tenantId}`);
}
// Replace hyphens with underscores for valid schema name
const safeTenantId = tenantId.replace(/-/g, '_');
return `tenant_${safeTenantId}`;
}
/**
* Create tenant context
*/
export function createTenantContext(tenantId: string, userId?: string): TenantContext {
return {
tenantId,
schemaName: getSchemaName(tenantId),
userId,
};
}
/**
* Read the tenant schema migration SQL
*/
function getTenantSchemaSql(): string {
const migrationPath = join(__dirname, 'migrations', '002_tenant_schema.sql');
return readFileSync(migrationPath, 'utf-8');
}
/**
* Create a new tenant schema with all required tables
*/
export async function createTenantSchema(
db: DatabaseConnection,
options: CreateTenantOptions
): Promise<TenantInfo> {
const { tenantId, companyName, ownerId, planId, settings } = options;
const schemaName = getSchemaName(tenantId);
const slug = companyName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
// Merge settings with defaults
const finalSettings = { ...DEFAULT_TENANT_SETTINGS, ...settings };
// Check if schema already exists
const exists = await db.schemaExists(schemaName);
if (exists) {
throw new Error(`Tenant schema already exists: ${schemaName}`);
}
// Start transaction
const result = await db.transaction<TenantInfo>(async (client: PoolClient) => {
// 1. Create tenant record in public schema
const tenantResult = await client.query<{
id: string;
name: string;
slug: string;
schema_name: string;
status: TenantStatus;
plan_id: string;
created_at: Date;
updated_at: Date;
}>(
`INSERT INTO public.tenants (id, name, slug, schema_name, owner_id, plan_id, status, settings)
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7)
RETURNING id, name, slug, schema_name, status, plan_id, created_at, updated_at`,
[
tenantId,
companyName,
slug,
schemaName,
ownerId,
planId || 'startup',
JSON.stringify(finalSettings),
]
);
const tenant = tenantResult.rows[0];
// 2. Create the schema
await client.query(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
// 3. Set search path to the new schema
await client.query(`SET search_path TO "${schemaName}", public`);
// 4. Execute tenant schema migration
const tenantSchemaSql = getTenantSchemaSql();
// Replace schema placeholder with actual schema name
const schemaSql = tenantSchemaSql.replace(/\$\{SCHEMA_NAME\}/g, schemaName);
await client.query(schemaSql);
// 5. Insert default settings
await client.query(
`INSERT INTO settings (key, value, category)
VALUES
('timezone', $1, 'general'),
('currency', $2, 'general'),
('language', $3, 'general'),
('fiscal_year_start', $4, 'accounting'),
('date_format', $5, 'display'),
('number_format', $6, 'display')`,
[
finalSettings.timezone,
finalSettings.currency,
finalSettings.language,
String(finalSettings.fiscalYearStart),
finalSettings.dateFormat,
finalSettings.numberFormat,
]
);
// 6. Insert default categories
await insertDefaultCategories(client);
// 7. Insert default accounts (chart of accounts)
await insertDefaultAccounts(client);
// 8. Log the creation in audit log
await client.query(
`INSERT INTO public.audit_log (tenant_id, user_id, action, entity_type, entity_id, details)
VALUES ($1, $2, 'tenant.created', 'tenant', $1, $3)`,
[tenantId, ownerId, JSON.stringify({ company_name: companyName, schema_name: schemaName })]
);
// Reset search path
await client.query('RESET search_path');
return {
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
schemaName: tenant.schema_name,
status: tenant.status,
planId: tenant.plan_id,
createdAt: tenant.created_at,
updatedAt: tenant.updated_at,
};
});
return result;
}
/**
* Insert default expense/income categories
*/
async function insertDefaultCategories(client: PoolClient): Promise<void> {
// Mexican-specific expense categories
const categories = [
// Income categories
{ code: 'ING-001', name: 'Ventas de productos', type: 'income', sat_key: '84111506' },
{ code: 'ING-002', name: 'Servicios profesionales', type: 'income', sat_key: '80111601' },
{ code: 'ING-003', name: 'Comisiones', type: 'income', sat_key: '84111502' },
{ code: 'ING-004', name: 'Intereses ganados', type: 'income', sat_key: '84111503' },
{ code: 'ING-005', name: 'Otros ingresos', type: 'income', sat_key: '84111599' },
// Expense categories - Operating
{ code: 'GAS-001', name: 'Sueldos y salarios', type: 'expense', sat_key: '80111501' },
{ code: 'GAS-002', name: 'Servicios profesionales', type: 'expense', sat_key: '80111601' },
{ code: 'GAS-003', name: 'Arrendamiento', type: 'expense', sat_key: '80131501' },
{ code: 'GAS-004', name: 'Servicios de luz', type: 'expense', sat_key: '83101801' },
{ code: 'GAS-005', name: 'Servicios de agua', type: 'expense', sat_key: '83101802' },
{ code: 'GAS-006', name: 'Telecomunicaciones', type: 'expense', sat_key: '83111501' },
{ code: 'GAS-007', name: 'Combustibles', type: 'expense', sat_key: '15101506' },
{ code: 'GAS-008', name: 'Mantenimiento', type: 'expense', sat_key: '72101507' },
{ code: 'GAS-009', name: 'Papeleria y utiles', type: 'expense', sat_key: '44121600' },
{ code: 'GAS-010', name: 'Seguros y fianzas', type: 'expense', sat_key: '84131501' },
// Expense categories - Administrative
{ code: 'ADM-001', name: 'Honorarios contables', type: 'expense', sat_key: '80111604' },
{ code: 'ADM-002', name: 'Honorarios legales', type: 'expense', sat_key: '80111607' },
{ code: 'ADM-003', name: 'Capacitacion', type: 'expense', sat_key: '86101701' },
{ code: 'ADM-004', name: 'Publicidad', type: 'expense', sat_key: '80141600' },
{ code: 'ADM-005', name: 'Viajes y viaticos', type: 'expense', sat_key: '90101800' },
// Expense categories - Financial
{ code: 'FIN-001', name: 'Comisiones bancarias', type: 'expense', sat_key: '84111502' },
{ code: 'FIN-002', name: 'Intereses pagados', type: 'expense', sat_key: '84111503' },
// Cost categories
{ code: 'COS-001', name: 'Costo de ventas', type: 'cost', sat_key: '84111506' },
{ code: 'COS-002', name: 'Materia prima', type: 'cost', sat_key: '11000000' },
{ code: 'COS-003', name: 'Mano de obra directa', type: 'cost', sat_key: '80111501' },
];
for (const cat of categories) {
await client.query(
`INSERT INTO categories (code, name, type, sat_key, is_system)
VALUES ($1, $2, $3, $4, true)`,
[cat.code, cat.name, cat.type, cat.sat_key]
);
}
}
/**
* Insert default chart of accounts (Mexican CUCA standard)
*/
async function insertDefaultAccounts(client: PoolClient): Promise<void> {
// Mexican Chart of Accounts (simplified)
const accounts = [
// Assets (1xxx)
{ code: '1000', name: 'Activo', type: 'asset', parent: null },
{ code: '1100', name: 'Activo Circulante', type: 'asset', parent: '1000' },
{ code: '1101', name: 'Caja', type: 'asset', parent: '1100' },
{ code: '1102', name: 'Bancos', type: 'asset', parent: '1100' },
{ code: '1103', name: 'Inversiones temporales', type: 'asset', parent: '1100' },
{ code: '1110', name: 'Clientes', type: 'asset', parent: '1100' },
{ code: '1120', name: 'Documentos por cobrar', type: 'asset', parent: '1100' },
{ code: '1130', name: 'Deudores diversos', type: 'asset', parent: '1100' },
{ code: '1140', name: 'Inventarios', type: 'asset', parent: '1100' },
{ code: '1150', name: 'Anticipo a proveedores', type: 'asset', parent: '1100' },
{ code: '1160', name: 'IVA acreditable', type: 'asset', parent: '1100' },
{ code: '1200', name: 'Activo Fijo', type: 'asset', parent: '1000' },
{ code: '1201', name: 'Terrenos', type: 'asset', parent: '1200' },
{ code: '1202', name: 'Edificios', type: 'asset', parent: '1200' },
{ code: '1203', name: 'Maquinaria y equipo', type: 'asset', parent: '1200' },
{ code: '1204', name: 'Equipo de transporte', type: 'asset', parent: '1200' },
{ code: '1205', name: 'Equipo de computo', type: 'asset', parent: '1200' },
{ code: '1206', name: 'Mobiliario y equipo', type: 'asset', parent: '1200' },
{ code: '1210', name: 'Depreciacion acumulada', type: 'asset', parent: '1200' },
// Liabilities (2xxx)
{ code: '2000', name: 'Pasivo', type: 'liability', parent: null },
{ code: '2100', name: 'Pasivo Corto Plazo', type: 'liability', parent: '2000' },
{ code: '2101', name: 'Proveedores', type: 'liability', parent: '2100' },
{ code: '2102', name: 'Acreedores diversos', type: 'liability', parent: '2100' },
{ code: '2103', name: 'Documentos por pagar', type: 'liability', parent: '2100' },
{ code: '2110', name: 'Impuestos por pagar', type: 'liability', parent: '2100' },
{ code: '2111', name: 'IVA por pagar', type: 'liability', parent: '2110' },
{ code: '2112', name: 'ISR por pagar', type: 'liability', parent: '2110' },
{ code: '2113', name: 'Retenciones por pagar', type: 'liability', parent: '2110' },
{ code: '2120', name: 'Anticipo de clientes', type: 'liability', parent: '2100' },
{ code: '2200', name: 'Pasivo Largo Plazo', type: 'liability', parent: '2000' },
{ code: '2201', name: 'Prestamos bancarios LP', type: 'liability', parent: '2200' },
{ code: '2202', name: 'Hipotecas por pagar', type: 'liability', parent: '2200' },
// Equity (3xxx)
{ code: '3000', name: 'Capital Contable', type: 'equity', parent: null },
{ code: '3100', name: 'Capital Social', type: 'equity', parent: '3000' },
{ code: '3200', name: 'Reserva legal', type: 'equity', parent: '3000' },
{ code: '3300', name: 'Resultados acumulados', type: 'equity', parent: '3000' },
{ code: '3400', name: 'Resultado del ejercicio', type: 'equity', parent: '3000' },
// Revenue (4xxx)
{ code: '4000', name: 'Ingresos', type: 'revenue', parent: null },
{ code: '4100', name: 'Ventas', type: 'revenue', parent: '4000' },
{ code: '4101', name: 'Ventas nacionales', type: 'revenue', parent: '4100' },
{ code: '4102', name: 'Ventas de exportacion', type: 'revenue', parent: '4100' },
{ code: '4200', name: 'Productos financieros', type: 'revenue', parent: '4000' },
{ code: '4300', name: 'Otros ingresos', type: 'revenue', parent: '4000' },
// Expenses (5xxx-6xxx)
{ code: '5000', name: 'Costo de Ventas', type: 'expense', parent: null },
{ code: '5100', name: 'Costo de lo vendido', type: 'expense', parent: '5000' },
{ code: '6000', name: 'Gastos de Operacion', type: 'expense', parent: null },
{ code: '6100', name: 'Gastos de venta', type: 'expense', parent: '6000' },
{ code: '6200', name: 'Gastos de administracion', type: 'expense', parent: '6000' },
{ code: '6300', name: 'Gastos financieros', type: 'expense', parent: '6000' },
{ code: '6400', name: 'Otros gastos', type: 'expense', parent: '6000' },
];
// First pass: insert accounts without parent references
for (const account of accounts) {
await client.query(
`INSERT INTO accounts (code, name, type, is_system)
VALUES ($1, $2, $3, true)`,
[account.code, account.name, account.type]
);
}
// Second pass: update parent references
for (const account of accounts) {
if (account.parent) {
await client.query(
`UPDATE accounts SET parent_id = (SELECT id FROM accounts WHERE code = $1)
WHERE code = $2`,
[account.parent, account.code]
);
}
}
}
/**
* Delete a tenant schema and all its data
* WARNING: This is destructive and cannot be undone!
*/
export async function deleteTenantSchema(
db: DatabaseConnection,
tenantId: string,
deletedBy: string,
hardDelete: boolean = false
): Promise<void> {
const schemaName = getSchemaName(tenantId);
await db.transaction(async (client: PoolClient) => {
// Verify tenant exists
const tenantResult = await client.query<{ status: TenantStatus }>(
'SELECT status FROM public.tenants WHERE id = $1',
[tenantId]
);
if (tenantResult.rows.length === 0) {
throw new Error(`Tenant not found: ${tenantId}`);
}
if (hardDelete) {
// Drop the schema and all its objects
await client.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
// Delete tenant record
await client.query('DELETE FROM public.tenants WHERE id = $1', [tenantId]);
} else {
// Soft delete: mark as deleted but keep data
await client.query(
`UPDATE public.tenants SET status = 'deleted', updated_at = NOW() WHERE id = $1`,
[tenantId]
);
}
// Log the deletion
await client.query(
`INSERT INTO public.audit_log (tenant_id, user_id, action, entity_type, entity_id, details)
VALUES ($1, $2, 'tenant.deleted', 'tenant', $1, $3)`,
[tenantId, deletedBy, JSON.stringify({ hard_delete: hardDelete, schema_name: schemaName })]
);
});
}
/**
* Suspend a tenant (deactivate but don't delete)
*/
export async function suspendTenant(
db: DatabaseConnection,
tenantId: string,
suspendedBy: string,
reason?: string
): Promise<void> {
await db.transaction(async (client: PoolClient) => {
await client.query(
`UPDATE public.tenants SET status = 'suspended', updated_at = NOW() WHERE id = $1`,
[tenantId]
);
await client.query(
`INSERT INTO public.audit_log (tenant_id, user_id, action, entity_type, entity_id, details)
VALUES ($1, $2, 'tenant.suspended', 'tenant', $1, $3)`,
[tenantId, suspendedBy, JSON.stringify({ reason })]
);
});
}
/**
* Reactivate a suspended tenant
*/
export async function reactivateTenant(
db: DatabaseConnection,
tenantId: string,
reactivatedBy: string
): Promise<void> {
await db.transaction(async (client: PoolClient) => {
await client.query(
`UPDATE public.tenants SET status = 'active', updated_at = NOW() WHERE id = $1`,
[tenantId]
);
await client.query(
`INSERT INTO public.audit_log (tenant_id, user_id, action, entity_type, entity_id, details)
VALUES ($1, $2, 'tenant.reactivated', 'tenant', $1, $3)`,
[tenantId, reactivatedBy, JSON.stringify({})]
);
});
}
/**
* Get tenant information
*/
export async function getTenant(
db: DatabaseConnection,
tenantId: string
): Promise<TenantInfo | null> {
const result = await db.query<{
id: string;
name: string;
slug: string;
schema_name: string;
status: TenantStatus;
plan_id: string;
created_at: Date;
updated_at: Date;
}>(
`SELECT id, name, slug, schema_name, status, plan_id, created_at, updated_at
FROM public.tenants WHERE id = $1`,
[tenantId]
);
if (result.rows.length === 0) {
return null;
}
const tenant = result.rows[0];
return {
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
schemaName: tenant.schema_name,
status: tenant.status,
planId: tenant.plan_id,
createdAt: tenant.created_at,
updatedAt: tenant.updated_at,
};
}
/**
* List all tenants
*/
export async function listTenants(
db: DatabaseConnection,
status?: TenantStatus
): Promise<TenantInfo[]> {
let query = `
SELECT id, name, slug, schema_name, status, plan_id, created_at, updated_at
FROM public.tenants
`;
const params: unknown[] = [];
if (status) {
query += ' WHERE status = $1';
params.push(status);
}
query += ' ORDER BY created_at DESC';
const result = await db.query<{
id: string;
name: string;
slug: string;
schema_name: string;
status: TenantStatus;
plan_id: string;
created_at: Date;
updated_at: Date;
}>(query, params);
return result.rows.map(tenant => ({
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
schemaName: tenant.schema_name,
status: tenant.status,
planId: tenant.plan_id,
createdAt: tenant.created_at,
updatedAt: tenant.updated_at,
}));
}
/**
* Update tenant settings
*/
export async function updateTenantSettings(
db: DatabaseConnection,
tenantId: string,
settings: Partial<TenantSettings>,
updatedBy: string
): Promise<void> {
const tenant = await getTenant(db, tenantId);
if (!tenant) {
throw new Error(`Tenant not found: ${tenantId}`);
}
await db.transaction(async (client: PoolClient) => {
// Update settings in tenant schema
await client.query(`SET search_path TO "${tenant.schemaName}", public`);
for (const [key, value] of Object.entries(settings)) {
if (value !== undefined) {
const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
await client.query(
`INSERT INTO settings (key, value, category)
VALUES ($1, $2, 'general')
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
[snakeKey, String(value)]
);
}
}
await client.query('RESET search_path');
// Log the update
await client.query(
`INSERT INTO public.audit_log (tenant_id, user_id, action, entity_type, entity_id, details)
VALUES ($1, $2, 'tenant.settings_updated', 'tenant', $1, $3)`,
[tenantId, updatedBy, JSON.stringify(settings)]
);
});
}
/**
* Get tenant by slug
*/
export async function getTenantBySlug(
db: DatabaseConnection,
slug: string
): Promise<TenantInfo | null> {
const result = await db.query<{
id: string;
name: string;
slug: string;
schema_name: string;
status: TenantStatus;
plan_id: string;
created_at: Date;
updated_at: Date;
}>(
`SELECT id, name, slug, schema_name, status, plan_id, created_at, updated_at
FROM public.tenants WHERE slug = $1`,
[slug]
);
if (result.rows.length === 0) {
return null;
}
const tenant = result.rows[0];
return {
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
schemaName: tenant.schema_name,
status: tenant.status,
planId: tenant.plan_id,
createdAt: tenant.created_at,
updatedAt: tenant.updated_at,
};
}
/**
* Validate tenant access
* Returns true if the tenant exists and is active
*/
export async function validateTenantAccess(
db: DatabaseConnection,
tenantId: string
): Promise<boolean> {
const result = await db.query<{ status: TenantStatus }>(
'SELECT status FROM public.tenants WHERE id = $1',
[tenantId]
);
if (result.rows.length === 0) {
return false;
}
return result.rows[0].status === 'active';
}

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

@@ -0,0 +1,23 @@
{
"name": "@horux/shared",
"version": "0.1.0",
"private": true,
"description": "Shared types and utilities for Horux Strategy",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist node_modules"
},
"dependencies": {
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.11.0",
"eslint": "^8.56.0",
"typescript": "^5.3.3"
}
}

View File

@@ -0,0 +1,630 @@
/**
* Constants for Horux Strategy
* Roles, permissions, document states, and error codes
*/
import type { UserRole, UserPermission } from '../types/auth';
// ============================================================================
// Roles & Permissions
// ============================================================================
/**
* Available user roles
*/
export const USER_ROLES = {
SUPER_ADMIN: 'super_admin' as const,
TENANT_ADMIN: 'tenant_admin' as const,
ACCOUNTANT: 'accountant' as const,
ASSISTANT: 'assistant' as const,
VIEWER: 'viewer' as const,
};
/**
* Role display names in Spanish
*/
export const ROLE_NAMES: Record<UserRole, string> = {
super_admin: 'Super Administrador',
tenant_admin: 'Administrador',
accountant: 'Contador',
assistant: 'Asistente',
viewer: 'Solo Lectura',
};
/**
* Role descriptions
*/
export const ROLE_DESCRIPTIONS: Record<UserRole, string> = {
super_admin: 'Acceso completo al sistema y todas las empresas',
tenant_admin: 'Administración completa de la empresa',
accountant: 'Acceso completo a funciones contables y financieras',
assistant: 'Acceso limitado para captura de información',
viewer: 'Solo puede visualizar información, sin editar',
};
/**
* Available resources for permissions
*/
export const RESOURCES = {
TRANSACTIONS: 'transactions',
INVOICES: 'invoices',
CONTACTS: 'contacts',
ACCOUNTS: 'accounts',
CATEGORIES: 'categories',
REPORTS: 'reports',
SETTINGS: 'settings',
USERS: 'users',
BILLING: 'billing',
INTEGRATIONS: 'integrations',
} as const;
/**
* Default permissions per role
*/
export const DEFAULT_ROLE_PERMISSIONS: Record<UserRole, UserPermission[]> = {
super_admin: [
{ resource: '*', actions: ['create', 'read', 'update', 'delete'] },
],
tenant_admin: [
{ resource: 'transactions', actions: ['create', 'read', 'update', 'delete'] },
{ resource: 'invoices', actions: ['create', 'read', 'update', 'delete'] },
{ resource: 'contacts', actions: ['create', 'read', 'update', 'delete'] },
{ resource: 'accounts', actions: ['create', 'read', 'update', 'delete'] },
{ resource: 'categories', actions: ['create', 'read', 'update', 'delete'] },
{ resource: 'reports', actions: ['create', 'read', 'update', 'delete'] },
{ resource: 'settings', actions: ['create', 'read', 'update', 'delete'] },
{ resource: 'users', actions: ['create', 'read', 'update', 'delete'] },
{ resource: 'billing', actions: ['create', 'read', 'update', 'delete'] },
{ resource: 'integrations', actions: ['create', 'read', 'update', 'delete'] },
],
accountant: [
{ resource: 'transactions', actions: ['create', 'read', 'update', 'delete'] },
{ resource: 'invoices', actions: ['create', 'read', 'update', 'delete'] },
{ resource: 'contacts', actions: ['create', 'read', 'update'] },
{ resource: 'accounts', actions: ['create', 'read', 'update'] },
{ resource: 'categories', actions: ['create', 'read', 'update'] },
{ resource: 'reports', actions: ['create', 'read'] },
{ resource: 'settings', actions: ['read'] },
{ resource: 'integrations', actions: ['read'] },
],
assistant: [
{ resource: 'transactions', actions: ['create', 'read', 'update'] },
{ resource: 'invoices', actions: ['create', 'read'] },
{ resource: 'contacts', actions: ['create', 'read'] },
{ resource: 'accounts', actions: ['read'] },
{ resource: 'categories', actions: ['read'] },
{ resource: 'reports', actions: ['read'] },
],
viewer: [
{ resource: 'transactions', actions: ['read'] },
{ resource: 'invoices', actions: ['read'] },
{ resource: 'contacts', actions: ['read'] },
{ resource: 'accounts', actions: ['read'] },
{ resource: 'categories', actions: ['read'] },
{ resource: 'reports', actions: ['read'] },
],
};
// ============================================================================
// Document States
// ============================================================================
/**
* Transaction statuses
*/
export const TRANSACTION_STATUS = {
PENDING: 'pending',
CLEARED: 'cleared',
RECONCILED: 'reconciled',
VOIDED: 'voided',
} as const;
export const TRANSACTION_STATUS_NAMES: Record<string, string> = {
pending: 'Pendiente',
cleared: 'Procesado',
reconciled: 'Conciliado',
voided: 'Anulado',
};
export const TRANSACTION_STATUS_COLORS: Record<string, string> = {
pending: 'yellow',
cleared: 'blue',
reconciled: 'green',
voided: 'gray',
};
/**
* CFDI statuses
*/
export const CFDI_STATUS = {
DRAFT: 'draft',
PENDING: 'pending',
STAMPED: 'stamped',
SENT: 'sent',
PAID: 'paid',
PARTIAL_PAID: 'partial_paid',
CANCELLED: 'cancelled',
CANCELLATION_PENDING: 'cancellation_pending',
} as const;
export const CFDI_STATUS_NAMES: Record<string, string> = {
draft: 'Borrador',
pending: 'Pendiente de Timbrar',
stamped: 'Timbrado',
sent: 'Enviado',
paid: 'Pagado',
partial_paid: 'Pago Parcial',
cancelled: 'Cancelado',
cancellation_pending: 'Cancelación Pendiente',
};
export const CFDI_STATUS_COLORS: Record<string, string> = {
draft: 'gray',
pending: 'yellow',
stamped: 'blue',
sent: 'indigo',
paid: 'green',
partial_paid: 'orange',
cancelled: 'red',
cancellation_pending: 'pink',
};
/**
* CFDI types
*/
export const CFDI_TYPES = {
INGRESO: 'I',
EGRESO: 'E',
TRASLADO: 'T',
NOMINA: 'N',
PAGO: 'P',
} as const;
export const CFDI_TYPE_NAMES: Record<string, string> = {
I: 'Ingreso',
E: 'Egreso',
T: 'Traslado',
N: 'Nómina',
P: 'Pago',
};
/**
* CFDI Usage codes (Uso del CFDI)
*/
export const CFDI_USAGE_CODES: Record<string, string> = {
G01: 'Adquisición de mercancías',
G02: 'Devoluciones, descuentos o bonificaciones',
G03: 'Gastos en general',
I01: 'Construcciones',
I02: 'Mobiliario y equipo de oficina por inversiones',
I03: 'Equipo de transporte',
I04: 'Equipo de cómputo y accesorios',
I05: 'Dados, troqueles, moldes, matrices y herramental',
I06: 'Comunicaciones telefónicas',
I07: 'Comunicaciones satelitales',
I08: 'Otra maquinaria y equipo',
D01: 'Honorarios médicos, dentales y gastos hospitalarios',
D02: 'Gastos médicos por incapacidad o discapacidad',
D03: 'Gastos funerales',
D04: 'Donativos',
D05: 'Intereses reales efectivamente pagados por créditos hipotecarios',
D06: 'Aportaciones voluntarias al SAR',
D07: 'Primas por seguros de gastos médicos',
D08: 'Gastos de transportación escolar obligatoria',
D09: 'Depósitos en cuentas para el ahorro',
D10: 'Pagos por servicios educativos (colegiaturas)',
S01: 'Sin efectos fiscales',
CP01: 'Pagos',
CN01: 'Nómina',
};
/**
* Payment forms (Forma de pago SAT)
*/
export const PAYMENT_FORMS: Record<string, string> = {
'01': 'Efectivo',
'02': 'Cheque nominativo',
'03': 'Transferencia electrónica de fondos',
'04': 'Tarjeta de crédito',
'05': 'Monedero electrónico',
'06': 'Dinero electrónico',
'08': 'Vales de despensa',
'12': 'Dación en pago',
'13': 'Pago por subrogación',
'14': 'Pago por consignación',
'15': 'Condonación',
'17': 'Compensación',
'23': 'Novación',
'24': 'Confusión',
'25': 'Remisión de deuda',
'26': 'Prescripción o caducidad',
'27': 'A satisfacción del acreedor',
'28': 'Tarjeta de débito',
'29': 'Tarjeta de servicios',
'30': 'Aplicación de anticipos',
'31': 'Intermediario pagos',
'99': 'Por definir',
};
/**
* CFDI Cancellation reasons
*/
export const CFDI_CANCELLATION_REASONS: Record<string, string> = {
'01': 'Comprobante emitido con errores con relación',
'02': 'Comprobante emitido con errores sin relación',
'03': 'No se llevó a cabo la operación',
'04': 'Operación nominativa relacionada en una factura global',
};
/**
* Tenant statuses
*/
export const TENANT_STATUS = {
PENDING: 'pending',
ACTIVE: 'active',
SUSPENDED: 'suspended',
CANCELLED: 'cancelled',
TRIAL: 'trial',
EXPIRED: 'expired',
} as const;
export const TENANT_STATUS_NAMES: Record<string, string> = {
pending: 'Pendiente',
active: 'Activo',
suspended: 'Suspendido',
cancelled: 'Cancelado',
trial: 'Prueba',
expired: 'Expirado',
};
/**
* Subscription statuses
*/
export const SUBSCRIPTION_STATUS = {
TRIALING: 'trialing',
ACTIVE: 'active',
PAST_DUE: 'past_due',
CANCELED: 'canceled',
UNPAID: 'unpaid',
PAUSED: 'paused',
} as const;
export const SUBSCRIPTION_STATUS_NAMES: Record<string, string> = {
trialing: 'Período de Prueba',
active: 'Activa',
past_due: 'Pago Atrasado',
canceled: 'Cancelada',
unpaid: 'Sin Pagar',
paused: 'Pausada',
};
// ============================================================================
// Error Codes
// ============================================================================
export const ERROR_CODES = {
// Authentication errors (1xxx)
AUTH_INVALID_CREDENTIALS: 'AUTH_001',
AUTH_TOKEN_EXPIRED: 'AUTH_002',
AUTH_TOKEN_INVALID: 'AUTH_003',
AUTH_REFRESH_TOKEN_EXPIRED: 'AUTH_004',
AUTH_USER_NOT_FOUND: 'AUTH_005',
AUTH_USER_DISABLED: 'AUTH_006',
AUTH_EMAIL_NOT_VERIFIED: 'AUTH_007',
AUTH_TWO_FACTOR_REQUIRED: 'AUTH_008',
AUTH_TWO_FACTOR_INVALID: 'AUTH_009',
AUTH_SESSION_EXPIRED: 'AUTH_010',
AUTH_PASSWORD_INCORRECT: 'AUTH_011',
AUTH_PASSWORD_WEAK: 'AUTH_012',
AUTH_EMAIL_ALREADY_EXISTS: 'AUTH_013',
AUTH_INVITATION_EXPIRED: 'AUTH_014',
AUTH_INVITATION_INVALID: 'AUTH_015',
// Authorization errors (2xxx)
AUTHZ_FORBIDDEN: 'AUTHZ_001',
AUTHZ_INSUFFICIENT_PERMISSIONS: 'AUTHZ_002',
AUTHZ_RESOURCE_NOT_ACCESSIBLE: 'AUTHZ_003',
AUTHZ_TENANT_MISMATCH: 'AUTHZ_004',
// Validation errors (3xxx)
VALIDATION_FAILED: 'VAL_001',
VALIDATION_REQUIRED_FIELD: 'VAL_002',
VALIDATION_INVALID_FORMAT: 'VAL_003',
VALIDATION_INVALID_VALUE: 'VAL_004',
VALIDATION_TOO_LONG: 'VAL_005',
VALIDATION_TOO_SHORT: 'VAL_006',
VALIDATION_OUT_OF_RANGE: 'VAL_007',
VALIDATION_DUPLICATE: 'VAL_008',
// Resource errors (4xxx)
RESOURCE_NOT_FOUND: 'RES_001',
RESOURCE_ALREADY_EXISTS: 'RES_002',
RESOURCE_CONFLICT: 'RES_003',
RESOURCE_LOCKED: 'RES_004',
RESOURCE_DELETED: 'RES_005',
// Business logic errors (5xxx)
BUSINESS_INVALID_OPERATION: 'BIZ_001',
BUSINESS_INSUFFICIENT_BALANCE: 'BIZ_002',
BUSINESS_LIMIT_EXCEEDED: 'BIZ_003',
BUSINESS_INVALID_STATE: 'BIZ_004',
BUSINESS_DEPENDENCY_ERROR: 'BIZ_005',
// CFDI errors (6xxx)
CFDI_STAMPING_FAILED: 'CFDI_001',
CFDI_CANCELLATION_FAILED: 'CFDI_002',
CFDI_INVALID_RFC: 'CFDI_003',
CFDI_INVALID_POSTAL_CODE: 'CFDI_004',
CFDI_ALREADY_STAMPED: 'CFDI_005',
CFDI_ALREADY_CANCELLED: 'CFDI_006',
CFDI_NOT_FOUND_SAT: 'CFDI_007',
CFDI_XML_INVALID: 'CFDI_008',
CFDI_CERTIFICATE_EXPIRED: 'CFDI_009',
CFDI_PAC_ERROR: 'CFDI_010',
// Subscription/Billing errors (7xxx)
BILLING_PAYMENT_FAILED: 'BILL_001',
BILLING_CARD_DECLINED: 'BILL_002',
BILLING_SUBSCRIPTION_EXPIRED: 'BILL_003',
BILLING_PLAN_NOT_AVAILABLE: 'BILL_004',
BILLING_PROMO_CODE_INVALID: 'BILL_005',
BILLING_PROMO_CODE_EXPIRED: 'BILL_006',
BILLING_DOWNGRADE_NOT_ALLOWED: 'BILL_007',
// Integration errors (8xxx)
INTEGRATION_CONNECTION_FAILED: 'INT_001',
INTEGRATION_AUTH_FAILED: 'INT_002',
INTEGRATION_SYNC_FAILED: 'INT_003',
INTEGRATION_NOT_CONFIGURED: 'INT_004',
INTEGRATION_RATE_LIMITED: 'INT_005',
// System errors (9xxx)
SYSTEM_INTERNAL_ERROR: 'SYS_001',
SYSTEM_SERVICE_UNAVAILABLE: 'SYS_002',
SYSTEM_TIMEOUT: 'SYS_003',
SYSTEM_MAINTENANCE: 'SYS_004',
SYSTEM_RATE_LIMITED: 'SYS_005',
SYSTEM_STORAGE_FULL: 'SYS_006',
} as const;
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
/**
* Error messages in Spanish
*/
export const ERROR_MESSAGES: Record<ErrorCode, string> = {
// Auth
AUTH_001: 'Credenciales inválidas',
AUTH_002: 'La sesión ha expirado',
AUTH_003: 'Token de acceso inválido',
AUTH_004: 'Token de actualización expirado',
AUTH_005: 'Usuario no encontrado',
AUTH_006: 'Usuario deshabilitado',
AUTH_007: 'Correo electrónico no verificado',
AUTH_008: 'Se requiere autenticación de dos factores',
AUTH_009: 'Código de verificación inválido',
AUTH_010: 'La sesión ha expirado',
AUTH_011: 'Contraseña incorrecta',
AUTH_012: 'La contraseña no cumple los requisitos de seguridad',
AUTH_013: 'El correo electrónico ya está registrado',
AUTH_014: 'La invitación ha expirado',
AUTH_015: 'Invitación inválida',
// Authz
AUTHZ_001: 'No tienes permiso para realizar esta acción',
AUTHZ_002: 'Permisos insuficientes',
AUTHZ_003: 'No tienes acceso a este recurso',
AUTHZ_004: 'No tienes acceso a esta empresa',
// Validation
VAL_001: 'Error de validación',
VAL_002: 'Campo requerido',
VAL_003: 'Formato inválido',
VAL_004: 'Valor inválido',
VAL_005: 'El valor es demasiado largo',
VAL_006: 'El valor es demasiado corto',
VAL_007: 'Valor fuera de rango',
VAL_008: 'El valor ya existe',
// Resource
RES_001: 'Recurso no encontrado',
RES_002: 'El recurso ya existe',
RES_003: 'Conflicto de recursos',
RES_004: 'El recurso está bloqueado',
RES_005: 'El recurso ha sido eliminado',
// Business
BIZ_001: 'Operación no válida',
BIZ_002: 'Saldo insuficiente',
BIZ_003: 'Límite excedido',
BIZ_004: 'Estado no válido para esta operación',
BIZ_005: 'Error de dependencia',
// CFDI
CFDI_001: 'Error al timbrar el CFDI',
CFDI_002: 'Error al cancelar el CFDI',
CFDI_003: 'RFC inválido',
CFDI_004: 'Código postal inválido',
CFDI_005: 'El CFDI ya está timbrado',
CFDI_006: 'El CFDI ya está cancelado',
CFDI_007: 'CFDI no encontrado en el SAT',
CFDI_008: 'XML inválido',
CFDI_009: 'Certificado expirado',
CFDI_010: 'Error del proveedor de certificación',
// Billing
BILL_001: 'Error en el pago',
BILL_002: 'Tarjeta rechazada',
BILL_003: 'Suscripción expirada',
BILL_004: 'Plan no disponible',
BILL_005: 'Código promocional inválido',
BILL_006: 'Código promocional expirado',
BILL_007: 'No es posible cambiar a un plan inferior',
// Integration
INT_001: 'Error de conexión con el servicio externo',
INT_002: 'Error de autenticación con el servicio externo',
INT_003: 'Error de sincronización',
INT_004: 'Integración no configurada',
INT_005: 'Límite de solicitudes excedido',
// System
SYS_001: 'Error interno del servidor',
SYS_002: 'Servicio no disponible',
SYS_003: 'Tiempo de espera agotado',
SYS_004: 'Sistema en mantenimiento',
SYS_005: 'Demasiadas solicitudes, intenta más tarde',
SYS_006: 'Almacenamiento lleno',
};
// ============================================================================
// Fiscal Regimes (Mexico SAT)
// ============================================================================
export const FISCAL_REGIMES: Record<string, string> = {
'601': 'General de Ley Personas Morales',
'603': 'Personas Morales con Fines no Lucrativos',
'605': 'Sueldos y Salarios e Ingresos Asimilados a Salarios',
'606': 'Arrendamiento',
'607': 'Régimen de Enajenación o Adquisición de Bienes',
'608': 'Demás ingresos',
'609': 'Consolidación',
'610': 'Residentes en el Extranjero sin Establecimiento Permanente en México',
'611': 'Ingresos por Dividendos (Socios y Accionistas)',
'612': 'Personas Físicas con Actividades Empresariales y Profesionales',
'614': 'Ingresos por intereses',
'615': 'Régimen de los ingresos por obtención de premios',
'616': 'Sin obligaciones fiscales',
'620': 'Sociedades Cooperativas de Producción que optan por diferir sus ingresos',
'621': 'Incorporación Fiscal',
'622': 'Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras',
'623': 'Opcional para Grupos de Sociedades',
'624': 'Coordinados',
'625': 'Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas',
'626': 'Régimen Simplificado de Confianza',
};
// ============================================================================
// Mexican States
// ============================================================================
export const MEXICAN_STATES: Record<string, string> = {
AGU: 'Aguascalientes',
BCN: 'Baja California',
BCS: 'Baja California Sur',
CAM: 'Campeche',
CHP: 'Chiapas',
CHH: 'Chihuahua',
COA: 'Coahuila',
COL: 'Colima',
CMX: 'Ciudad de México',
DUR: 'Durango',
GUA: 'Guanajuato',
GRO: 'Guerrero',
HID: 'Hidalgo',
JAL: 'Jalisco',
MEX: 'Estado de México',
MIC: 'Michoacán',
MOR: 'Morelos',
NAY: 'Nayarit',
NLE: 'Nuevo León',
OAX: 'Oaxaca',
PUE: 'Puebla',
QUE: 'Querétaro',
ROO: 'Quintana Roo',
SLP: 'San Luis Potosí',
SIN: 'Sinaloa',
SON: 'Sonora',
TAB: 'Tabasco',
TAM: 'Tamaulipas',
TLA: 'Tlaxcala',
VER: 'Veracruz',
YUC: 'Yucatán',
ZAC: 'Zacatecas',
};
// ============================================================================
// Currencies
// ============================================================================
export const CURRENCIES: Record<string, { name: string; symbol: string; decimals: number }> = {
MXN: { name: 'Peso Mexicano', symbol: '$', decimals: 2 },
USD: { name: 'Dólar Estadounidense', symbol: 'US$', decimals: 2 },
EUR: { name: 'Euro', symbol: '€', decimals: 2 },
CAD: { name: 'Dólar Canadiense', symbol: 'CA$', decimals: 2 },
GBP: { name: 'Libra Esterlina', symbol: '£', decimals: 2 },
JPY: { name: 'Yen Japonés', symbol: '¥', decimals: 0 },
};
export const DEFAULT_CURRENCY = 'MXN';
// ============================================================================
// Date & Time
// ============================================================================
export const DEFAULT_TIMEZONE = 'America/Mexico_City';
export const DEFAULT_LOCALE = 'es-MX';
export const DEFAULT_DATE_FORMAT = 'dd/MM/yyyy';
export const DEFAULT_TIME_FORMAT = 'HH:mm';
export const DEFAULT_DATETIME_FORMAT = 'dd/MM/yyyy HH:mm';
// ============================================================================
// Limits
// ============================================================================
export const LIMITS = {
// Pagination
DEFAULT_PAGE_SIZE: 20,
MAX_PAGE_SIZE: 100,
// File uploads
MAX_FILE_SIZE_MB: 10,
MAX_ATTACHMENT_SIZE_MB: 25,
ALLOWED_FILE_TYPES: ['pdf', 'xml', 'jpg', 'jpeg', 'png', 'xlsx', 'csv'],
// Text fields
MAX_DESCRIPTION_LENGTH: 500,
MAX_NOTES_LENGTH: 2000,
MAX_NAME_LENGTH: 200,
// Lists
MAX_TAGS: 10,
MAX_BATCH_SIZE: 100,
// Rate limiting
MAX_API_REQUESTS_PER_MINUTE: 60,
MAX_LOGIN_ATTEMPTS: 5,
LOGIN_LOCKOUT_MINUTES: 15,
// Sessions
ACCESS_TOKEN_EXPIRY_MINUTES: 15,
REFRESH_TOKEN_EXPIRY_DAYS: 7,
SESSION_TIMEOUT_MINUTES: 60,
// Passwords
MIN_PASSWORD_LENGTH: 8,
MAX_PASSWORD_LENGTH: 128,
PASSWORD_HISTORY_COUNT: 5,
// Export/Import
MAX_EXPORT_ROWS: 50000,
MAX_IMPORT_ROWS: 10000,
} as const;
// ============================================================================
// Regular Expressions
// ============================================================================
export const REGEX = {
RFC: /^([A-ZÑ&]{3,4})(\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])([A-Z\d]{2})([A\d])$/,
CURP: /^[A-Z]{4}\d{6}[HM][A-Z]{5}[A-Z\d]\d$/,
CLABE: /^\d{18}$/,
POSTAL_CODE_MX: /^\d{5}$/,
PHONE_MX: /^(\+52)?[\s.-]?\(?[0-9]{2,3}\)?[\s.-]?[0-9]{3,4}[\s.-]?[0-9]{4}$/,
UUID: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/,
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
SLUG: /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
HEX_COLOR: /^#[0-9A-Fa-f]{6}$/,
SAT_PRODUCT_CODE: /^\d{8}$/,
SAT_UNIT_CODE: /^[A-Z0-9]{2,3}$/,
};

View File

@@ -0,0 +1,362 @@
/**
* Authentication Validation Schemas
* Zod schemas for auth-related data validation
*/
import { z } from 'zod';
// ============================================================================
// Common Validators
// ============================================================================
/**
* Email validation
*/
export const emailSchema = z
.string()
.min(1, 'El correo electrónico es requerido')
.email('El correo electrónico no es válido')
.max(255, 'El correo electrónico es demasiado largo')
.toLowerCase()
.trim();
/**
* Password validation with Mexican-friendly messages
*/
export const passwordSchema = z
.string()
.min(8, 'La contraseña debe tener al menos 8 caracteres')
.max(128, 'La contraseña es demasiado larga')
.regex(/[A-Z]/, 'La contraseña debe contener al menos una mayúscula')
.regex(/[a-z]/, 'La contraseña debe contener al menos una minúscula')
.regex(/[0-9]/, 'La contraseña debe contener al menos un número')
.regex(
/[^A-Za-z0-9]/,
'La contraseña debe contener al menos un carácter especial'
);
/**
* Simple password (for login, without complexity requirements)
*/
export const simplePasswordSchema = z
.string()
.min(1, 'La contraseña es requerida')
.max(128, 'La contraseña es demasiado larga');
/**
* User role enum
*/
export const userRoleSchema = z.enum([
'super_admin',
'tenant_admin',
'accountant',
'assistant',
'viewer',
]);
/**
* Phone number (Mexican format)
*/
export const phoneSchema = z
.string()
.regex(
/^(\+52)?[\s.-]?\(?[0-9]{2,3}\)?[\s.-]?[0-9]{3,4}[\s.-]?[0-9]{4}$/,
'El número de teléfono no es válido'
)
.optional()
.or(z.literal(''));
/**
* Name validation
*/
export const nameSchema = z
.string()
.min(2, 'El nombre debe tener al menos 2 caracteres')
.max(100, 'El nombre es demasiado largo')
.regex(
/^[a-zA-ZáéíóúüñÁÉÍÓÚÜÑ\s'-]+$/,
'El nombre contiene caracteres no válidos'
)
.trim();
// ============================================================================
// Login Schema
// ============================================================================
export const loginRequestSchema = z.object({
email: emailSchema,
password: simplePasswordSchema,
rememberMe: z.boolean().optional().default(false),
tenantSlug: z
.string()
.min(2, 'El identificador de empresa es muy corto')
.max(50, 'El identificador de empresa es muy largo')
.regex(
/^[a-z0-9-]+$/,
'El identificador solo puede contener letras minúsculas, números y guiones'
)
.optional(),
});
export type LoginRequestInput = z.infer<typeof loginRequestSchema>;
// ============================================================================
// Register Schema
// ============================================================================
export const registerRequestSchema = z
.object({
email: emailSchema,
password: passwordSchema,
confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'),
firstName: nameSchema,
lastName: nameSchema,
phone: phoneSchema,
tenantName: z
.string()
.min(2, 'El nombre de empresa debe tener al menos 2 caracteres')
.max(100, 'El nombre de empresa es demasiado largo')
.optional(),
inviteCode: z
.string()
.length(32, 'El código de invitación no es válido')
.optional(),
acceptTerms: z.literal(true, {
errorMap: () => ({
message: 'Debes aceptar los términos y condiciones',
}),
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Las contraseñas no coinciden',
path: ['confirmPassword'],
})
.refine(
(data) => data.tenantName || data.inviteCode,
{
message: 'Debes proporcionar un nombre de empresa o código de invitación',
path: ['tenantName'],
}
);
export type RegisterRequestInput = z.infer<typeof registerRequestSchema>;
// ============================================================================
// Password Reset Schemas
// ============================================================================
export const forgotPasswordRequestSchema = z.object({
email: emailSchema,
});
export type ForgotPasswordRequestInput = z.infer<typeof forgotPasswordRequestSchema>;
export const resetPasswordRequestSchema = z
.object({
token: z
.string()
.min(1, 'El token es requerido')
.length(64, 'El token no es válido'),
password: passwordSchema,
confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Las contraseñas no coinciden',
path: ['confirmPassword'],
});
export type ResetPasswordRequestInput = z.infer<typeof resetPasswordRequestSchema>;
export const changePasswordRequestSchema = z
.object({
currentPassword: simplePasswordSchema,
newPassword: passwordSchema,
confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'Las contraseñas no coinciden',
path: ['confirmPassword'],
})
.refine((data) => data.currentPassword !== data.newPassword, {
message: 'La nueva contraseña debe ser diferente a la actual',
path: ['newPassword'],
});
export type ChangePasswordRequestInput = z.infer<typeof changePasswordRequestSchema>;
// ============================================================================
// Email Verification
// ============================================================================
export const verifyEmailRequestSchema = z.object({
token: z
.string()
.min(1, 'El token es requerido')
.length(64, 'El token no es válido'),
});
export type VerifyEmailRequestInput = z.infer<typeof verifyEmailRequestSchema>;
// ============================================================================
// Refresh Token
// ============================================================================
export const refreshTokenRequestSchema = z.object({
refreshToken: z.string().min(1, 'El token de actualización es requerido'),
});
export type RefreshTokenRequestInput = z.infer<typeof refreshTokenRequestSchema>;
// ============================================================================
// User Profile Update
// ============================================================================
export const updateProfileSchema = z.object({
firstName: nameSchema.optional(),
lastName: nameSchema.optional(),
phone: phoneSchema,
timezone: z
.string()
.min(1, 'La zona horaria es requerida')
.max(50, 'La zona horaria no es válida')
.optional(),
locale: z
.string()
.regex(/^[a-z]{2}(-[A-Z]{2})?$/, 'El idioma no es válido')
.optional(),
avatar: z.string().url('La URL del avatar no es válida').optional().nullable(),
});
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
// ============================================================================
// User Invitation
// ============================================================================
export const inviteUserRequestSchema = z.object({
email: emailSchema,
role: userRoleSchema,
message: z
.string()
.max(500, 'El mensaje es demasiado largo')
.optional(),
});
export type InviteUserRequestInput = z.infer<typeof inviteUserRequestSchema>;
export const acceptInvitationSchema = z
.object({
token: z.string().min(1, 'El token es requerido'),
firstName: nameSchema,
lastName: nameSchema,
password: passwordSchema,
confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Las contraseñas no coinciden',
path: ['confirmPassword'],
});
export type AcceptInvitationInput = z.infer<typeof acceptInvitationSchema>;
// ============================================================================
// Two-Factor Authentication
// ============================================================================
export const twoFactorVerifySchema = z.object({
code: z
.string()
.length(6, 'El código debe tener 6 dígitos')
.regex(/^\d+$/, 'El código solo debe contener números'),
});
export type TwoFactorVerifyInput = z.infer<typeof twoFactorVerifySchema>;
export const twoFactorLoginSchema = z.object({
tempToken: z.string().min(1, 'El token temporal es requerido'),
code: z
.string()
.length(6, 'El código debe tener 6 dígitos')
.regex(/^\d+$/, 'El código solo debe contener números'),
});
export type TwoFactorLoginInput = z.infer<typeof twoFactorLoginSchema>;
export const twoFactorBackupCodeSchema = z.object({
backupCode: z
.string()
.length(10, 'El código de respaldo debe tener 10 caracteres')
.regex(/^[A-Z0-9]+$/, 'El código de respaldo no es válido'),
});
export type TwoFactorBackupCodeInput = z.infer<typeof twoFactorBackupCodeSchema>;
// ============================================================================
// Session Management
// ============================================================================
export const revokeSessionSchema = z.object({
sessionId: z.string().uuid('El ID de sesión no es válido'),
});
export type RevokeSessionInput = z.infer<typeof revokeSessionSchema>;
export const revokeAllSessionsSchema = z.object({
exceptCurrent: z.boolean().default(true),
});
export type RevokeAllSessionsInput = z.infer<typeof revokeAllSessionsSchema>;
// ============================================================================
// User Management (Admin)
// ============================================================================
export const createUserSchema = z.object({
email: emailSchema,
firstName: nameSchema,
lastName: nameSchema,
role: userRoleSchema,
phone: phoneSchema,
sendInvitation: z.boolean().default(true),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export const updateUserSchema = z.object({
firstName: nameSchema.optional(),
lastName: nameSchema.optional(),
role: userRoleSchema.optional(),
phone: phoneSchema,
isActive: z.boolean().optional(),
});
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export const userFilterSchema = z.object({
search: z.string().max(100).optional(),
role: userRoleSchema.optional(),
isActive: z.boolean().optional(),
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.enum(['createdAt', 'email', 'firstName', 'lastName', 'role']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
export type UserFilterInput = z.infer<typeof userFilterSchema>;
// ============================================================================
// Permission Schema
// ============================================================================
export const permissionSchema = z.object({
resource: z.string().min(1).max(50),
actions: z.array(z.enum(['create', 'read', 'update', 'delete'])).min(1),
});
export const rolePermissionsSchema = z.object({
role: userRoleSchema,
permissions: z.array(permissionSchema),
});
export type PermissionInput = z.infer<typeof permissionSchema>;
export type RolePermissionsInput = z.infer<typeof rolePermissionsSchema>;

View File

@@ -0,0 +1,730 @@
/**
* Financial Validation Schemas
* Zod schemas for transactions, CFDI, contacts, accounts, and categories
*/
import { z } from 'zod';
// ============================================================================
// Common Validators
// ============================================================================
/**
* RFC validation (Mexican tax ID)
*/
export const rfcSchema = z
.string()
.regex(
/^([A-ZÑ&]{3,4})(\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])([A-Z\d]{2})([A\d])$/,
'El RFC no tiene un formato válido'
)
.toUpperCase()
.trim();
/**
* CURP validation
*/
export const curpSchema = z
.string()
.regex(
/^[A-Z]{4}\d{6}[HM][A-Z]{5}[A-Z\d]\d$/,
'El CURP no tiene un formato válido'
)
.toUpperCase()
.trim();
/**
* CLABE validation (18 digits)
*/
export const clabeSchema = z
.string()
.regex(/^\d{18}$/, 'La CLABE debe tener 18 dígitos');
/**
* Money amount validation
*/
export const moneySchema = z
.number()
.multipleOf(0.01, 'El monto debe tener máximo 2 decimales');
/**
* Positive money amount
*/
export const positiveMoneySchema = moneySchema
.positive('El monto debe ser mayor a cero');
/**
* Non-negative money amount
*/
export const nonNegativeMoneySchema = moneySchema
.nonnegative('El monto no puede ser negativo');
/**
* Currency code
*/
export const currencySchema = z
.string()
.length(3, 'El código de moneda debe tener 3 letras')
.toUpperCase()
.default('MXN');
/**
* UUID validation
*/
export const uuidSchema = z.string().uuid('El ID no es válido');
// ============================================================================
// Enums
// ============================================================================
export const transactionTypeSchema = z.enum([
'income',
'expense',
'transfer',
'adjustment',
]);
export const transactionStatusSchema = z.enum([
'pending',
'cleared',
'reconciled',
'voided',
]);
export const paymentMethodSchema = z.enum([
'cash',
'bank_transfer',
'credit_card',
'debit_card',
'check',
'digital_wallet',
'other',
]);
export const cfdiTypeSchema = z.enum(['I', 'E', 'T', 'N', 'P']);
export const cfdiStatusSchema = z.enum([
'draft',
'pending',
'stamped',
'sent',
'paid',
'partial_paid',
'cancelled',
'cancellation_pending',
]);
export const cfdiUsageSchema = z.enum([
'G01', 'G02', 'G03',
'I01', 'I02', 'I03', 'I04', 'I05', 'I06', 'I07', 'I08',
'D01', 'D02', 'D03', 'D04', 'D05', 'D06', 'D07', 'D08', 'D09', 'D10',
'S01', 'CP01', 'CN01',
]);
export const paymentFormSchema = z.enum([
'01', '02', '03', '04', '05', '06', '08', '12', '13', '14', '15',
'17', '23', '24', '25', '26', '27', '28', '29', '30', '31', '99',
]);
export const paymentMethodCFDISchema = z.enum(['PUE', 'PPD']);
export const contactTypeSchema = z.enum([
'customer',
'supplier',
'both',
'employee',
]);
export const categoryTypeSchema = z.enum(['income', 'expense']);
export const accountTypeSchema = z.enum([
'bank',
'cash',
'credit_card',
'loan',
'investment',
'other',
]);
export const accountSubtypeSchema = z.enum([
'checking',
'savings',
'money_market',
'cd',
'credit',
'line_of_credit',
'mortgage',
'auto_loan',
'personal_loan',
'brokerage',
'retirement',
'other',
]);
// ============================================================================
// Transaction Schemas
// ============================================================================
export const createTransactionSchema = z.object({
type: transactionTypeSchema,
amount: positiveMoneySchema,
currency: currencySchema,
exchangeRate: z.number().positive().optional(),
description: z
.string()
.min(1, 'La descripción es requerida')
.max(500, 'La descripción es demasiado larga')
.trim(),
reference: z.string().max(100).optional(),
notes: z.string().max(2000).optional(),
date: z.coerce.date(),
valueDate: z.coerce.date().optional(),
accountId: uuidSchema,
destinationAccountId: uuidSchema.optional(),
categoryId: uuidSchema.optional(),
contactId: uuidSchema.optional(),
cfdiId: uuidSchema.optional(),
paymentMethod: paymentMethodSchema.optional(),
paymentReference: z.string().max(100).optional(),
tags: z.array(z.string().max(50)).max(10).default([]),
isRecurring: z.boolean().default(false),
recurringRuleId: uuidSchema.optional(),
}).refine(
(data) => {
if (data.type === 'transfer') {
return !!data.destinationAccountId;
}
return true;
},
{
message: 'La cuenta destino es requerida para transferencias',
path: ['destinationAccountId'],
}
).refine(
(data) => {
if (data.type === 'transfer' && data.destinationAccountId) {
return data.accountId !== data.destinationAccountId;
}
return true;
},
{
message: 'La cuenta origen y destino no pueden ser la misma',
path: ['destinationAccountId'],
}
);
export type CreateTransactionInput = z.infer<typeof createTransactionSchema>;
export const updateTransactionSchema = z.object({
amount: positiveMoneySchema.optional(),
description: z.string().min(1).max(500).trim().optional(),
reference: z.string().max(100).optional().nullable(),
notes: z.string().max(2000).optional().nullable(),
date: z.coerce.date().optional(),
valueDate: z.coerce.date().optional().nullable(),
categoryId: uuidSchema.optional().nullable(),
contactId: uuidSchema.optional().nullable(),
paymentMethod: paymentMethodSchema.optional().nullable(),
paymentReference: z.string().max(100).optional().nullable(),
tags: z.array(z.string().max(50)).max(10).optional(),
});
export type UpdateTransactionInput = z.infer<typeof updateTransactionSchema>;
export const transactionFilterSchema = z.object({
type: z.array(transactionTypeSchema).optional(),
status: z.array(transactionStatusSchema).optional(),
accountId: uuidSchema.optional(),
categoryId: uuidSchema.optional(),
contactId: uuidSchema.optional(),
dateFrom: z.coerce.date().optional(),
dateTo: z.coerce.date().optional(),
amountMin: nonNegativeMoneySchema.optional(),
amountMax: nonNegativeMoneySchema.optional(),
search: z.string().max(100).optional(),
tags: z.array(z.string()).optional(),
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.enum(['date', 'amount', 'description', 'createdAt']).default('date'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
export type TransactionFilterInput = z.infer<typeof transactionFilterSchema>;
// ============================================================================
// CFDI Schemas
// ============================================================================
export const cfdiTaxSchema = z.object({
type: z.enum(['transferred', 'withheld']),
tax: z.enum(['IVA', 'ISR', 'IEPS']),
factor: z.enum(['Tasa', 'Cuota', 'Exento']),
rate: z.number().min(0).max(1),
base: positiveMoneySchema,
amount: nonNegativeMoneySchema,
});
export const cfdiItemSchema = z.object({
productCode: z
.string()
.regex(/^\d{8}$/, 'La clave del producto debe tener 8 dígitos'),
unitCode: z
.string()
.regex(/^[A-Z0-9]{2,3}$/, 'La clave de unidad no es válida'),
description: z
.string()
.min(1, 'La descripción es requerida')
.max(1000, 'La descripción es demasiado larga'),
quantity: z.number().positive('La cantidad debe ser mayor a cero'),
unitPrice: positiveMoneySchema,
discount: nonNegativeMoneySchema.optional().default(0),
identificationNumber: z.string().max(100).optional(),
unit: z.string().max(50).optional(),
taxes: z.array(cfdiTaxSchema).default([]),
});
export const cfdiRelationSchema = z.object({
type: z.enum(['01', '02', '03', '04', '05', '06', '07', '08', '09']),
uuid: z
.string()
.regex(
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/,
'El UUID no tiene un formato válido'
),
});
export const createCFDISchema = z.object({
type: cfdiTypeSchema,
series: z
.string()
.max(25, 'La serie es demasiado larga')
.regex(/^[A-Z0-9]*$/, 'La serie solo puede contener letras y números')
.optional(),
// Receiver
receiverRfc: rfcSchema,
receiverName: z
.string()
.min(1, 'El nombre del receptor es requerido')
.max(300, 'El nombre del receptor es demasiado largo'),
receiverFiscalRegime: z.string().min(3).max(3).optional(),
receiverPostalCode: z.string().regex(/^\d{5}$/, 'El código postal debe tener 5 dígitos'),
receiverUsage: cfdiUsageSchema,
receiverEmail: z.string().email('El correo electrónico no es válido').optional(),
// Items
items: z
.array(cfdiItemSchema)
.min(1, 'Debe haber al menos un concepto'),
// Payment
paymentForm: paymentFormSchema,
paymentMethod: paymentMethodCFDISchema,
paymentConditions: z.string().max(1000).optional(),
// Currency
currency: currencySchema,
exchangeRate: z.number().positive().optional(),
// Related CFDIs
relatedCfdis: z.array(cfdiRelationSchema).optional(),
// Contact
contactId: uuidSchema.optional(),
// Dates
issueDate: z.coerce.date().optional(),
expirationDate: z.coerce.date().optional(),
}).refine(
(data) => {
if (data.currency !== 'MXN') {
return !!data.exchangeRate;
}
return true;
},
{
message: 'El tipo de cambio es requerido para monedas diferentes a MXN',
path: ['exchangeRate'],
}
);
export type CreateCFDIInput = z.infer<typeof createCFDISchema>;
export const updateCFDIDraftSchema = z.object({
receiverRfc: rfcSchema.optional(),
receiverName: z.string().min(1).max(300).optional(),
receiverFiscalRegime: z.string().min(3).max(3).optional().nullable(),
receiverPostalCode: z.string().regex(/^\d{5}$/).optional(),
receiverUsage: cfdiUsageSchema.optional(),
receiverEmail: z.string().email().optional().nullable(),
items: z.array(cfdiItemSchema).min(1).optional(),
paymentForm: paymentFormSchema.optional(),
paymentMethod: paymentMethodCFDISchema.optional(),
paymentConditions: z.string().max(1000).optional().nullable(),
expirationDate: z.coerce.date().optional().nullable(),
});
export type UpdateCFDIDraftInput = z.infer<typeof updateCFDIDraftSchema>;
export const cfdiFilterSchema = z.object({
type: z.array(cfdiTypeSchema).optional(),
status: z.array(cfdiStatusSchema).optional(),
contactId: uuidSchema.optional(),
receiverRfc: z.string().optional(),
dateFrom: z.coerce.date().optional(),
dateTo: z.coerce.date().optional(),
amountMin: nonNegativeMoneySchema.optional(),
amountMax: nonNegativeMoneySchema.optional(),
search: z.string().max(100).optional(),
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.enum(['issueDate', 'total', 'folio', 'createdAt']).default('issueDate'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
export type CFDIFilterInput = z.infer<typeof cfdiFilterSchema>;
export const cancelCFDISchema = z.object({
reason: z.enum(['01', '02', '03', '04'], {
errorMap: () => ({ message: 'El motivo de cancelación no es válido' }),
}),
substitutedByUuid: z
.string()
.regex(
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/
)
.optional(),
}).refine(
(data) => {
// If reason is 01 (substitution), substitutedByUuid is required
if (data.reason === '01') {
return !!data.substitutedByUuid;
}
return true;
},
{
message: 'El UUID del CFDI sustituto es requerido para el motivo 01',
path: ['substitutedByUuid'],
}
);
export type CancelCFDIInput = z.infer<typeof cancelCFDISchema>;
export const registerPaymentSchema = z.object({
cfdiId: uuidSchema,
amount: positiveMoneySchema,
paymentDate: z.coerce.date(),
paymentForm: paymentFormSchema,
transactionId: uuidSchema.optional(),
});
export type RegisterPaymentInput = z.infer<typeof registerPaymentSchema>;
// ============================================================================
// Contact Schemas
// ============================================================================
export const contactAddressSchema = z.object({
street: z.string().min(1).max(200),
exteriorNumber: z.string().min(1).max(20),
interiorNumber: z.string().max(20).optional().or(z.literal('')),
neighborhood: z.string().min(1).max(100),
city: z.string().min(1).max(100),
state: z.string().min(1).max(100),
country: z.string().min(1).max(100).default('México'),
postalCode: z.string().regex(/^\d{5}$/),
});
export const contactBankAccountSchema = z.object({
bankName: z.string().min(1).max(100),
accountNumber: z.string().min(1).max(20),
clabe: clabeSchema.optional(),
accountHolder: z.string().min(1).max(200),
currency: currencySchema,
isDefault: z.boolean().default(false),
});
export const createContactSchema = z.object({
type: contactTypeSchema,
name: z
.string()
.min(1, 'El nombre es requerido')
.max(200, 'El nombre es demasiado largo')
.trim(),
displayName: z.string().max(200).optional(),
rfc: rfcSchema.optional(),
curp: curpSchema.optional(),
email: z.string().email('El correo electrónico no es válido').optional(),
phone: z
.string()
.regex(
/^(\+52)?[\s.-]?\(?[0-9]{2,3}\)?[\s.-]?[0-9]{3,4}[\s.-]?[0-9]{4}$/
)
.optional(),
mobile: z.string().optional(),
website: z.string().url().optional(),
fiscalRegime: z.string().min(3).max(3).optional(),
fiscalName: z.string().max(300).optional(),
cfdiUsage: cfdiUsageSchema.optional(),
address: contactAddressSchema.optional(),
creditLimit: nonNegativeMoneySchema.optional(),
creditDays: z.number().int().min(0).max(365).optional(),
bankAccounts: z.array(contactBankAccountSchema).optional(),
tags: z.array(z.string().max(50)).max(10).default([]),
groupId: uuidSchema.optional(),
notes: z.string().max(2000).optional(),
});
export type CreateContactInput = z.infer<typeof createContactSchema>;
export const updateContactSchema = z.object({
type: contactTypeSchema.optional(),
name: z.string().min(1).max(200).trim().optional(),
displayName: z.string().max(200).optional().nullable(),
rfc: rfcSchema.optional().nullable(),
curp: curpSchema.optional().nullable(),
email: z.string().email().optional().nullable(),
phone: z.string().optional().nullable(),
mobile: z.string().optional().nullable(),
website: z.string().url().optional().nullable(),
fiscalRegime: z.string().min(3).max(3).optional().nullable(),
fiscalName: z.string().max(300).optional().nullable(),
cfdiUsage: cfdiUsageSchema.optional().nullable(),
address: contactAddressSchema.optional().nullable(),
creditLimit: nonNegativeMoneySchema.optional().nullable(),
creditDays: z.number().int().min(0).max(365).optional().nullable(),
bankAccounts: z.array(contactBankAccountSchema).optional(),
tags: z.array(z.string().max(50)).max(10).optional(),
groupId: uuidSchema.optional().nullable(),
notes: z.string().max(2000).optional().nullable(),
isActive: z.boolean().optional(),
});
export type UpdateContactInput = z.infer<typeof updateContactSchema>;
export const contactFilterSchema = z.object({
type: z.array(contactTypeSchema).optional(),
search: z.string().max(100).optional(),
isActive: z.boolean().optional(),
hasBalance: z.boolean().optional(),
tags: z.array(z.string()).optional(),
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.enum(['name', 'balance', 'createdAt']).default('name'),
sortOrder: z.enum(['asc', 'desc']).default('asc'),
});
export type ContactFilterInput = z.infer<typeof contactFilterSchema>;
// ============================================================================
// Category Schemas
// ============================================================================
export const createCategorySchema = z.object({
type: categoryTypeSchema,
name: z
.string()
.min(1, 'El nombre es requerido')
.max(100, 'El nombre es demasiado largo')
.trim(),
description: z.string().max(500).optional(),
code: z.string().max(20).optional(),
color: z
.string()
.regex(/^#[0-9A-Fa-f]{6}$/)
.optional(),
icon: z.string().max(50).optional(),
parentId: uuidSchema.optional(),
satCode: z.string().max(10).optional(),
sortOrder: z.number().int().min(0).default(0),
});
export type CreateCategoryInput = z.infer<typeof createCategorySchema>;
export const updateCategorySchema = z.object({
name: z.string().min(1).max(100).trim().optional(),
description: z.string().max(500).optional().nullable(),
code: z.string().max(20).optional().nullable(),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional().nullable(),
icon: z.string().max(50).optional().nullable(),
parentId: uuidSchema.optional().nullable(),
satCode: z.string().max(10).optional().nullable(),
sortOrder: z.number().int().min(0).optional(),
isActive: z.boolean().optional(),
});
export type UpdateCategoryInput = z.infer<typeof updateCategorySchema>;
export const categoryFilterSchema = z.object({
type: categoryTypeSchema.optional(),
search: z.string().max(100).optional(),
parentId: uuidSchema.optional(),
isActive: z.boolean().optional(),
includeSystem: z.boolean().default(false),
});
export type CategoryFilterInput = z.infer<typeof categoryFilterSchema>;
// ============================================================================
// Account Schemas
// ============================================================================
export const createAccountSchema = z.object({
type: accountTypeSchema,
subtype: accountSubtypeSchema.optional(),
name: z
.string()
.min(1, 'El nombre es requerido')
.max(100, 'El nombre es demasiado largo')
.trim(),
description: z.string().max(500).optional(),
accountNumber: z.string().max(30).optional(),
currency: currencySchema,
bankName: z.string().max(100).optional(),
bankBranch: z.string().max(100).optional(),
clabe: clabeSchema.optional(),
swiftCode: z.string().max(11).optional(),
currentBalance: moneySchema.default(0),
creditLimit: nonNegativeMoneySchema.optional(),
isDefault: z.boolean().default(false),
isReconcilable: z.boolean().default(true),
});
export type CreateAccountInput = z.infer<typeof createAccountSchema>;
export const updateAccountSchema = z.object({
name: z.string().min(1).max(100).trim().optional(),
description: z.string().max(500).optional().nullable(),
accountNumber: z.string().max(30).optional().nullable(),
bankName: z.string().max(100).optional().nullable(),
bankBranch: z.string().max(100).optional().nullable(),
clabe: clabeSchema.optional().nullable(),
swiftCode: z.string().max(11).optional().nullable(),
creditLimit: nonNegativeMoneySchema.optional().nullable(),
isDefault: z.boolean().optional(),
isReconcilable: z.boolean().optional(),
isActive: z.boolean().optional(),
});
export type UpdateAccountInput = z.infer<typeof updateAccountSchema>;
export const accountFilterSchema = z.object({
type: z.array(accountTypeSchema).optional(),
search: z.string().max(100).optional(),
isActive: z.boolean().optional(),
currency: currencySchema.optional(),
});
export type AccountFilterInput = z.infer<typeof accountFilterSchema>;
export const adjustBalanceSchema = z.object({
newBalance: moneySchema,
reason: z
.string()
.min(1, 'El motivo es requerido')
.max(500, 'El motivo es demasiado largo'),
date: z.coerce.date().optional(),
});
export type AdjustBalanceInput = z.infer<typeof adjustBalanceSchema>;
// ============================================================================
// Recurring Rule Schemas
// ============================================================================
export const recurringFrequencySchema = z.enum([
'daily',
'weekly',
'biweekly',
'monthly',
'quarterly',
'yearly',
]);
export const createRecurringRuleSchema = z.object({
name: z.string().min(1).max(100).trim(),
type: transactionTypeSchema,
frequency: recurringFrequencySchema,
interval: z.number().int().min(1).max(12).default(1),
startDate: z.coerce.date(),
endDate: z.coerce.date().optional(),
amount: positiveMoneySchema,
description: z.string().min(1).max(500).trim(),
accountId: uuidSchema,
categoryId: uuidSchema.optional(),
contactId: uuidSchema.optional(),
maxOccurrences: z.number().int().positive().optional(),
}).refine(
(data) => {
if (data.endDate) {
return data.endDate > data.startDate;
}
return true;
},
{
message: 'La fecha de fin debe ser posterior a la fecha de inicio',
path: ['endDate'],
}
);
export type CreateRecurringRuleInput = z.infer<typeof createRecurringRuleSchema>;
export const updateRecurringRuleSchema = z.object({
name: z.string().min(1).max(100).trim().optional(),
frequency: recurringFrequencySchema.optional(),
interval: z.number().int().min(1).max(12).optional(),
endDate: z.coerce.date().optional().nullable(),
amount: positiveMoneySchema.optional(),
description: z.string().min(1).max(500).trim().optional(),
categoryId: uuidSchema.optional().nullable(),
contactId: uuidSchema.optional().nullable(),
maxOccurrences: z.number().int().positive().optional().nullable(),
isActive: z.boolean().optional(),
});
export type UpdateRecurringRuleInput = z.infer<typeof updateRecurringRuleSchema>;
// ============================================================================
// Bank Statement Schemas
// ============================================================================
export const uploadBankStatementSchema = z.object({
accountId: uuidSchema,
file: z.string().min(1, 'El archivo es requerido'),
format: z.enum(['ofx', 'csv', 'xlsx']).optional(),
});
export type UploadBankStatementInput = z.infer<typeof uploadBankStatementSchema>;
export const matchTransactionSchema = z.object({
statementLineId: uuidSchema,
transactionId: uuidSchema,
});
export type MatchTransactionInput = z.infer<typeof matchTransactionSchema>;
export const createFromStatementLineSchema = z.object({
statementLineId: uuidSchema,
categoryId: uuidSchema.optional(),
contactId: uuidSchema.optional(),
description: z.string().max(500).optional(),
});
export type CreateFromStatementLineInput = z.infer<typeof createFromStatementLineSchema>;
// ============================================================================
// Bulk Operations
// ============================================================================
export const bulkCategorizeSchema = z.object({
transactionIds: z.array(uuidSchema).min(1, 'Selecciona al menos una transacción'),
categoryId: uuidSchema,
});
export type BulkCategorizeInput = z.infer<typeof bulkCategorizeSchema>;
export const bulkDeleteSchema = z.object({
ids: z.array(uuidSchema).min(1, 'Selecciona al menos un elemento'),
});
export type BulkDeleteInput = z.infer<typeof bulkDeleteSchema>;

View File

@@ -0,0 +1,12 @@
/**
* Schemas Index - Re-export all validation schemas
*/
// Authentication schemas
export * from './auth.schema';
// Tenant & subscription schemas
export * from './tenant.schema';
// Financial schemas
export * from './financial.schema';

View File

@@ -0,0 +1,509 @@
/**
* Tenant Validation Schemas
* Zod schemas for tenant, subscription, and billing validation
*/
import { z } from 'zod';
// ============================================================================
// Common Validators
// ============================================================================
/**
* RFC validation (Mexican tax ID)
*/
export const rfcSchema = z
.string()
.regex(
/^([A-ZÑ&]{3,4})(\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])([A-Z\d]{2})([A\d])$/,
'El RFC no tiene un formato válido'
)
.toUpperCase()
.trim();
/**
* Slug validation for URLs
*/
export const slugSchema = z
.string()
.min(2, 'El identificador debe tener al menos 2 caracteres')
.max(50, 'El identificador es demasiado largo')
.regex(
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
'El identificador solo puede contener letras minúsculas, números y guiones'
)
.toLowerCase()
.trim();
/**
* Tenant status enum
*/
export const tenantStatusSchema = z.enum([
'pending',
'active',
'suspended',
'cancelled',
'trial',
'expired',
]);
/**
* Subscription status enum
*/
export const subscriptionStatusSchema = z.enum([
'trialing',
'active',
'past_due',
'canceled',
'unpaid',
'paused',
]);
/**
* Billing cycle enum
*/
export const billingCycleSchema = z.enum(['monthly', 'annual']);
/**
* Plan tier enum
*/
export const planTierSchema = z.enum(['free', 'starter', 'professional', 'enterprise']);
// ============================================================================
// Address Schema
// ============================================================================
export const addressSchema = z.object({
street: z
.string()
.min(1, 'La calle es requerida')
.max(200, 'La calle es demasiado larga'),
exteriorNumber: z
.string()
.min(1, 'El número exterior es requerido')
.max(20, 'El número exterior es demasiado largo'),
interiorNumber: z
.string()
.max(20, 'El número interior es demasiado largo')
.optional()
.or(z.literal('')),
neighborhood: z
.string()
.min(1, 'La colonia es requerida')
.max(100, 'La colonia es demasiado larga'),
city: z
.string()
.min(1, 'La ciudad es requerida')
.max(100, 'La ciudad es demasiado larga'),
state: z
.string()
.min(1, 'El estado es requerido')
.max(100, 'El estado es demasiado largo'),
country: z
.string()
.min(1, 'El país es requerido')
.max(100, 'El país es demasiado largo')
.default('México'),
postalCode: z
.string()
.regex(/^\d{5}$/, 'El código postal debe tener 5 dígitos'),
});
export type AddressInput = z.infer<typeof addressSchema>;
// ============================================================================
// Tenant Creation Schema
// ============================================================================
export const createTenantSchema = z.object({
name: z
.string()
.min(2, 'El nombre debe tener al menos 2 caracteres')
.max(100, 'El nombre es demasiado largo')
.trim(),
slug: slugSchema,
legalName: z
.string()
.min(2, 'La razón social debe tener al menos 2 caracteres')
.max(200, 'La razón social es demasiado larga')
.optional(),
rfc: rfcSchema.optional(),
email: z
.string()
.email('El correo electrónico no es válido')
.max(255),
phone: z
.string()
.regex(
/^(\+52)?[\s.-]?\(?[0-9]{2,3}\)?[\s.-]?[0-9]{3,4}[\s.-]?[0-9]{4}$/,
'El número de teléfono no es válido'
)
.optional(),
website: z.string().url('La URL del sitio web no es válida').optional(),
planId: z.string().uuid('El ID del plan no es válido'),
});
export type CreateTenantInput = z.infer<typeof createTenantSchema>;
// ============================================================================
// Tenant Update Schema
// ============================================================================
export const updateTenantSchema = z.object({
name: z
.string()
.min(2, 'El nombre debe tener al menos 2 caracteres')
.max(100, 'El nombre es demasiado largo')
.trim()
.optional(),
legalName: z
.string()
.min(2, 'La razón social debe tener al menos 2 caracteres')
.max(200, 'La razón social es demasiado larga')
.optional()
.nullable(),
rfc: rfcSchema.optional().nullable(),
fiscalRegime: z
.string()
.min(3, 'El régimen fiscal no es válido')
.max(10, 'El régimen fiscal no es válido')
.optional()
.nullable(),
fiscalAddress: addressSchema.optional().nullable(),
email: z
.string()
.email('El correo electrónico no es válido')
.max(255)
.optional(),
phone: z
.string()
.regex(
/^(\+52)?[\s.-]?\(?[0-9]{2,3}\)?[\s.-]?[0-9]{3,4}[\s.-]?[0-9]{4}$/,
'El número de teléfono no es válido'
)
.optional()
.nullable(),
website: z.string().url('La URL del sitio web no es válida').optional().nullable(),
logo: z.string().url('La URL del logo no es válida').optional().nullable(),
primaryColor: z
.string()
.regex(/^#[0-9A-Fa-f]{6}$/, 'El color primario no es válido')
.optional()
.nullable(),
secondaryColor: z
.string()
.regex(/^#[0-9A-Fa-f]{6}$/, 'El color secundario no es válido')
.optional()
.nullable(),
});
export type UpdateTenantInput = z.infer<typeof updateTenantSchema>;
// ============================================================================
// Tenant Settings Schema
// ============================================================================
export const tenantSettingsSchema = z.object({
// General
timezone: z
.string()
.min(1, 'La zona horaria es requerida')
.max(50)
.default('America/Mexico_City'),
locale: z
.string()
.regex(/^[a-z]{2}(-[A-Z]{2})?$/)
.default('es-MX'),
currency: z
.string()
.length(3, 'La moneda debe ser un código de 3 letras')
.toUpperCase()
.default('MXN'),
fiscalYearStart: z
.number()
.int()
.min(1)
.max(12)
.default(1),
// Invoicing
defaultPaymentTerms: z
.number()
.int()
.min(0)
.max(365)
.default(30),
invoicePrefix: z
.string()
.max(10, 'El prefijo es demasiado largo')
.regex(/^[A-Z0-9]*$/, 'El prefijo solo puede contener letras mayúsculas y números')
.default(''),
invoiceNextNumber: z
.number()
.int()
.positive()
.default(1),
// Notifications
emailNotifications: z.boolean().default(true),
invoiceReminders: z.boolean().default(true),
paymentReminders: z.boolean().default(true),
// Security
sessionTimeout: z
.number()
.int()
.min(5)
.max(1440)
.default(60),
requireTwoFactor: z.boolean().default(false),
allowedIPs: z
.array(
z.string().ip({ message: 'La dirección IP no es válida' })
)
.optional(),
// Integrations
satIntegration: z.boolean().default(false),
bankingIntegration: z.boolean().default(false),
});
export type TenantSettingsInput = z.infer<typeof tenantSettingsSchema>;
// ============================================================================
// Plan Schema
// ============================================================================
export const planFeaturesSchema = z.object({
// Modules
invoicing: z.boolean(),
expenses: z.boolean(),
bankReconciliation: z.boolean(),
reports: z.boolean(),
budgets: z.boolean(),
forecasting: z.boolean(),
multiCurrency: z.boolean(),
// CFDI
cfdiGeneration: z.boolean(),
cfdiCancellation: z.boolean(),
cfdiAddenda: z.boolean(),
massInvoicing: z.boolean(),
// Integrations
satIntegration: z.boolean(),
bankIntegration: z.boolean(),
erpIntegration: z.boolean(),
apiAccess: z.boolean(),
webhooks: z.boolean(),
// Collaboration
multiUser: z.boolean(),
customRoles: z.boolean(),
auditLog: z.boolean(),
comments: z.boolean(),
// Support
emailSupport: z.boolean(),
chatSupport: z.boolean(),
phoneSupport: z.boolean(),
prioritySupport: z.boolean(),
dedicatedManager: z.boolean(),
// Extras
customBranding: z.boolean(),
whiteLabel: z.boolean(),
dataExport: z.boolean(),
advancedReports: z.boolean(),
});
export const planLimitsSchema = z.object({
maxUsers: z.number().int().positive(),
maxTransactionsPerMonth: z.number().int().positive(),
maxInvoicesPerMonth: z.number().int().positive(),
maxContacts: z.number().int().positive(),
maxBankAccounts: z.number().int().positive(),
storageMB: z.number().int().positive(),
apiRequestsPerDay: z.number().int().positive(),
retentionDays: z.number().int().positive(),
});
export const planPricingSchema = z.object({
monthlyPrice: z.number().nonnegative(),
annualPrice: z.number().nonnegative(),
currency: z.string().length(3).default('MXN'),
trialDays: z.number().int().nonnegative().default(14),
setupFee: z.number().nonnegative().optional(),
});
export const createPlanSchema = z.object({
name: z.string().min(1).max(100),
tier: planTierSchema,
description: z.string().max(500),
features: planFeaturesSchema,
limits: planLimitsSchema,
pricing: planPricingSchema,
isActive: z.boolean().default(true),
isPopular: z.boolean().default(false),
sortOrder: z.number().int().default(0),
});
export type CreatePlanInput = z.infer<typeof createPlanSchema>;
// ============================================================================
// Subscription Schema
// ============================================================================
export const createSubscriptionSchema = z.object({
tenantId: z.string().uuid('El ID del tenant no es válido'),
planId: z.string().uuid('El ID del plan no es válido'),
billingCycle: billingCycleSchema,
paymentMethodId: z.string().optional(),
promoCode: z.string().max(50).optional(),
});
export type CreateSubscriptionInput = z.infer<typeof createSubscriptionSchema>;
export const updateSubscriptionSchema = z.object({
planId: z.string().uuid('El ID del plan no es válido').optional(),
billingCycle: billingCycleSchema.optional(),
cancelAtPeriodEnd: z.boolean().optional(),
});
export type UpdateSubscriptionInput = z.infer<typeof updateSubscriptionSchema>;
export const cancelSubscriptionSchema = z.object({
reason: z
.string()
.max(500, 'La razón es demasiado larga')
.optional(),
feedback: z
.string()
.max(1000, 'La retroalimentación es demasiado larga')
.optional(),
immediate: z.boolean().default(false),
});
export type CancelSubscriptionInput = z.infer<typeof cancelSubscriptionSchema>;
// ============================================================================
// Payment Method Schema
// ============================================================================
export const paymentMethodTypeSchema = z.enum([
'card',
'bank_transfer',
'oxxo',
'spei',
]);
export const addPaymentMethodSchema = z.object({
type: paymentMethodTypeSchema,
token: z.string().min(1, 'El token es requerido'),
setAsDefault: z.boolean().default(false),
billingAddress: addressSchema.optional(),
});
export type AddPaymentMethodInput = z.infer<typeof addPaymentMethodSchema>;
export const updatePaymentMethodSchema = z.object({
setAsDefault: z.boolean().optional(),
billingAddress: addressSchema.optional(),
});
export type UpdatePaymentMethodInput = z.infer<typeof updatePaymentMethodSchema>;
// ============================================================================
// Promo Code Schema
// ============================================================================
export const promoCodeSchema = z.object({
code: z
.string()
.min(3, 'El código es muy corto')
.max(50, 'El código es muy largo')
.toUpperCase()
.trim(),
});
export type PromoCodeInput = z.infer<typeof promoCodeSchema>;
export const createPromoCodeSchema = z.object({
code: z
.string()
.min(3)
.max(50)
.regex(/^[A-Z0-9_-]+$/, 'El código solo puede contener letras, números, guiones y guiones bajos')
.toUpperCase(),
discountType: z.enum(['percentage', 'fixed']),
discountValue: z.number().positive(),
maxRedemptions: z.number().int().positive().optional(),
validFrom: z.coerce.date(),
validUntil: z.coerce.date(),
applicablePlans: z.array(z.string().uuid()).optional(),
minBillingCycles: z.number().int().positive().optional(),
}).refine(
(data) => data.validUntil > data.validFrom,
{
message: 'La fecha de fin debe ser posterior a la fecha de inicio',
path: ['validUntil'],
}
).refine(
(data) => {
if (data.discountType === 'percentage') {
return data.discountValue <= 100;
}
return true;
},
{
message: 'El porcentaje de descuento no puede ser mayor a 100',
path: ['discountValue'],
}
);
export type CreatePromoCodeInput = z.infer<typeof createPromoCodeSchema>;
// ============================================================================
// Invoice Schema
// ============================================================================
export const invoiceFilterSchema = z.object({
status: z.enum(['draft', 'open', 'paid', 'void', 'uncollectible']).optional(),
dateFrom: z.coerce.date().optional(),
dateTo: z.coerce.date().optional(),
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
export type InvoiceFilterInput = z.infer<typeof invoiceFilterSchema>;
// ============================================================================
// Usage Query Schema
// ============================================================================
export const usageQuerySchema = z.object({
period: z
.string()
.regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'El período debe tener formato YYYY-MM')
.optional(),
});
export type UsageQueryInput = z.infer<typeof usageQuerySchema>;
// ============================================================================
// Tenant Filter Schema (Admin)
// ============================================================================
export const tenantFilterSchema = z.object({
search: z.string().max(100).optional(),
status: tenantStatusSchema.optional(),
planId: z.string().uuid().optional(),
dateFrom: z.coerce.date().optional(),
dateTo: z.coerce.date().optional(),
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.enum(['createdAt', 'name', 'status']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
export type TenantFilterInput = z.infer<typeof tenantFilterSchema>;

View File

@@ -0,0 +1,264 @@
/**
* Authentication Types for Horux Strategy
*/
// ============================================================================
// User Roles
// ============================================================================
export type UserRole =
| 'super_admin' // Administrador del sistema completo
| 'tenant_admin' // Administrador del tenant
| 'accountant' // Contador con acceso completo a finanzas
| 'assistant' // Asistente con acceso limitado
| 'viewer'; // Solo lectura
export interface UserPermission {
resource: string;
actions: ('create' | 'read' | 'update' | 'delete')[];
}
export interface RolePermissions {
role: UserRole;
permissions: UserPermission[];
}
// ============================================================================
// User
// ============================================================================
export interface User {
id: string;
tenantId: string;
email: string;
firstName: string;
lastName: string;
fullName: string;
role: UserRole;
permissions: UserPermission[];
avatar?: string;
phone?: string;
timezone: string;
locale: string;
isActive: boolean;
isEmailVerified: boolean;
lastLoginAt?: Date;
passwordChangedAt?: Date;
createdAt: Date;
updatedAt: Date;
}
export interface UserProfile {
id: string;
email: string;
firstName: string;
lastName: string;
fullName: string;
role: UserRole;
avatar?: string;
phone?: string;
timezone: string;
locale: string;
tenant: {
id: string;
name: string;
slug: string;
};
}
// ============================================================================
// User Session
// ============================================================================
export interface UserSession {
id: string;
userId: string;
tenantId: string;
token: string;
refreshToken: string;
userAgent?: string;
ipAddress?: string;
isValid: boolean;
expiresAt: Date;
createdAt: Date;
lastActivityAt: Date;
}
export interface ActiveSession {
id: string;
userAgent?: string;
ipAddress?: string;
location?: string;
lastActivityAt: Date;
createdAt: Date;
isCurrent: boolean;
}
// ============================================================================
// Authentication Requests & Responses
// ============================================================================
export interface LoginRequest {
email: string;
password: string;
rememberMe?: boolean;
tenantSlug?: string;
}
export interface LoginResponse {
user: UserProfile;
accessToken: string;
refreshToken: string;
expiresIn: number;
tokenType: 'Bearer';
}
export interface RegisterRequest {
email: string;
password: string;
confirmPassword: string;
firstName: string;
lastName: string;
phone?: string;
tenantName?: string;
inviteCode?: string;
acceptTerms: boolean;
}
export interface RegisterResponse {
user: UserProfile;
accessToken: string;
refreshToken: string;
expiresIn: number;
tokenType: 'Bearer';
requiresEmailVerification: boolean;
}
export interface RefreshTokenRequest {
refreshToken: string;
}
export interface RefreshTokenResponse {
accessToken: string;
refreshToken: string;
expiresIn: number;
tokenType: 'Bearer';
}
export interface ForgotPasswordRequest {
email: string;
}
export interface ResetPasswordRequest {
token: string;
password: string;
confirmPassword: string;
}
export interface ChangePasswordRequest {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
export interface VerifyEmailRequest {
token: string;
}
// ============================================================================
// Token Payload
// ============================================================================
export interface TokenPayload {
sub: string; // User ID
email: string;
tenantId: string;
role: UserRole;
permissions: string[];
sessionId: string;
iat: number; // Issued at
exp: number; // Expiration
iss: string; // Issuer
aud: string; // Audience
}
export interface RefreshTokenPayload {
sub: string; // User ID
sessionId: string;
tokenFamily: string; // Para detección de reuso
iat: number;
exp: number;
iss: string;
aud: string;
}
// ============================================================================
// Invitation
// ============================================================================
export interface UserInvitation {
id: string;
tenantId: string;
email: string;
role: UserRole;
invitedBy: string;
token: string;
expiresAt: Date;
acceptedAt?: Date;
createdAt: Date;
}
export interface InviteUserRequest {
email: string;
role: UserRole;
message?: string;
}
// ============================================================================
// Two-Factor Authentication
// ============================================================================
export interface TwoFactorSetup {
secret: string;
qrCodeUrl: string;
backupCodes: string[];
}
export interface TwoFactorVerifyRequest {
code: string;
}
export interface TwoFactorLoginRequest {
tempToken: string;
code: string;
}
// ============================================================================
// Audit Log
// ============================================================================
export interface AuthAuditLog {
id: string;
userId: string;
tenantId: string;
action: AuthAction;
ipAddress?: string;
userAgent?: string;
metadata?: Record<string, unknown>;
success: boolean;
failureReason?: string;
createdAt: Date;
}
export type AuthAction =
| 'login'
| 'logout'
| 'register'
| 'password_reset_request'
| 'password_reset_complete'
| 'password_change'
| 'email_verification'
| 'two_factor_enable'
| 'two_factor_disable'
| 'session_revoke'
| 'token_refresh';

View File

@@ -0,0 +1,634 @@
/**
* Financial Types for Horux Strategy
* Core financial entities for Mexican accounting
*/
// ============================================================================
// Transaction Types
// ============================================================================
export type TransactionType =
| 'income' // Ingreso
| 'expense' // Egreso
| 'transfer' // Transferencia entre cuentas
| 'adjustment'; // Ajuste contable
export type TransactionStatus =
| 'pending' // Pendiente de procesar
| 'cleared' // Conciliado
| 'reconciled' // Conciliado con banco
| 'voided'; // Anulado
export type PaymentMethod =
| 'cash' // Efectivo
| 'bank_transfer' // Transferencia bancaria
| 'credit_card' // Tarjeta de crédito
| 'debit_card' // Tarjeta de débito
| 'check' // Cheque
| 'digital_wallet' // Wallet digital (SPEI, etc)
| 'other'; // Otro
// ============================================================================
// Transaction
// ============================================================================
export interface Transaction {
id: string;
tenantId: string;
type: TransactionType;
status: TransactionStatus;
// Monto
amount: number;
currency: string;
exchangeRate?: number;
amountInBaseCurrency: number;
// Detalles
description: string;
reference?: string;
notes?: string;
// Fecha
date: Date;
valueDate?: Date;
// Relaciones
accountId: string;
destinationAccountId?: string; // Para transferencias
categoryId?: string;
contactId?: string;
cfdiId?: string;
// Pago
paymentMethod?: PaymentMethod;
paymentReference?: string;
// Tags y metadata
tags: string[];
metadata?: Record<string, unknown>;
// Recurrencia
isRecurring: boolean;
recurringRuleId?: string;
// Conciliación
bankStatementId?: string;
reconciledAt?: Date;
// Auditoría
createdBy: string;
createdAt: Date;
updatedAt: Date;
deletedAt?: Date;
}
export interface TransactionSummary {
id: string;
type: TransactionType;
status: TransactionStatus;
amount: number;
description: string;
date: Date;
categoryName?: string;
contactName?: string;
accountName: string;
}
export interface TransactionFilters {
type?: TransactionType[];
status?: TransactionStatus[];
accountId?: string;
categoryId?: string;
contactId?: string;
dateFrom?: Date;
dateTo?: Date;
amountMin?: number;
amountMax?: number;
search?: string;
tags?: string[];
}
// ============================================================================
// CFDI Types (Factura Electrónica México)
// ============================================================================
export type CFDIType =
| 'I' // Ingreso
| 'E' // Egreso
| 'T' // Traslado
| 'N' // Nómina
| 'P'; // Pago
export type CFDIStatus =
| 'draft' // Borrador
| 'pending' // Pendiente de timbrar
| 'stamped' // Timbrado
| 'sent' // Enviado al cliente
| 'paid' // Pagado
| 'partial_paid' // Parcialmente pagado
| 'cancelled' // Cancelado
| 'cancellation_pending'; // Cancelación pendiente
export type CFDIUsage =
| 'G01' // Adquisición de mercancías
| 'G02' // Devoluciones, descuentos o bonificaciones
| 'G03' // Gastos en general
| 'I01' // Construcciones
| 'I02' // Mobiliario y equipo de oficina
| 'I03' // Equipo de transporte
| 'I04' // Equipo de cómputo
| 'I05' // Dados, troqueles, moldes
| 'I06' // Comunicaciones telefónicas
| 'I07' // Comunicaciones satelitales
| 'I08' // Otra maquinaria y equipo
| 'D01' // Honorarios médicos
| 'D02' // Gastos médicos por incapacidad
| 'D03' // Gastos funerales
| 'D04' // Donativos
| 'D05' // Intereses hipotecarios
| 'D06' // Aportaciones voluntarias SAR
| 'D07' // Primas seguros gastos médicos
| 'D08' // Gastos transportación escolar
| 'D09' // Depósitos ahorro
| 'D10' // Servicios educativos
| 'S01' // Sin efectos fiscales
| 'CP01' // Pagos
| 'CN01'; // Nómina
export type PaymentForm =
| '01' // Efectivo
| '02' // Cheque nominativo
| '03' // Transferencia electrónica
| '04' // Tarjeta de crédito
| '05' // Monedero electrónico
| '06' // Dinero electrónico
| '08' // Vales de despensa
| '12' // Dación en pago
| '13' // Pago por subrogación
| '14' // Pago por consignación
| '15' // Condonación
| '17' // Compensación
| '23' // Novación
| '24' // Confusión
| '25' // Remisión de deuda
| '26' // Prescripción o caducidad
| '27' // A satisfacción del acreedor
| '28' // Tarjeta de débito
| '29' // Tarjeta de servicios
| '30' // Aplicación de anticipos
| '31' // Intermediario pagos
| '99'; // Por definir
export type PaymentMethod_CFDI =
| 'PUE' // Pago en Una sola Exhibición
| 'PPD'; // Pago en Parcialidades o Diferido
// ============================================================================
// CFDI
// ============================================================================
export interface CFDI {
id: string;
tenantId: string;
type: CFDIType;
status: CFDIStatus;
// Identificación
series?: string;
folio?: string;
uuid?: string;
// Emisor
issuerRfc: string;
issuerName: string;
issuerFiscalRegime: string;
issuerPostalCode: string;
// Receptor
receiverRfc: string;
receiverName: string;
receiverFiscalRegime?: string;
receiverPostalCode: string;
receiverUsage: CFDIUsage;
receiverEmail?: string;
// Montos
subtotal: number;
discount: number;
taxes: CFDITax[];
total: number;
currency: string;
exchangeRate?: number;
// Pago
paymentForm: PaymentForm;
paymentMethod: PaymentMethod_CFDI;
paymentConditions?: string;
// Conceptos
items: CFDIItem[];
// Relaciones
relatedCfdis?: CFDIRelation[];
contactId?: string;
// Fechas
issueDate: Date;
certificationDate?: Date;
cancellationDate?: Date;
expirationDate?: Date;
// Certificación
certificateNumber?: string;
satCertificateNumber?: string;
digitalSignature?: string;
satSignature?: string;
// Archivos
xmlUrl?: string;
pdfUrl?: string;
// Cancelación
cancellationReason?: string;
substitutedByUuid?: string;
cancellationAcknowledgment?: string;
// Pago tracking
amountPaid: number;
balance: number;
lastPaymentDate?: Date;
// Auditoría
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface CFDIItem {
id: string;
productCode: string; // Clave del producto SAT
unitCode: string; // Clave de unidad SAT
description: string;
quantity: number;
unitPrice: number;
discount?: number;
subtotal: number;
taxes: CFDIItemTax[];
total: number;
// Opcional
identificationNumber?: string;
unit?: string;
}
export interface CFDITax {
type: 'transferred' | 'withheld';
tax: 'IVA' | 'ISR' | 'IEPS';
factor: 'Tasa' | 'Cuota' | 'Exento';
rate: number;
base: number;
amount: number;
}
export interface CFDIItemTax {
type: 'transferred' | 'withheld';
tax: 'IVA' | 'ISR' | 'IEPS';
factor: 'Tasa' | 'Cuota' | 'Exento';
rate: number;
base: number;
amount: number;
}
export interface CFDIRelation {
type: CFDIRelationType;
uuid: string;
}
export type CFDIRelationType =
| '01' // Nota de crédito
| '02' // Nota de débito
| '03' // Devolución de mercancía
| '04' // Sustitución de CFDI previos
| '05' // Traslados de mercancías facturadas
| '06' // Factura por traslados previos
| '07' // CFDI por aplicación de anticipo
| '08' // Factura por pagos en parcialidades
| '09'; // Factura por pagos diferidos
export interface CFDIPayment {
id: string;
cfdiId: string;
amount: number;
currency: string;
exchangeRate?: number;
paymentDate: Date;
paymentForm: PaymentForm;
relatedCfdi: string;
previousBalance: number;
paidAmount: number;
remainingBalance: number;
transactionId?: string;
createdAt: Date;
}
// ============================================================================
// Contact Types
// ============================================================================
export type ContactType =
| 'customer' // Cliente
| 'supplier' // Proveedor
| 'both' // Ambos
| 'employee'; // Empleado
// ============================================================================
// Contact
// ============================================================================
export interface Contact {
id: string;
tenantId: string;
type: ContactType;
// Información básica
name: string;
displayName: string;
rfc?: string;
curp?: string;
email?: string;
phone?: string;
mobile?: string;
website?: string;
// Fiscal
fiscalRegime?: string;
fiscalName?: string;
cfdiUsage?: CFDIUsage;
// Dirección
address?: ContactAddress;
// Crédito
creditLimit?: number;
creditDays?: number;
balance: number;
// Bancarios
bankAccounts?: ContactBankAccount[];
// Categorización
tags: string[];
groupId?: string;
// Estado
isActive: boolean;
// Notas
notes?: string;
// Auditoría
createdBy: string;
createdAt: Date;
updatedAt: Date;
deletedAt?: Date;
}
export interface ContactAddress {
street: string;
exteriorNumber: string;
interiorNumber?: string;
neighborhood: string;
city: string;
state: string;
country: string;
postalCode: string;
}
export interface ContactBankAccount {
id: string;
bankName: string;
accountNumber: string;
clabe?: string;
accountHolder: string;
currency: string;
isDefault: boolean;
}
export interface ContactSummary {
id: string;
type: ContactType;
name: string;
rfc?: string;
email?: string;
balance: number;
isActive: boolean;
}
// ============================================================================
// Category
// ============================================================================
export type CategoryType = 'income' | 'expense';
export interface Category {
id: string;
tenantId: string;
type: CategoryType;
name: string;
description?: string;
code?: string;
color?: string;
icon?: string;
parentId?: string;
satCode?: string; // Código SAT para mapeo
isSystem: boolean; // Categoría del sistema (no editable)
isActive: boolean;
sortOrder: number;
createdAt: Date;
updatedAt: Date;
}
export interface CategoryTree extends Category {
children: CategoryTree[];
fullPath: string;
level: number;
}
// ============================================================================
// Account Types
// ============================================================================
export type AccountType =
| 'bank' // Cuenta bancaria
| 'cash' // Caja/Efectivo
| 'credit_card' // Tarjeta de crédito
| 'loan' // Préstamo
| 'investment' // Inversión
| 'other'; // Otro
export type AccountSubtype =
| 'checking' // Cuenta de cheques
| 'savings' // Cuenta de ahorro
| 'money_market' // Mercado de dinero
| 'cd' // Certificado de depósito
| 'credit' // Crédito
| 'line_of_credit' // Línea de crédito
| 'mortgage' // Hipoteca
| 'auto_loan' // Préstamo auto
| 'personal_loan' // Préstamo personal
| 'brokerage' // Corretaje
| 'retirement' // Retiro
| 'other'; // Otro
// ============================================================================
// Account
// ============================================================================
export interface Account {
id: string;
tenantId: string;
type: AccountType;
subtype?: AccountSubtype;
// Información básica
name: string;
description?: string;
accountNumber?: string;
currency: string;
// Banco
bankName?: string;
bankBranch?: string;
clabe?: string;
swiftCode?: string;
// Saldos
currentBalance: number;
availableBalance: number;
creditLimit?: number;
// Estado
isActive: boolean;
isDefault: boolean;
isReconcilable: boolean;
// Sincronización
lastSyncAt?: Date;
connectionId?: string;
// Auditoría
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface AccountSummary {
id: string;
type: AccountType;
name: string;
bankName?: string;
currentBalance: number;
currency: string;
isActive: boolean;
}
export interface AccountBalance {
accountId: string;
date: Date;
balance: number;
availableBalance: number;
}
// ============================================================================
// Recurring Rules
// ============================================================================
export type RecurringFrequency =
| 'daily'
| 'weekly'
| 'biweekly'
| 'monthly'
| 'quarterly'
| 'yearly';
export interface RecurringRule {
id: string;
tenantId: string;
name: string;
type: TransactionType;
frequency: RecurringFrequency;
interval: number; // Cada N períodos
startDate: Date;
endDate?: Date;
nextOccurrence: Date;
lastOccurrence?: Date;
// Template de transacción
amount: number;
description: string;
accountId: string;
categoryId?: string;
contactId?: string;
// Estado
isActive: boolean;
occurrenceCount: number;
maxOccurrences?: number;
createdAt: Date;
updatedAt: Date;
}
// ============================================================================
// Bank Statement & Reconciliation
// ============================================================================
export interface BankStatement {
id: string;
tenantId: string;
accountId: string;
// Período
startDate: Date;
endDate: Date;
// Saldos
openingBalance: number;
closingBalance: number;
// Estado
status: 'pending' | 'in_progress' | 'completed';
reconciledAt?: Date;
reconciledBy?: string;
// Archivo
fileUrl?: string;
fileName?: string;
// Conteos
totalTransactions: number;
matchedTransactions: number;
unmatchedTransactions: number;
createdAt: Date;
updatedAt: Date;
}
export interface BankStatementLine {
id: string;
statementId: string;
date: Date;
description: string;
reference?: string;
amount: number;
balance?: number;
type: 'debit' | 'credit';
// Matching
matchedTransactionId?: string;
matchConfidence?: number;
matchStatus: 'unmatched' | 'matched' | 'created' | 'ignored';
createdAt: Date;
}

View File

@@ -0,0 +1,305 @@
/**
* Types Index - Re-export all types
*/
// Authentication types
export * from './auth';
// Tenant & multi-tenancy types
export * from './tenant';
// Financial & accounting types
export * from './financial';
// Metrics & analytics types
export * from './metrics';
// Reports & alerts types
export * from './reports';
// ============================================================================
// Common Types
// ============================================================================
/**
* Generic API response wrapper
*/
export interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
timestamp: string;
}
/**
* Paginated API response
*/
export interface PaginatedResponse<T> {
success: boolean;
data: T[];
pagination: PaginationMeta;
timestamp: string;
}
/**
* Pagination metadata
*/
export interface PaginationMeta {
page: number;
pageSize: number;
totalItems: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
}
/**
* Pagination request parameters
*/
export interface PaginationParams {
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
/**
* API Error response
*/
export interface ApiError {
success: false;
error: {
code: string;
message: string;
details?: Record<string, unknown>;
field?: string;
stack?: string;
};
timestamp: string;
}
/**
* Validation error details
*/
export interface ValidationError {
field: string;
message: string;
code: string;
value?: unknown;
}
/**
* Batch operation result
*/
export interface BatchResult<T> {
success: boolean;
total: number;
succeeded: number;
failed: number;
results: BatchItemResult<T>[];
}
export interface BatchItemResult<T> {
index: number;
success: boolean;
data?: T;
error?: string;
}
/**
* Selection for bulk operations
*/
export interface SelectionState {
selectedIds: string[];
selectAll: boolean;
excludedIds: string[];
}
/**
* Sort configuration
*/
export interface SortConfig {
field: string;
direction: 'asc' | 'desc';
}
/**
* Filter configuration
*/
export interface FilterConfig {
field: string;
operator: FilterOperator;
value: unknown;
}
export type FilterOperator =
| 'eq'
| 'neq'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'contains'
| 'starts_with'
| 'ends_with'
| 'in'
| 'not_in'
| 'between'
| 'is_null'
| 'is_not_null';
/**
* Search parameters
*/
export interface SearchParams {
query: string;
fields?: string[];
limit?: number;
}
/**
* Audit information
*/
export interface AuditInfo {
createdBy: string;
createdAt: Date;
updatedBy?: string;
updatedAt: Date;
deletedBy?: string;
deletedAt?: Date;
}
/**
* Entity with soft delete
*/
export interface SoftDeletable {
deletedAt?: Date;
deletedBy?: string;
isDeleted: boolean;
}
/**
* Entity with timestamps
*/
export interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
/**
* Base entity with common fields
*/
export interface BaseEntity extends Timestamped {
id: string;
tenantId: string;
}
/**
* Lookup option for dropdowns
*/
export interface LookupOption {
value: string;
label: string;
description?: string;
icon?: string;
disabled?: boolean;
metadata?: Record<string, unknown>;
}
/**
* Tree node for hierarchical data
*/
export interface TreeNode<T> {
data: T;
children: TreeNode<T>[];
parent?: TreeNode<T>;
level: number;
isExpanded?: boolean;
isSelected?: boolean;
}
/**
* File upload
*/
export interface FileUpload {
id: string;
name: string;
originalName: string;
mimeType: string;
size: number;
url: string;
thumbnailUrl?: string;
uploadedBy: string;
uploadedAt: Date;
}
/**
* Attachment
*/
export interface Attachment extends FileUpload {
entityType: string;
entityId: string;
description?: string;
}
/**
* Comment
*/
export interface Comment {
id: string;
tenantId: string;
entityType: string;
entityId: string;
content: string;
authorId: string;
authorName: string;
authorAvatar?: string;
parentId?: string;
replies?: Comment[];
attachments?: Attachment[];
isEdited: boolean;
editedAt?: Date;
createdAt: Date;
updatedAt: Date;
}
/**
* Activity log entry
*/
export interface ActivityLog {
id: string;
tenantId: string;
userId: string;
userName: string;
action: string;
entityType: string;
entityId: string;
entityName?: string;
changes?: Record<string, { old: unknown; new: unknown }>;
metadata?: Record<string, unknown>;
ipAddress?: string;
userAgent?: string;
createdAt: Date;
}
/**
* Feature flag
*/
export interface FeatureFlag {
key: string;
enabled: boolean;
description?: string;
rolloutPercentage?: number;
conditions?: Record<string, unknown>;
}
/**
* App configuration
*/
export interface AppConfig {
environment: 'development' | 'staging' | 'production';
version: string;
apiUrl: string;
features: Record<string, boolean>;
limits: Record<string, number>;
}

View File

@@ -0,0 +1,490 @@
/**
* Metrics Types for Horux Strategy
* Analytics, KPIs and Dashboard data structures
*/
// ============================================================================
// Metric Period
// ============================================================================
export type MetricPeriod =
| 'today'
| 'yesterday'
| 'this_week'
| 'last_week'
| 'this_month'
| 'last_month'
| 'this_quarter'
| 'last_quarter'
| 'this_year'
| 'last_year'
| 'last_7_days'
| 'last_30_days'
| 'last_90_days'
| 'last_12_months'
| 'custom';
export type MetricGranularity =
| 'hour'
| 'day'
| 'week'
| 'month'
| 'quarter'
| 'year';
export type MetricAggregation =
| 'sum'
| 'avg'
| 'min'
| 'max'
| 'count'
| 'first'
| 'last';
// ============================================================================
// Date Range
// ============================================================================
export interface DateRange {
start: Date;
end: Date;
period?: MetricPeriod;
}
export interface DateRangeComparison {
current: DateRange;
previous: DateRange;
}
// ============================================================================
// Metric
// ============================================================================
export interface Metric {
id: string;
tenantId: string;
category: MetricCategory;
name: string;
key: string;
description?: string;
// Tipo de dato
valueType: 'number' | 'currency' | 'percentage' | 'count';
currency?: string;
// Configuración
aggregation: MetricAggregation;
isPositiveGood: boolean; // Para determinar color del cambio
// Objetivo
targetValue?: number;
warningThreshold?: number;
criticalThreshold?: number;
// Estado
isActive: boolean;
isSystem: boolean;
createdAt: Date;
updatedAt: Date;
}
export type MetricCategory =
| 'revenue'
| 'expenses'
| 'profit'
| 'cash_flow'
| 'receivables'
| 'payables'
| 'taxes'
| 'invoicing'
| 'operations';
// ============================================================================
// Metric Value
// ============================================================================
export interface MetricValue {
metricId: string;
metricKey: string;
period: DateRange;
value: number;
formattedValue: string;
count?: number; // Número de elementos que componen el valor
breakdown?: MetricBreakdown[];
}
export interface MetricBreakdown {
key: string;
label: string;
value: number;
percentage: number;
color?: string;
}
export interface MetricTimeSeries {
metricId: string;
metricKey: string;
period: DateRange;
granularity: MetricGranularity;
dataPoints: MetricDataPoint[];
}
export interface MetricDataPoint {
date: Date;
value: number;
formattedValue?: string;
}
// ============================================================================
// Metric Comparison
// ============================================================================
export interface MetricComparison {
metricId: string;
metricKey: string;
current: MetricValue;
previous: MetricValue;
change: MetricChange;
}
export interface MetricChange {
absolute: number;
percentage: number;
direction: 'up' | 'down' | 'unchanged';
isPositive: boolean; // Basado en isPositiveGood del Metric
formattedAbsolute: string;
formattedPercentage: string;
}
// ============================================================================
// KPI Card
// ============================================================================
export interface KPICard {
id: string;
title: string;
description?: string;
icon?: string;
color?: string;
// Valor principal
value: number;
formattedValue: string;
valueType: 'number' | 'currency' | 'percentage' | 'count';
currency?: string;
// Comparación
comparison?: MetricChange;
comparisonLabel?: string;
// Objetivo
target?: {
value: number;
formattedValue: string;
progress: number; // 0-100
};
// Trend
trend?: {
direction: 'up' | 'down' | 'stable';
dataPoints: number[];
};
// Desglose
breakdown?: MetricBreakdown[];
// Acción
actionLabel?: string;
actionUrl?: string;
}
// ============================================================================
// Dashboard Data
// ============================================================================
export interface DashboardData {
tenantId: string;
period: DateRange;
comparisonPeriod?: DateRange;
generatedAt: Date;
// KPIs principales
kpis: DashboardKPIs;
// Resumen financiero
financialSummary: FinancialSummary;
// Flujo de efectivo
cashFlow: CashFlowData;
// Por cobrar y por pagar
receivables: ReceivablesData;
payables: PayablesData;
// Gráficas
revenueChart: MetricTimeSeries;
expenseChart: MetricTimeSeries;
profitChart: MetricTimeSeries;
// Top lists
topCustomers: TopListItem[];
topSuppliers: TopListItem[];
topCategories: TopListItem[];
// Alertas y pendientes
alerts: DashboardAlert[];
pendingItems: PendingItem[];
}
export interface DashboardKPIs {
totalRevenue: KPICard;
totalExpenses: KPICard;
netProfit: KPICard;
profitMargin: KPICard;
cashBalance: KPICard;
accountsReceivable: KPICard;
accountsPayable: KPICard;
pendingInvoices: KPICard;
}
export interface FinancialSummary {
period: DateRange;
// Ingresos
totalRevenue: number;
invoicedRevenue: number;
otherRevenue: number;
// Gastos
totalExpenses: number;
operatingExpenses: number;
costOfGoods: number;
otherExpenses: number;
// Impuestos
ivaCollected: number;
ivaPaid: number;
ivaBalance: number;
isrRetained: number;
// Resultado
grossProfit: number;
grossMargin: number;
netProfit: number;
netMargin: number;
// Comparación
comparison?: {
revenueChange: MetricChange;
expensesChange: MetricChange;
profitChange: MetricChange;
};
}
export interface CashFlowData {
period: DateRange;
// Saldos
openingBalance: number;
closingBalance: number;
netChange: number;
// Flujos
operatingCashFlow: number;
investingCashFlow: number;
financingCashFlow: number;
// Desglose operativo
cashFromCustomers: number;
cashToSuppliers: number;
cashToEmployees: number;
taxesPaid: number;
otherOperating: number;
// Proyección
projection?: CashFlowProjection[];
// Serie temporal
timeSeries: MetricDataPoint[];
}
export interface CashFlowProjection {
date: Date;
projectedBalance: number;
expectedInflows: number;
expectedOutflows: number;
confidence: 'high' | 'medium' | 'low';
}
export interface ReceivablesData {
total: number;
current: number; // No vencido
overdue: number; // Vencido
overduePercentage: number;
// Por antigüedad
aging: AgingBucket[];
// Top deudores
topDebtors: {
contactId: string;
contactName: string;
amount: number;
oldestDueDate: Date;
}[];
// Cobros esperados
expectedCollections: {
thisWeek: number;
thisMonth: number;
nextMonth: number;
};
}
export interface PayablesData {
total: number;
current: number;
overdue: number;
overduePercentage: number;
// Por antigüedad
aging: AgingBucket[];
// Top acreedores
topCreditors: {
contactId: string;
contactName: string;
amount: number;
oldestDueDate: Date;
}[];
// Pagos programados
scheduledPayments: {
thisWeek: number;
thisMonth: number;
nextMonth: number;
};
}
export interface AgingBucket {
label: string;
minDays: number;
maxDays?: number;
amount: number;
count: number;
percentage: number;
}
export interface TopListItem {
id: string;
name: string;
value: number;
formattedValue: string;
percentage: number;
count?: number;
trend?: 'up' | 'down' | 'stable';
}
export interface DashboardAlert {
id: string;
type: AlertType;
severity: 'info' | 'warning' | 'error';
title: string;
message: string;
actionLabel?: string;
actionUrl?: string;
createdAt: Date;
}
export type AlertType =
| 'overdue_invoice'
| 'overdue_payment'
| 'low_cash'
| 'high_expenses'
| 'tax_deadline'
| 'subscription_expiring'
| 'usage_limit'
| 'reconciliation_needed'
| 'pending_approval';
export interface PendingItem {
id: string;
type: PendingItemType;
title: string;
description?: string;
amount?: number;
dueDate?: Date;
priority: 'low' | 'medium' | 'high';
actionLabel: string;
actionUrl: string;
}
export type PendingItemType =
| 'draft_invoice'
| 'pending_approval'
| 'unreconciled_transaction'
| 'uncategorized_expense'
| 'missing_document'
| 'overdue_task';
// ============================================================================
// Widget Configuration
// ============================================================================
export interface DashboardWidget {
id: string;
type: WidgetType;
title: string;
description?: string;
// Layout
x: number;
y: number;
width: number;
height: number;
// Configuración
config: WidgetConfig;
// Estado
isVisible: boolean;
}
export type WidgetType =
| 'kpi'
| 'chart_line'
| 'chart_bar'
| 'chart_pie'
| 'chart_area'
| 'table'
| 'list'
| 'calendar'
| 'alerts';
export interface WidgetConfig {
metricKey?: string;
period?: MetricPeriod;
showComparison?: boolean;
showTarget?: boolean;
showTrend?: boolean;
showBreakdown?: boolean;
limit?: number;
filters?: Record<string, unknown>;
}
// ============================================================================
// Dashboard Layout
// ============================================================================
export interface DashboardLayout {
id: string;
tenantId: string;
userId?: string; // null = layout por defecto
name: string;
description?: string;
isDefault: boolean;
widgets: DashboardWidget[];
createdAt: Date;
updatedAt: Date;
}

View File

@@ -0,0 +1,578 @@
/**
* Report Types for Horux Strategy
* Reports, exports, and alert configurations
*/
// ============================================================================
// Report Types
// ============================================================================
export type ReportType =
// Financieros
| 'income_statement' // Estado de resultados
| 'balance_sheet' // Balance general
| 'cash_flow' // Flujo de efectivo
| 'trial_balance' // Balanza de comprobación
// Operativos
| 'accounts_receivable' // Cuentas por cobrar
| 'accounts_payable' // Cuentas por pagar
| 'aging_report' // Antigüedad de saldos
| 'transactions' // Movimientos
// Fiscales
| 'tax_summary' // Resumen de impuestos
| 'iva_report' // Reporte de IVA
| 'isr_report' // Reporte de ISR
| 'diot' // DIOT
// CFDI
| 'invoices_issued' // Facturas emitidas
| 'invoices_received' // Facturas recibidas
| 'cfdi_cancellations' // Cancelaciones
// Análisis
| 'expense_analysis' // Análisis de gastos
| 'revenue_analysis' // Análisis de ingresos
| 'category_analysis' // Análisis por categoría
| 'contact_analysis' // Análisis por contacto
// Custom
| 'custom';
export type ReportStatus =
| 'pending' // En cola
| 'processing' // Procesando
| 'completed' // Completado
| 'failed' // Error
| 'expired'; // Expirado (archivo eliminado)
export type ReportFormat =
| 'pdf'
| 'xlsx'
| 'csv'
| 'xml'
| 'json';
// ============================================================================
// Report
// ============================================================================
export interface Report {
id: string;
tenantId: string;
type: ReportType;
name: string;
description?: string;
status: ReportStatus;
// Configuración
config: ReportConfig;
// Período
periodStart: Date;
periodEnd: Date;
// Formato
format: ReportFormat;
locale: string;
timezone: string;
// Archivo generado
fileUrl?: string;
fileName?: string;
fileSizeBytes?: number;
expiresAt?: Date;
// Procesamiento
startedAt?: Date;
completedAt?: Date;
error?: string;
progress?: number; // 0-100
// Metadatos
rowCount?: number;
metadata?: Record<string, unknown>;
// Auditoría
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface ReportConfig {
// Filtros generales
accountIds?: string[];
categoryIds?: string[];
contactIds?: string[];
transactionTypes?: string[];
// Agrupación
groupBy?: ReportGroupBy[];
sortBy?: ReportSortConfig[];
// Columnas
columns?: string[];
includeSubtotals?: boolean;
includeTotals?: boolean;
// Comparación
compareWithPreviousPeriod?: boolean;
comparisonPeriodStart?: Date;
comparisonPeriodEnd?: Date;
// Moneda
currency?: string;
showOriginalCurrency?: boolean;
// Formato específico
showZeroBalances?: boolean;
showInactiveAccounts?: boolean;
consolidateAccounts?: boolean;
// PDF específico
includeCharts?: boolean;
includeSummary?: boolean;
includeNotes?: boolean;
companyLogo?: boolean;
pageOrientation?: 'portrait' | 'landscape';
// Personalizado
customFilters?: Record<string, unknown>;
customOptions?: Record<string, unknown>;
}
export type ReportGroupBy =
| 'date'
| 'week'
| 'month'
| 'quarter'
| 'year'
| 'account'
| 'category'
| 'contact'
| 'type'
| 'status';
export interface ReportSortConfig {
field: string;
direction: 'asc' | 'desc';
}
// ============================================================================
// Report Template
// ============================================================================
export interface ReportTemplate {
id: string;
tenantId: string;
type: ReportType;
name: string;
description?: string;
config: ReportConfig;
isDefault: boolean;
isSystem: boolean;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
// ============================================================================
// Scheduled Report
// ============================================================================
export type ScheduleFrequency =
| 'daily'
| 'weekly'
| 'biweekly'
| 'monthly'
| 'quarterly'
| 'yearly';
export interface ScheduledReport {
id: string;
tenantId: string;
templateId: string;
name: string;
description?: string;
// Programación
frequency: ScheduleFrequency;
dayOfWeek?: number; // 0-6 (domingo-sábado)
dayOfMonth?: number; // 1-31
time: string; // HH:mm formato 24h
timezone: string;
// Período del reporte
periodType: 'previous' | 'current' | 'custom';
periodOffset?: number; // Para períodos anteriores
// Formato
format: ReportFormat;
// Entrega
deliveryMethod: DeliveryMethod[];
recipients: ReportRecipient[];
// Estado
isActive: boolean;
lastRunAt?: Date;
nextRunAt: Date;
lastReportId?: string;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export type DeliveryMethod =
| 'email'
| 'download'
| 'webhook'
| 'storage';
export interface ReportRecipient {
type: 'user' | 'email';
userId?: string;
email?: string;
name?: string;
}
// ============================================================================
// Report Execution
// ============================================================================
export interface ReportExecution {
id: string;
reportId?: string;
scheduledReportId?: string;
status: ReportStatus;
startedAt: Date;
completedAt?: Date;
durationMs?: number;
error?: string;
retryCount: number;
}
// ============================================================================
// Alert Types
// ============================================================================
export type AlertSeverity =
| 'info'
| 'warning'
| 'error'
| 'critical';
export type AlertChannel =
| 'in_app'
| 'email'
| 'sms'
| 'push'
| 'webhook';
export type AlertTriggerType =
// Financieros
| 'low_cash_balance'
| 'high_expenses'
| 'revenue_drop'
| 'profit_margin_low'
// Cobros y pagos
| 'invoice_overdue'
| 'payment_due'
| 'receivable_aging'
| 'payable_aging'
// Límites
| 'budget_exceeded'
| 'credit_limit_reached'
| 'usage_limit_warning'
// Operaciones
| 'reconciliation_discrepancy'
| 'duplicate_transaction'
| 'unusual_activity'
// Fiscales
| 'tax_deadline'
| 'cfdi_rejection'
| 'sat_notification'
// Sistema
| 'subscription_expiring'
| 'integration_error'
| 'backup_failed';
// ============================================================================
// Alert
// ============================================================================
export interface Alert {
id: string;
tenantId: string;
ruleId?: string;
type: AlertTriggerType;
severity: AlertSeverity;
// Contenido
title: string;
message: string;
details?: Record<string, unknown>;
// Contexto
entityType?: string;
entityId?: string;
entityName?: string;
// Valores
currentValue?: number;
thresholdValue?: number;
// Acción
actionLabel?: string;
actionUrl?: string;
actionRequired: boolean;
// Estado
status: AlertStatus;
acknowledgedBy?: string;
acknowledgedAt?: Date;
resolvedBy?: string;
resolvedAt?: Date;
resolution?: string;
// Notificaciones
channels: AlertChannel[];
notifiedAt?: Date;
// Auditoría
createdAt: Date;
updatedAt: Date;
}
export type AlertStatus =
| 'active'
| 'acknowledged'
| 'resolved'
| 'dismissed';
// ============================================================================
// Alert Rule
// ============================================================================
export interface AlertRule {
id: string;
tenantId: string;
name: string;
description?: string;
type: AlertTriggerType;
severity: AlertSeverity;
// Condición
condition: AlertCondition;
// Mensaje
titleTemplate: string;
messageTemplate: string;
// Notificación
channels: AlertChannel[];
recipients: AlertRecipient[];
// Cooldown
cooldownMinutes: number; // Tiempo mínimo entre alertas
lastTriggeredAt?: Date;
// Estado
isActive: boolean;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
export interface AlertCondition {
metric: string;
operator: AlertOperator;
value: number;
unit?: string;
// Para condiciones compuestas
and?: AlertCondition[];
or?: AlertCondition[];
// Contexto
accountId?: string;
categoryId?: string;
contactId?: string;
// Período de evaluación
evaluationPeriod?: string; // e.g., "1d", "7d", "30d"
}
export type AlertOperator =
| 'gt' // Mayor que
| 'gte' // Mayor o igual que
| 'lt' // Menor que
| 'lte' // Menor o igual que
| 'eq' // Igual a
| 'neq' // Diferente de
| 'between' // Entre (requiere value2)
| 'change_gt' // Cambio mayor que %
| 'change_lt'; // Cambio menor que %
export interface AlertRecipient {
type: 'user' | 'role' | 'email' | 'webhook';
userId?: string;
role?: string;
email?: string;
webhookUrl?: string;
}
// ============================================================================
// Notification
// ============================================================================
export interface Notification {
id: string;
tenantId: string;
userId: string;
alertId?: string;
// Contenido
type: NotificationType;
title: string;
message: string;
icon?: string;
color?: string;
// Acción
actionLabel?: string;
actionUrl?: string;
// Estado
isRead: boolean;
readAt?: Date;
// Metadatos
metadata?: Record<string, unknown>;
createdAt: Date;
expiresAt?: Date;
}
export type NotificationType =
| 'alert'
| 'report_ready'
| 'task_assigned'
| 'mention'
| 'system'
| 'update'
| 'reminder';
// ============================================================================
// Export Job
// ============================================================================
export interface ExportJob {
id: string;
tenantId: string;
type: ExportType;
status: ReportStatus;
// Configuración
entityType: string;
filters?: Record<string, unknown>;
columns?: string[];
format: ReportFormat;
// Archivo
fileUrl?: string;
fileName?: string;
fileSizeBytes?: number;
rowCount?: number;
// Procesamiento
startedAt?: Date;
completedAt?: Date;
expiresAt?: Date;
error?: string;
createdBy: string;
createdAt: Date;
}
export type ExportType =
| 'transactions'
| 'invoices'
| 'contacts'
| 'categories'
| 'accounts'
| 'reports'
| 'full_backup';
// ============================================================================
// Import Job
// ============================================================================
export interface ImportJob {
id: string;
tenantId: string;
type: ImportType;
status: ImportStatus;
// Archivo
fileUrl: string;
fileName: string;
fileSizeBytes: number;
// Mapeo
mapping?: ImportMapping;
// Resultados
totalRows?: number;
processedRows?: number;
successRows?: number;
errorRows?: number;
errors?: ImportError[];
// Procesamiento
startedAt?: Date;
completedAt?: Date;
createdBy: string;
createdAt: Date;
}
export type ImportType =
| 'transactions'
| 'invoices'
| 'contacts'
| 'categories'
| 'bank_statement'
| 'cfdi_xml';
export type ImportStatus =
| 'pending'
| 'mapping'
| 'validating'
| 'processing'
| 'completed'
| 'failed'
| 'cancelled';
export interface ImportMapping {
[targetField: string]: {
sourceField: string;
transform?: string;
defaultValue?: unknown;
};
}
export interface ImportError {
row: number;
field?: string;
value?: string;
message: string;
}

View File

@@ -0,0 +1,379 @@
/**
* Tenant Types for Horux Strategy
* Multi-tenancy support for SaaS architecture
*/
// ============================================================================
// Tenant Status
// ============================================================================
export type TenantStatus =
| 'pending' // Registro pendiente de aprobación
| 'active' // Tenant activo y operativo
| 'suspended' // Suspendido por falta de pago o violación
| 'cancelled' // Cancelado por el usuario
| 'trial' // En período de prueba
| 'expired'; // Período de prueba expirado
// ============================================================================
// Tenant
// ============================================================================
export interface Tenant {
id: string;
name: string;
slug: string;
legalName?: string;
rfc?: string;
status: TenantStatus;
planId: string;
subscriptionId?: string;
// Configuración fiscal México
fiscalRegime?: string;
fiscalAddress?: TenantAddress;
// Información de contacto
email: string;
phone?: string;
website?: string;
// Branding
logo?: string;
primaryColor?: string;
secondaryColor?: string;
// Configuración
settings: TenantSettings;
features: string[];
// Límites
maxUsers: number;
maxTransactionsPerMonth: number;
storageUsedMB: number;
storageLimitMB: number;
// Fechas
trialEndsAt?: Date;
createdAt: Date;
updatedAt: Date;
deletedAt?: Date;
}
export interface TenantAddress {
street: string;
exteriorNumber: string;
interiorNumber?: string;
neighborhood: string;
city: string;
state: string;
country: string;
postalCode: string;
}
export interface TenantSettings {
// General
timezone: string;
locale: string;
currency: string;
fiscalYearStart: number; // Mes (1-12)
// Facturación
defaultPaymentTerms: number; // Días
invoicePrefix: string;
invoiceNextNumber: number;
// Notificaciones
emailNotifications: boolean;
invoiceReminders: boolean;
paymentReminders: boolean;
// Seguridad
sessionTimeout: number; // Minutos
requireTwoFactor: boolean;
allowedIPs?: string[];
// Integraciones
satIntegration: boolean;
bankingIntegration: boolean;
}
export interface TenantSummary {
id: string;
name: string;
slug: string;
logo?: string;
status: TenantStatus;
}
// ============================================================================
// Plan & Features
// ============================================================================
export type PlanTier = 'free' | 'starter' | 'professional' | 'enterprise';
export interface Plan {
id: string;
name: string;
tier: PlanTier;
description: string;
features: PlanFeatures;
limits: PlanLimits;
pricing: PlanPricing;
isActive: boolean;
isPopular: boolean;
sortOrder: number;
createdAt: Date;
updatedAt: Date;
}
export interface PlanFeatures {
// Módulos
invoicing: boolean;
expenses: boolean;
bankReconciliation: boolean;
reports: boolean;
budgets: boolean;
forecasting: boolean;
multiCurrency: boolean;
// Facturación electrónica
cfdiGeneration: boolean;
cfdiCancellation: boolean;
cfdiAddenda: boolean;
massInvoicing: boolean;
// Integraciones
satIntegration: boolean;
bankIntegration: boolean;
erpIntegration: boolean;
apiAccess: boolean;
webhooks: boolean;
// Colaboración
multiUser: boolean;
customRoles: boolean;
auditLog: boolean;
comments: boolean;
// Soporte
emailSupport: boolean;
chatSupport: boolean;
phoneSupport: boolean;
prioritySupport: boolean;
dedicatedManager: boolean;
// Extras
customBranding: boolean;
whiteLabel: boolean;
dataExport: boolean;
advancedReports: boolean;
}
export interface PlanLimits {
maxUsers: number;
maxTransactionsPerMonth: number;
maxInvoicesPerMonth: number;
maxContacts: number;
maxBankAccounts: number;
storageMB: number;
apiRequestsPerDay: number;
retentionDays: number;
}
export interface PlanPricing {
monthlyPrice: number;
annualPrice: number;
currency: string;
trialDays: number;
setupFee?: number;
}
// ============================================================================
// Subscription
// ============================================================================
export type SubscriptionStatus =
| 'trialing' // En período de prueba
| 'active' // Suscripción activa
| 'past_due' // Pago atrasado
| 'canceled' // Cancelada
| 'unpaid' // Sin pagar
| 'paused'; // Pausada
export type BillingCycle = 'monthly' | 'annual';
export interface Subscription {
id: string;
tenantId: string;
planId: string;
status: SubscriptionStatus;
billingCycle: BillingCycle;
// Precios
pricePerCycle: number;
currency: string;
discount?: SubscriptionDiscount;
// Fechas
currentPeriodStart: Date;
currentPeriodEnd: Date;
trialStart?: Date;
trialEnd?: Date;
canceledAt?: Date;
cancelAtPeriodEnd: boolean;
// Pago
paymentMethodId?: string;
lastPaymentAt?: Date;
nextPaymentAt?: Date;
// Stripe/Pasarela
externalId?: string;
externalCustomerId?: string;
createdAt: Date;
updatedAt: Date;
}
export interface SubscriptionDiscount {
code: string;
type: 'percentage' | 'fixed';
value: number;
validUntil?: Date;
}
// ============================================================================
// Usage & Billing
// ============================================================================
export interface TenantUsage {
tenantId: string;
period: string; // YYYY-MM
// Conteos
activeUsers: number;
totalTransactions: number;
totalInvoices: number;
totalContacts: number;
// Storage
documentsStorageMB: number;
attachmentsStorageMB: number;
totalStorageMB: number;
// API
apiRequests: number;
webhookDeliveries: number;
// Límites
limits: PlanLimits;
updatedAt: Date;
}
export interface Invoice {
id: string;
tenantId: string;
subscriptionId: string;
number: string;
status: InvoiceStatus;
// Montos
subtotal: number;
discount: number;
tax: number;
total: number;
currency: string;
// Detalles
items: InvoiceItem[];
// Fechas
periodStart: Date;
periodEnd: Date;
dueDate: Date;
paidAt?: Date;
// Pago
paymentMethod?: string;
paymentIntentId?: string;
// PDF
pdfUrl?: string;
createdAt: Date;
}
export type InvoiceStatus =
| 'draft'
| 'open'
| 'paid'
| 'void'
| 'uncollectible';
export interface InvoiceItem {
description: string;
quantity: number;
unitPrice: number;
amount: number;
}
// ============================================================================
// Payment Method
// ============================================================================
export interface PaymentMethod {
id: string;
tenantId: string;
type: PaymentMethodType;
isDefault: boolean;
// Card details (masked)
card?: {
brand: string;
last4: string;
expMonth: number;
expYear: number;
};
// Bank account (masked)
bankAccount?: {
bankName: string;
last4: string;
};
billingAddress?: TenantAddress;
externalId?: string;
createdAt: Date;
}
export type PaymentMethodType = 'card' | 'bank_transfer' | 'oxxo' | 'spei';
// ============================================================================
// Tenant Events
// ============================================================================
export interface TenantEvent {
id: string;
tenantId: string;
type: TenantEventType;
data: Record<string, unknown>;
createdAt: Date;
}
export type TenantEventType =
| 'tenant.created'
| 'tenant.updated'
| 'tenant.suspended'
| 'tenant.activated'
| 'tenant.deleted'
| 'subscription.created'
| 'subscription.updated'
| 'subscription.canceled'
| 'subscription.renewed'
| 'payment.succeeded'
| 'payment.failed'
| 'usage.limit_warning'
| 'usage.limit_reached';

View File

@@ -0,0 +1,658 @@
/**
* Formatting Utilities for Horux Strategy
* Currency, percentage, date, and other formatting functions
*/
import { DEFAULT_LOCALE, DEFAULT_TIMEZONE, CURRENCIES, DEFAULT_CURRENCY } from '../constants';
// ============================================================================
// Currency Formatting
// ============================================================================
export interface CurrencyFormatOptions {
currency?: string;
locale?: string;
showSymbol?: boolean;
showCode?: boolean;
minimumFractionDigits?: number;
maximumFractionDigits?: number;
signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never';
}
/**
* Format a number as currency (default: Mexican Pesos)
*
* @example
* formatCurrency(1234.56) // "$1,234.56"
* formatCurrency(1234.56, { currency: 'USD' }) // "US$1,234.56"
* formatCurrency(-1234.56) // "-$1,234.56"
* formatCurrency(1234.56, { showCode: true }) // "$1,234.56 MXN"
*/
export function formatCurrency(
amount: number,
options: CurrencyFormatOptions = {}
): string {
const {
currency = DEFAULT_CURRENCY,
locale = DEFAULT_LOCALE,
showSymbol = true,
showCode = false,
minimumFractionDigits,
maximumFractionDigits,
signDisplay = 'auto',
} = options;
const currencyInfo = CURRENCIES[currency] || CURRENCIES[DEFAULT_CURRENCY];
const decimals = minimumFractionDigits ?? maximumFractionDigits ?? currencyInfo.decimals;
try {
const formatter = new Intl.NumberFormat(locale, {
style: showSymbol ? 'currency' : 'decimal',
currency: showSymbol ? currency : undefined,
minimumFractionDigits: decimals,
maximumFractionDigits: maximumFractionDigits ?? decimals,
signDisplay,
});
let formatted = formatter.format(amount);
// Add currency code if requested
if (showCode) {
formatted = `${formatted} ${currency}`;
}
return formatted;
} catch {
// Fallback formatting
const symbol = showSymbol ? currencyInfo.symbol : '';
const sign = amount < 0 ? '-' : '';
const absAmount = Math.abs(amount).toFixed(decimals);
const [intPart, decPart] = absAmount.split('.');
const formattedInt = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
let result = `${sign}${symbol}${formattedInt}`;
if (decPart) {
result += `.${decPart}`;
}
if (showCode) {
result += ` ${currency}`;
}
return result;
}
}
/**
* Format currency for display in compact form
*
* @example
* formatCurrencyCompact(1234) // "$1.2K"
* formatCurrencyCompact(1234567) // "$1.2M"
*/
export function formatCurrencyCompact(
amount: number,
options: Omit<CurrencyFormatOptions, 'minimumFractionDigits' | 'maximumFractionDigits'> = {}
): string {
const {
currency = DEFAULT_CURRENCY,
locale = DEFAULT_LOCALE,
showSymbol = true,
} = options;
try {
const formatter = new Intl.NumberFormat(locale, {
style: showSymbol ? 'currency' : 'decimal',
currency: showSymbol ? currency : undefined,
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 1,
});
return formatter.format(amount);
} catch {
// Fallback
const currencyInfo = CURRENCIES[currency] || CURRENCIES[DEFAULT_CURRENCY];
const symbol = showSymbol ? currencyInfo.symbol : '';
const absAmount = Math.abs(amount);
const sign = amount < 0 ? '-' : '';
if (absAmount >= 1000000000) {
return `${sign}${symbol}${(absAmount / 1000000000).toFixed(1)}B`;
}
if (absAmount >= 1000000) {
return `${sign}${symbol}${(absAmount / 1000000).toFixed(1)}M`;
}
if (absAmount >= 1000) {
return `${sign}${symbol}${(absAmount / 1000).toFixed(1)}K`;
}
return `${sign}${symbol}${absAmount.toFixed(0)}`;
}
}
/**
* Parse a currency string to number
*
* @example
* parseCurrency("$1,234.56") // 1234.56
* parseCurrency("-$1,234.56") // -1234.56
*/
export function parseCurrency(value: string): number {
// Remove currency symbols, spaces, and thousand separators
const cleaned = value
.replace(/[^0-9.,-]/g, '')
.replace(/,/g, '');
const number = parseFloat(cleaned);
return isNaN(number) ? 0 : number;
}
// ============================================================================
// Percentage Formatting
// ============================================================================
export interface PercentFormatOptions {
locale?: string;
minimumFractionDigits?: number;
maximumFractionDigits?: number;
signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never';
multiply?: boolean; // If true, multiply by 100 (e.g., 0.16 -> 16%)
}
/**
* Format a number as percentage
*
* @example
* formatPercent(16.5) // "16.5%"
* formatPercent(0.165, { multiply: true }) // "16.5%"
* formatPercent(-5.2, { signDisplay: 'always' }) // "-5.2%"
*/
export function formatPercent(
value: number,
options: PercentFormatOptions = {}
): string {
const {
locale = DEFAULT_LOCALE,
minimumFractionDigits = 0,
maximumFractionDigits = 2,
signDisplay = 'auto',
multiply = false,
} = options;
const displayValue = multiply ? value : value / 100;
try {
const formatter = new Intl.NumberFormat(locale, {
style: 'percent',
minimumFractionDigits,
maximumFractionDigits,
signDisplay,
});
return formatter.format(displayValue);
} catch {
// Fallback
const sign = signDisplay === 'always' && value > 0 ? '+' : '';
const actualValue = multiply ? value * 100 : value;
return `${sign}${actualValue.toFixed(maximumFractionDigits)}%`;
}
}
/**
* Format a percentage change with color indicator
* Returns an object with formatted value and direction
*/
export function formatPercentChange(
value: number,
options: PercentFormatOptions = {}
): {
formatted: string;
direction: 'up' | 'down' | 'unchanged';
isPositive: boolean;
} {
const formatted = formatPercent(value, { ...options, signDisplay: 'exceptZero' });
const direction = value > 0 ? 'up' : value < 0 ? 'down' : 'unchanged';
return {
formatted,
direction,
isPositive: value > 0,
};
}
// ============================================================================
// Date Formatting
// ============================================================================
export interface DateFormatOptions {
locale?: string;
timezone?: string;
format?: 'short' | 'medium' | 'long' | 'full' | 'relative' | 'iso';
includeTime?: boolean;
}
/**
* Format a date
*
* @example
* formatDate(new Date()) // "31/01/2024"
* formatDate(new Date(), { format: 'long' }) // "31 de enero de 2024"
* formatDate(new Date(), { includeTime: true }) // "31/01/2024 14:30"
*/
export function formatDate(
date: Date | string | number,
options: DateFormatOptions = {}
): string {
const {
locale = DEFAULT_LOCALE,
timezone = DEFAULT_TIMEZONE,
format = 'short',
includeTime = false,
} = options;
const dateObj = date instanceof Date ? date : new Date(date);
if (isNaN(dateObj.getTime())) {
return 'Fecha inválida';
}
// ISO format
if (format === 'iso') {
return dateObj.toISOString();
}
// Relative format
if (format === 'relative') {
return formatRelativeTime(dateObj, locale);
}
try {
const dateStyle = format === 'short' ? 'short'
: format === 'medium' ? 'medium'
: format === 'long' ? 'long'
: 'full';
const formatter = new Intl.DateTimeFormat(locale, {
dateStyle,
timeStyle: includeTime ? 'short' : undefined,
timeZone: timezone,
});
return formatter.format(dateObj);
} catch {
// Fallback
const day = dateObj.getDate().toString().padStart(2, '0');
const month = (dateObj.getMonth() + 1).toString().padStart(2, '0');
const year = dateObj.getFullYear();
let result = `${day}/${month}/${year}`;
if (includeTime) {
const hours = dateObj.getHours().toString().padStart(2, '0');
const minutes = dateObj.getMinutes().toString().padStart(2, '0');
result += ` ${hours}:${minutes}`;
}
return result;
}
}
/**
* Format a date as relative time (e.g., "hace 2 días")
*/
export function formatRelativeTime(
date: Date | string | number,
locale: string = DEFAULT_LOCALE
): string {
const dateObj = date instanceof Date ? date : new Date(date);
const now = new Date();
const diffMs = now.getTime() - dateObj.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
const diffYears = Math.floor(diffDays / 365);
try {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
if (Math.abs(diffSeconds) < 60) {
return rtf.format(-diffSeconds, 'second');
}
if (Math.abs(diffMinutes) < 60) {
return rtf.format(-diffMinutes, 'minute');
}
if (Math.abs(diffHours) < 24) {
return rtf.format(-diffHours, 'hour');
}
if (Math.abs(diffDays) < 7) {
return rtf.format(-diffDays, 'day');
}
if (Math.abs(diffWeeks) < 4) {
return rtf.format(-diffWeeks, 'week');
}
if (Math.abs(diffMonths) < 12) {
return rtf.format(-diffMonths, 'month');
}
return rtf.format(-diffYears, 'year');
} catch {
// Fallback for environments without Intl.RelativeTimeFormat
if (diffSeconds < 60) return 'hace un momento';
if (diffMinutes < 60) return `hace ${diffMinutes} minuto${diffMinutes !== 1 ? 's' : ''}`;
if (diffHours < 24) return `hace ${diffHours} hora${diffHours !== 1 ? 's' : ''}`;
if (diffDays < 7) return `hace ${diffDays} día${diffDays !== 1 ? 's' : ''}`;
if (diffWeeks < 4) return `hace ${diffWeeks} semana${diffWeeks !== 1 ? 's' : ''}`;
if (diffMonths < 12) return `hace ${diffMonths} mes${diffMonths !== 1 ? 'es' : ''}`;
return `hace ${diffYears} año${diffYears !== 1 ? 's' : ''}`;
}
}
/**
* Format a date range
*
* @example
* formatDateRange(start, end) // "1 - 31 de enero de 2024"
*/
export function formatDateRange(
start: Date | string | number,
end: Date | string | number,
options: Omit<DateFormatOptions, 'includeTime'> = {}
): string {
const { locale = DEFAULT_LOCALE, timezone = DEFAULT_TIMEZONE } = options;
const startDate = start instanceof Date ? start : new Date(start);
const endDate = end instanceof Date ? end : new Date(end);
try {
const formatter = new Intl.DateTimeFormat(locale, {
dateStyle: 'long',
timeZone: timezone,
});
return formatter.formatRange(startDate, endDate);
} catch {
// Fallback
return `${formatDate(startDate, options)} - ${formatDate(endDate, options)}`;
}
}
/**
* Format time only
*/
export function formatTime(
date: Date | string | number,
options: { locale?: string; timezone?: string; style?: 'short' | 'medium' } = {}
): string {
const { locale = DEFAULT_LOCALE, timezone = DEFAULT_TIMEZONE, style = 'short' } = options;
const dateObj = date instanceof Date ? date : new Date(date);
try {
const formatter = new Intl.DateTimeFormat(locale, {
timeStyle: style,
timeZone: timezone,
});
return formatter.format(dateObj);
} catch {
const hours = dateObj.getHours().toString().padStart(2, '0');
const minutes = dateObj.getMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`;
}
}
// ============================================================================
// Number Formatting
// ============================================================================
export interface NumberFormatOptions {
locale?: string;
minimumFractionDigits?: number;
maximumFractionDigits?: number;
notation?: 'standard' | 'scientific' | 'engineering' | 'compact';
signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never';
}
/**
* Format a number with thousand separators
*
* @example
* formatNumber(1234567.89) // "1,234,567.89"
* formatNumber(1234567, { notation: 'compact' }) // "1.2M"
*/
export function formatNumber(
value: number,
options: NumberFormatOptions = {}
): string {
const {
locale = DEFAULT_LOCALE,
minimumFractionDigits,
maximumFractionDigits,
notation = 'standard',
signDisplay = 'auto',
} = options;
try {
const formatter = new Intl.NumberFormat(locale, {
minimumFractionDigits,
maximumFractionDigits,
notation,
signDisplay,
});
return formatter.format(value);
} catch {
return value.toLocaleString();
}
}
/**
* Format a number in compact notation
*
* @example
* formatCompactNumber(1234) // "1.2K"
* formatCompactNumber(1234567) // "1.2M"
*/
export function formatCompactNumber(
value: number,
options: Omit<NumberFormatOptions, 'notation'> = {}
): string {
return formatNumber(value, { ...options, notation: 'compact' });
}
/**
* Format bytes to human readable size
*
* @example
* formatBytes(1024) // "1 KB"
* formatBytes(1536) // "1.5 KB"
* formatBytes(1048576) // "1 MB"
*/
export function formatBytes(
bytes: number,
options: { locale?: string; decimals?: number } = {}
): string {
const { locale = DEFAULT_LOCALE, decimals = 1 } = options;
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / Math.pow(k, i);
try {
const formatter = new Intl.NumberFormat(locale, {
minimumFractionDigits: 0,
maximumFractionDigits: decimals,
});
return `${formatter.format(value)} ${sizes[i]}`;
} catch {
return `${value.toFixed(decimals)} ${sizes[i]}`;
}
}
// ============================================================================
// Text Formatting
// ============================================================================
/**
* Truncate text with ellipsis
*
* @example
* truncate("Hello World", 8) // "Hello..."
*/
export function truncate(text: string, maxLength: number, suffix = '...'): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength - suffix.length).trim() + suffix;
}
/**
* Capitalize first letter
*
* @example
* capitalize("hello world") // "Hello world"
*/
export function capitalize(text: string): string {
if (!text) return '';
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
}
/**
* Title case
*
* @example
* titleCase("hello world") // "Hello World"
*/
export function titleCase(text: string): string {
return text
.toLowerCase()
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Format RFC for display (with spaces)
*
* @example
* formatRFC("XAXX010101000") // "XAXX 010101 000"
*/
export function formatRFC(rfc: string): string {
const cleaned = rfc.replace(/\s/g, '').toUpperCase();
if (cleaned.length === 12) {
// Persona física
return `${cleaned.slice(0, 4)} ${cleaned.slice(4, 10)} ${cleaned.slice(10)}`;
}
if (cleaned.length === 13) {
// Persona moral
return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 9)} ${cleaned.slice(9)}`;
}
return cleaned;
}
/**
* Mask sensitive data
*
* @example
* maskString("1234567890", 4) // "******7890"
* maskString("email@example.com", 3, { maskChar: '*', type: 'email' }) // "ema***@example.com"
*/
export function maskString(
value: string,
visibleChars: number = 4,
options: { maskChar?: string; position?: 'start' | 'end' } = {}
): string {
const { maskChar = '*', position = 'end' } = options;
if (value.length <= visibleChars) return value;
const maskLength = value.length - visibleChars;
const mask = maskChar.repeat(maskLength);
if (position === 'start') {
return value.slice(0, visibleChars) + mask;
}
return mask + value.slice(-visibleChars);
}
/**
* Format CLABE for display
*
* @example
* formatCLABE("123456789012345678") // "123 456 789012345678"
*/
export function formatCLABE(clabe: string): string {
const cleaned = clabe.replace(/\s/g, '');
if (cleaned.length !== 18) return clabe;
return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 6)} ${cleaned.slice(6)}`;
}
/**
* Format phone number (Mexican format)
*
* @example
* formatPhone("5512345678") // "(55) 1234-5678"
*/
export function formatPhone(phone: string): string {
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length === 10) {
return `(${cleaned.slice(0, 2)}) ${cleaned.slice(2, 6)}-${cleaned.slice(6)}`;
}
if (cleaned.length === 12 && cleaned.startsWith('52')) {
const national = cleaned.slice(2);
return `+52 (${national.slice(0, 2)}) ${national.slice(2, 6)}-${national.slice(6)}`;
}
return phone;
}
// ============================================================================
// Pluralization
// ============================================================================
/**
* Simple Spanish pluralization
*
* @example
* pluralize(1, 'factura', 'facturas') // "1 factura"
* pluralize(5, 'factura', 'facturas') // "5 facturas"
*/
export function pluralize(
count: number,
singular: string,
plural: string
): string {
const word = count === 1 ? singular : plural;
return `${formatNumber(count)} ${word}`;
}
/**
* Format a list of items with proper grammar
*
* @example
* formatList(['a', 'b', 'c']) // "a, b y c"
* formatList(['a', 'b']) // "a y b"
* formatList(['a']) // "a"
*/
export function formatList(
items: string[],
options: { locale?: string; type?: 'conjunction' | 'disjunction' } = {}
): string {
const { locale = DEFAULT_LOCALE, type = 'conjunction' } = options;
if (items.length === 0) return '';
if (items.length === 1) return items[0];
try {
const formatter = new Intl.ListFormat(locale, {
style: 'long',
type,
});
return formatter.format(items);
} catch {
// Fallback
const connector = type === 'conjunction' ? 'y' : 'o';
if (items.length === 2) {
return `${items[0]} ${connector} ${items[1]}`;
}
return `${items.slice(0, -1).join(', ')} ${connector} ${items[items.length - 1]}`;
}
}

32
packages/ui/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@horux/ui",
"version": "0.1.0",
"private": true,
"description": "UI components for Horux Strategy",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"lint": "eslint src --ext .ts,.tsx",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist node_modules"
},
"dependencies": {
"react": "^18.2.0",
"recharts": "^2.12.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0",
"lucide-react": "^0.312.0",
"date-fns": "^3.3.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^18.2.48",
"eslint": "^8.56.0",
"typescript": "^5.3.3"
},
"peerDependencies": {
"react": "^18.2.0"
}
}

View File

@@ -0,0 +1,280 @@
import React from 'react';
import {
AlertCircle,
AlertTriangle,
CheckCircle,
Info,
XCircle,
type LucideIcon,
} from 'lucide-react';
import { cn } from '../utils/cn';
// ============================================================================
// Types
// ============================================================================
export type AlertSeverity = 'info' | 'success' | 'warning' | 'critical' | 'error';
export interface AlertBadgeProps {
/** The severity level of the alert */
severity: AlertSeverity;
/** Optional label text */
label?: string;
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Show icon */
showIcon?: boolean;
/** Make the badge pulsate for critical alerts */
pulse?: boolean;
/** Additional CSS classes */
className?: string;
/** Click handler */
onClick?: () => void;
}
// ============================================================================
// Severity Configuration
// ============================================================================
interface SeverityConfig {
icon: LucideIcon;
bgColor: string;
textColor: string;
borderColor: string;
pulseColor: string;
label: string;
}
const severityConfigs: Record<AlertSeverity, SeverityConfig> = {
info: {
icon: Info,
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
textColor: 'text-blue-700 dark:text-blue-300',
borderColor: 'border-blue-200 dark:border-blue-800',
pulseColor: 'bg-blue-400',
label: 'Info',
},
success: {
icon: CheckCircle,
bgColor: 'bg-green-50 dark:bg-green-900/20',
textColor: 'text-green-700 dark:text-green-300',
borderColor: 'border-green-200 dark:border-green-800',
pulseColor: 'bg-green-400',
label: 'Bueno',
},
warning: {
icon: AlertTriangle,
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
textColor: 'text-yellow-700 dark:text-yellow-300',
borderColor: 'border-yellow-200 dark:border-yellow-800',
pulseColor: 'bg-yellow-400',
label: 'Alerta',
},
critical: {
icon: XCircle,
bgColor: 'bg-red-50 dark:bg-red-900/20',
textColor: 'text-red-700 dark:text-red-300',
borderColor: 'border-red-200 dark:border-red-800',
pulseColor: 'bg-red-400',
label: 'Critico',
},
error: {
icon: AlertCircle,
bgColor: 'bg-red-50 dark:bg-red-900/20',
textColor: 'text-red-700 dark:text-red-300',
borderColor: 'border-red-200 dark:border-red-800',
pulseColor: 'bg-red-400',
label: 'Error',
},
};
// ============================================================================
// Size Configuration
// ============================================================================
const sizeConfigs = {
sm: {
padding: 'px-2 py-0.5',
text: 'text-xs',
iconSize: 12,
gap: 'gap-1',
},
md: {
padding: 'px-2.5 py-1',
text: 'text-sm',
iconSize: 14,
gap: 'gap-1.5',
},
lg: {
padding: 'px-3 py-1.5',
text: 'text-base',
iconSize: 16,
gap: 'gap-2',
},
};
// ============================================================================
// Component
// ============================================================================
export function AlertBadge({
severity,
label,
size = 'md',
showIcon = true,
pulse = false,
className,
onClick,
}: AlertBadgeProps): React.ReactElement {
const config = severityConfigs[severity];
const sizeConfig = sizeConfigs[size];
const Icon = config.icon;
const displayLabel = label ?? config.label;
const isClickable = Boolean(onClick);
return (
<span
className={cn(
'inline-flex items-center rounded-full border font-medium',
config.bgColor,
config.textColor,
config.borderColor,
sizeConfig.padding,
sizeConfig.text,
sizeConfig.gap,
isClickable && 'cursor-pointer hover:opacity-80 transition-opacity',
className
)}
onClick={onClick}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
>
{pulse && (severity === 'critical' || severity === 'error') && (
<span className="relative flex h-2 w-2">
<span
className={cn(
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
config.pulseColor
)}
/>
<span
className={cn(
'relative inline-flex h-2 w-2 rounded-full',
config.pulseColor
)}
/>
</span>
)}
{showIcon && !pulse && (
<Icon size={sizeConfig.iconSize} className="flex-shrink-0" />
)}
{displayLabel && <span>{displayLabel}</span>}
</span>
);
}
// ============================================================================
// Status Badge Variant (simpler dot + text)
// ============================================================================
export interface StatusBadgeProps {
status: 'active' | 'inactive' | 'pending' | 'error';
label?: string;
size?: 'sm' | 'md';
className?: string;
}
const statusConfigs = {
active: {
dotColor: 'bg-green-500',
textColor: 'text-green-700 dark:text-green-400',
label: 'Activo',
},
inactive: {
dotColor: 'bg-gray-400',
textColor: 'text-gray-600 dark:text-gray-400',
label: 'Inactivo',
},
pending: {
dotColor: 'bg-yellow-500',
textColor: 'text-yellow-700 dark:text-yellow-400',
label: 'Pendiente',
},
error: {
dotColor: 'bg-red-500',
textColor: 'text-red-700 dark:text-red-400',
label: 'Error',
},
};
export function StatusBadge({
status,
label,
size = 'md',
className,
}: StatusBadgeProps): React.ReactElement {
const config = statusConfigs[status];
const displayLabel = label ?? config.label;
return (
<span
className={cn(
'inline-flex items-center gap-2',
config.textColor,
size === 'sm' ? 'text-xs' : 'text-sm',
className
)}
>
<span
className={cn(
'rounded-full',
config.dotColor,
size === 'sm' ? 'h-1.5 w-1.5' : 'h-2 w-2'
)}
/>
<span className="font-medium">{displayLabel}</span>
</span>
);
}
// ============================================================================
// Notification Badge (for counts)
// ============================================================================
export interface NotificationBadgeProps {
count: number;
maxCount?: number;
severity?: 'default' | 'warning' | 'critical';
className?: string;
}
export function NotificationBadge({
count,
maxCount = 99,
severity = 'default',
className,
}: NotificationBadgeProps): React.ReactElement | null {
if (count <= 0) return null;
const displayCount = count > maxCount ? `${maxCount}+` : count.toString();
const severityStyles = {
default: 'bg-blue-500 text-white',
warning: 'bg-yellow-500 text-white',
critical: 'bg-red-500 text-white',
};
return (
<span
className={cn(
'inline-flex items-center justify-center rounded-full text-xs font-bold',
'min-w-[20px] h-5 px-1.5',
severityStyles[severity],
className
)}
>
{displayCount}
</span>
);
}

View File

@@ -0,0 +1,699 @@
import React, { useState, useMemo, useCallback } from 'react';
import {
ChevronUp,
ChevronDown,
ChevronsUpDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Search,
X,
Filter,
} from 'lucide-react';
import { cn } from '../utils/cn';
import { SkeletonTable } from './Skeleton';
// ============================================================================
// Types
// ============================================================================
export type SortDirection = 'asc' | 'desc' | null;
export type ColumnAlign = 'left' | 'center' | 'right';
export interface ColumnDef<T> {
/** Unique column identifier */
id: string;
/** Column header label */
header: string;
/** Data accessor key or function */
accessorKey?: keyof T;
accessorFn?: (row: T) => unknown;
/** Custom cell renderer */
cell?: (value: unknown, row: T, rowIndex: number) => React.ReactNode;
/** Column alignment */
align?: ColumnAlign;
/** Whether column is sortable */
sortable?: boolean;
/** Whether column is filterable */
filterable?: boolean;
/** Column width */
width?: string | number;
/** Minimum column width */
minWidth?: string | number;
/** Whether to hide on mobile */
hideOnMobile?: boolean;
/** Custom sort function */
sortFn?: (a: T, b: T, direction: SortDirection) => number;
/** Custom filter function */
filterFn?: (row: T, filterValue: string) => boolean;
}
export interface PaginationConfig {
/** Current page (1-indexed) */
page: number;
/** Items per page */
pageSize: number;
/** Total number of items (for server-side pagination) */
totalItems?: number;
/** Available page sizes */
pageSizeOptions?: number[];
/** Callback when page changes */
onPageChange?: (page: number) => void;
/** Callback when page size changes */
onPageSizeChange?: (pageSize: number) => void;
}
export interface DataTableProps<T extends Record<string, unknown>> {
/** Column definitions */
columns: ColumnDef<T>[];
/** Table data */
data: T[];
/** Row key extractor */
getRowId?: (row: T, index: number) => string;
/** Pagination configuration */
pagination?: PaginationConfig;
/** Enable global search */
enableSearch?: boolean;
/** Search placeholder */
searchPlaceholder?: string;
/** Enable column filters */
enableFilters?: boolean;
/** Default sort column */
defaultSortColumn?: string;
/** Default sort direction */
defaultSortDirection?: SortDirection;
/** Loading state */
isLoading?: boolean;
/** Empty state message */
emptyMessage?: string;
/** Table title */
title?: string;
/** Table subtitle */
subtitle?: string;
/** Row click handler */
onRowClick?: (row: T, index: number) => void;
/** Selected rows (controlled) */
selectedRows?: Set<string>;
/** Row selection handler */
onRowSelect?: (rowId: string, selected: boolean) => void;
/** Enable row selection */
enableRowSelection?: boolean;
/** Striped rows */
striped?: boolean;
/** Hover effect on rows */
hoverable?: boolean;
/** Compact mode */
compact?: boolean;
/** Additional CSS classes */
className?: string;
}
// ============================================================================
// Utility Functions
// ============================================================================
function getCellValue<T>(row: T, column: ColumnDef<T>): unknown {
if (column.accessorFn) {
return column.accessorFn(row);
}
if (column.accessorKey) {
return row[column.accessorKey];
}
return null;
}
function defaultSort<T>(
a: T,
b: T,
column: ColumnDef<T>,
direction: SortDirection
): number {
if (!direction) return 0;
const aVal = getCellValue(a, column);
const bVal = getCellValue(b, column);
let comparison = 0;
if (aVal === null || aVal === undefined) comparison = 1;
else if (bVal === null || bVal === undefined) comparison = -1;
else if (typeof aVal === 'number' && typeof bVal === 'number') {
comparison = aVal - bVal;
} else if (typeof aVal === 'string' && typeof bVal === 'string') {
comparison = aVal.localeCompare(bVal, 'es-MX');
} else if (aVal instanceof Date && bVal instanceof Date) {
comparison = aVal.getTime() - bVal.getTime();
} else {
comparison = String(aVal).localeCompare(String(bVal), 'es-MX');
}
return direction === 'asc' ? comparison : -comparison;
}
function defaultFilter<T>(row: T, column: ColumnDef<T>, filterValue: string): boolean {
const value = getCellValue(row, column);
if (value === null || value === undefined) return false;
return String(value).toLowerCase().includes(filterValue.toLowerCase());
}
// ============================================================================
// Sub-Components
// ============================================================================
interface SortIconProps {
direction: SortDirection;
}
function SortIcon({ direction }: SortIconProps): React.ReactElement {
if (direction === 'asc') {
return <ChevronUp size={14} className="text-blue-500" />;
}
if (direction === 'desc') {
return <ChevronDown size={14} className="text-blue-500" />;
}
return <ChevronsUpDown size={14} className="text-gray-400" />;
}
interface PaginationProps {
currentPage: number;
pageSize: number;
totalItems: number;
pageSizeOptions: number[];
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
}
function Pagination({
currentPage,
pageSize,
totalItems,
pageSizeOptions,
onPageChange,
onPageSizeChange,
}: PaginationProps): React.ReactElement {
const totalPages = Math.ceil(totalItems / pageSize);
const startItem = (currentPage - 1) * pageSize + 1;
const endItem = Math.min(currentPage * pageSize, totalItems);
const canGoPrev = currentPage > 1;
const canGoNext = currentPage < totalPages;
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-4 py-3 border-t border-gray-200 dark:border-gray-700">
{/* Page size selector */}
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<span>Mostrar</span>
<select
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="rounded border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<span>por pagina</span>
</div>
{/* Info and controls */}
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600 dark:text-gray-400">
{startItem}-{endItem} de {totalItems}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => onPageChange(1)}
disabled={!canGoPrev}
className={cn(
'p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700',
!canGoPrev && 'opacity-50 cursor-not-allowed'
)}
aria-label="Primera pagina"
>
<ChevronsLeft size={18} />
</button>
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={!canGoPrev}
className={cn(
'p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700',
!canGoPrev && 'opacity-50 cursor-not-allowed'
)}
aria-label="Pagina anterior"
>
<ChevronLeft size={18} />
</button>
<span className="px-3 text-sm font-medium text-gray-700 dark:text-gray-300">
{currentPage} / {totalPages}
</span>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={!canGoNext}
className={cn(
'p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700',
!canGoNext && 'opacity-50 cursor-not-allowed'
)}
aria-label="Pagina siguiente"
>
<ChevronRight size={18} />
</button>
<button
onClick={() => onPageChange(totalPages)}
disabled={!canGoNext}
className={cn(
'p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700',
!canGoNext && 'opacity-50 cursor-not-allowed'
)}
aria-label="Ultima pagina"
>
<ChevronsRight size={18} />
</button>
</div>
</div>
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function DataTable<T extends Record<string, unknown>>({
columns,
data,
getRowId,
pagination,
enableSearch = false,
searchPlaceholder = 'Buscar...',
enableFilters = false,
defaultSortColumn,
defaultSortDirection = null,
isLoading = false,
emptyMessage = 'No hay datos disponibles',
title,
subtitle,
onRowClick,
selectedRows,
onRowSelect,
enableRowSelection = false,
striped = false,
hoverable = true,
compact = false,
className,
}: DataTableProps<T>): React.ReactElement {
// State
const [searchQuery, setSearchQuery] = useState('');
const [sortColumn, setSortColumn] = useState<string | null>(defaultSortColumn ?? null);
const [sortDirection, setSortDirection] = useState<SortDirection>(defaultSortDirection);
const [columnFilters, setColumnFilters] = useState<Record<string, string>>({});
const [showFilters, setShowFilters] = useState(false);
// Internal pagination state (for client-side pagination)
const [internalPage, setInternalPage] = useState(pagination?.page ?? 1);
const [internalPageSize, setInternalPageSize] = useState(pagination?.pageSize ?? 10);
// Effective pagination values
const currentPage = pagination?.page ?? internalPage;
const pageSize = pagination?.pageSize ?? internalPageSize;
const pageSizeOptions = pagination?.pageSizeOptions ?? [10, 25, 50, 100];
// Row ID helper
const getRowIdFn = useCallback(
(row: T, index: number): string => {
if (getRowId) return getRowId(row, index);
if ('id' in row) return String(row.id);
return String(index);
},
[getRowId]
);
// Filter and sort data
const processedData = useMemo(() => {
let result = [...data];
// Apply global search
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter((row) =>
columns.some((col) => {
const value = getCellValue(row, col);
return value !== null && String(value).toLowerCase().includes(query);
})
);
}
// Apply column filters
if (Object.keys(columnFilters).length > 0) {
result = result.filter((row) =>
Object.entries(columnFilters).every(([colId, filterValue]) => {
if (!filterValue) return true;
const column = columns.find((c) => c.id === colId);
if (!column) return true;
if (column.filterFn) return column.filterFn(row, filterValue);
return defaultFilter(row, column, filterValue);
})
);
}
// Apply sorting
if (sortColumn && sortDirection) {
const column = columns.find((c) => c.id === sortColumn);
if (column) {
result.sort((a, b) => {
if (column.sortFn) return column.sortFn(a, b, sortDirection);
return defaultSort(a, b, column, sortDirection);
});
}
}
return result;
}, [data, columns, searchQuery, columnFilters, sortColumn, sortDirection]);
// Calculate total items
const totalItems = pagination?.totalItems ?? processedData.length;
// Apply pagination (client-side only if not server-side)
const paginatedData = useMemo(() => {
if (pagination?.totalItems !== undefined) {
// Server-side pagination - data is already paginated
return processedData;
}
// Client-side pagination
const start = (currentPage - 1) * pageSize;
return processedData.slice(start, start + pageSize);
}, [processedData, pagination?.totalItems, currentPage, pageSize]);
// Handlers
const handleSort = useCallback((columnId: string) => {
setSortColumn((prev) => {
if (prev !== columnId) {
setSortDirection('asc');
return columnId;
}
setSortDirection((dir) => {
if (dir === 'asc') return 'desc';
if (dir === 'desc') return null;
return 'asc';
});
return columnId;
});
}, []);
const handlePageChange = useCallback(
(page: number) => {
if (pagination?.onPageChange) {
pagination.onPageChange(page);
} else {
setInternalPage(page);
}
},
[pagination]
);
const handlePageSizeChange = useCallback(
(size: number) => {
if (pagination?.onPageSizeChange) {
pagination.onPageSizeChange(size);
} else {
setInternalPageSize(size);
setInternalPage(1);
}
},
[pagination]
);
const handleColumnFilterChange = useCallback((columnId: string, value: string) => {
setColumnFilters((prev) => ({
...prev,
[columnId]: value,
}));
setInternalPage(1);
}, []);
const clearFilters = useCallback(() => {
setColumnFilters({});
setSearchQuery('');
setInternalPage(1);
}, []);
const hasActiveFilters = searchQuery || Object.values(columnFilters).some(Boolean);
if (isLoading) {
return (
<SkeletonTable
rows={pageSize}
columns={columns.length}
className={className}
/>
);
}
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800',
className
)}
>
{/* Header */}
{(title || subtitle || enableSearch || enableFilters) && (
<div className="border-b border-gray-200 px-4 py-3 dark:border-gray-700">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
{/* Title */}
{(title || subtitle) && (
<div>
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
)}
{subtitle && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{subtitle}
</p>
)}
</div>
)}
{/* Search and filters */}
<div className="flex items-center gap-2">
{enableSearch && (
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setInternalPage(1);
}}
placeholder={searchPlaceholder}
className="w-full sm:w-64 rounded-md border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-500"
/>
</div>
)}
{enableFilters && (
<button
onClick={() => setShowFilters(!showFilters)}
className={cn(
'flex items-center gap-1 rounded-md border px-3 py-2 text-sm font-medium transition-colors',
showFilters || hasActiveFilters
? 'border-blue-500 bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400'
: 'border-gray-300 text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700'
)}
>
<Filter size={16} />
<span>Filtros</span>
</button>
)}
{hasActiveFilters && (
<button
onClick={clearFilters}
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
<X size={14} />
<span>Limpiar</span>
</button>
)}
</div>
</div>
{/* Column filters */}
{showFilters && enableFilters && (
<div className="mt-3 flex flex-wrap gap-2">
{columns
.filter((col) => col.filterable !== false)
.map((column) => (
<div key={column.id} className="flex-shrink-0">
<input
type="text"
value={columnFilters[column.id] || ''}
onChange={(e) =>
handleColumnFilterChange(column.id, e.target.value)
}
placeholder={column.header}
className="w-32 rounded border border-gray-300 bg-white px-2 py-1 text-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
))}
</div>
)}
</div>
)}
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50">
{enableRowSelection && (
<th className="w-10 px-4 py-3">
<input
type="checkbox"
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
onChange={(e) => {
paginatedData.forEach((row, index) => {
const rowId = getRowIdFn(row, index);
onRowSelect?.(rowId, e.target.checked);
});
}}
/>
</th>
)}
{columns.map((column) => (
<th
key={column.id}
className={cn(
'px-4 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400',
compact ? 'py-2' : 'py-3',
column.align === 'center' && 'text-center',
column.align === 'right' && 'text-right',
column.hideOnMobile && 'hidden md:table-cell',
column.sortable !== false && 'cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-300'
)}
style={{
width: column.width,
minWidth: column.minWidth,
}}
onClick={() => column.sortable !== false && handleSort(column.id)}
>
<div
className={cn(
'flex items-center gap-1',
column.align === 'center' && 'justify-center',
column.align === 'right' && 'justify-end'
)}
>
<span>{column.header}</span>
{column.sortable !== false && (
<SortIcon
direction={sortColumn === column.id ? sortDirection : null}
/>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{paginatedData.length === 0 ? (
<tr>
<td
colSpan={columns.length + (enableRowSelection ? 1 : 0)}
className="px-4 py-8 text-center text-gray-500 dark:text-gray-400"
>
{emptyMessage}
</td>
</tr>
) : (
paginatedData.map((row, rowIndex) => {
const rowId = getRowIdFn(row, rowIndex);
const isSelected = selectedRows?.has(rowId);
return (
<tr
key={rowId}
className={cn(
'transition-colors',
striped && rowIndex % 2 === 1 && 'bg-gray-50 dark:bg-gray-800/30',
hoverable && 'hover:bg-gray-50 dark:hover:bg-gray-800/50',
onRowClick && 'cursor-pointer',
isSelected && 'bg-blue-50 dark:bg-blue-900/20'
)}
onClick={() => onRowClick?.(row, rowIndex)}
>
{enableRowSelection && (
<td className="w-10 px-4 py-3">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
onRowSelect?.(rowId, e.target.checked);
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
/>
</td>
)}
{columns.map((column) => {
const value = getCellValue(row, column);
const displayValue = column.cell
? column.cell(value, row, rowIndex)
: value !== null && value !== undefined
? String(value)
: '-';
return (
<td
key={column.id}
className={cn(
'px-4 text-sm text-gray-900 dark:text-gray-100',
compact ? 'py-2' : 'py-3',
column.align === 'center' && 'text-center',
column.align === 'right' && 'text-right',
column.hideOnMobile && 'hidden md:table-cell'
)}
style={{
width: column.width,
minWidth: column.minWidth,
}}
>
{displayValue}
</td>
);
})}
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
{pagination !== undefined && totalItems > 0 && (
<Pagination
currentPage={currentPage}
pageSize={pageSize}
totalItems={totalItems}
pageSizeOptions={pageSizeOptions}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,400 @@
import React, { useMemo } from 'react';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
import { cn } from '../utils/cn';
import { SkeletonKPICard } from './Skeleton';
// ============================================================================
// Types
// ============================================================================
export type ValueFormat = 'currency' | 'percent' | 'number' | 'compact';
export interface SparklineDataPoint {
value: number;
}
export interface KPICardProps {
/** Title of the KPI */
title: string;
/** Current value */
value: number;
/** Previous period value for comparison */
previousValue?: number;
/** Format to display the value */
format?: ValueFormat;
/** Currency code for currency format (default: MXN) */
currency?: string;
/** Number of decimal places */
decimals?: number;
/** Optional prefix (e.g., "$") */
prefix?: string;
/** Optional suffix (e.g., "%", "users") */
suffix?: string;
/** Sparkline data points */
sparklineData?: SparklineDataPoint[];
/** Whether the card is loading */
isLoading?: boolean;
/** Invert the color logic (lower is better) */
invertColors?: boolean;
/** Optional icon component */
icon?: React.ReactNode;
/** Additional CSS classes */
className?: string;
/** Period label (e.g., "vs mes anterior") */
periodLabel?: string;
/** Click handler */
onClick?: () => void;
}
// ============================================================================
// Formatting Utilities
// ============================================================================
function formatValue(
value: number,
format: ValueFormat,
options: {
currency?: string;
decimals?: number;
prefix?: string;
suffix?: string;
} = {}
): string {
const { currency = 'MXN', decimals, prefix = '', suffix = '' } = options;
let formatted: string;
switch (format) {
case 'currency':
formatted = new Intl.NumberFormat('es-MX', {
style: 'currency',
currency,
minimumFractionDigits: decimals ?? 0,
maximumFractionDigits: decimals ?? 0,
}).format(value);
break;
case 'percent':
formatted = new Intl.NumberFormat('es-MX', {
style: 'percent',
minimumFractionDigits: decimals ?? 1,
maximumFractionDigits: decimals ?? 1,
}).format(value / 100);
break;
case 'compact':
formatted = new Intl.NumberFormat('es-MX', {
notation: 'compact',
compactDisplay: 'short',
minimumFractionDigits: decimals ?? 1,
maximumFractionDigits: decimals ?? 1,
}).format(value);
break;
case 'number':
default:
formatted = new Intl.NumberFormat('es-MX', {
minimumFractionDigits: decimals ?? 0,
maximumFractionDigits: decimals ?? 2,
}).format(value);
break;
}
return `${prefix}${formatted}${suffix}`;
}
function calculateVariation(
current: number,
previous: number
): { percentage: number; direction: 'up' | 'down' | 'neutral' } {
if (previous === 0) {
return { percentage: 0, direction: 'neutral' };
}
const percentage = ((current - previous) / Math.abs(previous)) * 100;
if (Math.abs(percentage) < 0.1) {
return { percentage: 0, direction: 'neutral' };
}
return {
percentage,
direction: percentage > 0 ? 'up' : 'down',
};
}
// ============================================================================
// Mini Sparkline Component
// ============================================================================
interface SparklineProps {
data: SparklineDataPoint[];
width?: number;
height?: number;
strokeColor?: string;
strokeWidth?: number;
className?: string;
}
function Sparkline({
data,
width = 80,
height = 32,
strokeColor,
strokeWidth = 2,
className,
}: SparklineProps): React.ReactElement | null {
const pathD = useMemo(() => {
if (data.length < 2) return null;
const values = data.map((d) => d.value);
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const padding = 2;
const chartWidth = width - padding * 2;
const chartHeight = height - padding * 2;
const points = values.map((value, index) => {
const x = padding + (index / (values.length - 1)) * chartWidth;
const y = padding + chartHeight - ((value - min) / range) * chartHeight;
return { x, y };
});
return points
.map((point, i) => `${i === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
.join(' ');
}, [data, width, height]);
if (!pathD) return null;
// Determine color based on trend
const trend = data[data.length - 1].value >= data[0].value;
const color = strokeColor ?? (trend ? '#10B981' : '#EF4444');
return (
<svg
width={width}
height={height}
className={cn('overflow-visible', className)}
viewBox={`0 0 ${width} ${height}`}
>
<path
d={pathD}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
// ============================================================================
// Variation Badge Component
// ============================================================================
interface VariationBadgeProps {
percentage: number;
direction: 'up' | 'down' | 'neutral';
invertColors?: boolean;
periodLabel?: string;
}
function VariationBadge({
percentage,
direction,
invertColors = false,
periodLabel,
}: VariationBadgeProps): React.ReactElement {
const isPositive = direction === 'up';
const isNeutral = direction === 'neutral';
// Determine if this change is "good" or "bad"
const isGood = invertColors ? !isPositive : isPositive;
const colorClasses = isNeutral
? 'text-gray-500 bg-gray-100 dark:bg-gray-700 dark:text-gray-400'
: isGood
? 'text-green-700 bg-green-100 dark:bg-green-900/30 dark:text-green-400'
: 'text-red-700 bg-red-100 dark:bg-red-900/30 dark:text-red-400';
const Icon = isNeutral ? Minus : isPositive ? TrendingUp : TrendingDown;
return (
<div className="flex items-center gap-2">
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-sm font-medium',
colorClasses
)}
>
<Icon size={14} className="flex-shrink-0" />
<span>{Math.abs(percentage).toFixed(1)}%</span>
</span>
{periodLabel && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{periodLabel}
</span>
)}
</div>
);
}
// ============================================================================
// Main KPICard Component
// ============================================================================
export function KPICard({
title,
value,
previousValue,
format = 'number',
currency = 'MXN',
decimals,
prefix,
suffix,
sparklineData,
isLoading = false,
invertColors = false,
icon,
className,
periodLabel = 'vs periodo anterior',
onClick,
}: KPICardProps): React.ReactElement {
// Calculate variation if previous value is provided
const variation = useMemo(() => {
if (previousValue === undefined) return null;
return calculateVariation(value, previousValue);
}, [value, previousValue]);
// Format the display value
const formattedValue = useMemo(() => {
return formatValue(value, format, { currency, decimals, prefix, suffix });
}, [value, format, currency, decimals, prefix, suffix]);
if (isLoading) {
return <SkeletonKPICard showSparkline={Boolean(sparklineData)} className={className} />;
}
const isClickable = Boolean(onClick);
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-all',
'dark:border-gray-700 dark:bg-gray-800',
isClickable && 'cursor-pointer hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600',
className
)}
onClick={onClick}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Title with optional icon */}
<div className="flex items-center gap-2 mb-1">
{icon && (
<span className="text-gray-400 dark:text-gray-500 flex-shrink-0">
{icon}
</span>
)}
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
{title}
</h3>
</div>
{/* Main Value */}
<p className="text-2xl font-bold text-gray-900 dark:text-white mb-2 truncate">
{formattedValue}
</p>
{/* Variation Badge */}
{variation && (
<VariationBadge
percentage={variation.percentage}
direction={variation.direction}
invertColors={invertColors}
periodLabel={periodLabel}
/>
)}
</div>
{/* Sparkline */}
{sparklineData && sparklineData.length > 1 && (
<div className="ml-4 flex-shrink-0">
<Sparkline data={sparklineData} />
</div>
)}
</div>
</div>
);
}
// ============================================================================
// Compact KPI Card Variant
// ============================================================================
export interface CompactKPICardProps {
title: string;
value: number;
format?: ValueFormat;
icon?: React.ReactNode;
trend?: 'up' | 'down' | 'neutral';
className?: string;
}
export function CompactKPICard({
title,
value,
format = 'number',
icon,
trend,
className,
}: CompactKPICardProps): React.ReactElement {
const formattedValue = formatValue(value, format, {});
const trendColors = {
up: 'text-green-500',
down: 'text-red-500',
neutral: 'text-gray-400',
};
return (
<div
className={cn(
'flex items-center gap-3 rounded-lg bg-gray-50 p-3 dark:bg-gray-800/50',
className
)}
>
{icon && (
<div
className={cn(
'flex-shrink-0',
trend ? trendColors[trend] : 'text-gray-400'
)}
>
{icon}
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{title}
</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white truncate">
{formattedValue}
</p>
</div>
{trend && (
<div className={cn('flex-shrink-0', trendColors[trend])}>
{trend === 'up' && <TrendingUp size={16} />}
{trend === 'down' && <TrendingDown size={16} />}
{trend === 'neutral' && <Minus size={16} />}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,425 @@
import React, { useMemo } from 'react';
import {
TrendingUp,
TrendingDown,
Minus,
ArrowRight,
type LucideIcon,
} from 'lucide-react';
import { cn } from '../utils/cn';
import { AlertBadge, type AlertSeverity } from './AlertBadge';
import { SkeletonCard } from './Skeleton';
// ============================================================================
// Types
// ============================================================================
export type MetricStatus = 'good' | 'warning' | 'critical' | 'neutral';
export type MetricTrend = 'up' | 'down' | 'stable';
export type MetricFormat = 'currency' | 'percent' | 'number' | 'compact' | 'days';
export interface MetricValue {
current: number;
previous?: number;
target?: number;
}
export interface MetricPeriod {
label: string;
startDate?: Date;
endDate?: Date;
}
export interface MetricComparison {
type: 'previous_period' | 'previous_year' | 'target' | 'budget';
value: number;
label: string;
}
export interface MetricCardProps {
/** Metric name/title */
title: string;
/** Description or subtitle */
description?: string;
/** Metric values */
metric: MetricValue;
/** Current period */
period?: MetricPeriod;
/** Comparison data */
comparison?: MetricComparison;
/** Value format */
format?: MetricFormat;
/** Currency code */
currency?: string;
/** Number of decimal places */
decimals?: number;
/** Status thresholds - automatically determines status */
thresholds?: {
good: number;
warning: number;
};
/** Override automatic status */
status?: MetricStatus;
/** Invert threshold logic (lower is better) */
invertThresholds?: boolean;
/** Icon to display */
icon?: LucideIcon;
/** Loading state */
isLoading?: boolean;
/** Click handler */
onClick?: () => void;
/** Link to detailed view */
detailsLink?: string;
/** Additional CSS classes */
className?: string;
}
// ============================================================================
// Formatting Utilities
// ============================================================================
function formatMetricValue(
value: number,
format: MetricFormat,
options: { currency?: string; decimals?: number } = {}
): string {
const { currency = 'MXN', decimals } = options;
switch (format) {
case 'currency':
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency,
minimumFractionDigits: decimals ?? 0,
maximumFractionDigits: decimals ?? 0,
}).format(value);
case 'percent':
return new Intl.NumberFormat('es-MX', {
style: 'percent',
minimumFractionDigits: decimals ?? 1,
maximumFractionDigits: decimals ?? 1,
}).format(value / 100);
case 'compact':
return new Intl.NumberFormat('es-MX', {
notation: 'compact',
compactDisplay: 'short',
minimumFractionDigits: decimals ?? 1,
maximumFractionDigits: decimals ?? 1,
}).format(value);
case 'days':
return `${value.toFixed(decimals ?? 0)} dias`;
case 'number':
default:
return new Intl.NumberFormat('es-MX', {
minimumFractionDigits: decimals ?? 0,
maximumFractionDigits: decimals ?? 2,
}).format(value);
}
}
function calculateTrend(current: number, previous?: number): MetricTrend {
if (previous === undefined) return 'stable';
const change = ((current - previous) / Math.abs(previous || 1)) * 100;
if (Math.abs(change) < 1) return 'stable';
return change > 0 ? 'up' : 'down';
}
function calculateVariationPercent(current: number, previous: number): number {
if (previous === 0) return 0;
return ((current - previous) / Math.abs(previous)) * 100;
}
function determineStatus(
value: number,
thresholds?: { good: number; warning: number },
invert: boolean = false
): MetricStatus {
if (!thresholds) return 'neutral';
if (invert) {
// Lower is better (e.g., DSO, costs)
if (value <= thresholds.good) return 'good';
if (value <= thresholds.warning) return 'warning';
return 'critical';
} else {
// Higher is better (e.g., revenue, margins)
if (value >= thresholds.good) return 'good';
if (value >= thresholds.warning) return 'warning';
return 'critical';
}
}
function statusToSeverity(status: MetricStatus): AlertSeverity {
switch (status) {
case 'good':
return 'success';
case 'warning':
return 'warning';
case 'critical':
return 'critical';
default:
return 'info';
}
}
// ============================================================================
// Sub-components
// ============================================================================
interface TrendIndicatorProps {
trend: MetricTrend;
percentage: number;
invertColors?: boolean;
}
function TrendIndicator({
trend,
percentage,
invertColors = false,
}: TrendIndicatorProps): React.ReactElement {
const isPositive = trend === 'up';
const isNeutral = trend === 'stable';
const isGood = invertColors ? !isPositive : isPositive;
const colorClass = isNeutral
? 'text-gray-500'
: isGood
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400';
const Icon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : Minus;
return (
<div className={cn('flex items-center gap-1 text-sm font-medium', colorClass)}>
<Icon size={16} />
<span>
{trend !== 'stable' && (trend === 'up' ? '+' : '')}
{percentage.toFixed(1)}%
</span>
</div>
);
}
interface TargetProgressProps {
current: number;
target: number;
format: MetricFormat;
currency?: string;
}
function TargetProgress({
current,
target,
format,
currency,
}: TargetProgressProps): React.ReactElement {
const progress = Math.min((current / target) * 100, 100);
const isOnTrack = progress >= 80;
const isAhead = current >= target;
return (
<div className="mt-3">
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
<span>Objetivo: {formatMetricValue(target, format, { currency })}</span>
<span>{progress.toFixed(0)}%</span>
</div>
<div className="h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
isAhead
? 'bg-green-500'
: isOnTrack
? 'bg-blue-500'
: 'bg-yellow-500'
)}
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
}
// ============================================================================
// Main MetricCard Component
// ============================================================================
export function MetricCard({
title,
description,
metric,
period,
comparison,
format = 'number',
currency = 'MXN',
decimals,
thresholds,
status: statusOverride,
invertThresholds = false,
icon: Icon,
isLoading = false,
onClick,
detailsLink,
className,
}: MetricCardProps): React.ReactElement {
// Calculate derived values
const trend = useMemo(
() => calculateTrend(metric.current, metric.previous),
[metric.current, metric.previous]
);
const variationPercent = useMemo(() => {
if (metric.previous === undefined) return 0;
return calculateVariationPercent(metric.current, metric.previous);
}, [metric.current, metric.previous]);
const status = useMemo(() => {
if (statusOverride) return statusOverride;
return determineStatus(metric.current, thresholds, invertThresholds);
}, [metric.current, thresholds, invertThresholds, statusOverride]);
const formattedValue = useMemo(
() => formatMetricValue(metric.current, format, { currency, decimals }),
[metric.current, format, currency, decimals]
);
if (isLoading) {
return <SkeletonCard className={className} showHeader lines={4} />;
}
const isClickable = Boolean(onClick || detailsLink);
return (
<div
className={cn(
'rounded-xl border border-gray-200 bg-white p-5 shadow-sm transition-all',
'dark:border-gray-700 dark:bg-gray-800',
isClickable &&
'cursor-pointer hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600',
className
)}
onClick={onClick}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
{Icon && (
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-700">
<Icon size={20} className="text-gray-600 dark:text-gray-300" />
</div>
)}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
{title}
</h3>
{description && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{description}
</p>
)}
</div>
</div>
{status !== 'neutral' && (
<AlertBadge
severity={statusToSeverity(status)}
size="sm"
label={status === 'good' ? 'Bueno' : status === 'warning' ? 'Alerta' : 'Critico'}
/>
)}
</div>
{/* Main Value */}
<div className="mb-3">
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{formattedValue}
</p>
{period && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{period.label}
</p>
)}
</div>
{/* Trend & Comparison */}
<div className="flex items-center justify-between">
{metric.previous !== undefined && (
<TrendIndicator
trend={trend}
percentage={variationPercent}
invertColors={invertThresholds}
/>
)}
{comparison && (
<div className="text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium">
{formatMetricValue(comparison.value, format, { currency })}
</span>
<span className="ml-1">{comparison.label}</span>
</div>
)}
</div>
{/* Target Progress */}
{metric.target !== undefined && (
<TargetProgress
current={metric.current}
target={metric.target}
format={format}
currency={currency}
/>
)}
{/* Details Link */}
{detailsLink && (
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-gray-700">
<a
href={detailsLink}
className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
onClick={(e) => e.stopPropagation()}
>
Ver detalles
<ArrowRight size={14} />
</a>
</div>
)}
</div>
);
}
// ============================================================================
// Metric Card Grid Component
// ============================================================================
export interface MetricCardGridProps {
children: React.ReactNode;
columns?: 2 | 3 | 4;
className?: string;
}
export function MetricCardGrid({
children,
columns = 3,
className,
}: MetricCardGridProps): React.ReactElement {
const gridCols = {
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
};
return (
<div className={cn('grid gap-4', gridCols[columns], className)}>
{children}
</div>
);
}

View File

@@ -0,0 +1,673 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
Calendar,
ChevronDown,
ChevronLeft,
ChevronRight,
Check,
} from 'lucide-react';
import { cn } from '../utils/cn';
// ============================================================================
// Types
// ============================================================================
export type PeriodType = 'month' | 'quarter' | 'year' | 'custom';
export type ComparisonType =
| 'previous_period'
| 'previous_year'
| 'previous_quarter'
| 'budget'
| 'none';
export interface DateRange {
startDate: Date;
endDate: Date;
}
export interface PeriodValue {
type: PeriodType;
year: number;
month?: number; // 1-12
quarter?: number; // 1-4
customRange?: DateRange;
}
export interface PeriodSelectorProps {
/** Current selected period */
value: PeriodValue;
/** Period change handler */
onChange: (value: PeriodValue) => void;
/** Comparison type */
comparisonType?: ComparisonType;
/** Comparison type change handler */
onComparisonChange?: (type: ComparisonType) => void;
/** Available period types */
availablePeriodTypes?: PeriodType[];
/** Show comparison selector */
showComparison?: boolean;
/** Minimum selectable date */
minDate?: Date;
/** Maximum selectable date */
maxDate?: Date;
/** Locale for formatting */
locale?: string;
/** Additional CSS classes */
className?: string;
/** Compact mode */
compact?: boolean;
}
// ============================================================================
// Constants
// ============================================================================
const MONTHS_ES = [
'Enero',
'Febrero',
'Marzo',
'Abril',
'Mayo',
'Junio',
'Julio',
'Agosto',
'Septiembre',
'Octubre',
'Noviembre',
'Diciembre',
];
const QUARTERS_ES = ['Q1 (Ene-Mar)', 'Q2 (Abr-Jun)', 'Q3 (Jul-Sep)', 'Q4 (Oct-Dic)'];
const PERIOD_TYPE_LABELS: Record<PeriodType, string> = {
month: 'Mes',
quarter: 'Trimestre',
year: 'Anio',
custom: 'Personalizado',
};
const COMPARISON_LABELS: Record<ComparisonType, string> = {
previous_period: 'Periodo anterior',
previous_year: 'Mismo periodo anio anterior',
previous_quarter: 'Trimestre anterior',
budget: 'Presupuesto',
none: 'Sin comparacion',
};
// ============================================================================
// Helper Functions
// ============================================================================
function formatPeriodLabel(value: PeriodValue): string {
switch (value.type) {
case 'month':
return `${MONTHS_ES[(value.month ?? 1) - 1]} ${value.year}`;
case 'quarter':
return `Q${value.quarter} ${value.year}`;
case 'year':
return `${value.year}`;
case 'custom':
if (value.customRange) {
const start = value.customRange.startDate.toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
});
const end = value.customRange.endDate.toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
return `${start} - ${end}`;
}
return 'Seleccionar fechas';
default:
return '';
}
}
function getQuarterMonths(quarter: number): number[] {
const startMonth = (quarter - 1) * 3 + 1;
return [startMonth, startMonth + 1, startMonth + 2];
}
// ============================================================================
// Sub-Components
// ============================================================================
interface DropdownProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
}
function Dropdown({ isOpen, onClose, children, className }: DropdownProps): React.ReactElement | null {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
onClose();
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
ref={ref}
className={cn(
'absolute top-full left-0 z-50 mt-1 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800',
className
)}
>
{children}
</div>
);
}
interface MonthPickerProps {
year: number;
selectedMonth: number;
onSelect: (month: number, year: number) => void;
onYearChange: (year: number) => void;
minDate?: Date;
maxDate?: Date;
}
function MonthPicker({
year,
selectedMonth,
onSelect,
onYearChange,
minDate,
maxDate,
}: MonthPickerProps): React.ReactElement {
const isMonthDisabled = (month: number): boolean => {
const date = new Date(year, month - 1, 1);
if (minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), 1)) {
return true;
}
if (maxDate && date > new Date(maxDate.getFullYear(), maxDate.getMonth(), 1)) {
return true;
}
return false;
};
return (
<div className="p-3">
{/* Year navigation */}
<div className="flex items-center justify-between mb-3">
<button
onClick={() => onYearChange(year - 1)}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
>
<ChevronLeft size={18} />
</button>
<span className="font-semibold text-gray-900 dark:text-white">{year}</span>
<button
onClick={() => onYearChange(year + 1)}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
>
<ChevronRight size={18} />
</button>
</div>
{/* Month grid */}
<div className="grid grid-cols-3 gap-1">
{MONTHS_ES.map((month, index) => {
const monthNum = index + 1;
const isSelected = monthNum === selectedMonth;
const isDisabled = isMonthDisabled(monthNum);
return (
<button
key={month}
onClick={() => !isDisabled && onSelect(monthNum, year)}
disabled={isDisabled}
className={cn(
'px-2 py-2 text-sm rounded transition-colors',
isSelected
? 'bg-blue-500 text-white'
: isDisabled
? 'text-gray-300 cursor-not-allowed dark:text-gray-600'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
)}
>
{month.slice(0, 3)}
</button>
);
})}
</div>
</div>
);
}
interface QuarterPickerProps {
year: number;
selectedQuarter: number;
onSelect: (quarter: number, year: number) => void;
onYearChange: (year: number) => void;
}
function QuarterPicker({
year,
selectedQuarter,
onSelect,
onYearChange,
}: QuarterPickerProps): React.ReactElement {
return (
<div className="p-3">
{/* Year navigation */}
<div className="flex items-center justify-between mb-3">
<button
onClick={() => onYearChange(year - 1)}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
>
<ChevronLeft size={18} />
</button>
<span className="font-semibold text-gray-900 dark:text-white">{year}</span>
<button
onClick={() => onYearChange(year + 1)}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
>
<ChevronRight size={18} />
</button>
</div>
{/* Quarter buttons */}
<div className="space-y-1">
{QUARTERS_ES.map((quarter, index) => {
const quarterNum = index + 1;
const isSelected = quarterNum === selectedQuarter;
return (
<button
key={quarter}
onClick={() => onSelect(quarterNum, year)}
className={cn(
'w-full px-3 py-2 text-sm rounded text-left transition-colors',
isSelected
? 'bg-blue-500 text-white'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
)}
>
{quarter}
</button>
);
})}
</div>
</div>
);
}
interface YearPickerProps {
selectedYear: number;
onSelect: (year: number) => void;
minYear?: number;
maxYear?: number;
}
function YearPicker({
selectedYear,
onSelect,
minYear = 2020,
maxYear,
}: YearPickerProps): React.ReactElement {
const currentYear = new Date().getFullYear();
const max = maxYear ?? currentYear + 1;
const years = Array.from({ length: max - minYear + 1 }, (_, i) => max - i);
return (
<div className="p-3 max-h-64 overflow-y-auto">
<div className="space-y-1">
{years.map((year) => {
const isSelected = year === selectedYear;
return (
<button
key={year}
onClick={() => onSelect(year)}
className={cn(
'w-full px-3 py-2 text-sm rounded text-left transition-colors flex items-center justify-between',
isSelected
? 'bg-blue-500 text-white'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
)}
>
<span>{year}</span>
{isSelected && <Check size={16} />}
</button>
);
})}
</div>
</div>
);
}
interface DateRangePickerProps {
value?: DateRange;
onChange: (range: DateRange) => void;
}
function DateRangePicker({ value, onChange }: DateRangePickerProps): React.ReactElement {
const [startDate, setStartDate] = useState(
value?.startDate?.toISOString().split('T')[0] ?? ''
);
const [endDate, setEndDate] = useState(
value?.endDate?.toISOString().split('T')[0] ?? ''
);
const handleApply = () => {
if (startDate && endDate) {
onChange({
startDate: new Date(startDate),
endDate: new Date(endDate),
});
}
};
return (
<div className="p-3 space-y-3">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Fecha inicio
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Fecha fin
</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
</div>
<button
onClick={handleApply}
disabled={!startDate || !endDate}
className={cn(
'w-full rounded bg-blue-500 px-3 py-2 text-sm font-medium text-white transition-colors',
!startDate || !endDate
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-blue-600'
)}
>
Aplicar
</button>
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function PeriodSelector({
value,
onChange,
comparisonType = 'none',
onComparisonChange,
availablePeriodTypes = ['month', 'quarter', 'year'],
showComparison = true,
minDate,
maxDate,
className,
compact = false,
}: PeriodSelectorProps): React.ReactElement {
const [isPeriodOpen, setIsPeriodOpen] = useState(false);
const [isComparisonOpen, setIsComparisonOpen] = useState(false);
const [tempYear, setTempYear] = useState(value.year);
const handlePeriodTypeChange = useCallback(
(type: PeriodType) => {
const newValue: PeriodValue = { ...value, type };
if (type === 'month' && !value.month) {
newValue.month = new Date().getMonth() + 1;
}
if (type === 'quarter' && !value.quarter) {
newValue.quarter = Math.ceil((new Date().getMonth() + 1) / 3);
}
onChange(newValue);
},
[value, onChange]
);
const handleMonthSelect = useCallback(
(month: number, year: number) => {
onChange({ ...value, type: 'month', month, year });
setIsPeriodOpen(false);
},
[value, onChange]
);
const handleQuarterSelect = useCallback(
(quarter: number, year: number) => {
onChange({ ...value, type: 'quarter', quarter, year });
setIsPeriodOpen(false);
},
[value, onChange]
);
const handleYearSelect = useCallback(
(year: number) => {
onChange({ ...value, type: 'year', year });
setIsPeriodOpen(false);
},
[value, onChange]
);
const handleCustomRangeSelect = useCallback(
(range: DateRange) => {
onChange({ ...value, type: 'custom', customRange: range });
setIsPeriodOpen(false);
},
[value, onChange]
);
const handleComparisonSelect = useCallback(
(type: ComparisonType) => {
onComparisonChange?.(type);
setIsComparisonOpen(false);
},
[onComparisonChange]
);
return (
<div className={cn('flex flex-wrap items-center gap-2', className)}>
{/* Period Type Tabs */}
{availablePeriodTypes.length > 1 && (
<div className="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{availablePeriodTypes.map((type) => (
<button
key={type}
onClick={() => handlePeriodTypeChange(type)}
className={cn(
'px-3 py-1.5 text-sm font-medium transition-colors',
value.type === type
? 'bg-blue-500 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
)}
>
{PERIOD_TYPE_LABELS[type]}
</button>
))}
</div>
)}
{/* Period Selector */}
<div className="relative">
<button
onClick={() => setIsPeriodOpen(!isPeriodOpen)}
className={cn(
'flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700',
compact && 'px-2 py-1.5'
)}
>
<Calendar size={16} className="text-gray-400" />
<span>{formatPeriodLabel(value)}</span>
<ChevronDown size={14} className="text-gray-400" />
</button>
<Dropdown
isOpen={isPeriodOpen}
onClose={() => setIsPeriodOpen(false)}
className="min-w-[240px]"
>
{value.type === 'month' && (
<MonthPicker
year={tempYear}
selectedMonth={value.month ?? 1}
onSelect={handleMonthSelect}
onYearChange={setTempYear}
minDate={minDate}
maxDate={maxDate}
/>
)}
{value.type === 'quarter' && (
<QuarterPicker
year={tempYear}
selectedQuarter={value.quarter ?? 1}
onSelect={handleQuarterSelect}
onYearChange={setTempYear}
/>
)}
{value.type === 'year' && (
<YearPicker
selectedYear={value.year}
onSelect={handleYearSelect}
minYear={minDate?.getFullYear()}
maxYear={maxDate?.getFullYear()}
/>
)}
{value.type === 'custom' && (
<DateRangePicker
value={value.customRange}
onChange={handleCustomRangeSelect}
/>
)}
</Dropdown>
</div>
{/* Comparison Selector */}
{showComparison && onComparisonChange && (
<div className="relative">
<button
onClick={() => setIsComparisonOpen(!isComparisonOpen)}
className={cn(
'flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
compact && 'px-2 py-1.5',
comparisonType !== 'none' && 'border-blue-300 bg-blue-50 text-blue-600 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-400'
)}
>
<span>vs {COMPARISON_LABELS[comparisonType]}</span>
<ChevronDown size={14} />
</button>
<Dropdown
isOpen={isComparisonOpen}
onClose={() => setIsComparisonOpen(false)}
className="min-w-[200px]"
>
<div className="p-2 space-y-1">
{(Object.entries(COMPARISON_LABELS) as [ComparisonType, string][]).map(
([type, label]) => (
<button
key={type}
onClick={() => handleComparisonSelect(type)}
className={cn(
'w-full flex items-center justify-between px-3 py-2 text-sm rounded transition-colors',
comparisonType === type
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
)}
>
<span>{label}</span>
{comparisonType === type && <Check size={14} />}
</button>
)
)}
</div>
</Dropdown>
</div>
)}
</div>
);
}
// ============================================================================
// Quick Period Selector (Preset buttons)
// ============================================================================
export interface QuickPeriodSelectorProps {
onSelect: (value: PeriodValue) => void;
className?: string;
}
export function QuickPeriodSelector({
onSelect,
className,
}: QuickPeriodSelectorProps): React.ReactElement {
const now = new Date();
const currentMonth = now.getMonth() + 1;
const currentYear = now.getFullYear();
const currentQuarter = Math.ceil(currentMonth / 3);
const presets = [
{
label: 'Este mes',
value: { type: 'month' as const, year: currentYear, month: currentMonth },
},
{
label: 'Mes anterior',
value: {
type: 'month' as const,
year: currentMonth === 1 ? currentYear - 1 : currentYear,
month: currentMonth === 1 ? 12 : currentMonth - 1,
},
},
{
label: 'Este trimestre',
value: { type: 'quarter' as const, year: currentYear, quarter: currentQuarter },
},
{
label: 'Este anio',
value: { type: 'year' as const, year: currentYear },
},
{
label: 'Anio anterior',
value: { type: 'year' as const, year: currentYear - 1 },
},
];
return (
<div className={cn('flex flex-wrap gap-2', className)}>
{presets.map((preset) => (
<button
key={preset.label}
onClick={() => onSelect(preset.value)}
className="rounded-full border border-gray-200 bg-white px-3 py-1 text-sm text-gray-600 hover:bg-gray-50 hover:border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{preset.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,350 @@
import React from 'react';
import { cn } from '../utils/cn';
// ============================================================================
// Base Skeleton Component
// ============================================================================
interface SkeletonProps {
className?: string;
variant?: 'rectangular' | 'circular' | 'text';
width?: string | number;
height?: string | number;
animation?: 'pulse' | 'shimmer' | 'none';
}
export function Skeleton({
className,
variant = 'rectangular',
width,
height,
animation = 'pulse',
}: SkeletonProps): React.ReactElement {
const baseStyles = 'bg-gray-200 dark:bg-gray-700';
const variantStyles = {
rectangular: 'rounded-md',
circular: 'rounded-full',
text: 'rounded h-4 w-full',
};
const animationStyles = {
pulse: 'animate-pulse',
shimmer:
'relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/20 before:to-transparent',
none: '',
};
const style: React.CSSProperties = {};
if (width) style.width = typeof width === 'number' ? `${width}px` : width;
if (height) style.height = typeof height === 'number' ? `${height}px` : height;
return (
<div
className={cn(
baseStyles,
variantStyles[variant],
animationStyles[animation],
className
)}
style={style}
aria-hidden="true"
/>
);
}
// ============================================================================
// Skeleton Card
// ============================================================================
interface SkeletonCardProps {
className?: string;
showHeader?: boolean;
showFooter?: boolean;
lines?: number;
}
export function SkeletonCard({
className,
showHeader = true,
showFooter = false,
lines = 3,
}: SkeletonCardProps): React.ReactElement {
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800',
className
)}
>
{showHeader && (
<div className="mb-4 flex items-center gap-3">
<Skeleton variant="circular" width={40} height={40} />
<div className="flex-1 space-y-2">
<Skeleton height={16} width="60%" />
<Skeleton height={12} width="40%" />
</div>
</div>
)}
<div className="space-y-3">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
height={14}
width={i === lines - 1 ? '70%' : '100%'}
/>
))}
</div>
{showFooter && (
<div className="mt-4 flex gap-2">
<Skeleton height={32} width={80} />
<Skeleton height={32} width={80} />
</div>
)}
</div>
);
}
// ============================================================================
// Skeleton KPI Card
// ============================================================================
interface SkeletonKPICardProps {
className?: string;
showSparkline?: boolean;
}
export function SkeletonKPICard({
className,
showSparkline = false,
}: SkeletonKPICardProps): React.ReactElement {
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800',
className
)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<Skeleton height={14} width={100} className="mb-2" />
<Skeleton height={32} width={120} className="mb-3" />
<div className="flex items-center gap-2">
<Skeleton height={20} width={60} />
<Skeleton height={14} width={80} />
</div>
</div>
{showSparkline && (
<Skeleton height={40} width={80} className="ml-4" />
)}
</div>
</div>
);
}
// ============================================================================
// Skeleton Table
// ============================================================================
interface SkeletonTableProps {
className?: string;
rows?: number;
columns?: number;
}
export function SkeletonTable({
className,
rows = 5,
columns = 4,
}: SkeletonTableProps): React.ReactElement {
return (
<div
className={cn(
'overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700',
className
)}
>
{/* Header */}
<div className="flex gap-4 border-b border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800">
{Array.from({ length: columns }).map((_, i) => (
<Skeleton key={i} height={14} className="flex-1" />
))}
</div>
{/* Rows */}
{Array.from({ length: rows }).map((_, rowIndex) => (
<div
key={rowIndex}
className="flex gap-4 border-b border-gray-100 p-4 last:border-0 dark:border-gray-700"
>
{Array.from({ length: columns }).map((_, colIndex) => (
<Skeleton
key={colIndex}
height={14}
className="flex-1"
width={colIndex === 0 ? '80%' : undefined}
/>
))}
</div>
))}
</div>
);
}
// ============================================================================
// Skeleton Chart
// ============================================================================
interface SkeletonChartProps {
className?: string;
type?: 'line' | 'bar' | 'pie' | 'area';
height?: number;
}
export function SkeletonChart({
className,
type = 'line',
height = 300,
}: SkeletonChartProps): React.ReactElement {
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800',
className
)}
>
<div className="mb-4 flex items-center justify-between">
<Skeleton height={20} width={150} />
<Skeleton height={32} width={120} />
</div>
<div
className="flex items-end justify-around gap-2"
style={{ height }}
>
{type === 'pie' ? (
<div className="flex items-center justify-center flex-1">
<Skeleton variant="circular" width={200} height={200} />
</div>
) : type === 'bar' ? (
Array.from({ length: 8 }).map((_, i) => (
<Skeleton
key={i}
className="flex-1"
height={`${Math.random() * 60 + 40}%`}
/>
))
) : (
<div className="relative w-full h-full">
<Skeleton height="100%" className="opacity-30" />
<div className="absolute inset-0 flex items-center justify-center">
<Skeleton height={2} width="90%" />
</div>
</div>
)}
</div>
{/* Legend */}
<div className="mt-4 flex justify-center gap-6">
<div className="flex items-center gap-2">
<Skeleton variant="circular" width={12} height={12} />
<Skeleton height={12} width={60} />
</div>
<div className="flex items-center gap-2">
<Skeleton variant="circular" width={12} height={12} />
<Skeleton height={12} width={60} />
</div>
</div>
</div>
);
}
// ============================================================================
// Skeleton Text Block
// ============================================================================
interface SkeletonTextProps {
className?: string;
lines?: number;
lastLineWidth?: string;
}
export function SkeletonText({
className,
lines = 3,
lastLineWidth = '60%',
}: SkeletonTextProps): React.ReactElement {
return (
<div className={cn('space-y-2', className)}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
variant="text"
width={i === lines - 1 ? lastLineWidth : '100%'}
/>
))}
</div>
);
}
// ============================================================================
// Skeleton Avatar
// ============================================================================
interface SkeletonAvatarProps {
className?: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
export function SkeletonAvatar({
className,
size = 'md',
}: SkeletonAvatarProps): React.ReactElement {
const sizes = {
sm: 32,
md: 40,
lg: 48,
xl: 64,
};
return (
<Skeleton
variant="circular"
width={sizes[size]}
height={sizes[size]}
className={className}
/>
);
}
// ============================================================================
// Skeleton Button
// ============================================================================
interface SkeletonButtonProps {
className?: string;
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
}
export function SkeletonButton({
className,
size = 'md',
fullWidth = false,
}: SkeletonButtonProps): React.ReactElement {
const sizes = {
sm: { height: 32, width: 80 },
md: { height: 40, width: 100 },
lg: { height: 48, width: 120 },
};
return (
<Skeleton
height={sizes[size].height}
width={fullWidth ? '100%' : sizes[size].width}
className={cn('rounded-md', className)}
/>
);
}

View File

@@ -0,0 +1,508 @@
import React, { useMemo } from 'react';
import {
AreaChart as RechartsAreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
ReferenceLine,
type TooltipProps,
} from 'recharts';
import { cn } from '../../utils/cn';
import { SkeletonChart } from '../Skeleton';
// ============================================================================
// Types
// ============================================================================
export interface AreaConfig {
/** Data key to plot */
dataKey: string;
/** Display name for legend/tooltip */
name: string;
/** Area color */
color: string;
/** Gradient ID (auto-generated if not provided) */
gradientId?: string;
/** Fill opacity */
fillOpacity?: number;
/** Stroke width */
strokeWidth?: number;
/** Stack ID for stacked areas */
stackId?: string;
/** Area type */
type?: 'monotone' | 'linear' | 'step' | 'stepBefore' | 'stepAfter';
/** Whether area is hidden initially */
hidden?: boolean;
}
export interface AreaChartProps<T extends Record<string, unknown>> {
/** Chart data */
data: T[];
/** Area configurations */
areas: AreaConfig[];
/** Key for X-axis values */
xAxisKey: string;
/** Chart title */
title?: string;
/** Chart subtitle/description */
subtitle?: string;
/** Chart height */
height?: number;
/** Show grid lines */
showGrid?: boolean;
/** Show legend */
showLegend?: boolean;
/** Legend position */
legendPosition?: 'top' | 'bottom';
/** X-axis label */
xAxisLabel?: string;
/** Y-axis label */
yAxisLabel?: string;
/** Format function for X-axis ticks */
xAxisFormatter?: (value: string | number) => string;
/** Format function for Y-axis ticks */
yAxisFormatter?: (value: number) => string;
/** Format function for tooltip values */
tooltipFormatter?: (value: number, name: string) => string;
/** Y-axis domain */
yAxisDomain?: [number | 'auto' | 'dataMin' | 'dataMax', number | 'auto' | 'dataMin' | 'dataMax'];
/** Reference line at Y value */
referenceLineY?: number;
/** Reference line label */
referenceLineLabel?: string;
/** Gradient style */
gradientStyle?: 'solid' | 'fade' | 'none';
/** Loading state */
isLoading?: boolean;
/** Empty state message */
emptyMessage?: string;
/** Additional CSS classes */
className?: string;
}
// ============================================================================
// Default Colors
// ============================================================================
export const defaultAreaColors = [
'#3B82F6', // blue-500
'#10B981', // emerald-500
'#F59E0B', // amber-500
'#EF4444', // red-500
'#8B5CF6', // violet-500
'#EC4899', // pink-500
];
// ============================================================================
// Custom Tooltip
// ============================================================================
interface CustomTooltipProps extends TooltipProps<number, string> {
formatter?: (value: number, name: string) => string;
}
function CustomTooltip({
active,
payload,
label,
formatter,
}: CustomTooltipProps): React.ReactElement | null {
if (!active || !payload || payload.length === 0) {
return null;
}
return (
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<p className="mb-2 text-sm font-medium text-gray-900 dark:text-white">
{label}
</p>
<div className="space-y-1">
{payload.map((entry, index) => {
const value = entry.value as number;
const formattedValue = formatter
? formatter(value, entry.name ?? '')
: value.toLocaleString('es-MX');
return (
<div key={index} className="flex items-center gap-2">
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-sm text-gray-600 dark:text-gray-300">
{entry.name}:
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{formattedValue}
</span>
</div>
);
})}
</div>
</div>
);
}
// ============================================================================
// Custom Legend
// ============================================================================
interface CustomLegendProps {
payload?: Array<{ value: string; color: string }>;
}
function CustomLegend({ payload }: CustomLegendProps): React.ReactElement | null {
if (!payload) return null;
return (
<div className="flex flex-wrap items-center justify-center gap-4 pt-2">
{payload.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-sm text-gray-600 dark:text-gray-300">
{entry.value}
</span>
</div>
))}
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function AreaChart<T extends Record<string, unknown>>({
data,
areas,
xAxisKey,
title,
subtitle,
height = 300,
showGrid = true,
showLegend = true,
legendPosition = 'bottom',
xAxisLabel,
yAxisLabel,
xAxisFormatter,
yAxisFormatter,
tooltipFormatter,
yAxisDomain,
referenceLineY,
referenceLineLabel,
gradientStyle = 'fade',
isLoading = false,
emptyMessage = 'No hay datos disponibles',
className,
}: AreaChartProps<T>): React.ReactElement {
// Assign colors and gradient IDs to areas
const areasWithConfig = useMemo(() => {
return areas.map((area, index) => ({
...area,
color: area.color || defaultAreaColors[index % defaultAreaColors.length],
gradientId: area.gradientId || `gradient-${area.dataKey}-${index}`,
fillOpacity: area.fillOpacity ?? 0.3,
strokeWidth: area.strokeWidth ?? 2,
type: area.type || 'monotone',
}));
}, [areas]);
if (isLoading) {
return <SkeletonChart type="area" height={height} className={className} />;
}
const isEmpty = !data || data.length === 0;
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800',
className
)}
>
{/* Header */}
{(title || subtitle) && (
<div className="mb-4">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
)}
{subtitle && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{subtitle}
</p>
)}
</div>
)}
{/* Chart */}
{isEmpty ? (
<div
className="flex items-center justify-center text-gray-500 dark:text-gray-400"
style={{ height }}
>
{emptyMessage}
</div>
) : (
<ResponsiveContainer width="100%" height={height}>
<RechartsAreaChart
data={data}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
{/* Gradient Definitions */}
<defs>
{areasWithConfig.map((area) => (
<linearGradient
key={area.gradientId}
id={area.gradientId}
x1="0"
y1="0"
x2="0"
y2="1"
>
{gradientStyle === 'fade' ? (
<>
<stop
offset="5%"
stopColor={area.color}
stopOpacity={area.fillOpacity}
/>
<stop
offset="95%"
stopColor={area.color}
stopOpacity={0.05}
/>
</>
) : gradientStyle === 'solid' ? (
<stop
offset="0%"
stopColor={area.color}
stopOpacity={area.fillOpacity}
/>
) : (
<stop offset="0%" stopColor={area.color} stopOpacity={0} />
)}
</linearGradient>
))}
</defs>
{showGrid && (
<CartesianGrid
strokeDasharray="3 3"
stroke="#E5E7EB"
className="dark:stroke-gray-700"
/>
)}
<XAxis
dataKey={xAxisKey}
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#E5E7EB' }}
tickFormatter={xAxisFormatter}
label={
xAxisLabel
? {
value: xAxisLabel,
position: 'bottom',
offset: -5,
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
<YAxis
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={false}
tickFormatter={yAxisFormatter}
domain={yAxisDomain}
label={
yAxisLabel
? {
value: yAxisLabel,
angle: -90,
position: 'insideLeft',
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
<Tooltip
content={<CustomTooltip formatter={tooltipFormatter} />}
cursor={{ stroke: '#9CA3AF', strokeDasharray: '5 5' }}
/>
{showLegend && (
<Legend
verticalAlign={legendPosition}
content={<CustomLegend />}
/>
)}
{referenceLineY !== undefined && (
<ReferenceLine
y={referenceLineY}
stroke="#9CA3AF"
strokeDasharray="5 5"
label={
referenceLineLabel
? {
value: referenceLineLabel,
fill: '#6B7280',
fontSize: 12,
position: 'right',
}
: undefined
}
/>
)}
{areasWithConfig
.filter((area) => !area.hidden)
.map((area) => (
<Area
key={area.dataKey}
type={area.type}
dataKey={area.dataKey}
name={area.name}
stroke={area.color}
strokeWidth={area.strokeWidth}
fill={`url(#${area.gradientId})`}
stackId={area.stackId}
activeDot={{ r: 6, strokeWidth: 2 }}
/>
))}
</RechartsAreaChart>
</ResponsiveContainer>
)}
</div>
);
}
// ============================================================================
// Stacked Area Chart Variant
// ============================================================================
export interface StackedAreaChartProps<T extends Record<string, unknown>>
extends Omit<AreaChartProps<T>, 'areas'> {
areas: Omit<AreaConfig, 'stackId'>[];
}
export function StackedAreaChart<T extends Record<string, unknown>>({
areas,
...props
}: StackedAreaChartProps<T>): React.ReactElement {
const stackedAreas = useMemo(() => {
return areas.map((area) => ({
...area,
stackId: 'stack',
}));
}, [areas]);
return <AreaChart {...props} areas={stackedAreas} />;
}
// ============================================================================
// Cash Flow Chart (specialized for cumulative cash flow)
// ============================================================================
export interface CashFlowChartProps<T extends Record<string, unknown>> {
data: T[];
xAxisKey: string;
cashFlowKey: string;
cumulativeKey?: string;
title?: string;
subtitle?: string;
height?: number;
showCumulative?: boolean;
zeroLineLabel?: string;
currency?: string;
isLoading?: boolean;
className?: string;
}
export function CashFlowChart<T extends Record<string, unknown>>({
data,
xAxisKey,
cashFlowKey,
cumulativeKey = 'cumulative',
title = 'Flujo de Caja',
subtitle,
height = 300,
showCumulative = true,
zeroLineLabel = 'Punto de equilibrio',
currency = 'MXN',
isLoading = false,
className,
}: CashFlowChartProps<T>): React.ReactElement {
const areas: AreaConfig[] = useMemo(() => {
const config: AreaConfig[] = [
{
dataKey: cashFlowKey,
name: 'Flujo Mensual',
color: '#3B82F6',
type: 'monotone',
},
];
if (showCumulative && cumulativeKey) {
config.push({
dataKey: cumulativeKey,
name: 'Acumulado',
color: '#10B981',
type: 'monotone',
fillOpacity: 0.1,
});
}
return config;
}, [cashFlowKey, cumulativeKey, showCumulative]);
const currencyFormatter = (value: number): string => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
return (
<AreaChart
data={data}
areas={areas}
xAxisKey={xAxisKey}
title={title}
subtitle={subtitle}
height={height}
yAxisFormatter={(value) => {
if (Math.abs(value) >= 1000000) {
return `$${(value / 1000000).toFixed(1)}M`;
}
if (Math.abs(value) >= 1000) {
return `$${(value / 1000).toFixed(0)}K`;
}
return `$${value}`;
}}
tooltipFormatter={currencyFormatter}
referenceLineY={0}
referenceLineLabel={zeroLineLabel}
gradientStyle="fade"
isLoading={isLoading}
className={className}
/>
);
}

View File

@@ -0,0 +1,526 @@
import React, { useMemo } from 'react';
import {
BarChart as RechartsBarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Cell,
type TooltipProps,
} from 'recharts';
import { cn } from '../../utils/cn';
import { SkeletonChart } from '../Skeleton';
// ============================================================================
// Types
// ============================================================================
export interface BarConfig {
/** Data key to plot */
dataKey: string;
/** Display name for legend/tooltip */
name: string;
/** Bar color */
color: string;
/** Stacked group ID (for stacked bars) */
stackId?: string;
/** Bar radius */
radius?: number | [number, number, number, number];
}
export type BarLayout = 'horizontal' | 'vertical';
export interface BarChartProps<T extends Record<string, unknown>> {
/** Chart data */
data: T[];
/** Bar configurations */
bars: BarConfig[];
/** Key for category axis (X for vertical, Y for horizontal) */
categoryKey: string;
/** Chart layout */
layout?: BarLayout;
/** Chart title */
title?: string;
/** Chart subtitle/description */
subtitle?: string;
/** Chart height */
height?: number;
/** Show grid lines */
showGrid?: boolean;
/** Show legend */
showLegend?: boolean;
/** Legend position */
legendPosition?: 'top' | 'bottom';
/** Category axis label */
categoryAxisLabel?: string;
/** Value axis label */
valueAxisLabel?: string;
/** Format function for category axis ticks */
categoryAxisFormatter?: (value: string) => string;
/** Format function for value axis ticks */
valueAxisFormatter?: (value: number) => string;
/** Format function for tooltip values */
tooltipFormatter?: (value: number, name: string) => string;
/** Bar size (width for vertical, height for horizontal) */
barSize?: number;
/** Gap between bar groups */
barGap?: number;
/** Gap between bars in a group */
barCategoryGap?: string | number;
/** Enable bar labels */
showBarLabels?: boolean;
/** Loading state */
isLoading?: boolean;
/** Empty state message */
emptyMessage?: string;
/** Additional CSS classes */
className?: string;
/** Color each bar differently based on value */
colorByValue?: (value: number) => string;
}
// ============================================================================
// Default Colors
// ============================================================================
export const defaultBarColors = [
'#3B82F6', // blue-500
'#10B981', // emerald-500
'#F59E0B', // amber-500
'#EF4444', // red-500
'#8B5CF6', // violet-500
'#EC4899', // pink-500
'#06B6D4', // cyan-500
'#84CC16', // lime-500
];
// ============================================================================
// Custom Tooltip
// ============================================================================
interface CustomTooltipProps extends TooltipProps<number, string> {
formatter?: (value: number, name: string) => string;
}
function CustomTooltip({
active,
payload,
label,
formatter,
}: CustomTooltipProps): React.ReactElement | null {
if (!active || !payload || payload.length === 0) {
return null;
}
return (
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<p className="mb-2 text-sm font-medium text-gray-900 dark:text-white">
{label}
</p>
<div className="space-y-1">
{payload.map((entry, index) => {
const value = entry.value as number;
const formattedValue = formatter
? formatter(value, entry.name ?? '')
: value.toLocaleString('es-MX');
return (
<div key={index} className="flex items-center gap-2">
<span
className="h-3 w-3 rounded"
style={{ backgroundColor: entry.color }}
/>
<span className="text-sm text-gray-600 dark:text-gray-300">
{entry.name}:
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{formattedValue}
</span>
</div>
);
})}
</div>
</div>
);
}
// ============================================================================
// Custom Legend
// ============================================================================
interface CustomLegendProps {
payload?: Array<{ value: string; color: string }>;
}
function CustomLegend({ payload }: CustomLegendProps): React.ReactElement | null {
if (!payload) return null;
return (
<div className="flex flex-wrap items-center justify-center gap-4 pt-2">
{payload.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<span
className="h-3 w-3 rounded"
style={{ backgroundColor: entry.color }}
/>
<span className="text-sm text-gray-600 dark:text-gray-300">
{entry.value}
</span>
</div>
))}
</div>
);
}
// ============================================================================
// Custom Bar Label
// ============================================================================
interface CustomLabelProps {
x?: number;
y?: number;
width?: number;
height?: number;
value?: number;
formatter?: (value: number) => string;
}
function CustomBarLabel({
x = 0,
y = 0,
width = 0,
height = 0,
value,
formatter,
}: CustomLabelProps): React.ReactElement | null {
if (value === undefined) return null;
const formattedValue = formatter ? formatter(value) : value.toLocaleString('es-MX');
return (
<text
x={x + width / 2}
y={y + height / 2}
fill="#fff"
textAnchor="middle"
dominantBaseline="middle"
className="text-xs font-medium"
>
{formattedValue}
</text>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function BarChart<T extends Record<string, unknown>>({
data,
bars,
categoryKey,
layout = 'vertical',
title,
subtitle,
height = 300,
showGrid = true,
showLegend = true,
legendPosition = 'bottom',
categoryAxisLabel,
valueAxisLabel,
categoryAxisFormatter,
valueAxisFormatter,
tooltipFormatter,
barSize,
barGap = 4,
barCategoryGap = '20%',
showBarLabels = false,
isLoading = false,
emptyMessage = 'No hay datos disponibles',
className,
colorByValue,
}: BarChartProps<T>): React.ReactElement {
// Assign colors to bars if not provided
const barsWithColors = useMemo(() => {
return bars.map((bar, index) => ({
...bar,
color: bar.color || defaultBarColors[index % defaultBarColors.length],
radius: bar.radius ?? 4,
}));
}, [bars]);
if (isLoading) {
return <SkeletonChart type="bar" height={height} className={className} />;
}
const isEmpty = !data || data.length === 0;
const isHorizontal = layout === 'horizontal';
// For horizontal layout, we need more height per item
const adjustedHeight = isHorizontal ? Math.max(height, data.length * 40) : height;
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800',
className
)}
>
{/* Header */}
{(title || subtitle) && (
<div className="mb-4">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
)}
{subtitle && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{subtitle}
</p>
)}
</div>
)}
{/* Chart */}
{isEmpty ? (
<div
className="flex items-center justify-center text-gray-500 dark:text-gray-400"
style={{ height }}
>
{emptyMessage}
</div>
) : (
<ResponsiveContainer width="100%" height={adjustedHeight}>
<RechartsBarChart
data={data}
layout={isHorizontal ? 'vertical' : 'horizontal'}
margin={{ top: 10, right: 10, left: isHorizontal ? 80 : 0, bottom: 0 }}
barGap={barGap}
barCategoryGap={barCategoryGap}
>
{showGrid && (
<CartesianGrid
strokeDasharray="3 3"
stroke="#E5E7EB"
className="dark:stroke-gray-700"
horizontal={!isHorizontal}
vertical={isHorizontal}
/>
)}
{isHorizontal ? (
<>
<XAxis
type="number"
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#E5E7EB' }}
tickFormatter={valueAxisFormatter}
label={
valueAxisLabel
? {
value: valueAxisLabel,
position: 'bottom',
offset: -5,
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
<YAxis
type="category"
dataKey={categoryKey}
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={false}
tickFormatter={categoryAxisFormatter}
width={80}
label={
categoryAxisLabel
? {
value: categoryAxisLabel,
angle: -90,
position: 'insideLeft',
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
</>
) : (
<>
<XAxis
dataKey={categoryKey}
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#E5E7EB' }}
tickFormatter={categoryAxisFormatter}
label={
categoryAxisLabel
? {
value: categoryAxisLabel,
position: 'bottom',
offset: -5,
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
<YAxis
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={false}
tickFormatter={valueAxisFormatter}
label={
valueAxisLabel
? {
value: valueAxisLabel,
angle: -90,
position: 'insideLeft',
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
</>
)}
<Tooltip
content={<CustomTooltip formatter={tooltipFormatter} />}
cursor={{ fill: '#F3F4F6', opacity: 0.5 }}
/>
{showLegend && bars.length > 1 && (
<Legend
verticalAlign={legendPosition}
content={<CustomLegend />}
/>
)}
{barsWithColors.map((bar) => (
<Bar
key={bar.dataKey}
dataKey={bar.dataKey}
name={bar.name}
fill={bar.color}
stackId={bar.stackId}
barSize={barSize}
radius={bar.radius}
label={
showBarLabels
? (props: CustomLabelProps) => (
<CustomBarLabel {...props} formatter={valueAxisFormatter} />
)
: undefined
}
>
{colorByValue &&
data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={colorByValue(entry[bar.dataKey] as number)}
/>
))}
</Bar>
))}
</RechartsBarChart>
</ResponsiveContainer>
)}
</div>
);
}
// ============================================================================
// Stacked Bar Chart Variant
// ============================================================================
export interface StackedBarChartProps<T extends Record<string, unknown>>
extends Omit<BarChartProps<T>, 'bars'> {
/** Bar configurations with automatic stackId */
bars: Omit<BarConfig, 'stackId'>[];
}
export function StackedBarChart<T extends Record<string, unknown>>({
bars,
...props
}: StackedBarChartProps<T>): React.ReactElement {
const stackedBars = useMemo(() => {
return bars.map((bar) => ({
...bar,
stackId: 'stack',
}));
}, [bars]);
return <BarChart {...props} bars={stackedBars} />;
}
// ============================================================================
// Comparison Bar Chart (side by side bars for comparison)
// ============================================================================
export interface ComparisonBarChartProps<T extends Record<string, unknown>> {
data: T[];
categoryKey: string;
currentKey: string;
previousKey: string;
currentLabel?: string;
previousLabel?: string;
currentColor?: string;
previousColor?: string;
title?: string;
subtitle?: string;
height?: number;
layout?: BarLayout;
valueAxisFormatter?: (value: number) => string;
tooltipFormatter?: (value: number, name: string) => string;
isLoading?: boolean;
className?: string;
}
export function ComparisonBarChart<T extends Record<string, unknown>>({
data,
categoryKey,
currentKey,
previousKey,
currentLabel = 'Actual',
previousLabel = 'Anterior',
currentColor = '#3B82F6',
previousColor = '#9CA3AF',
title,
subtitle,
height = 300,
layout = 'vertical',
valueAxisFormatter,
tooltipFormatter,
isLoading = false,
className,
}: ComparisonBarChartProps<T>): React.ReactElement {
const bars: BarConfig[] = [
{ dataKey: previousKey, name: previousLabel, color: previousColor },
{ dataKey: currentKey, name: currentLabel, color: currentColor },
];
return (
<BarChart
data={data}
bars={bars}
categoryKey={categoryKey}
layout={layout}
title={title}
subtitle={subtitle}
height={height}
valueAxisFormatter={valueAxisFormatter}
tooltipFormatter={tooltipFormatter}
isLoading={isLoading}
className={className}
/>
);
}

View File

@@ -0,0 +1,478 @@
import React, { useMemo } from 'react';
import {
LineChart as RechartsLineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
type TooltipProps,
} from 'recharts';
import { cn } from '../../utils/cn';
import { SkeletonChart } from '../Skeleton';
// ============================================================================
// Types
// ============================================================================
export interface LineConfig {
/** Data key to plot */
dataKey: string;
/** Display name for legend/tooltip */
name: string;
/** Line color */
color: string;
/** Line type */
type?: 'monotone' | 'linear' | 'step' | 'stepBefore' | 'stepAfter';
/** Whether to show dots on data points */
showDots?: boolean;
/** Stroke width */
strokeWidth?: number;
/** Dash pattern for line */
strokeDasharray?: string;
/** Whether line is hidden initially */
hidden?: boolean;
}
export interface LineChartProps<T extends Record<string, unknown>> {
/** Chart data */
data: T[];
/** Line configurations */
lines: LineConfig[];
/** Key for X-axis values */
xAxisKey: string;
/** Chart title */
title?: string;
/** Chart subtitle/description */
subtitle?: string;
/** Chart height */
height?: number;
/** Show grid lines */
showGrid?: boolean;
/** Show legend */
showLegend?: boolean;
/** Legend position */
legendPosition?: 'top' | 'bottom';
/** X-axis label */
xAxisLabel?: string;
/** Y-axis label */
yAxisLabel?: string;
/** Format function for X-axis ticks */
xAxisFormatter?: (value: string | number) => string;
/** Format function for Y-axis ticks */
yAxisFormatter?: (value: number) => string;
/** Format function for tooltip values */
tooltipFormatter?: (value: number, name: string) => string;
/** Y-axis domain (auto by default) */
yAxisDomain?: [number | 'auto' | 'dataMin' | 'dataMax', number | 'auto' | 'dataMin' | 'dataMax'];
/** Loading state */
isLoading?: boolean;
/** Empty state message */
emptyMessage?: string;
/** Additional CSS classes */
className?: string;
}
// ============================================================================
// Default Colors
// ============================================================================
export const defaultLineColors = [
'#3B82F6', // blue-500
'#10B981', // emerald-500
'#F59E0B', // amber-500
'#EF4444', // red-500
'#8B5CF6', // violet-500
'#EC4899', // pink-500
'#06B6D4', // cyan-500
'#84CC16', // lime-500
];
// ============================================================================
// Custom Tooltip
// ============================================================================
interface CustomTooltipProps extends TooltipProps<number, string> {
formatter?: (value: number, name: string) => string;
}
function CustomTooltip({
active,
payload,
label,
formatter,
}: CustomTooltipProps): React.ReactElement | null {
if (!active || !payload || payload.length === 0) {
return null;
}
return (
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<p className="mb-2 text-sm font-medium text-gray-900 dark:text-white">
{label}
</p>
<div className="space-y-1">
{payload.map((entry, index) => {
const value = entry.value as number;
const formattedValue = formatter
? formatter(value, entry.name ?? '')
: value.toLocaleString('es-MX');
return (
<div key={index} className="flex items-center gap-2">
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-sm text-gray-600 dark:text-gray-300">
{entry.name}:
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{formattedValue}
</span>
</div>
);
})}
</div>
</div>
);
}
// ============================================================================
// Custom Legend
// ============================================================================
interface CustomLegendProps {
payload?: Array<{ value: string; color: string }>;
}
function CustomLegend({ payload }: CustomLegendProps): React.ReactElement | null {
if (!payload) return null;
return (
<div className="flex flex-wrap items-center justify-center gap-4 pt-2">
{payload.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-sm text-gray-600 dark:text-gray-300">
{entry.value}
</span>
</div>
))}
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function LineChart<T extends Record<string, unknown>>({
data,
lines,
xAxisKey,
title,
subtitle,
height = 300,
showGrid = true,
showLegend = true,
legendPosition = 'bottom',
xAxisLabel,
yAxisLabel,
xAxisFormatter,
yAxisFormatter,
tooltipFormatter,
yAxisDomain,
isLoading = false,
emptyMessage = 'No hay datos disponibles',
className,
}: LineChartProps<T>): React.ReactElement {
// Assign colors to lines if not provided
const linesWithColors = useMemo(() => {
return lines.map((line, index) => ({
...line,
color: line.color || defaultLineColors[index % defaultLineColors.length],
}));
}, [lines]);
if (isLoading) {
return <SkeletonChart type="line" height={height} className={className} />;
}
const isEmpty = !data || data.length === 0;
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800',
className
)}
>
{/* Header */}
{(title || subtitle) && (
<div className="mb-4">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
)}
{subtitle && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{subtitle}
</p>
)}
</div>
)}
{/* Chart */}
{isEmpty ? (
<div
className="flex items-center justify-center text-gray-500 dark:text-gray-400"
style={{ height }}
>
{emptyMessage}
</div>
) : (
<ResponsiveContainer width="100%" height={height}>
<RechartsLineChart
data={data}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
{showGrid && (
<CartesianGrid
strokeDasharray="3 3"
stroke="#E5E7EB"
className="dark:stroke-gray-700"
/>
)}
<XAxis
dataKey={xAxisKey}
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#E5E7EB' }}
tickFormatter={xAxisFormatter}
label={
xAxisLabel
? {
value: xAxisLabel,
position: 'bottom',
offset: -5,
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
<YAxis
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={false}
tickFormatter={yAxisFormatter}
domain={yAxisDomain}
label={
yAxisLabel
? {
value: yAxisLabel,
angle: -90,
position: 'insideLeft',
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
<Tooltip
content={<CustomTooltip formatter={tooltipFormatter} />}
cursor={{ stroke: '#9CA3AF', strokeDasharray: '5 5' }}
/>
{showLegend && (
<Legend
verticalAlign={legendPosition}
content={<CustomLegend />}
/>
)}
{linesWithColors
.filter((line) => !line.hidden)
.map((line) => (
<Line
key={line.dataKey}
type={line.type || 'monotone'}
dataKey={line.dataKey}
name={line.name}
stroke={line.color}
strokeWidth={line.strokeWidth || 2}
strokeDasharray={line.strokeDasharray}
dot={line.showDots !== false ? { r: 4, fill: line.color } : false}
activeDot={{ r: 6, strokeWidth: 2 }}
/>
))}
</RechartsLineChart>
</ResponsiveContainer>
)}
</div>
);
}
// ============================================================================
// Multi-Axis Line Chart
// ============================================================================
export interface DualAxisLineChartProps<T extends Record<string, unknown>> {
data: T[];
leftLines: LineConfig[];
rightLines: LineConfig[];
xAxisKey: string;
title?: string;
subtitle?: string;
height?: number;
leftAxisLabel?: string;
rightAxisLabel?: string;
leftAxisFormatter?: (value: number) => string;
rightAxisFormatter?: (value: number) => string;
tooltipFormatter?: (value: number, name: string) => string;
isLoading?: boolean;
className?: string;
}
export function DualAxisLineChart<T extends Record<string, unknown>>({
data,
leftLines,
rightLines,
xAxisKey,
title,
subtitle,
height = 300,
leftAxisLabel,
rightAxisLabel,
leftAxisFormatter,
rightAxisFormatter,
tooltipFormatter,
isLoading = false,
className,
}: DualAxisLineChartProps<T>): React.ReactElement {
if (isLoading) {
return <SkeletonChart type="line" height={height} className={className} />;
}
const allLines = [...leftLines, ...rightLines];
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800',
className
)}
>
{(title || subtitle) && (
<div className="mb-4">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
)}
{subtitle && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{subtitle}
</p>
)}
</div>
)}
<ResponsiveContainer width="100%" height={height}>
<RechartsLineChart
data={data}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E5E7EB" />
<XAxis
dataKey={xAxisKey}
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={{ stroke: '#E5E7EB' }}
/>
<YAxis
yAxisId="left"
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={false}
tickFormatter={leftAxisFormatter}
label={
leftAxisLabel
? {
value: leftAxisLabel,
angle: -90,
position: 'insideLeft',
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fill: '#6B7280', fontSize: 12 }}
tickLine={false}
axisLine={false}
tickFormatter={rightAxisFormatter}
label={
rightAxisLabel
? {
value: rightAxisLabel,
angle: 90,
position: 'insideRight',
fill: '#6B7280',
fontSize: 12,
}
: undefined
}
/>
<Tooltip content={<CustomTooltip formatter={tooltipFormatter} />} />
<Legend content={<CustomLegend />} />
{leftLines.map((line) => (
<Line
key={line.dataKey}
yAxisId="left"
type={line.type || 'monotone'}
dataKey={line.dataKey}
name={line.name}
stroke={line.color}
strokeWidth={line.strokeWidth || 2}
dot={line.showDots !== false ? { r: 4, fill: line.color } : false}
/>
))}
{rightLines.map((line) => (
<Line
key={line.dataKey}
yAxisId="right"
type={line.type || 'monotone'}
dataKey={line.dataKey}
name={line.name}
stroke={line.color}
strokeWidth={line.strokeWidth || 2}
strokeDasharray={line.strokeDasharray || '5 5'}
dot={line.showDots !== false ? { r: 4, fill: line.color } : false}
/>
))}
</RechartsLineChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,582 @@
import React, { useMemo, useState, useCallback } from 'react';
import {
PieChart as RechartsPieChart,
Pie,
Cell,
Tooltip,
Legend,
ResponsiveContainer,
Sector,
type TooltipProps,
type PieSectorDataItem,
} from 'recharts';
import { cn } from '../../utils/cn';
import { SkeletonChart } from '../Skeleton';
// ============================================================================
// Types
// ============================================================================
export interface PieDataItem {
/** Category name */
name: string;
/** Numeric value */
value: number;
/** Optional custom color */
color?: string;
}
export interface PieChartProps {
/** Chart data */
data: PieDataItem[];
/** Chart title */
title?: string;
/** Chart subtitle */
subtitle?: string;
/** Chart height */
height?: number;
/** Inner radius for donut chart (0 for pie, >0 for donut) */
innerRadius?: number | string;
/** Outer radius */
outerRadius?: number | string;
/** Padding angle between slices */
paddingAngle?: number;
/** Show legend */
showLegend?: boolean;
/** Legend position */
legendPosition?: 'right' | 'bottom';
/** Show labels on slices */
showLabels?: boolean;
/** Label type */
labelType?: 'name' | 'value' | 'percent' | 'name-percent';
/** Format function for values */
valueFormatter?: (value: number) => string;
/** Active slice on hover effect */
activeOnHover?: boolean;
/** Center label (for donut charts) */
centerLabel?: {
title: string;
value: string;
};
/** Loading state */
isLoading?: boolean;
/** Empty state message */
emptyMessage?: string;
/** Additional CSS classes */
className?: string;
/** Click handler for slices */
onSliceClick?: (data: PieDataItem, index: number) => void;
}
// ============================================================================
// Default Colors
// ============================================================================
export const defaultPieColors = [
'#3B82F6', // blue-500
'#10B981', // emerald-500
'#F59E0B', // amber-500
'#EF4444', // red-500
'#8B5CF6', // violet-500
'#EC4899', // pink-500
'#06B6D4', // cyan-500
'#84CC16', // lime-500
'#F97316', // orange-500
'#6366F1', // indigo-500
];
// ============================================================================
// Custom Tooltip
// ============================================================================
interface CustomTooltipProps extends TooltipProps<number, string> {
valueFormatter?: (value: number) => string;
total: number;
}
function CustomTooltip({
active,
payload,
valueFormatter,
total,
}: CustomTooltipProps): React.ReactElement | null {
if (!active || !payload || payload.length === 0) {
return null;
}
const data = payload[0];
const value = data.value as number;
const percentage = ((value / total) * 100).toFixed(1);
const formattedValue = valueFormatter
? valueFormatter(value)
: value.toLocaleString('es-MX');
return (
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center gap-2 mb-1">
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: data.payload.color || data.payload.fill }}
/>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{data.name}
</span>
</div>
<div className="space-y-0.5 text-sm">
<p className="text-gray-600 dark:text-gray-300">
Valor: <span className="font-medium">{formattedValue}</span>
</p>
<p className="text-gray-600 dark:text-gray-300">
Porcentaje: <span className="font-medium">{percentage}%</span>
</p>
</div>
</div>
);
}
// ============================================================================
// Custom Legend
// ============================================================================
interface CustomLegendProps {
payload?: Array<{
value: string;
color: string;
payload: { value: number };
}>;
total: number;
valueFormatter?: (value: number) => string;
layout: 'horizontal' | 'vertical';
}
function CustomLegend({
payload,
total,
valueFormatter,
layout,
}: CustomLegendProps): React.ReactElement | null {
if (!payload) return null;
const isVertical = layout === 'vertical';
return (
<div
className={cn(
'flex gap-3',
isVertical ? 'flex-col' : 'flex-wrap justify-center'
)}
>
{payload.map((entry, index) => {
const value = entry.payload.value;
const percentage = ((value / total) * 100).toFixed(1);
const formattedValue = valueFormatter
? valueFormatter(value)
: value.toLocaleString('es-MX');
return (
<div
key={index}
className={cn(
'flex items-center gap-2',
isVertical && 'justify-between min-w-[180px]'
)}
>
<div className="flex items-center gap-2">
<span
className="h-3 w-3 rounded-full flex-shrink-0"
style={{ backgroundColor: entry.color }}
/>
<span className="text-sm text-gray-600 dark:text-gray-300 truncate max-w-[120px]">
{entry.value}
</span>
</div>
{isVertical && (
<span className="text-sm font-medium text-gray-900 dark:text-white">
{formattedValue} ({percentage}%)
</span>
)}
</div>
);
})}
</div>
);
}
// ============================================================================
// Active Shape (for hover effect)
// ============================================================================
interface ActiveShapeProps extends PieSectorDataItem {
cx: number;
cy: number;
innerRadius: number;
outerRadius: number;
startAngle: number;
endAngle: number;
fill: string;
payload: { name: string };
percent: number;
value: number;
valueFormatter?: (value: number) => string;
}
function renderActiveShape(props: ActiveShapeProps): React.ReactElement {
const {
cx,
cy,
innerRadius,
outerRadius,
startAngle,
endAngle,
fill,
payload,
percent,
value,
valueFormatter,
} = props;
const formattedValue = valueFormatter
? valueFormatter(value)
: value.toLocaleString('es-MX');
return (
<g>
{/* Expanded active sector */}
<Sector
cx={cx}
cy={cy}
innerRadius={innerRadius}
outerRadius={outerRadius + 8}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
/>
{/* Inner sector for donut */}
{innerRadius > 0 && (
<Sector
cx={cx}
cy={cy}
innerRadius={innerRadius - 4}
outerRadius={innerRadius}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
opacity={0.3}
/>
)}
{/* Center text for donut */}
{innerRadius > 0 && (
<>
<text
x={cx}
y={cy - 10}
textAnchor="middle"
fill="#374151"
className="text-sm font-medium"
>
{payload.name}
</text>
<text
x={cx}
y={cy + 10}
textAnchor="middle"
fill="#6B7280"
className="text-xs"
>
{formattedValue}
</text>
<text
x={cx}
y={cy + 28}
textAnchor="middle"
fill="#9CA3AF"
className="text-xs"
>
{(percent * 100).toFixed(1)}%
</text>
</>
)}
</g>
);
}
// ============================================================================
// Custom Label
// ============================================================================
interface CustomLabelProps {
cx: number;
cy: number;
midAngle: number;
innerRadius: number;
outerRadius: number;
percent: number;
name: string;
value: number;
labelType: 'name' | 'value' | 'percent' | 'name-percent';
valueFormatter?: (value: number) => string;
}
function renderCustomLabel({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
percent,
name,
value,
labelType,
valueFormatter,
}: CustomLabelProps): React.ReactElement | null {
if (percent < 0.05) return null; // Don't show label for small slices
const RADIAN = Math.PI / 180;
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
let labelText: string;
switch (labelType) {
case 'name':
labelText = name;
break;
case 'value':
labelText = valueFormatter ? valueFormatter(value) : value.toLocaleString('es-MX');
break;
case 'percent':
labelText = `${(percent * 100).toFixed(0)}%`;
break;
case 'name-percent':
labelText = `${name} (${(percent * 100).toFixed(0)}%)`;
break;
default:
labelText = `${(percent * 100).toFixed(0)}%`;
}
return (
<text
x={x}
y={y}
fill="#fff"
textAnchor="middle"
dominantBaseline="middle"
className="text-xs font-medium"
style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}
>
{labelText}
</text>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function PieChart({
data,
title,
subtitle,
height = 300,
innerRadius = 0,
outerRadius = '80%',
paddingAngle = 2,
showLegend = true,
legendPosition = 'bottom',
showLabels = false,
labelType = 'percent',
valueFormatter,
activeOnHover = true,
centerLabel,
isLoading = false,
emptyMessage = 'No hay datos disponibles',
className,
onSliceClick,
}: PieChartProps): React.ReactElement {
const [activeIndex, setActiveIndex] = useState<number | undefined>(undefined);
// Calculate total
const total = useMemo(() => data.reduce((sum, item) => sum + item.value, 0), [data]);
// Assign colors to data
const dataWithColors = useMemo(() => {
return data.map((item, index) => ({
...item,
color: item.color || defaultPieColors[index % defaultPieColors.length],
}));
}, [data]);
const onPieEnter = useCallback((_: unknown, index: number) => {
if (activeOnHover) {
setActiveIndex(index);
}
}, [activeOnHover]);
const onPieLeave = useCallback(() => {
if (activeOnHover) {
setActiveIndex(undefined);
}
}, [activeOnHover]);
const handleClick = useCallback(
(data: PieDataItem, index: number) => {
if (onSliceClick) {
onSliceClick(data, index);
}
},
[onSliceClick]
);
if (isLoading) {
return <SkeletonChart type="pie" height={height} className={className} />;
}
const isEmpty = !data || data.length === 0 || total === 0;
const isHorizontalLegend = legendPosition === 'bottom';
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800',
className
)}
>
{/* Header */}
{(title || subtitle) && (
<div className="mb-4">
{title && (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
)}
{subtitle && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{subtitle}
</p>
)}
</div>
)}
{/* Chart */}
{isEmpty ? (
<div
className="flex items-center justify-center text-gray-500 dark:text-gray-400"
style={{ height }}
>
{emptyMessage}
</div>
) : (
<div
className={cn(
'flex',
isHorizontalLegend ? 'flex-col' : 'flex-row items-center gap-4'
)}
>
<div className={cn('relative', !isHorizontalLegend && 'flex-1')}>
<ResponsiveContainer width="100%" height={height}>
<RechartsPieChart>
<Pie
data={dataWithColors}
cx="50%"
cy="50%"
innerRadius={innerRadius}
outerRadius={outerRadius}
paddingAngle={paddingAngle}
dataKey="value"
nameKey="name"
activeIndex={activeIndex}
activeShape={
activeOnHover
? (props: ActiveShapeProps) =>
renderActiveShape({ ...props, valueFormatter })
: undefined
}
onMouseEnter={onPieEnter}
onMouseLeave={onPieLeave}
onClick={(data, index) => handleClick(data as PieDataItem, index)}
label={
showLabels && !activeOnHover
? (props: CustomLabelProps) =>
renderCustomLabel({ ...props, labelType, valueFormatter })
: undefined
}
labelLine={false}
style={{ cursor: onSliceClick ? 'pointer' : 'default' }}
>
{dataWithColors.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
content={
<CustomTooltip total={total} valueFormatter={valueFormatter} />
}
/>
{showLegend && isHorizontalLegend && (
<Legend
content={
<CustomLegend
total={total}
valueFormatter={valueFormatter}
layout="horizontal"
/>
}
verticalAlign="bottom"
/>
)}
</RechartsPieChart>
</ResponsiveContainer>
{/* Center Label for Donut */}
{centerLabel && innerRadius && !activeOnHover && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<p className="text-sm text-gray-500 dark:text-gray-400">
{centerLabel.title}
</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{centerLabel.value}
</p>
</div>
</div>
)}
</div>
{/* Vertical Legend */}
{showLegend && !isHorizontalLegend && (
<div className="flex-shrink-0">
<CustomLegend
payload={dataWithColors.map((d) => ({
value: d.name,
color: d.color!,
payload: { value: d.value },
}))}
total={total}
valueFormatter={valueFormatter}
layout="vertical"
/>
</div>
)}
</div>
)}
</div>
);
}
// ============================================================================
// Donut Chart Variant (convenience wrapper)
// ============================================================================
export interface DonutChartProps extends Omit<PieChartProps, 'innerRadius'> {
/** Inner radius percentage (default: 60%) */
innerRadiusPercent?: number;
}
export function DonutChart({
innerRadiusPercent = 60,
...props
}: DonutChartProps): React.ReactElement {
return <PieChart {...props} innerRadius={`${innerRadiusPercent}%`} />;
}

View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": false,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "../dist",
"rootDir": ".",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["./**/*.ts", "./**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,36 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Utility function to merge Tailwind CSS classes with clsx
* Handles conditional classes and merges conflicting Tailwind classes correctly
*
* @example
* cn('px-2 py-1', 'px-4') // => 'py-1 px-4'
* cn('text-red-500', isActive && 'text-blue-500') // => 'text-blue-500' when isActive is true
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* Utility to conditionally apply classes based on a boolean condition
*/
export function conditionalClass(
condition: boolean,
trueClass: string,
falseClass: string = ''
): string {
return condition ? trueClass : falseClass;
}
/**
* Utility to create variant-based class mappings
*/
export function variantClasses<T extends string>(
variant: T,
variants: Record<T, string>,
defaultClass: string = ''
): string {
return variants[variant] ?? defaultClass;
}