feat: bulk XML upload, period selector, and session persistence
- Add bulk XML CFDI upload support (up to 300MB) - Add period selector component for month/year navigation - Fix session persistence on page refresh (Zustand hydration) - Fix income/expense classification based on tenant RFC - Fix IVA calculation from XML (correct Impuestos element) - Add error handling to reportes page - Support multiple CORS origins - Update reportes service with proper Decimal/BigInt handling - Add RFC to tenant view store for proper CFDI classification - Update README with changelog and new features Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
53
README.md
53
README.md
@@ -19,6 +19,7 @@ Horux360 es una aplicación SaaS que permite a las empresas mexicanas:
|
|||||||
- **Backend:** Node.js + Express + TypeScript
|
- **Backend:** Node.js + Express + TypeScript
|
||||||
- **Base de datos:** PostgreSQL (multi-tenant por schema)
|
- **Base de datos:** PostgreSQL (multi-tenant por schema)
|
||||||
- **Autenticación:** JWT personalizado
|
- **Autenticación:** JWT personalizado
|
||||||
|
- **Estado:** Zustand con persistencia
|
||||||
|
|
||||||
## Estructura del Proyecto
|
## Estructura del Proyecto
|
||||||
|
|
||||||
@@ -50,9 +51,32 @@ horux360/
|
|||||||
## Características Destacadas
|
## Características Destacadas
|
||||||
|
|
||||||
- **4 Temas visuales:** Light, Vibrant, Corporate, Dark
|
- **4 Temas visuales:** Light, Vibrant, Corporate, Dark
|
||||||
- **Multi-tenant:** Aislamiento de datos por empresa
|
- **Multi-tenant:** Aislamiento de datos por empresa (schema por tenant)
|
||||||
- **Responsive:** Funciona en desktop y móvil
|
- **Responsive:** Funciona en desktop y móvil
|
||||||
- **Tiempo real:** Dashboards actualizados al instante
|
- **Tiempo real:** Dashboards actualizados al instante
|
||||||
|
- **Carga masiva de XML:** Soporte para carga de hasta 300MB de archivos XML
|
||||||
|
- **Selector de período:** Navegación por mes/año en todos los dashboards
|
||||||
|
- **Clasificación automática:** Ingresos/egresos basado en RFC del tenant
|
||||||
|
|
||||||
|
## Configuración
|
||||||
|
|
||||||
|
### Variables de entorno (API)
|
||||||
|
|
||||||
|
```env
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=4000
|
||||||
|
DATABASE_URL="postgresql://user:pass@localhost:5432/horux360"
|
||||||
|
JWT_SECRET=your-secret-key
|
||||||
|
JWT_EXPIRES_IN=15m
|
||||||
|
JWT_REFRESH_EXPIRES_IN=7d
|
||||||
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variables de entorno (Web)
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:4000/api
|
||||||
|
```
|
||||||
|
|
||||||
## Demo
|
## Demo
|
||||||
|
|
||||||
@@ -61,6 +85,33 @@ Credenciales de demo:
|
|||||||
- **Contador:** contador@demo.com / demo123
|
- **Contador:** contador@demo.com / demo123
|
||||||
- **Visor:** visor@demo.com / demo123
|
- **Visor:** visor@demo.com / demo123
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### v0.4.0 (2026-01-22)
|
||||||
|
- Carga masiva de XML CFDI (hasta 300MB)
|
||||||
|
- Selector de período mes/año en dashboards
|
||||||
|
- Fix: Persistencia de sesión en refresh de página
|
||||||
|
- Fix: Clasificación ingreso/egreso basada en RFC
|
||||||
|
- Fix: Cálculo de IVA desde XML
|
||||||
|
- Mejoras en reportes con manejo de errores
|
||||||
|
- Soporte CORS para múltiples orígenes
|
||||||
|
|
||||||
|
### v0.3.0 (2026-01-22)
|
||||||
|
- Sistema multi-tenant con gestión de clientes
|
||||||
|
- Temas visuales (4 layouts diferentes)
|
||||||
|
|
||||||
|
### v0.2.0 (2026-01-22)
|
||||||
|
- Dashboard principal con KPIs
|
||||||
|
- Módulo de CFDI
|
||||||
|
- Control de IVA/ISR
|
||||||
|
- Calendario fiscal
|
||||||
|
- Sistema de alertas
|
||||||
|
|
||||||
|
### v0.1.0 (2026-01-22)
|
||||||
|
- Autenticación JWT
|
||||||
|
- Estructura multi-tenant
|
||||||
|
- Configuración inicial del proyecto
|
||||||
|
|
||||||
## Licencia
|
## Licencia
|
||||||
|
|
||||||
Propietario - Consultoría AS
|
Propietario - Consultoría AS
|
||||||
|
|||||||
@@ -18,11 +18,12 @@
|
|||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"zod": "^3.23.0",
|
"zod": "^3.23.0"
|
||||||
"exceljs": "^4.4.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { env } from './config/env.js';
|
import { env, getCorsOrigins } from './config/env.js';
|
||||||
import { errorMiddleware } from './middlewares/error.middleware.js';
|
import { errorMiddleware } from './middlewares/error.middleware.js';
|
||||||
import { authRoutes } from './routes/auth.routes.js';
|
import { authRoutes } from './routes/auth.routes.js';
|
||||||
import { dashboardRoutes } from './routes/dashboard.routes.js';
|
import { dashboardRoutes } from './routes/dashboard.routes.js';
|
||||||
@@ -19,13 +19,13 @@ const app = express();
|
|||||||
// Security
|
// Security
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: env.CORS_ORIGIN,
|
origin: getCorsOrigins(),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Body parsing
|
// Body parsing - increased limit for bulk XML uploads
|
||||||
app.use(express.json());
|
app.use(express.json({ limit: '300mb' }));
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true, limit: '300mb' }));
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
// Load .env file from the api package root
|
||||||
|
config({ path: resolve(process.cwd(), '.env') });
|
||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
@@ -10,6 +15,11 @@ const envSchema = z.object({
|
|||||||
CORS_ORIGIN: z.string().default('http://localhost:3000'),
|
CORS_ORIGIN: z.string().default('http://localhost:3000'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Parse CORS origins (comma-separated) into array
|
||||||
|
export function getCorsOrigins(): string[] {
|
||||||
|
return parsed.data.CORS_ORIGIN.split(',').map(origin => origin.trim());
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = envSchema.safeParse(process.env);
|
const parsed = envSchema.safeParse(process.env);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|||||||
@@ -60,3 +60,70 @@ export async function getResumen(req: Request, res: Response, next: NextFunction
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createCfdi(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.tenantSchema) {
|
||||||
|
return next(new AppError(400, 'Schema no configurado'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only admin and contador can create CFDIs
|
||||||
|
if (!['admin', 'contador'].includes(req.user!.role)) {
|
||||||
|
return next(new AppError(403, 'No tienes permisos para agregar CFDIs'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfdi = await cfdiService.createCfdi(req.tenantSchema, req.body);
|
||||||
|
res.status(201).json(cfdi);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message?.includes('duplicate')) {
|
||||||
|
return next(new AppError(409, 'Este CFDI ya existe (UUID duplicado)'));
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createManyCfdis(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.tenantSchema) {
|
||||||
|
return next(new AppError(400, 'Schema no configurado'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['admin', 'contador'].includes(req.user!.role)) {
|
||||||
|
return next(new AppError(403, 'No tienes permisos para agregar CFDIs'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(req.body.cfdis)) {
|
||||||
|
return next(new AppError(400, 'Se requiere un array de CFDIs'));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[CFDI Bulk] Recibidos ${req.body.cfdis.length} CFDIs para schema ${req.tenantSchema}`);
|
||||||
|
|
||||||
|
// Log first CFDI for debugging
|
||||||
|
if (req.body.cfdis.length > 0) {
|
||||||
|
console.log('[CFDI Bulk] Primer CFDI:', JSON.stringify(req.body.cfdis[0], null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await cfdiService.createManyCfdis(req.tenantSchema, req.body.cfdis);
|
||||||
|
res.status(201).json({ message: `${count} CFDIs creados exitosamente`, count });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[CFDI Bulk Error]', error.message, error.stack);
|
||||||
|
next(new AppError(400, error.message || 'Error al procesar CFDIs'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCfdi(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.tenantSchema) {
|
||||||
|
return next(new AppError(400, 'Schema no configurado'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['admin', 'contador'].includes(req.user!.role)) {
|
||||||
|
return next(new AppError(403, 'No tienes permisos para eliminar CFDIs'));
|
||||||
|
}
|
||||||
|
|
||||||
|
await cfdiService.deleteCfdi(req.tenantSchema, req.params.id);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ export async function getEstadoResultados(req: Request, res: Response, next: Nex
|
|||||||
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
|
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
|
||||||
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
console.log('[reportes] getEstadoResultados - schema:', req.tenantSchema, 'inicio:', inicio, 'fin:', fin);
|
||||||
const data = await reportesService.getEstadoResultados(req.tenantSchema!, inicio, fin);
|
const data = await reportesService.getEstadoResultados(req.tenantSchema!, inicio, fin);
|
||||||
res.json(data);
|
res.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('[reportes] Error en getEstadoResultados:', error);
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,3 +58,40 @@ export async function createTenant(req: Request, res: Response, next: NextFuncti
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateTenant(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (req.user!.role !== 'admin') {
|
||||||
|
throw new AppError(403, 'Solo administradores pueden editar clientes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const { nombre, rfc, plan, cfdiLimit, usersLimit, active } = req.body;
|
||||||
|
|
||||||
|
const tenant = await tenantsService.updateTenant(id, {
|
||||||
|
nombre,
|
||||||
|
rfc,
|
||||||
|
plan,
|
||||||
|
cfdiLimit,
|
||||||
|
usersLimit,
|
||||||
|
active,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(tenant);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTenant(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (req.user!.role !== 'admin') {
|
||||||
|
throw new AppError(403, 'Solo administradores pueden eliminar clientes');
|
||||||
|
}
|
||||||
|
|
||||||
|
await tenantsService.deleteTenant(req.params.id);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,5 +11,8 @@ router.use(tenantMiddleware);
|
|||||||
router.get('/', cfdiController.getCfdis);
|
router.get('/', cfdiController.getCfdis);
|
||||||
router.get('/resumen', cfdiController.getResumen);
|
router.get('/resumen', cfdiController.getResumen);
|
||||||
router.get('/:id', cfdiController.getCfdiById);
|
router.get('/:id', cfdiController.getCfdiById);
|
||||||
|
router.post('/', cfdiController.createCfdi);
|
||||||
|
router.post('/bulk', cfdiController.createManyCfdis);
|
||||||
|
router.delete('/:id', cfdiController.deleteCfdi);
|
||||||
|
|
||||||
export { router as cfdiRoutes };
|
export { router as cfdiRoutes };
|
||||||
|
|||||||
@@ -9,5 +9,7 @@ router.use(authenticate);
|
|||||||
router.get('/', tenantsController.getAllTenants);
|
router.get('/', tenantsController.getAllTenants);
|
||||||
router.get('/:id', tenantsController.getTenant);
|
router.get('/:id', tenantsController.getTenant);
|
||||||
router.post('/', tenantsController.createTenant);
|
router.post('/', tenantsController.createTenant);
|
||||||
|
router.put('/:id', tenantsController.updateTenant);
|
||||||
|
router.delete('/:id', tenantsController.deleteTenant);
|
||||||
|
|
||||||
export { router as tenantsRoutes };
|
export { router as tenantsRoutes };
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
|||||||
role: user.role,
|
role: user.role,
|
||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
tenantName: tenant.nombre,
|
tenantName: tenant.nombre,
|
||||||
|
tenantRfc: tenant.rfc,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -140,6 +141,7 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
|
|||||||
role: user.role,
|
role: user.role,
|
||||||
tenantId: user.tenantId,
|
tenantId: user.tenantId,
|
||||||
tenantName: user.tenant.nombre,
|
tenantName: user.tenant.nombre,
|
||||||
|
tenantRfc: user.tenant.rfc,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,147 @@ export async function getCfdiById(schema: string, id: string): Promise<Cfdi | nu
|
|||||||
return result[0] || null;
|
return result[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateCfdiData {
|
||||||
|
uuidFiscal: string;
|
||||||
|
tipo: 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago';
|
||||||
|
serie?: string;
|
||||||
|
folio?: string;
|
||||||
|
fechaEmision: string;
|
||||||
|
fechaTimbrado: string;
|
||||||
|
rfcEmisor: string;
|
||||||
|
nombreEmisor: string;
|
||||||
|
rfcReceptor: string;
|
||||||
|
nombreReceptor: string;
|
||||||
|
subtotal: number;
|
||||||
|
descuento?: number;
|
||||||
|
iva?: number;
|
||||||
|
isrRetenido?: number;
|
||||||
|
ivaRetenido?: number;
|
||||||
|
total: number;
|
||||||
|
moneda?: string;
|
||||||
|
tipoCambio?: number;
|
||||||
|
metodoPago?: string;
|
||||||
|
formaPago?: string;
|
||||||
|
usoCfdi?: string;
|
||||||
|
estado?: string;
|
||||||
|
xmlUrl?: string;
|
||||||
|
pdfUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCfdi(schema: string, data: CreateCfdiData): Promise<Cfdi> {
|
||||||
|
// Validate required fields
|
||||||
|
if (!data.uuidFiscal) throw new Error('UUID Fiscal es requerido');
|
||||||
|
if (!data.fechaEmision) throw new Error('Fecha de emisión es requerida');
|
||||||
|
if (!data.rfcEmisor) throw new Error('RFC Emisor es requerido');
|
||||||
|
if (!data.rfcReceptor) throw new Error('RFC Receptor es requerido');
|
||||||
|
|
||||||
|
// Parse dates safely - handle YYYY-MM-DD format explicitly
|
||||||
|
let fechaEmision: Date;
|
||||||
|
let fechaTimbrado: Date;
|
||||||
|
|
||||||
|
// If date is in YYYY-MM-DD format, add time to avoid timezone issues
|
||||||
|
const dateStr = typeof data.fechaEmision === 'string' && data.fechaEmision.match(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
? `${data.fechaEmision}T12:00:00`
|
||||||
|
: data.fechaEmision;
|
||||||
|
|
||||||
|
fechaEmision = new Date(dateStr);
|
||||||
|
|
||||||
|
if (isNaN(fechaEmision.getTime())) {
|
||||||
|
throw new Error(`Fecha de emisión inválida: ${data.fechaEmision}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timbradoStr = data.fechaTimbrado
|
||||||
|
? (typeof data.fechaTimbrado === 'string' && data.fechaTimbrado.match(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
? `${data.fechaTimbrado}T12:00:00`
|
||||||
|
: data.fechaTimbrado)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
fechaTimbrado = timbradoStr ? new Date(timbradoStr) : fechaEmision;
|
||||||
|
|
||||||
|
if (isNaN(fechaTimbrado.getTime())) {
|
||||||
|
throw new Error(`Fecha de timbrado inválida: ${data.fechaTimbrado}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
|
||||||
|
INSERT INTO "${schema}".cfdis (
|
||||||
|
uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
|
||||||
|
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||||
|
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
||||||
|
moneda, tipo_cambio, metodo_pago, forma_pago, uso_cfdi, estado, xml_url, pdf_url
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
|
||||||
|
RETURNING
|
||||||
|
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
|
||||||
|
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
|
||||||
|
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
|
||||||
|
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
|
||||||
|
subtotal, descuento, iva, isr_retenido as "isrRetenido",
|
||||||
|
iva_retenido as "ivaRetenido", total, moneda,
|
||||||
|
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
|
||||||
|
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
||||||
|
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
||||||
|
created_at as "createdAt"
|
||||||
|
`,
|
||||||
|
data.uuidFiscal,
|
||||||
|
data.tipo || 'ingreso',
|
||||||
|
data.serie || null,
|
||||||
|
data.folio || null,
|
||||||
|
fechaEmision,
|
||||||
|
fechaTimbrado,
|
||||||
|
data.rfcEmisor,
|
||||||
|
data.nombreEmisor || 'Sin nombre',
|
||||||
|
data.rfcReceptor,
|
||||||
|
data.nombreReceptor || 'Sin nombre',
|
||||||
|
data.subtotal || 0,
|
||||||
|
data.descuento || 0,
|
||||||
|
data.iva || 0,
|
||||||
|
data.isrRetenido || 0,
|
||||||
|
data.ivaRetenido || 0,
|
||||||
|
data.total || 0,
|
||||||
|
data.moneda || 'MXN',
|
||||||
|
data.tipoCambio || 1,
|
||||||
|
data.metodoPago || null,
|
||||||
|
data.formaPago || null,
|
||||||
|
data.usoCfdi || null,
|
||||||
|
data.estado || 'vigente',
|
||||||
|
data.xmlUrl || null,
|
||||||
|
data.pdfUrl || null
|
||||||
|
);
|
||||||
|
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createManyCfdis(schema: string, cfdis: CreateCfdiData[]): Promise<number> {
|
||||||
|
let count = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < cfdis.length; i++) {
|
||||||
|
const cfdi = cfdis[i];
|
||||||
|
try {
|
||||||
|
await createCfdi(schema, cfdi);
|
||||||
|
count++;
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMsg = error.message || 'Error desconocido';
|
||||||
|
// Skip duplicates (uuid_fiscal is unique)
|
||||||
|
if (errorMsg.includes('duplicate') || errorMsg.includes('unique')) {
|
||||||
|
console.log(`[CFDI ${i + 1}] Duplicado: ${cfdi.uuidFiscal}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.error(`[CFDI ${i + 1}] Error: ${errorMsg}`, { uuid: cfdi.uuidFiscal });
|
||||||
|
errors.push(`CFDI ${i + 1} (${cfdi.uuidFiscal?.substring(0, 8) || 'sin UUID'}): ${errorMsg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0 && count === 0) {
|
||||||
|
throw new Error(`No se pudo crear ningun CFDI. Errores: ${errors.slice(0, 3).join('; ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCfdi(schema: string, id: string): Promise<void> {
|
||||||
|
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".cfdis WHERE id = $1`, id);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getResumenCfdis(schema: string, año: number, mes: number) {
|
export async function getResumenCfdis(schema: string, año: number, mes: number) {
|
||||||
const result = await prisma.$queryRawUnsafe<[{
|
const result = await prisma.$queryRawUnsafe<[{
|
||||||
total_ingresos: number;
|
total_ingresos: number;
|
||||||
|
|||||||
@@ -1,48 +1,61 @@
|
|||||||
import { prisma } from '../config/database.js';
|
import { prisma } from '../config/database.js';
|
||||||
import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared';
|
import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared';
|
||||||
|
|
||||||
|
// Helper to convert Prisma Decimal/BigInt to number
|
||||||
|
function toNumber(value: unknown): number {
|
||||||
|
if (value === null || value === undefined) return 0;
|
||||||
|
if (typeof value === 'number') return value;
|
||||||
|
if (typeof value === 'bigint') return Number(value);
|
||||||
|
if (typeof value === 'string') return parseFloat(value) || 0;
|
||||||
|
if (typeof value === 'object' && value !== null && 'toNumber' in value) {
|
||||||
|
return (value as { toNumber: () => number }).toNumber();
|
||||||
|
}
|
||||||
|
return Number(value) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getEstadoResultados(
|
export async function getEstadoResultados(
|
||||||
schema: string,
|
schema: string,
|
||||||
fechaInicio: string,
|
fechaInicio: string,
|
||||||
fechaFin: string
|
fechaFin: string
|
||||||
): Promise<EstadoResultados> {
|
): Promise<EstadoResultados> {
|
||||||
const ingresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: number }[]>(`
|
const ingresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: unknown }[]>(`
|
||||||
SELECT rfc_receptor as rfc, nombre_receptor as nombre, SUM(subtotal) as total
|
SELECT rfc_receptor as rfc, nombre_receptor as nombre, SUM(subtotal) as total
|
||||||
FROM "${schema}".cfdis
|
FROM "${schema}".cfdis
|
||||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||||
AND fecha_emision BETWEEN $1 AND $2
|
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||||
GROUP BY rfc_receptor, nombre_receptor
|
GROUP BY rfc_receptor, nombre_receptor
|
||||||
ORDER BY total DESC LIMIT 10
|
ORDER BY total DESC LIMIT 10
|
||||||
`, fechaInicio, fechaFin);
|
`, fechaInicio, fechaFin);
|
||||||
|
|
||||||
const egresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: number }[]>(`
|
const egresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: unknown }[]>(`
|
||||||
SELECT rfc_emisor as rfc, nombre_emisor as nombre, SUM(subtotal) as total
|
SELECT rfc_emisor as rfc, nombre_emisor as nombre, SUM(subtotal) as total
|
||||||
FROM "${schema}".cfdis
|
FROM "${schema}".cfdis
|
||||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||||
AND fecha_emision BETWEEN $1 AND $2
|
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||||
GROUP BY rfc_emisor, nombre_emisor
|
GROUP BY rfc_emisor, nombre_emisor
|
||||||
ORDER BY total DESC LIMIT 10
|
ORDER BY total DESC LIMIT 10
|
||||||
`, fechaInicio, fechaFin);
|
`, fechaInicio, fechaFin);
|
||||||
|
|
||||||
const [totales] = await prisma.$queryRawUnsafe<[{ ingresos: number; egresos: number; iva: number }]>(`
|
const totalesResult = await prisma.$queryRawUnsafe<{ ingresos: unknown; egresos: unknown; iva: unknown }[]>(`
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN subtotal ELSE 0 END), 0) as ingresos,
|
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN subtotal ELSE 0 END), 0) as ingresos,
|
||||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN subtotal ELSE 0 END), 0) as egresos,
|
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN subtotal ELSE 0 END), 0) as egresos,
|
||||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) -
|
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) -
|
||||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva
|
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva
|
||||||
FROM "${schema}".cfdis
|
FROM "${schema}".cfdis
|
||||||
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1 AND $2
|
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1::date AND $2::date
|
||||||
`, fechaInicio, fechaFin);
|
`, fechaInicio, fechaFin);
|
||||||
|
|
||||||
const totalIngresos = Number(totales?.ingresos || 0);
|
const totales = totalesResult[0];
|
||||||
const totalEgresos = Number(totales?.egresos || 0);
|
const totalIngresos = toNumber(totales?.ingresos);
|
||||||
|
const totalEgresos = toNumber(totales?.egresos);
|
||||||
const utilidadBruta = totalIngresos - totalEgresos;
|
const utilidadBruta = totalIngresos - totalEgresos;
|
||||||
const impuestos = Number(totales?.iva || 0);
|
const impuestos = toNumber(totales?.iva);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
periodo: { inicio: fechaInicio, fin: fechaFin },
|
periodo: { inicio: fechaInicio, fin: fechaFin },
|
||||||
ingresos: ingresos.map(i => ({ concepto: i.nombre, monto: Number(i.total) })),
|
ingresos: ingresos.map(i => ({ concepto: i.nombre, monto: toNumber(i.total) })),
|
||||||
egresos: egresos.map(e => ({ concepto: e.nombre, monto: Number(e.total) })),
|
egresos: egresos.map(e => ({ concepto: e.nombre, monto: toNumber(e.total) })),
|
||||||
totalIngresos,
|
totalIngresos,
|
||||||
totalEgresos,
|
totalEgresos,
|
||||||
utilidadBruta,
|
utilidadBruta,
|
||||||
@@ -56,32 +69,32 @@ export async function getFlujoEfectivo(
|
|||||||
fechaInicio: string,
|
fechaInicio: string,
|
||||||
fechaFin: string
|
fechaFin: string
|
||||||
): Promise<FlujoEfectivo> {
|
): Promise<FlujoEfectivo> {
|
||||||
const entradas = await prisma.$queryRawUnsafe<{ mes: string; total: number }[]>(`
|
const entradas = await prisma.$queryRawUnsafe<{ mes: string; total: unknown }[]>(`
|
||||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
|
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
|
||||||
FROM "${schema}".cfdis
|
FROM "${schema}".cfdis
|
||||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||||
AND fecha_emision BETWEEN $1 AND $2
|
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||||
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
|
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
|
||||||
ORDER BY mes
|
ORDER BY mes
|
||||||
`, fechaInicio, fechaFin);
|
`, fechaInicio, fechaFin);
|
||||||
|
|
||||||
const salidas = await prisma.$queryRawUnsafe<{ mes: string; total: number }[]>(`
|
const salidas = await prisma.$queryRawUnsafe<{ mes: string; total: unknown }[]>(`
|
||||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
|
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
|
||||||
FROM "${schema}".cfdis
|
FROM "${schema}".cfdis
|
||||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||||
AND fecha_emision BETWEEN $1 AND $2
|
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||||
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
|
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
|
||||||
ORDER BY mes
|
ORDER BY mes
|
||||||
`, fechaInicio, fechaFin);
|
`, fechaInicio, fechaFin);
|
||||||
|
|
||||||
const totalEntradas = entradas.reduce((sum, e) => sum + Number(e.total), 0);
|
const totalEntradas = entradas.reduce((sum, e) => sum + toNumber(e.total), 0);
|
||||||
const totalSalidas = salidas.reduce((sum, s) => sum + Number(s.total), 0);
|
const totalSalidas = salidas.reduce((sum, s) => sum + toNumber(s.total), 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
periodo: { inicio: fechaInicio, fin: fechaFin },
|
periodo: { inicio: fechaInicio, fin: fechaFin },
|
||||||
saldoInicial: 0,
|
saldoInicial: 0,
|
||||||
entradas: entradas.map(e => ({ concepto: e.mes, monto: Number(e.total) })),
|
entradas: entradas.map(e => ({ concepto: e.mes, monto: toNumber(e.total) })),
|
||||||
salidas: salidas.map(s => ({ concepto: s.mes, monto: Number(s.total) })),
|
salidas: salidas.map(s => ({ concepto: s.mes, monto: toNumber(s.total) })),
|
||||||
totalEntradas,
|
totalEntradas,
|
||||||
totalSalidas,
|
totalSalidas,
|
||||||
flujoNeto: totalEntradas - totalSalidas,
|
flujoNeto: totalEntradas - totalSalidas,
|
||||||
@@ -93,7 +106,7 @@ export async function getComparativo(
|
|||||||
schema: string,
|
schema: string,
|
||||||
año: number
|
año: number
|
||||||
): Promise<ComparativoPeriodos> {
|
): Promise<ComparativoPeriodos> {
|
||||||
const actual = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(`
|
const actual = await prisma.$queryRawUnsafe<{ mes: number; ingresos: unknown; egresos: unknown }[]>(`
|
||||||
SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
|
SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
|
||||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
|
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
|
||||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
|
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
|
||||||
@@ -102,7 +115,7 @@ export async function getComparativo(
|
|||||||
GROUP BY mes ORDER BY mes
|
GROUP BY mes ORDER BY mes
|
||||||
`, año);
|
`, año);
|
||||||
|
|
||||||
const anterior = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(`
|
const anterior = await prisma.$queryRawUnsafe<{ mes: number; ingresos: unknown; egresos: unknown }[]>(`
|
||||||
SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
|
SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
|
||||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
|
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
|
||||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
|
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
|
||||||
@@ -112,14 +125,14 @@ export async function getComparativo(
|
|||||||
`, año - 1);
|
`, año - 1);
|
||||||
|
|
||||||
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
||||||
const ingresos = meses.map((_, i) => Number(actual.find(a => a.mes === i + 1)?.ingresos || 0));
|
const ingresos = meses.map((_, i) => toNumber(actual.find(a => a.mes === i + 1)?.ingresos));
|
||||||
const egresos = meses.map((_, i) => Number(actual.find(a => a.mes === i + 1)?.egresos || 0));
|
const egresos = meses.map((_, i) => toNumber(actual.find(a => a.mes === i + 1)?.egresos));
|
||||||
const utilidad = ingresos.map((ing, i) => ing - egresos[i]);
|
const utilidad = ingresos.map((ing, i) => ing - egresos[i]);
|
||||||
|
|
||||||
const totalActualIng = ingresos.reduce((a, b) => a + b, 0);
|
const totalActualIng = ingresos.reduce((a, b) => a + b, 0);
|
||||||
const totalAnteriorIng = anterior.reduce((a, b) => a + Number(b.ingresos), 0);
|
const totalAnteriorIng = anterior.reduce((a, b) => a + toNumber(b.ingresos), 0);
|
||||||
const totalActualEgr = egresos.reduce((a, b) => a + b, 0);
|
const totalActualEgr = egresos.reduce((a, b) => a + b, 0);
|
||||||
const totalAnteriorEgr = anterior.reduce((a, b) => a + Number(b.egresos), 0);
|
const totalAnteriorEgr = anterior.reduce((a, b) => a + toNumber(b.egresos), 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
periodos: meses,
|
periodos: meses,
|
||||||
@@ -139,7 +152,7 @@ export async function getConcentradoRfc(
|
|||||||
tipo: 'cliente' | 'proveedor'
|
tipo: 'cliente' | 'proveedor'
|
||||||
): Promise<ConcentradoRfc[]> {
|
): Promise<ConcentradoRfc[]> {
|
||||||
if (tipo === 'cliente') {
|
if (tipo === 'cliente') {
|
||||||
const data = await prisma.$queryRawUnsafe<ConcentradoRfc[]>(`
|
const data = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; tipo: string; totalFacturado: unknown; totalIva: unknown; cantidadCfdis: number }[]>(`
|
||||||
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
|
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
|
||||||
'cliente' as tipo,
|
'cliente' as tipo,
|
||||||
SUM(total) as "totalFacturado",
|
SUM(total) as "totalFacturado",
|
||||||
@@ -147,13 +160,20 @@ export async function getConcentradoRfc(
|
|||||||
COUNT(*)::int as "cantidadCfdis"
|
COUNT(*)::int as "cantidadCfdis"
|
||||||
FROM "${schema}".cfdis
|
FROM "${schema}".cfdis
|
||||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||||
AND fecha_emision BETWEEN $1 AND $2
|
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||||
GROUP BY rfc_receptor, nombre_receptor
|
GROUP BY rfc_receptor, nombre_receptor
|
||||||
ORDER BY "totalFacturado" DESC
|
ORDER BY "totalFacturado" DESC
|
||||||
`, fechaInicio, fechaFin);
|
`, fechaInicio, fechaFin);
|
||||||
return data.map(d => ({ ...d, totalFacturado: Number(d.totalFacturado), totalIva: Number(d.totalIva) }));
|
return data.map(d => ({
|
||||||
|
rfc: d.rfc,
|
||||||
|
nombre: d.nombre,
|
||||||
|
tipo: 'cliente' as const,
|
||||||
|
totalFacturado: toNumber(d.totalFacturado),
|
||||||
|
totalIva: toNumber(d.totalIva),
|
||||||
|
cantidadCfdis: d.cantidadCfdis
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
const data = await prisma.$queryRawUnsafe<ConcentradoRfc[]>(`
|
const data = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; tipo: string; totalFacturado: unknown; totalIva: unknown; cantidadCfdis: number }[]>(`
|
||||||
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
|
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
|
||||||
'proveedor' as tipo,
|
'proveedor' as tipo,
|
||||||
SUM(total) as "totalFacturado",
|
SUM(total) as "totalFacturado",
|
||||||
@@ -161,10 +181,17 @@ export async function getConcentradoRfc(
|
|||||||
COUNT(*)::int as "cantidadCfdis"
|
COUNT(*)::int as "cantidadCfdis"
|
||||||
FROM "${schema}".cfdis
|
FROM "${schema}".cfdis
|
||||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||||
AND fecha_emision BETWEEN $1 AND $2
|
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||||
GROUP BY rfc_emisor, nombre_emisor
|
GROUP BY rfc_emisor, nombre_emisor
|
||||||
ORDER BY "totalFacturado" DESC
|
ORDER BY "totalFacturado" DESC
|
||||||
`, fechaInicio, fechaFin);
|
`, fechaInicio, fechaFin);
|
||||||
return data.map(d => ({ ...d, totalFacturado: Number(d.totalFacturado), totalIva: Number(d.totalIva) }));
|
return data.map(d => ({
|
||||||
|
rfc: d.rfc,
|
||||||
|
nombre: d.nombre,
|
||||||
|
tipo: 'proveedor' as const,
|
||||||
|
totalFacturado: toNumber(d.totalFacturado),
|
||||||
|
totalIva: toNumber(d.totalIva),
|
||||||
|
cantidadCfdis: d.cantidadCfdis
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,3 +139,43 @@ export async function createTenant(data: {
|
|||||||
|
|
||||||
return tenant;
|
return tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateTenant(id: string, data: {
|
||||||
|
nombre?: string;
|
||||||
|
rfc?: string;
|
||||||
|
plan?: 'starter' | 'business' | 'professional' | 'enterprise';
|
||||||
|
cfdiLimit?: number;
|
||||||
|
usersLimit?: number;
|
||||||
|
active?: boolean;
|
||||||
|
}) {
|
||||||
|
return prisma.tenant.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(data.nombre && { nombre: data.nombre }),
|
||||||
|
...(data.rfc && { rfc: data.rfc.toUpperCase() }),
|
||||||
|
...(data.plan && { plan: data.plan }),
|
||||||
|
...(data.cfdiLimit !== undefined && { cfdiLimit: data.cfdiLimit }),
|
||||||
|
...(data.usersLimit !== undefined && { usersLimit: data.usersLimit }),
|
||||||
|
...(data.active !== undefined && { active: data.active }),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
nombre: true,
|
||||||
|
rfc: true,
|
||||||
|
plan: true,
|
||||||
|
schemaName: true,
|
||||||
|
cfdiLimit: true,
|
||||||
|
usersLimit: true,
|
||||||
|
active: true,
|
||||||
|
createdAt: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTenant(id: string) {
|
||||||
|
// Soft delete - just mark as inactive
|
||||||
|
return prisma.tenant.update({
|
||||||
|
where: { id },
|
||||||
|
data: { active: false }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,10 +45,7 @@ export default function AlertasPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardShell
|
<DashboardShell title="Alertas">
|
||||||
title="Alertas"
|
|
||||||
description="Gestiona tus alertas y notificaciones"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
|||||||
@@ -69,10 +69,7 @@ export default function CalendarioPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardShell
|
<DashboardShell title="Calendario Fiscal">
|
||||||
title="Calendario Fiscal"
|
|
||||||
description="Obligaciones fiscales y eventos importantes"
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
{/* Calendar */}
|
{/* Calendar */}
|
||||||
<Card className="lg:col-span-2">
|
<Card className="lg:col-span-2">
|
||||||
|
|||||||
@@ -1,22 +1,226 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { Header } from '@/components/layouts/header';
|
import { Header } from '@/components/layouts/header';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useCfdis } from '@/lib/hooks/use-cfdi';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { useCfdis, useCreateCfdi, useCreateManyCfdis, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
|
||||||
import type { CfdiFilters, TipoCfdi } from '@horux/shared';
|
import type { CfdiFilters, TipoCfdi } from '@horux/shared';
|
||||||
import { FileText, Search, ChevronLeft, ChevronRight } from 'lucide-react';
|
import type { CreateCfdiData } from '@/lib/api/cfdi';
|
||||||
|
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { useTenantViewStore } from '@/stores/tenant-view-store';
|
||||||
|
|
||||||
|
type CfdiTipo = 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago';
|
||||||
|
|
||||||
|
const initialFormData: CreateCfdiData = {
|
||||||
|
uuidFiscal: '',
|
||||||
|
tipo: 'ingreso',
|
||||||
|
serie: '',
|
||||||
|
folio: '',
|
||||||
|
fechaEmision: new Date().toISOString().split('T')[0],
|
||||||
|
fechaTimbrado: new Date().toISOString().split('T')[0],
|
||||||
|
rfcEmisor: '',
|
||||||
|
nombreEmisor: '',
|
||||||
|
rfcReceptor: '',
|
||||||
|
nombreReceptor: '',
|
||||||
|
subtotal: 0,
|
||||||
|
descuento: 0,
|
||||||
|
iva: 0,
|
||||||
|
isrRetenido: 0,
|
||||||
|
ivaRetenido: 0,
|
||||||
|
total: 0,
|
||||||
|
moneda: 'MXN',
|
||||||
|
metodoPago: 'PUE',
|
||||||
|
formaPago: '03',
|
||||||
|
usoCfdi: 'G03',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to find element regardless of namespace prefix
|
||||||
|
function findElement(doc: Document, localName: string): Element | null {
|
||||||
|
// Try common prefixes first (most reliable for CFDI)
|
||||||
|
const prefixes = ['cfdi', 'tfd', 'pago20', 'pago10', 'nomina12', ''];
|
||||||
|
for (const prefix of prefixes) {
|
||||||
|
const tagName = prefix ? `${prefix}:${localName}` : localName;
|
||||||
|
const el = doc.getElementsByTagName(tagName)[0] as Element;
|
||||||
|
if (el) return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try with wildcard - search all elements by localName
|
||||||
|
const elements = doc.getElementsByTagName('*');
|
||||||
|
for (let i = 0; i < elements.length; i++) {
|
||||||
|
if (elements[i].localName === localName) {
|
||||||
|
return elements[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse CFDI XML and extract data
|
||||||
|
function parseCfdiXml(xmlString: string, tenantRfc: string): CreateCfdiData | null {
|
||||||
|
try {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(xmlString, 'text/xml');
|
||||||
|
|
||||||
|
// Check for parse errors
|
||||||
|
const parseError = doc.querySelector('parsererror');
|
||||||
|
if (parseError) {
|
||||||
|
console.error('XML parse error:', parseError.textContent);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the Comprobante element (root)
|
||||||
|
const comprobante = findElement(doc, 'Comprobante');
|
||||||
|
if (!comprobante) {
|
||||||
|
console.error('No se encontro elemento Comprobante');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get TimbreFiscalDigital for UUID
|
||||||
|
const timbre = findElement(doc, 'TimbreFiscalDigital');
|
||||||
|
const uuid = timbre?.getAttribute('UUID') || '';
|
||||||
|
const fechaTimbradoRaw = timbre?.getAttribute('FechaTimbrado') || '';
|
||||||
|
|
||||||
|
// Get Emisor
|
||||||
|
const emisor = findElement(doc, 'Emisor');
|
||||||
|
const rfcEmisor = emisor?.getAttribute('Rfc') || emisor?.getAttribute('rfc') || '';
|
||||||
|
const nombreEmisor = emisor?.getAttribute('Nombre') || emisor?.getAttribute('nombre') || '';
|
||||||
|
|
||||||
|
// Get Receptor
|
||||||
|
const receptor = findElement(doc, 'Receptor');
|
||||||
|
const rfcReceptor = receptor?.getAttribute('Rfc') || receptor?.getAttribute('rfc') || '';
|
||||||
|
const nombreReceptor = receptor?.getAttribute('Nombre') || receptor?.getAttribute('nombre') || '';
|
||||||
|
const usoCfdi = receptor?.getAttribute('UsoCFDI') || '';
|
||||||
|
|
||||||
|
// Determine type based on tenant RFC
|
||||||
|
// If tenant is emisor -> ingreso (we issued the invoice)
|
||||||
|
// If tenant is receptor -> egreso (we received the invoice)
|
||||||
|
const tenantRfcUpper = tenantRfc.toUpperCase();
|
||||||
|
let tipoFinal: CreateCfdiData['tipo'];
|
||||||
|
if (rfcEmisor.toUpperCase() === tenantRfcUpper) {
|
||||||
|
tipoFinal = 'ingreso';
|
||||||
|
} else if (rfcReceptor.toUpperCase() === tenantRfcUpper) {
|
||||||
|
tipoFinal = 'egreso';
|
||||||
|
} else {
|
||||||
|
// Fallback: use TipoDeComprobante
|
||||||
|
const tipoComprobante = comprobante.getAttribute('TipoDeComprobante') || 'I';
|
||||||
|
tipoFinal = tipoComprobante === 'E' ? 'egreso' : 'ingreso';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get impuestos - search for the Impuestos element that is direct child of Comprobante
|
||||||
|
// (not the ones inside Conceptos)
|
||||||
|
let totalImpuestosTrasladados = 0;
|
||||||
|
let totalImpuestosRetenidos = 0;
|
||||||
|
|
||||||
|
// Try to get TotalImpuestosTrasladados from Comprobante's direct Impuestos child
|
||||||
|
const allImpuestos = doc.getElementsByTagName('*');
|
||||||
|
for (let i = 0; i < allImpuestos.length; i++) {
|
||||||
|
const el = allImpuestos[i];
|
||||||
|
if (el.localName === 'Impuestos' && el.parentElement?.localName === 'Comprobante') {
|
||||||
|
totalImpuestosTrasladados = parseFloat(el.getAttribute('TotalImpuestosTrasladados') || '0');
|
||||||
|
totalImpuestosRetenidos = parseFloat(el.getAttribute('TotalImpuestosRetenidos') || '0');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: calculate IVA from total - subtotal if not found
|
||||||
|
const subtotal = parseFloat(comprobante.getAttribute('SubTotal') || '0');
|
||||||
|
const descuento = parseFloat(comprobante.getAttribute('Descuento') || '0');
|
||||||
|
const total = parseFloat(comprobante.getAttribute('Total') || '0');
|
||||||
|
|
||||||
|
if (totalImpuestosTrasladados === 0 && total > subtotal) {
|
||||||
|
totalImpuestosTrasladados = Math.max(0, total - subtotal + descuento + totalImpuestosRetenidos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retenciones breakdown
|
||||||
|
let isrRetenido = 0;
|
||||||
|
let ivaRetenido = 0;
|
||||||
|
const retenciones = doc.querySelectorAll('[localName="Retencion"], Retencion, cfdi\\:Retencion');
|
||||||
|
retenciones.forEach((ret: Element) => {
|
||||||
|
const impuesto = ret.getAttribute('Impuesto');
|
||||||
|
const importe = parseFloat(ret.getAttribute('Importe') || '0');
|
||||||
|
if (impuesto === '001') isrRetenido = importe; // ISR
|
||||||
|
if (impuesto === '002') ivaRetenido = importe; // IVA
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse dates - handle both ISO format and datetime format
|
||||||
|
const fechaEmisionRaw = comprobante.getAttribute('Fecha') || '';
|
||||||
|
const fechaEmision = fechaEmisionRaw.includes('T') ? fechaEmisionRaw.split('T')[0] : fechaEmisionRaw;
|
||||||
|
const fechaTimbrado = fechaTimbradoRaw.includes('T') ? fechaTimbradoRaw.split('T')[0] : fechaTimbradoRaw;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!uuid) {
|
||||||
|
console.error('UUID no encontrado en el XML');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!rfcEmisor || !rfcReceptor) {
|
||||||
|
console.error('RFC emisor o receptor no encontrado');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!fechaEmision) {
|
||||||
|
console.error('Fecha de emision no encontrada');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuidFiscal: uuid.toUpperCase(),
|
||||||
|
tipo: tipoFinal,
|
||||||
|
serie: comprobante.getAttribute('Serie') || '',
|
||||||
|
folio: comprobante.getAttribute('Folio') || '',
|
||||||
|
fechaEmision,
|
||||||
|
fechaTimbrado: fechaTimbrado || fechaEmision,
|
||||||
|
rfcEmisor,
|
||||||
|
nombreEmisor: nombreEmisor || 'Sin nombre',
|
||||||
|
rfcReceptor,
|
||||||
|
nombreReceptor: nombreReceptor || 'Sin nombre',
|
||||||
|
subtotal,
|
||||||
|
descuento,
|
||||||
|
iva: totalImpuestosTrasladados,
|
||||||
|
isrRetenido,
|
||||||
|
ivaRetenido,
|
||||||
|
total,
|
||||||
|
moneda: comprobante.getAttribute('Moneda') || 'MXN',
|
||||||
|
tipoCambio: parseFloat(comprobante.getAttribute('TipoCambio') || '1'),
|
||||||
|
metodoPago: comprobante.getAttribute('MetodoPago') || '',
|
||||||
|
formaPago: comprobante.getAttribute('FormaPago') || '',
|
||||||
|
usoCfdi,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing XML:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function CfdiPage() {
|
export default function CfdiPage() {
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const { viewingTenantRfc } = useTenantViewStore();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Get the effective tenant RFC (viewing tenant or user's tenant)
|
||||||
|
const tenantRfc = viewingTenantRfc || user?.tenantRfc || '';
|
||||||
const [filters, setFilters] = useState<CfdiFilters>({
|
const [filters, setFilters] = useState<CfdiFilters>({
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
});
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [showBulkForm, setShowBulkForm] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<CreateCfdiData>(initialFormData);
|
||||||
|
const [bulkData, setBulkData] = useState('');
|
||||||
|
const [xmlFiles, setXmlFiles] = useState<File[]>([]);
|
||||||
|
const [parsedXmls, setParsedXmls] = useState<{ file: string; data: CreateCfdiData | null; error?: string }[]>([]);
|
||||||
|
const [uploadMode, setUploadMode] = useState<'xml' | 'json'>('xml');
|
||||||
|
|
||||||
const { data, isLoading } = useCfdis(filters);
|
const { data, isLoading } = useCfdis(filters);
|
||||||
|
const createCfdi = useCreateCfdi();
|
||||||
|
const createManyCfdis = useCreateManyCfdis();
|
||||||
|
const deleteCfdi = useDeleteCfdi();
|
||||||
|
|
||||||
|
const canEdit = user?.role === 'admin' || user?.role === 'contador';
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
setFilters({ ...filters, search: searchTerm, page: 1 });
|
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||||
@@ -26,6 +230,112 @@ export default function CfdiPage() {
|
|||||||
setFilters({ ...filters, tipo, page: 1 });
|
setFilters({ ...filters, tipo, page: 1 });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await createCfdi.mutateAsync(formData);
|
||||||
|
setFormData(initialFormData);
|
||||||
|
setShowForm(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error.response?.data?.message || 'Error al crear CFDI');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const cfdis = JSON.parse(bulkData);
|
||||||
|
if (!Array.isArray(cfdis)) {
|
||||||
|
throw new Error('El formato debe ser un array de CFDIs');
|
||||||
|
}
|
||||||
|
const result = await createManyCfdis.mutateAsync(cfdis);
|
||||||
|
alert(`Se crearon ${result.count} CFDIs exitosamente`);
|
||||||
|
setBulkData('');
|
||||||
|
setShowBulkForm(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error.message || 'Error al procesar CFDIs');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleXmlFilesChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
setXmlFiles(files);
|
||||||
|
|
||||||
|
// Parse each XML file
|
||||||
|
const parsed = await Promise.all(
|
||||||
|
files.map(async (file) => {
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const data = parseCfdiXml(text, tenantRfc);
|
||||||
|
if (!data) {
|
||||||
|
return { file: file.name, data: null, error: 'No se pudo parsear el XML' };
|
||||||
|
}
|
||||||
|
if (!data.uuidFiscal) {
|
||||||
|
return { file: file.name, data: null, error: 'UUID no encontrado en el XML' };
|
||||||
|
}
|
||||||
|
return { file: file.name, data };
|
||||||
|
} catch (error) {
|
||||||
|
return { file: file.name, data: null, error: 'Error al leer el archivo' };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setParsedXmls(parsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleXmlBulkSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const validCfdis = parsedXmls
|
||||||
|
.filter((p) => p.data !== null)
|
||||||
|
.map((p) => p.data as CreateCfdiData);
|
||||||
|
|
||||||
|
if (validCfdis.length === 0) {
|
||||||
|
alert('No hay CFDIs validos para cargar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await createManyCfdis.mutateAsync(validCfdis);
|
||||||
|
alert(`Se crearon ${result.count} CFDIs exitosamente`);
|
||||||
|
setXmlFiles([]);
|
||||||
|
setParsedXmls([]);
|
||||||
|
setShowBulkForm(false);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error.response?.data?.message || 'Error al cargar CFDIs');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearXmlFiles = () => {
|
||||||
|
setXmlFiles([]);
|
||||||
|
setParsedXmls([]);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (confirm('¿Eliminar este CFDI?')) {
|
||||||
|
try {
|
||||||
|
await deleteCfdi.mutateAsync(id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting CFDI:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTotal = () => {
|
||||||
|
const subtotal = formData.subtotal || 0;
|
||||||
|
const descuento = formData.descuento || 0;
|
||||||
|
const iva = formData.iva || 0;
|
||||||
|
const isrRetenido = formData.isrRetenido || 0;
|
||||||
|
const ivaRetenido = formData.ivaRetenido || 0;
|
||||||
|
return subtotal - descuento + iva - isrRetenido - ivaRetenido;
|
||||||
|
};
|
||||||
|
|
||||||
const formatCurrency = (value: number) =>
|
const formatCurrency = (value: number) =>
|
||||||
new Intl.NumberFormat('es-MX', {
|
new Intl.NumberFormat('es-MX', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -39,6 +349,14 @@ export default function CfdiPage() {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const generateUUID = () => {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
}).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Gestion de CFDI" />
|
<Header title="Gestion de CFDI" />
|
||||||
@@ -81,10 +399,378 @@ export default function CfdiPage() {
|
|||||||
Egresos
|
Egresos
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={() => { setShowForm(true); setShowBulkForm(false); }}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Agregar
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => { setShowBulkForm(true); setShowForm(false); }}>
|
||||||
|
<Upload className="h-4 w-4 mr-1" />
|
||||||
|
Carga Masiva
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Add CFDI Form */}
|
||||||
|
{showForm && canEdit && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">Agregar CFDI</CardTitle>
|
||||||
|
<CardDescription>Ingresa los datos del comprobante fiscal</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setShowForm(false)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>UUID Fiscal</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={formData.uuidFiscal}
|
||||||
|
onChange={(e) => setFormData({ ...formData, uuidFiscal: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setFormData({ ...formData, uuidFiscal: generateUUID() })}>
|
||||||
|
Gen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Tipo</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.tipo}
|
||||||
|
onValueChange={(v) => setFormData({ ...formData, tipo: v as CfdiTipo })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ingreso">Ingreso</SelectItem>
|
||||||
|
<SelectItem value="egreso">Egreso</SelectItem>
|
||||||
|
<SelectItem value="traslado">Traslado</SelectItem>
|
||||||
|
<SelectItem value="nomina">Nomina</SelectItem>
|
||||||
|
<SelectItem value="pago">Pago</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Serie</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.serie}
|
||||||
|
onChange={(e) => setFormData({ ...formData, serie: e.target.value })}
|
||||||
|
placeholder="A"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Folio</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.folio}
|
||||||
|
onChange={(e) => setFormData({ ...formData, folio: e.target.value })}
|
||||||
|
placeholder="001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Fecha Emision</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formData.fechaEmision}
|
||||||
|
onChange={(e) => setFormData({ ...formData, fechaEmision: e.target.value, fechaTimbrado: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Fecha Timbrado</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formData.fechaTimbrado}
|
||||||
|
onChange={(e) => setFormData({ ...formData, fechaTimbrado: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="p-4 border rounded-lg space-y-3">
|
||||||
|
<h4 className="font-medium">Emisor</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>RFC Emisor</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.rfcEmisor}
|
||||||
|
onChange={(e) => setFormData({ ...formData, rfcEmisor: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="XAXX010101000"
|
||||||
|
maxLength={13}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Nombre Emisor</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.nombreEmisor}
|
||||||
|
onChange={(e) => setFormData({ ...formData, nombreEmisor: e.target.value })}
|
||||||
|
placeholder="Empresa Emisora SA de CV"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border rounded-lg space-y-3">
|
||||||
|
<h4 className="font-medium">Receptor</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>RFC Receptor</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.rfcReceptor}
|
||||||
|
onChange={(e) => setFormData({ ...formData, rfcReceptor: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="XAXX010101000"
|
||||||
|
maxLength={13}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Nombre Receptor</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.nombreReceptor}
|
||||||
|
onChange={(e) => setFormData({ ...formData, nombreReceptor: e.target.value })}
|
||||||
|
placeholder="Empresa Receptora SA de CV"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Subtotal</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={formData.subtotal}
|
||||||
|
onChange={(e) => setFormData({ ...formData, subtotal: parseFloat(e.target.value) || 0 })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Descuento</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={formData.descuento}
|
||||||
|
onChange={(e) => setFormData({ ...formData, descuento: parseFloat(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>IVA</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={formData.iva}
|
||||||
|
onChange={(e) => setFormData({ ...formData, iva: parseFloat(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>ISR Ret.</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={formData.isrRetenido}
|
||||||
|
onChange={(e) => setFormData({ ...formData, isrRetenido: parseFloat(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>IVA Ret.</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={formData.ivaRetenido}
|
||||||
|
onChange={(e) => setFormData({ ...formData, ivaRetenido: parseFloat(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Total</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={formData.total || calculateTotal()}
|
||||||
|
onChange={(e) => setFormData({ ...formData, total: parseFloat(e.target.value) || 0 })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowForm(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={createCfdi.isPending}>
|
||||||
|
{createCfdi.isPending ? 'Guardando...' : 'Guardar CFDI'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bulk Upload Form */}
|
||||||
|
{showBulkForm && canEdit && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">Carga Masiva de CFDIs</CardTitle>
|
||||||
|
<CardDescription>Sube archivos XML o pega datos en formato JSON</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => { setShowBulkForm(false); clearXmlFiles(); }}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* Mode selector */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={uploadMode === 'xml' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setUploadMode('xml')}
|
||||||
|
>
|
||||||
|
<FileUp className="h-4 w-4 mr-1" />
|
||||||
|
Subir XMLs
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={uploadMode === 'json' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setUploadMode('json')}
|
||||||
|
>
|
||||||
|
JSON
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uploadMode === 'xml' ? (
|
||||||
|
<form onSubmit={handleXmlBulkSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Archivos XML de CFDI</Label>
|
||||||
|
<div className="border-2 border-dashed rounded-lg p-6 text-center">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xml"
|
||||||
|
multiple
|
||||||
|
onChange={handleXmlFilesChange}
|
||||||
|
className="hidden"
|
||||||
|
id="xml-upload"
|
||||||
|
/>
|
||||||
|
<label htmlFor="xml-upload" className="cursor-pointer">
|
||||||
|
<FileUp className="h-10 w-10 mx-auto text-muted-foreground mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Haz clic para seleccionar archivos XML o arrastralos aqui
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Puedes seleccionar multiples archivos
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show parsed results */}
|
||||||
|
{parsedXmls.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Archivos procesados ({parsedXmls.filter(p => p.data).length} validos de {parsedXmls.length})</Label>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={clearXmlFiles}>
|
||||||
|
Limpiar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-48 overflow-y-auto border rounded-lg divide-y">
|
||||||
|
{parsedXmls.map((parsed, idx) => (
|
||||||
|
<div key={idx} className="p-2 flex items-center gap-2 text-sm">
|
||||||
|
{parsed.data ? (
|
||||||
|
<CheckCircle className="h-4 w-4 text-success flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{parsed.file}</p>
|
||||||
|
{parsed.data ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{parsed.data.tipo === 'ingreso' ? 'Ingreso' : 'Egreso'} - {parsed.data.nombreEmisor?.substring(0, 30)}... - ${parsed.data.total?.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-destructive">{parsed.error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button type="button" variant="outline" onClick={() => { setShowBulkForm(false); clearXmlFiles(); }}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={createManyCfdis.isPending || parsedXmls.filter(p => p.data).length === 0}
|
||||||
|
>
|
||||||
|
{createManyCfdis.isPending ? 'Procesando...' : `Cargar ${parsedXmls.filter(p => p.data).length} CFDIs`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleBulkSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Datos JSON</Label>
|
||||||
|
<textarea
|
||||||
|
className="w-full h-48 p-3 border rounded-lg font-mono text-sm bg-background"
|
||||||
|
value={bulkData}
|
||||||
|
onChange={(e) => setBulkData(e.target.value)}
|
||||||
|
placeholder={`[
|
||||||
|
{
|
||||||
|
"uuidFiscal": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
|
||||||
|
"tipo": "ingreso",
|
||||||
|
"fechaEmision": "2025-01-15",
|
||||||
|
"fechaTimbrado": "2025-01-15",
|
||||||
|
"rfcEmisor": "XAXX010101000",
|
||||||
|
"nombreEmisor": "Empresa SA de CV",
|
||||||
|
"rfcReceptor": "XAXX010101001",
|
||||||
|
"nombreReceptor": "Cliente SA de CV",
|
||||||
|
"subtotal": 10000,
|
||||||
|
"iva": 1600,
|
||||||
|
"total": 11600
|
||||||
|
}
|
||||||
|
]`}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowBulkForm(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={createManyCfdis.isPending}>
|
||||||
|
{createManyCfdis.isPending ? 'Procesando...' : 'Cargar CFDIs'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -109,10 +795,12 @@ export default function CfdiPage() {
|
|||||||
<tr className="border-b text-left text-sm text-muted-foreground">
|
<tr className="border-b text-left text-sm text-muted-foreground">
|
||||||
<th className="pb-3 font-medium">Fecha</th>
|
<th className="pb-3 font-medium">Fecha</th>
|
||||||
<th className="pb-3 font-medium">Tipo</th>
|
<th className="pb-3 font-medium">Tipo</th>
|
||||||
<th className="pb-3 font-medium">Serie/Folio</th>
|
<th className="pb-3 font-medium">Folio</th>
|
||||||
<th className="pb-3 font-medium">Emisor/Receptor</th>
|
<th className="pb-3 font-medium">Emisor</th>
|
||||||
|
<th className="pb-3 font-medium">Receptor</th>
|
||||||
<th className="pb-3 font-medium text-right">Total</th>
|
<th className="pb-3 font-medium text-right">Total</th>
|
||||||
<th className="pb-3 font-medium">Estado</th>
|
<th className="pb-3 font-medium">Estado</th>
|
||||||
|
{canEdit && <th className="pb-3 font-medium"></th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="text-sm">
|
<tbody className="text-sm">
|
||||||
@@ -131,19 +819,25 @@ export default function CfdiPage() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3">
|
<td className="py-3">
|
||||||
{cfdi.serie || '-'}-{cfdi.folio || '-'}
|
{cfdi.folio || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3">
|
<td className="py-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">
|
<p className="font-medium truncate max-w-[180px]" title={cfdi.nombreEmisor}>
|
||||||
{cfdi.tipo === 'ingreso'
|
{cfdi.nombreEmisor}
|
||||||
? cfdi.nombreReceptor
|
|
||||||
: cfdi.nombreEmisor}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{cfdi.tipo === 'ingreso'
|
{cfdi.rfcEmisor}
|
||||||
? cfdi.rfcReceptor
|
</p>
|
||||||
: cfdi.rfcEmisor}
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium truncate max-w-[180px]" title={cfdi.nombreReceptor}>
|
||||||
|
{cfdi.nombreReceptor}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{cfdi.rfcReceptor}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -161,6 +855,18 @@ export default function CfdiPage() {
|
|||||||
{cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado'}
|
{cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
{canEdit && (
|
||||||
|
<td className="py-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(cfdi.id)}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,28 +1,42 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Header } from '@/components/layouts/header';
|
import { Header } from '@/components/layouts/header';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { useTenants, useCreateTenant } from '@/lib/hooks/use-tenants';
|
import { useTenants, useCreateTenant, useUpdateTenant, useDeleteTenant } from '@/lib/hooks/use-tenants';
|
||||||
import { useTenantViewStore } from '@/stores/tenant-view-store';
|
import { useTenantViewStore } from '@/stores/tenant-view-store';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { Building, Plus, Users, Eye, Calendar } from 'lucide-react';
|
import { Building, Plus, Users, Eye, Calendar, Pencil, Trash2, X } from 'lucide-react';
|
||||||
|
import type { Tenant } from '@/lib/api/tenants';
|
||||||
|
|
||||||
|
type PlanType = 'starter' | 'business' | 'professional' | 'enterprise';
|
||||||
|
|
||||||
export default function ClientesPage() {
|
export default function ClientesPage() {
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const { data: tenants, isLoading } = useTenants();
|
const { data: tenants, isLoading } = useTenants();
|
||||||
const createTenant = useCreateTenant();
|
const createTenant = useCreateTenant();
|
||||||
|
const updateTenant = useUpdateTenant();
|
||||||
|
const deleteTenant = useDeleteTenant();
|
||||||
const { setViewingTenant } = useTenantViewStore();
|
const { setViewingTenant } = useTenantViewStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
|
||||||
|
const [formData, setFormData] = useState<{
|
||||||
|
nombre: string;
|
||||||
|
rfc: string;
|
||||||
|
plan: PlanType;
|
||||||
|
}>({
|
||||||
nombre: '',
|
nombre: '',
|
||||||
rfc: '',
|
rfc: '',
|
||||||
plan: 'starter' as const,
|
plan: 'starter',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only admins can access this page
|
// Only admins can access this page
|
||||||
@@ -47,17 +61,49 @@ export default function ClientesPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createTenant.mutateAsync(formData);
|
if (editingTenant) {
|
||||||
|
await updateTenant.mutateAsync({ id: editingTenant.id, data: formData });
|
||||||
|
setEditingTenant(null);
|
||||||
|
} else {
|
||||||
|
await createTenant.mutateAsync(formData);
|
||||||
|
}
|
||||||
setFormData({ nombre: '', rfc: '', plan: 'starter' });
|
setFormData({ nombre: '', rfc: '', plan: 'starter' });
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating tenant:', error);
|
console.error('Error:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEdit = (tenant: Tenant) => {
|
||||||
|
setEditingTenant(tenant);
|
||||||
|
setFormData({
|
||||||
|
nombre: tenant.nombre,
|
||||||
|
rfc: tenant.rfc,
|
||||||
|
plan: tenant.plan as PlanType,
|
||||||
|
});
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (tenant: Tenant) => {
|
||||||
|
if (confirm(`¿Eliminar el cliente "${tenant.nombre}"? Esta acción desactivará el cliente.`)) {
|
||||||
|
try {
|
||||||
|
await deleteTenant.mutateAsync(tenant.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting tenant:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelForm = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingTenant(null);
|
||||||
|
setFormData({ nombre: '', rfc: '', plan: 'starter' });
|
||||||
|
};
|
||||||
|
|
||||||
const handleViewClient = (tenantId: string, tenantName: string) => {
|
const handleViewClient = (tenantId: string, tenantName: string) => {
|
||||||
setViewingTenant(tenantId, tenantName);
|
setViewingTenant(tenantId, tenantName);
|
||||||
window.location.href = '/dashboard';
|
queryClient.invalidateQueries();
|
||||||
|
router.push('/dashboard');
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
@@ -126,14 +172,25 @@ export default function ClientesPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Client Form */}
|
{/* Add/Edit Client Form */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Nuevo Cliente</CardTitle>
|
<div className="flex items-center justify-between">
|
||||||
<CardDescription>
|
<div>
|
||||||
Registra un nuevo cliente para gestionar su facturación
|
<CardTitle className="text-base">
|
||||||
</CardDescription>
|
{editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{editingTenant
|
||||||
|
? 'Modifica los datos del cliente'
|
||||||
|
: 'Registra un nuevo cliente para gestionar su facturación'}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" onClick={handleCancelForm}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
@@ -157,6 +214,7 @@ export default function ClientesPage() {
|
|||||||
placeholder="XAXX010101000"
|
placeholder="XAXX010101000"
|
||||||
maxLength={13}
|
maxLength={13}
|
||||||
required
|
required
|
||||||
|
disabled={!!editingTenant} // Can't change RFC after creation
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,8 +222,8 @@ export default function ClientesPage() {
|
|||||||
<Label htmlFor="plan">Plan</Label>
|
<Label htmlFor="plan">Plan</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.plan}
|
value={formData.plan}
|
||||||
onValueChange={(value: 'starter' | 'business' | 'professional' | 'enterprise') =>
|
onValueChange={(value) =>
|
||||||
setFormData({ ...formData, plan: value })
|
setFormData({ ...formData, plan: value as PlanType })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -180,11 +238,13 @@ export default function ClientesPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<Button type="button" variant="outline" onClick={() => setShowForm(false)}>
|
<Button type="button" variant="outline" onClick={handleCancelForm}>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={createTenant.isPending}>
|
<Button type="submit" disabled={createTenant.isPending || updateTenant.isPending}>
|
||||||
{createTenant.isPending ? 'Creando...' : 'Crear Cliente'}
|
{editingTenant
|
||||||
|
? (updateTenant.isPending ? 'Guardando...' : 'Guardar Cambios')
|
||||||
|
: (createTenant.isPending ? 'Creando...' : 'Crear Cliente')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -223,7 +283,7 @@ export default function ClientesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="flex items-center gap-1 text-sm">
|
<div className="flex items-center gap-1 text-sm">
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
@@ -234,14 +294,33 @@ export default function ClientesPage() {
|
|||||||
<span>{formatDate(tenant.createdAt)}</span>
|
<span>{formatDate(tenant.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex gap-1">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => handleViewClient(tenant.id, tenant.nombre)}
|
size="sm"
|
||||||
>
|
onClick={() => handleViewClient(tenant.id, tenant.nombre)}
|
||||||
<Eye className="h-4 w-4 mr-1" />
|
>
|
||||||
Ver
|
<Eye className="h-4 w-4 mr-1" />
|
||||||
</Button>
|
Ver
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleEdit(tenant)}
|
||||||
|
title="Editar"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(tenant)}
|
||||||
|
title="Eliminar"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { Header } from '@/components/layouts/header';
|
import { Header } from '@/components/layouts/header';
|
||||||
import { KpiCard } from '@/components/charts/kpi-card';
|
import { KpiCard } from '@/components/charts/kpi-card';
|
||||||
import { BarChart } from '@/components/charts/bar-chart';
|
import { BarChart } from '@/components/charts/bar-chart';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { PeriodSelector } from '@/components/period-selector';
|
||||||
import { useKpis, useIngresosEgresos, useAlertas, useResumenFiscal } from '@/lib/hooks/use-dashboard';
|
import { useKpis, useIngresosEgresos, useAlertas, useResumenFiscal } from '@/lib/hooks/use-dashboard';
|
||||||
import {
|
import {
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -15,13 +17,13 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const currentYear = new Date().getFullYear();
|
const [año, setAño] = useState(new Date().getFullYear());
|
||||||
const currentMonth = new Date().getMonth() + 1;
|
const [mes, setMes] = useState(new Date().getMonth() + 1);
|
||||||
|
|
||||||
const { data: kpis, isLoading: kpisLoading } = useKpis(currentYear, currentMonth);
|
const { data: kpis, isLoading: kpisLoading } = useKpis(año, mes);
|
||||||
const { data: chartData, isLoading: chartLoading } = useIngresosEgresos(currentYear);
|
const { data: chartData, isLoading: chartLoading } = useIngresosEgresos(año);
|
||||||
const { data: alertas, isLoading: alertasLoading } = useAlertas(5);
|
const { data: alertas, isLoading: alertasLoading } = useAlertas(5);
|
||||||
const { data: resumenFiscal } = useResumenFiscal(currentYear, currentMonth);
|
const { data: resumenFiscal } = useResumenFiscal(año, mes);
|
||||||
|
|
||||||
const formatCurrency = (value: number) =>
|
const formatCurrency = (value: number) =>
|
||||||
new Intl.NumberFormat('es-MX', {
|
new Intl.NumberFormat('es-MX', {
|
||||||
@@ -32,7 +34,14 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Dashboard" />
|
<Header title="Dashboard">
|
||||||
|
<PeriodSelector
|
||||||
|
año={año}
|
||||||
|
mes={mes}
|
||||||
|
onAñoChange={setAño}
|
||||||
|
onMesChange={setMes}
|
||||||
|
/>
|
||||||
|
</Header>
|
||||||
<main className="p-6 space-y-6">
|
<main className="p-6 space-y-6">
|
||||||
{/* KPIs */}
|
{/* KPIs */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
|||||||
@@ -5,20 +5,20 @@ import { Header } from '@/components/layouts/header';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { KpiCard } from '@/components/charts/kpi-card';
|
import { KpiCard } from '@/components/charts/kpi-card';
|
||||||
|
import { PeriodSelector } from '@/components/period-selector';
|
||||||
import { useIvaMensual, useResumenIva, useResumenIsr } from '@/lib/hooks/use-impuestos';
|
import { useIvaMensual, useResumenIva, useResumenIsr } from '@/lib/hooks/use-impuestos';
|
||||||
import { Calculator, TrendingUp, TrendingDown, Receipt } from 'lucide-react';
|
import { Calculator, TrendingUp, TrendingDown, Receipt } from 'lucide-react';
|
||||||
|
|
||||||
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
||||||
|
|
||||||
export default function ImpuestosPage() {
|
export default function ImpuestosPage() {
|
||||||
const currentYear = new Date().getFullYear();
|
const [año, setAño] = useState(new Date().getFullYear());
|
||||||
const currentMonth = new Date().getMonth() + 1;
|
const [mes, setMes] = useState(new Date().getMonth() + 1);
|
||||||
const [año] = useState(currentYear);
|
|
||||||
const [activeTab, setActiveTab] = useState<'iva' | 'isr'>('iva');
|
const [activeTab, setActiveTab] = useState<'iva' | 'isr'>('iva');
|
||||||
|
|
||||||
const { data: ivaMensual, isLoading: ivaLoading } = useIvaMensual(año);
|
const { data: ivaMensual, isLoading: ivaLoading } = useIvaMensual(año);
|
||||||
const { data: resumenIva } = useResumenIva(año, currentMonth);
|
const { data: resumenIva } = useResumenIva(año, mes);
|
||||||
const { data: resumenIsr } = useResumenIsr(año, currentMonth);
|
const { data: resumenIsr } = useResumenIsr(año, mes);
|
||||||
|
|
||||||
const formatCurrency = (value: number) =>
|
const formatCurrency = (value: number) =>
|
||||||
new Intl.NumberFormat('es-MX', {
|
new Intl.NumberFormat('es-MX', {
|
||||||
@@ -29,7 +29,14 @@ export default function ImpuestosPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Control de Impuestos" />
|
<Header title="Control de Impuestos">
|
||||||
|
<PeriodSelector
|
||||||
|
año={año}
|
||||||
|
mes={mes}
|
||||||
|
onAñoChange={setAño}
|
||||||
|
onMesChange={setMes}
|
||||||
|
/>
|
||||||
|
</Header>
|
||||||
<main className="p-6 space-y-6">
|
<main className="p-6 space-y-6">
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -17,17 +17,27 @@ export default function DashboardLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isAuthenticated } = useAuthStore();
|
const { isAuthenticated, _hasHydrated } = useAuthStore();
|
||||||
const { theme } = useThemeStore();
|
const { theme } = useThemeStore();
|
||||||
|
|
||||||
const currentTheme = themes[theme];
|
const currentTheme = themes[theme];
|
||||||
const layout = currentTheme.layout;
|
const layout = currentTheme.layout;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) {
|
// Solo verificar autenticación después de que el store se rehidrate
|
||||||
|
if (_hasHydrated && !isAuthenticated) {
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, router]);
|
}, [isAuthenticated, _hasHydrated, router]);
|
||||||
|
|
||||||
|
// Mostrar loading mientras se rehidrata el store
|
||||||
|
if (!_hasHydrated) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="animate-pulse text-muted-foreground">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -4,26 +4,33 @@ import { useState } from 'react';
|
|||||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { PeriodSelector } from '@/components/period-selector';
|
||||||
import { useEstadoResultados, useFlujoEfectivo, useComparativo, useConcentradoRfc } from '@/lib/hooks/use-reportes';
|
import { useEstadoResultados, useFlujoEfectivo, useComparativo, useConcentradoRfc } from '@/lib/hooks/use-reportes';
|
||||||
import { BarChart } from '@/components/charts/bar-chart';
|
import { BarChart } from '@/components/charts/bar-chart';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import { FileText, TrendingUp, TrendingDown, Users } from 'lucide-react';
|
import { FileText, TrendingUp, TrendingDown, Users } from 'lucide-react';
|
||||||
|
|
||||||
export default function ReportesPage() {
|
export default function ReportesPage() {
|
||||||
const [año] = useState(new Date().getFullYear());
|
const [año, setAño] = useState(new Date().getFullYear());
|
||||||
const fechaInicio = `${año}-01-01`;
|
const fechaInicio = `${año}-01-01`;
|
||||||
const fechaFin = `${año}-12-31`;
|
const fechaFin = `${año}-12-31`;
|
||||||
|
|
||||||
const { data: estadoResultados, isLoading: loadingER } = useEstadoResultados(fechaInicio, fechaFin);
|
const { data: estadoResultados, isLoading: loadingER, error: errorER } = useEstadoResultados(fechaInicio, fechaFin);
|
||||||
const { data: flujoEfectivo, isLoading: loadingFE } = useFlujoEfectivo(fechaInicio, fechaFin);
|
const { data: flujoEfectivo, isLoading: loadingFE, error: errorFE } = useFlujoEfectivo(fechaInicio, fechaFin);
|
||||||
const { data: comparativo, isLoading: loadingComp } = useComparativo(año);
|
const { data: comparativo, isLoading: loadingComp, error: errorComp } = useComparativo(año);
|
||||||
const { data: clientes } = useConcentradoRfc('cliente', fechaInicio, fechaFin);
|
const { data: clientes, error: errorClientes } = useConcentradoRfc('cliente', fechaInicio, fechaFin);
|
||||||
const { data: proveedores } = useConcentradoRfc('proveedor', fechaInicio, fechaFin);
|
const { data: proveedores, error: errorProveedores } = useConcentradoRfc('proveedor', fechaInicio, fechaFin);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardShell
|
<DashboardShell
|
||||||
title="Reportes"
|
title="Reportes"
|
||||||
description="Analisis financiero y reportes fiscales"
|
headerContent={
|
||||||
|
<PeriodSelector
|
||||||
|
año={año}
|
||||||
|
onAñoChange={setAño}
|
||||||
|
showMonth={false}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Tabs defaultValue="estado-resultados" className="space-y-4">
|
<Tabs defaultValue="estado-resultados" className="space-y-4">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
@@ -36,7 +43,11 @@ export default function ReportesPage() {
|
|||||||
<TabsContent value="estado-resultados" className="space-y-4">
|
<TabsContent value="estado-resultados" className="space-y-4">
|
||||||
{loadingER ? (
|
{loadingER ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||||
) : estadoResultados ? (
|
) : errorER ? (
|
||||||
|
<div className="text-center py-8 text-destructive">Error: {(errorER as Error).message}</div>
|
||||||
|
) : !estadoResultados ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">No hay datos disponibles para el período seleccionado</div>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -118,13 +129,17 @@ export default function ReportesPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="flujo-efectivo" className="space-y-4">
|
<TabsContent value="flujo-efectivo" className="space-y-4">
|
||||||
{loadingFE ? (
|
{loadingFE ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||||
) : flujoEfectivo ? (
|
) : errorFE ? (
|
||||||
|
<div className="text-center py-8 text-destructive">Error: {(errorFE as Error).message}</div>
|
||||||
|
) : !flujoEfectivo ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">No hay datos de flujo de efectivo</div>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -159,28 +174,26 @@ export default function ReportesPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<BarChart
|
||||||
<CardHeader>
|
title="Flujo de Efectivo Mensual"
|
||||||
<CardTitle>Flujo de Efectivo Mensual</CardTitle>
|
data={flujoEfectivo.entradas.map((e, i) => ({
|
||||||
</CardHeader>
|
mes: e.concepto,
|
||||||
<CardContent>
|
ingresos: e.monto,
|
||||||
<BarChart
|
egresos: flujoEfectivo.salidas[i]?.monto || 0,
|
||||||
data={flujoEfectivo.entradas.map((e, i) => ({
|
}))}
|
||||||
mes: e.concepto,
|
/>
|
||||||
ingresos: e.monto,
|
|
||||||
egresos: flujoEfectivo.salidas[i]?.monto || 0,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="comparativo" className="space-y-4">
|
<TabsContent value="comparativo" className="space-y-4">
|
||||||
{loadingComp ? (
|
{loadingComp ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
|
||||||
) : comparativo ? (
|
) : errorComp ? (
|
||||||
|
<div className="text-center py-8 text-destructive">Error: {(errorComp as Error).message}</div>
|
||||||
|
) : !comparativo ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">No hay datos comparativos</div>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -213,69 +226,77 @@ export default function ReportesPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<BarChart
|
||||||
<CardHeader>
|
title={`Comparativo Mensual ${año}`}
|
||||||
<CardTitle>Comparativo Mensual {año}</CardTitle>
|
data={comparativo.periodos.map((mes, i) => ({
|
||||||
</CardHeader>
|
mes,
|
||||||
<CardContent>
|
ingresos: comparativo.ingresos[i],
|
||||||
<BarChart
|
egresos: comparativo.egresos[i],
|
||||||
data={comparativo.periodos.map((mes, i) => ({
|
}))}
|
||||||
mes,
|
/>
|
||||||
ingresos: comparativo.ingresos[i],
|
|
||||||
egresos: comparativo.egresos[i],
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="concentrado" className="space-y-4">
|
<TabsContent value="concentrado" className="space-y-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
{errorClientes || errorProveedores ? (
|
||||||
<Card>
|
<div className="text-center py-8 text-destructive">
|
||||||
<CardHeader>
|
Error: {((errorClientes || errorProveedores) as Error).message}
|
||||||
<CardTitle className="flex items-center gap-2">
|
</div>
|
||||||
<Users className="h-5 w-5" />
|
) : (
|
||||||
Clientes
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
</CardTitle>
|
<Card>
|
||||||
</CardHeader>
|
<CardHeader>
|
||||||
<CardContent>
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div className="space-y-2">
|
<Users className="h-5 w-5" />
|
||||||
{clientes?.slice(0, 10).map((c, i) => (
|
Clientes
|
||||||
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
|
</CardTitle>
|
||||||
<div>
|
</CardHeader>
|
||||||
<div className="font-medium text-sm">{c.nombre}</div>
|
<CardContent>
|
||||||
<div className="text-xs text-muted-foreground">{c.rfc} - {c.cantidadCfdis} CFDIs</div>
|
<div className="space-y-2">
|
||||||
</div>
|
{clientes && clientes.length > 0 ? (
|
||||||
<span className="font-medium">{formatCurrency(c.totalFacturado)}</span>
|
clientes.slice(0, 10).map((c, i) => (
|
||||||
</div>
|
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
|
||||||
))}
|
<div>
|
||||||
</div>
|
<div className="font-medium text-sm">{c.nombre}</div>
|
||||||
</CardContent>
|
<div className="text-xs text-muted-foreground">{c.rfc} - {c.cantidadCfdis} CFDIs</div>
|
||||||
</Card>
|
</div>
|
||||||
<Card>
|
<span className="font-medium">{formatCurrency(c.totalFacturado)}</span>
|
||||||
<CardHeader>
|
</div>
|
||||||
<CardTitle className="flex items-center gap-2">
|
))
|
||||||
<Users className="h-5 w-5" />
|
) : (
|
||||||
Proveedores
|
<div className="text-center py-4 text-muted-foreground text-sm">Sin clientes</div>
|
||||||
</CardTitle>
|
)}
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
</CardContent>
|
||||||
<div className="space-y-2">
|
</Card>
|
||||||
{proveedores?.slice(0, 10).map((p, i) => (
|
<Card>
|
||||||
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
|
<CardHeader>
|
||||||
<div>
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div className="font-medium text-sm">{p.nombre}</div>
|
<Users className="h-5 w-5" />
|
||||||
<div className="text-xs text-muted-foreground">{p.rfc} - {p.cantidadCfdis} CFDIs</div>
|
Proveedores
|
||||||
</div>
|
</CardTitle>
|
||||||
<span className="font-medium">{formatCurrency(p.totalFacturado)}</span>
|
</CardHeader>
|
||||||
</div>
|
<CardContent>
|
||||||
))}
|
<div className="space-y-2">
|
||||||
</div>
|
{proveedores && proveedores.length > 0 ? (
|
||||||
</CardContent>
|
proveedores.slice(0, 10).map((p, i) => (
|
||||||
</Card>
|
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
|
||||||
</div>
|
<div>
|
||||||
|
<div className="font-medium text-sm">{p.nombre}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{p.rfc} - {p.cantidadCfdis} CFDIs</div>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">{formatCurrency(p.totalFacturado)}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-4 text-muted-foreground text-sm">Sin proveedores</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</DashboardShell>
|
</DashboardShell>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function UsuariosPage() {
|
|||||||
const deleteUsuario = useDeleteUsuario();
|
const deleteUsuario = useDeleteUsuario();
|
||||||
|
|
||||||
const [showInvite, setShowInvite] = useState(false);
|
const [showInvite, setShowInvite] = useState(false);
|
||||||
const [inviteForm, setInviteForm] = useState({ email: '', nombre: '', role: 'visor' as const });
|
const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: 'admin' | 'contador' | 'visor' }>({ email: '', nombre: '', role: 'visor' });
|
||||||
|
|
||||||
const handleInvite = async (e: React.FormEvent) => {
|
const handleInvite = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -52,10 +52,7 @@ export default function UsuariosPage() {
|
|||||||
const isAdmin = currentUser?.role === 'admin';
|
const isAdmin = currentUser?.role === 'admin';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardShell
|
<DashboardShell title="Usuarios">
|
||||||
title="Usuarios"
|
|
||||||
description="Gestiona los usuarios de tu empresa"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
@@ -103,7 +100,7 @@ export default function UsuariosPage() {
|
|||||||
<Label htmlFor="role">Rol</Label>
|
<Label htmlFor="role">Rol</Label>
|
||||||
<Select
|
<Select
|
||||||
value={inviteForm.role}
|
value={inviteForm.role}
|
||||||
onValueChange={(v: 'admin' | 'contador' | 'visor') => setInviteForm({ ...inviteForm, role: v })}
|
onValueChange={(v) => setInviteForm({ ...inviteForm, role: v as 'admin' | 'contador' | 'visor' })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import { Header } from './header';
|
|||||||
interface DashboardShellProps {
|
interface DashboardShellProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
|
headerContent?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardShell({ children, title }: DashboardShellProps) {
|
export function DashboardShell({ children, title, headerContent }: DashboardShellProps) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="pl-64">
|
<div className="pl-64">
|
||||||
<Header title={title} />
|
<Header title={title}>{headerContent}</Header>
|
||||||
<main className="p-6">{children}</main>
|
<main className="p-6">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ const themeIcons: Record<ThemeName, React.ReactNode> = {
|
|||||||
|
|
||||||
const themeOrder: ThemeName[] = ['light', 'vibrant', 'corporate', 'dark'];
|
const themeOrder: ThemeName[] = ['light', 'vibrant', 'corporate', 'dark'];
|
||||||
|
|
||||||
export function Header({ title }: { title: string }) {
|
interface HeaderProps {
|
||||||
|
title: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ title, children }: HeaderProps) {
|
||||||
const { theme, setTheme } = useThemeStore();
|
const { theme, setTheme } = useThemeStore();
|
||||||
|
|
||||||
const cycleTheme = () => {
|
const cycleTheme = () => {
|
||||||
@@ -26,7 +31,10 @@ export function Header({ title }: { title: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b bg-background/95 backdrop-blur px-6">
|
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b bg-background/95 backdrop-blur px-6">
|
||||||
<h1 className="text-xl font-semibold">{title}</h1>
|
<div className="flex items-center gap-4">
|
||||||
|
<h1 className="text-xl font-semibold">{title}</h1>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<TenantSelector />
|
<TenantSelector />
|
||||||
|
|||||||
104
apps/web/components/period-selector.tsx
Normal file
104
apps/web/components/period-selector.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
const meses = [
|
||||||
|
'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
|
||||||
|
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'
|
||||||
|
];
|
||||||
|
|
||||||
|
interface PeriodSelectorProps {
|
||||||
|
año: number;
|
||||||
|
mes?: number;
|
||||||
|
onAñoChange: (año: number) => void;
|
||||||
|
onMesChange?: (mes: number) => void;
|
||||||
|
showMonth?: boolean;
|
||||||
|
minYear?: number;
|
||||||
|
maxYear?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PeriodSelector({
|
||||||
|
año,
|
||||||
|
mes,
|
||||||
|
onAñoChange,
|
||||||
|
onMesChange,
|
||||||
|
showMonth = true,
|
||||||
|
minYear = 2020,
|
||||||
|
maxYear = new Date().getFullYear(),
|
||||||
|
}: PeriodSelectorProps) {
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (showMonth && mes && onMesChange) {
|
||||||
|
if (mes === 1) {
|
||||||
|
if (año > minYear) {
|
||||||
|
onMesChange(12);
|
||||||
|
onAñoChange(año - 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onMesChange(mes - 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (año > minYear) {
|
||||||
|
onAñoChange(año - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (showMonth && mes && onMesChange) {
|
||||||
|
if (mes === 12) {
|
||||||
|
if (año < maxYear) {
|
||||||
|
onMesChange(1);
|
||||||
|
onAñoChange(año + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onMesChange(mes + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (año < maxYear) {
|
||||||
|
onAñoChange(año + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canGoPrev = showMonth && mes
|
||||||
|
? !(año === minYear && mes === 1)
|
||||||
|
: año > minYear;
|
||||||
|
|
||||||
|
const canGoNext = showMonth && mes
|
||||||
|
? !(año === maxYear && mes === 12)
|
||||||
|
: año < maxYear;
|
||||||
|
|
||||||
|
const displayText = showMonth && mes
|
||||||
|
? `${meses[mes - 1]} ${año}`
|
||||||
|
: `${año}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handlePrev}
|
||||||
|
disabled={!canGoPrev}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="min-w-[90px] text-center text-sm font-medium">
|
||||||
|
{displayText}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getTenants, type Tenant } from '@/lib/api/tenants';
|
import { getTenants, type Tenant } from '@/lib/api/tenants';
|
||||||
import { useTenantViewStore } from '@/stores/tenant-view-store';
|
import { useTenantViewStore } from '@/stores/tenant-view-store';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
@@ -11,6 +11,7 @@ import { cn } from '@/lib/utils';
|
|||||||
export function TenantSelector() {
|
export function TenantSelector() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { viewingTenantId, viewingTenantName, setViewingTenant, clearViewingTenant } = useTenantViewStore();
|
const { viewingTenantId, viewingTenantName, setViewingTenant, clearViewingTenant } = useTenantViewStore();
|
||||||
|
|
||||||
const { data: tenants, isLoading } = useQuery({
|
const { data: tenants, isLoading } = useQuery({
|
||||||
@@ -57,17 +58,26 @@ export function TenantSelector() {
|
|||||||
<Building className="h-4 w-4" />
|
<Building className="h-4 w-4" />
|
||||||
<span className="max-w-[150px] truncate">{displayName}</span>
|
<span className="max-w-[150px] truncate">{displayName}</span>
|
||||||
{isViewingOther && (
|
{isViewingOther && (
|
||||||
<button
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
clearViewingTenant();
|
clearViewingTenant();
|
||||||
window.location.reload();
|
queryClient.invalidateQueries();
|
||||||
}}
|
}}
|
||||||
className="ml-1 p-0.5 rounded hover:bg-primary/20"
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.stopPropagation();
|
||||||
|
clearViewingTenant();
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="ml-1 p-0.5 rounded hover:bg-primary/20 cursor-pointer"
|
||||||
title="Volver a mi empresa"
|
title="Volver a mi empresa"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</span>
|
||||||
)}
|
)}
|
||||||
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
|
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
|
||||||
</button>
|
</button>
|
||||||
@@ -87,7 +97,7 @@ export function TenantSelector() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
clearViewingTenant();
|
clearViewingTenant();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
window.location.reload();
|
queryClient.invalidateQueries();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors',
|
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors',
|
||||||
@@ -113,9 +123,9 @@ export function TenantSelector() {
|
|||||||
<button
|
<button
|
||||||
key={tenant.id}
|
key={tenant.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setViewingTenant(tenant.id, tenant.nombre);
|
setViewingTenant(tenant.id, tenant.nombre, tenant.rfc);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
window.location.reload();
|
queryClient.invalidateQueries();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors',
|
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors',
|
||||||
|
|||||||
@@ -30,3 +30,42 @@ export async function getResumenCfdi(año?: number, mes?: number) {
|
|||||||
const response = await apiClient.get(`/cfdi/resumen?${params}`);
|
const response = await apiClient.get(`/cfdi/resumen?${params}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateCfdiData {
|
||||||
|
uuidFiscal: string;
|
||||||
|
tipo: 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago';
|
||||||
|
serie?: string;
|
||||||
|
folio?: string;
|
||||||
|
fechaEmision: string;
|
||||||
|
fechaTimbrado: string;
|
||||||
|
rfcEmisor: string;
|
||||||
|
nombreEmisor: string;
|
||||||
|
rfcReceptor: string;
|
||||||
|
nombreReceptor: string;
|
||||||
|
subtotal: number;
|
||||||
|
descuento?: number;
|
||||||
|
iva?: number;
|
||||||
|
isrRetenido?: number;
|
||||||
|
ivaRetenido?: number;
|
||||||
|
total: number;
|
||||||
|
moneda?: string;
|
||||||
|
tipoCambio?: number;
|
||||||
|
metodoPago?: string;
|
||||||
|
formaPago?: string;
|
||||||
|
usoCfdi?: string;
|
||||||
|
estado?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCfdi(data: CreateCfdiData): Promise<Cfdi> {
|
||||||
|
const response = await apiClient.post<Cfdi>('/cfdi', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createManyCfdis(cfdis: CreateCfdiData[]): Promise<{ count: number }> {
|
||||||
|
const response = await apiClient.post<{ count: number; message: string }>('/cfdi/bulk', { cfdis });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCfdi(id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/cfdi/${id}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,3 +34,21 @@ export async function createTenant(data: CreateTenantData): Promise<Tenant> {
|
|||||||
const response = await apiClient.post<Tenant>('/tenants', data);
|
const response = await apiClient.post<Tenant>('/tenants', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateTenantData {
|
||||||
|
nombre?: string;
|
||||||
|
rfc?: string;
|
||||||
|
plan?: 'starter' | 'business' | 'professional' | 'enterprise';
|
||||||
|
cfdiLimit?: number;
|
||||||
|
usersLimit?: number;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTenant(id: string, data: UpdateTenantData): Promise<Tenant> {
|
||||||
|
const response = await apiClient.put<Tenant>(`/tenants/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTenant(id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/tenants/${id}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import * as cfdiApi from '@/lib/api/cfdi';
|
import * as cfdiApi from '@/lib/api/cfdi';
|
||||||
import type { CfdiFilters } from '@horux/shared';
|
import type { CfdiFilters } from '@horux/shared';
|
||||||
|
import type { CreateCfdiData } from '@/lib/api/cfdi';
|
||||||
|
|
||||||
export function useCfdis(filters: CfdiFilters) {
|
export function useCfdis(filters: CfdiFilters) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -23,3 +24,42 @@ export function useResumenCfdi(año?: number, mes?: number) {
|
|||||||
queryFn: () => cfdiApi.getResumenCfdi(año, mes),
|
queryFn: () => cfdiApi.getResumenCfdi(año, mes),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCreateCfdi() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateCfdiData) => cfdiApi.createCfdi(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cfdis'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cfdi-resumen'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateManyCfdis() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (cfdis: CreateCfdiData[]) => cfdiApi.createManyCfdis(cfdis),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cfdis'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cfdi-resumen'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteCfdi() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => cfdiApi.deleteCfdi(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cfdis'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cfdi-resumen'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getTenants, createTenant, type CreateTenantData } from '@/lib/api/tenants';
|
import { getTenants, createTenant, updateTenant, deleteTenant, type CreateTenantData, type UpdateTenantData } from '@/lib/api/tenants';
|
||||||
|
|
||||||
export function useTenants() {
|
export function useTenants() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -18,3 +18,25 @@ export function useCreateTenant() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpdateTenant() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdateTenantData }) => updateTenant(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tenants'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteTenant() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => deleteTenant(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tenants'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
transpilePackages: ['@horux/shared'],
|
transpilePackages: ['@horux/shared'],
|
||||||
experimental: {
|
|
||||||
typedRoutes: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import type { UserInfo } from '@horux/shared';
|
|||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: UserInfo | null;
|
user: UserInfo | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
|
_hasHydrated: boolean;
|
||||||
setUser: (user: UserInfo | null) => void;
|
setUser: (user: UserInfo | null) => void;
|
||||||
setTokens: (accessToken: string, refreshToken: string) => void;
|
setTokens: (accessToken: string, refreshToken: string) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
setHasHydrated: (state: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
@@ -15,6 +17,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
(set) => ({
|
(set) => ({
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
_hasHydrated: false,
|
||||||
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
||||||
setTokens: (accessToken, refreshToken) => {
|
setTokens: (accessToken, refreshToken) => {
|
||||||
localStorage.setItem('accessToken', accessToken);
|
localStorage.setItem('accessToken', accessToken);
|
||||||
@@ -25,10 +28,14 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
localStorage.removeItem('refreshToken');
|
localStorage.removeItem('refreshToken');
|
||||||
set({ user: null, isAuthenticated: false });
|
set({ user: null, isAuthenticated: false });
|
||||||
},
|
},
|
||||||
|
setHasHydrated: (state) => set({ _hasHydrated: state }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'horux-auth',
|
name: 'horux-auth',
|
||||||
partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
|
partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
state?.setHasHydrated(true);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { persist } from 'zustand/middleware';
|
|||||||
interface TenantViewState {
|
interface TenantViewState {
|
||||||
viewingTenantId: string | null;
|
viewingTenantId: string | null;
|
||||||
viewingTenantName: string | null;
|
viewingTenantName: string | null;
|
||||||
setViewingTenant: (id: string | null, name: string | null) => void;
|
viewingTenantRfc: string | null;
|
||||||
|
setViewingTenant: (id: string | null, name: string | null, rfc?: string | null) => void;
|
||||||
clearViewingTenant: () => void;
|
clearViewingTenant: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,8 +14,9 @@ export const useTenantViewStore = create<TenantViewState>()(
|
|||||||
(set) => ({
|
(set) => ({
|
||||||
viewingTenantId: null,
|
viewingTenantId: null,
|
||||||
viewingTenantName: null,
|
viewingTenantName: null,
|
||||||
setViewingTenant: (id, name) => set({ viewingTenantId: id, viewingTenantName: name }),
|
viewingTenantRfc: null,
|
||||||
clearViewingTenant: () => set({ viewingTenantId: null, viewingTenantName: null }),
|
setViewingTenant: (id, name, rfc = null) => set({ viewingTenantId: id, viewingTenantName: name, viewingTenantRfc: rfc }),
|
||||||
|
clearViewingTenant: () => set({ viewingTenantId: null, viewingTenantName: null, viewingTenantRfc: null }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'horux-tenant-view',
|
name: 'horux-tenant-view',
|
||||||
|
|||||||
@@ -32,5 +32,5 @@ export function getPlanLimits(plan: Plan) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function hasFeature(plan: Plan, feature: string): boolean {
|
export function hasFeature(plan: Plan, feature: string): boolean {
|
||||||
return PLANS[plan].features.includes(feature);
|
return (PLANS[plan].features as readonly string[]).includes(feature);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Role } from '../types/auth';
|
||||||
|
|
||||||
export const ROLES = {
|
export const ROLES = {
|
||||||
admin: {
|
admin: {
|
||||||
name: 'Administrador',
|
name: 'Administrador',
|
||||||
@@ -13,8 +15,6 @@ export const ROLES = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Role = keyof typeof ROLES;
|
|
||||||
|
|
||||||
export function hasPermission(role: Role, permission: string): boolean {
|
export function hasPermission(role: Role, permission: string): boolean {
|
||||||
return ROLES[role].permissions.includes(permission as any);
|
return ROLES[role].permissions.includes(permission as any);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface UserInfo {
|
|||||||
role: Role;
|
role: Role;
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
tenantName: string;
|
tenantName: string;
|
||||||
|
tenantRfc: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JWTPayload {
|
export interface JWTPayload {
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -29,6 +29,9 @@ importers:
|
|||||||
cors:
|
cors:
|
||||||
specifier: ^2.8.5
|
specifier: ^2.8.5
|
||||||
version: 2.8.5
|
version: 2.8.5
|
||||||
|
dotenv:
|
||||||
|
specifier: ^17.2.3
|
||||||
|
version: 17.2.3
|
||||||
exceljs:
|
exceljs:
|
||||||
specifier: ^4.4.0
|
specifier: ^4.4.0
|
||||||
version: 4.4.0
|
version: 4.4.0
|
||||||
@@ -1309,6 +1312,10 @@ packages:
|
|||||||
dom-helpers@5.2.1:
|
dom-helpers@5.2.1:
|
||||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||||
|
|
||||||
|
dotenv@17.2.3:
|
||||||
|
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3316,6 +3323,8 @@ snapshots:
|
|||||||
'@babel/runtime': 7.28.6
|
'@babel/runtime': 7.28.6
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
dotenv@17.2.3: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
|||||||
Reference in New Issue
Block a user