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:
31
packages/database/package.json
Normal file
31
packages/database/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
406
packages/database/src/connection.ts
Normal file
406
packages/database/src/connection.ts
Normal 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 };
|
||||
676
packages/database/src/index.ts
Normal file
676
packages/database/src/index.ts
Normal 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;
|
||||
}
|
||||
462
packages/database/src/migrate.ts
Normal file
462
packages/database/src/migrate.ts
Normal 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,
|
||||
};
|
||||
658
packages/database/src/migrations/001_public_schema.sql
Normal file
658
packages/database/src/migrations/001_public_schema.sql
Normal 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';
|
||||
889
packages/database/src/migrations/002_tenant_schema.sql
Normal file
889
packages/database/src/migrations/002_tenant_schema.sql
Normal 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';
|
||||
521
packages/database/src/seed.ts
Normal file
521
packages/database/src/seed.ts
Normal 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,
|
||||
};
|
||||
619
packages/database/src/tenant.ts
Normal file
619
packages/database/src/tenant.ts
Normal 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';
|
||||
}
|
||||
27
packages/database/tsconfig.json
Normal file
27
packages/database/tsconfig.json
Normal 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"]
|
||||
}
|
||||
23
packages/shared/package.json
Normal file
23
packages/shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
630
packages/shared/src/constants/index.ts
Normal file
630
packages/shared/src/constants/index.ts
Normal 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}$/,
|
||||
};
|
||||
362
packages/shared/src/schemas/auth.schema.ts
Normal file
362
packages/shared/src/schemas/auth.schema.ts
Normal 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>;
|
||||
730
packages/shared/src/schemas/financial.schema.ts
Normal file
730
packages/shared/src/schemas/financial.schema.ts
Normal 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>;
|
||||
12
packages/shared/src/schemas/index.ts
Normal file
12
packages/shared/src/schemas/index.ts
Normal 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';
|
||||
509
packages/shared/src/schemas/tenant.schema.ts
Normal file
509
packages/shared/src/schemas/tenant.schema.ts
Normal 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>;
|
||||
264
packages/shared/src/types/auth.ts
Normal file
264
packages/shared/src/types/auth.ts
Normal 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';
|
||||
634
packages/shared/src/types/financial.ts
Normal file
634
packages/shared/src/types/financial.ts
Normal 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;
|
||||
}
|
||||
305
packages/shared/src/types/index.ts
Normal file
305
packages/shared/src/types/index.ts
Normal 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>;
|
||||
}
|
||||
490
packages/shared/src/types/metrics.ts
Normal file
490
packages/shared/src/types/metrics.ts
Normal 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;
|
||||
}
|
||||
578
packages/shared/src/types/reports.ts
Normal file
578
packages/shared/src/types/reports.ts
Normal 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;
|
||||
}
|
||||
379
packages/shared/src/types/tenant.ts
Normal file
379
packages/shared/src/types/tenant.ts
Normal 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';
|
||||
658
packages/shared/src/utils/format.ts
Normal file
658
packages/shared/src/utils/format.ts
Normal 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
32
packages/ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
280
packages/ui/src/components/AlertBadge.tsx
Normal file
280
packages/ui/src/components/AlertBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
699
packages/ui/src/components/DataTable.tsx
Normal file
699
packages/ui/src/components/DataTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
400
packages/ui/src/components/KPICard.tsx
Normal file
400
packages/ui/src/components/KPICard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
425
packages/ui/src/components/MetricCard.tsx
Normal file
425
packages/ui/src/components/MetricCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
673
packages/ui/src/components/PeriodSelector.tsx
Normal file
673
packages/ui/src/components/PeriodSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
350
packages/ui/src/components/Skeleton.tsx
Normal file
350
packages/ui/src/components/Skeleton.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
508
packages/ui/src/components/charts/AreaChart.tsx
Normal file
508
packages/ui/src/components/charts/AreaChart.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
526
packages/ui/src/components/charts/BarChart.tsx
Normal file
526
packages/ui/src/components/charts/BarChart.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
478
packages/ui/src/components/charts/LineChart.tsx
Normal file
478
packages/ui/src/components/charts/LineChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
582
packages/ui/src/components/charts/PieChart.tsx
Normal file
582
packages/ui/src/components/charts/PieChart.tsx
Normal 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}%`} />;
|
||||
}
|
||||
32
packages/ui/src/tsconfig.json
Normal file
32
packages/ui/src/tsconfig.json
Normal 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"]
|
||||
}
|
||||
36
packages/ui/src/utils/cn.ts
Normal file
36
packages/ui/src/utils/cn.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user