Compare commits

...

17 Commits

Author SHA1 Message Date
Consultoria AS
2bbab12627 fix: resolve build errors and add dialog component
- Add Dialog UI component (shadcn/radix-ui)
- Fix html2pdf.js type annotations with const assertions
- Add @radix-ui/react-dialog dependency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:42:25 +00:00
Consultoria AS
cdb6f0c94e feat(web): integrate CFDI viewer modal into CFDI page
- Add Eye button to table rows to view invoice
- Add loading state while fetching CFDI details
- Integrate CfdiViewerModal component

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:38:24 +00:00
Consultoria AS
3beee1c174 feat(web): add CfdiViewerModal with PDF and XML download
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:37:20 +00:00
Consultoria AS
837831ccd4 feat(web): add CfdiInvoice component for PDF-like rendering
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:36:18 +00:00
Consultoria AS
f9d2161938 feat(web): add getCfdiXml API function
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:34:51 +00:00
Consultoria AS
427c94fb9d feat(api): add GET /cfdi/:id/xml endpoint
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:34:16 +00:00
Consultoria AS
266e547eb5 feat(api): add xmlOriginal to getCfdiById and add getXmlById
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:33:21 +00:00
Consultoria AS
ebd099f596 feat(types): add xmlOriginal field to Cfdi interface
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:32:34 +00:00
Consultoria AS
8c0bc799d3 chore: add html2pdf.js for PDF generation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:31:03 +00:00
Consultoria AS
6109294811 docs: add CFDI viewer implementation plan
Detailed step-by-step implementation plan for:
- PDF-like invoice visualization
- PDF download via html2pdf.js
- XML download endpoint
- Modal integration in CFDI page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:28:17 +00:00
Consultoria AS
67f74538b8 docs: add CFDI viewer design document
Design for PDF-like invoice visualization with:
- Modal viewer with invoice preview
- PDF download via html2pdf.js
- XML download from stored data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 02:26:19 +00:00
Consultoria AS
3466ec740e fix: resolve TypeScript compilation errors in API
- Add explicit IRouter type to all route files
- Add explicit Express type to app.ts
- Fix env.ts by moving getCorsOrigins after parsing
- Fix token.ts SignOptions type for expiresIn
- Cast req.params.id to String() in controllers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 06:48:26 +00:00
Consultoria AS
3098a40356 fix: add auth protection to onboarding and remove demo text
- Add authentication check using useAuthStore
- Redirect unauthenticated users to /login
- Show loading state while auth store hydrates
- Remove "Demo UI sin backend" text from production

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 03:33:22 +00:00
Consultoria AS
34864742d8 Merge branch 'DevMarlene' into main
feat: add onboarding screen and redirect new users after login
2026-01-31 03:09:42 +00:00
Consultoria AS
1fe462764f fix: use transaction in refreshTokens to prevent race conditions
- Wrap token refresh logic in Prisma transaction
- Use deleteMany instead of delete to handle race conditions gracefully

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 03:09:32 +00:00
Consultoria AS
ba012254db docs: add current state and next steps for SAT sync
- Document current implementation status
- Add pending items to verify after SAT rate limit resets
- Include test tenant info and verification commands
- List known issues and workarounds

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 04:38:43 +00:00
Consultoria AS
dcc33af523 feat: SAT sync improvements and documentation
- Add custom date range support for SAT synchronization
- Fix UUID cast in SQL queries for sat_sync_job_id
- Fix processInitialSync to respect custom dateFrom/dateTo parameters
- Add date picker UI for custom period sync
- Add comprehensive documentation for SAT sync implementation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 03:01:27 +00:00
37 changed files with 2266 additions and 121 deletions

View File

@@ -1,4 +1,4 @@
import express from 'express'; import express, { type Express } from 'express';
import cors from 'cors'; import cors from 'cors';
import helmet from 'helmet'; import helmet from 'helmet';
import { env, getCorsOrigins } from './config/env.js'; import { env, getCorsOrigins } from './config/env.js';
@@ -16,7 +16,7 @@ import { tenantsRoutes } from './routes/tenants.routes.js';
import fielRoutes from './routes/fiel.routes.js'; import fielRoutes from './routes/fiel.routes.js';
import satRoutes from './routes/sat.routes.js'; import satRoutes from './routes/sat.routes.js';
const app = express(); const app: Express = express();
// Security // Security
app.use(helmet()); app.use(helmet());

View File

@@ -15,11 +15,6 @@ 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) {
@@ -28,3 +23,8 @@ if (!parsed.success) {
} }
export const env = parsed.data; export const env = parsed.data;
// Parse CORS origins (comma-separated) into array
export function getCorsOrigins(): string[] {
return env.CORS_ORIGIN.split(',').map(origin => origin.trim());
}

View File

@@ -17,7 +17,7 @@ export async function getAlertas(req: Request, res: Response, next: NextFunction
export async function getAlerta(req: Request, res: Response, next: NextFunction) { export async function getAlerta(req: Request, res: Response, next: NextFunction) {
try { try {
const alerta = await alertasService.getAlertaById(req.tenantSchema!, parseInt(req.params.id)); const alerta = await alertasService.getAlertaById(req.tenantSchema!, parseInt(String(req.params.id)));
if (!alerta) { if (!alerta) {
return res.status(404).json({ message: 'Alerta no encontrada' }); return res.status(404).json({ message: 'Alerta no encontrada' });
} }
@@ -38,7 +38,7 @@ export async function createAlerta(req: Request, res: Response, next: NextFuncti
export async function updateAlerta(req: Request, res: Response, next: NextFunction) { export async function updateAlerta(req: Request, res: Response, next: NextFunction) {
try { try {
const alerta = await alertasService.updateAlerta(req.tenantSchema!, parseInt(req.params.id), req.body); const alerta = await alertasService.updateAlerta(req.tenantSchema!, parseInt(String(req.params.id)), req.body);
res.json(alerta); res.json(alerta);
} catch (error) { } catch (error) {
next(error); next(error);
@@ -47,7 +47,7 @@ export async function updateAlerta(req: Request, res: Response, next: NextFuncti
export async function deleteAlerta(req: Request, res: Response, next: NextFunction) { export async function deleteAlerta(req: Request, res: Response, next: NextFunction) {
try { try {
await alertasService.deleteAlerta(req.tenantSchema!, parseInt(req.params.id)); await alertasService.deleteAlerta(req.tenantSchema!, parseInt(String(req.params.id)));
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
next(error); next(error);

View File

@@ -35,7 +35,7 @@ export async function createEvento(req: Request, res: Response, next: NextFuncti
export async function updateEvento(req: Request, res: Response, next: NextFunction) { export async function updateEvento(req: Request, res: Response, next: NextFunction) {
try { try {
const evento = await calendarioService.updateEvento(req.tenantSchema!, parseInt(req.params.id), req.body); const evento = await calendarioService.updateEvento(req.tenantSchema!, parseInt(String(req.params.id)), req.body);
res.json(evento); res.json(evento);
} catch (error) { } catch (error) {
next(error); next(error);
@@ -44,7 +44,7 @@ export async function updateEvento(req: Request, res: Response, next: NextFuncti
export async function deleteEvento(req: Request, res: Response, next: NextFunction) { export async function deleteEvento(req: Request, res: Response, next: NextFunction) {
try { try {
await calendarioService.deleteEvento(req.tenantSchema!, parseInt(req.params.id)); await calendarioService.deleteEvento(req.tenantSchema!, parseInt(String(req.params.id)));
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
next(error); next(error);

View File

@@ -33,7 +33,7 @@ export async function getCfdiById(req: Request, res: Response, next: NextFunctio
return next(new AppError(400, 'Schema no configurado')); return next(new AppError(400, 'Schema no configurado'));
} }
const cfdi = await cfdiService.getCfdiById(req.tenantSchema, req.params.id); const cfdi = await cfdiService.getCfdiById(req.tenantSchema, String(req.params.id));
if (!cfdi) { if (!cfdi) {
return next(new AppError(404, 'CFDI no encontrado')); return next(new AppError(404, 'CFDI no encontrado'));
@@ -45,6 +45,26 @@ export async function getCfdiById(req: Request, res: Response, next: NextFunctio
} }
} }
export async function getXml(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
}
const xml = await cfdiService.getXmlById(req.tenantSchema, String(req.params.id));
if (!xml) {
return next(new AppError(404, 'XML no encontrado para este CFDI'));
}
res.set('Content-Type', 'application/xml');
res.set('Content-Disposition', `attachment; filename="cfdi-${req.params.id}.xml"`);
res.send(xml);
} catch (error) {
next(error);
}
}
export async function getResumen(req: Request, res: Response, next: NextFunction) { export async function getResumen(req: Request, res: Response, next: NextFunction) {
try { try {
if (!req.tenantSchema) { if (!req.tenantSchema) {
@@ -131,7 +151,7 @@ export async function deleteCfdi(req: Request, res: Response, next: NextFunction
return next(new AppError(403, 'No tienes permisos para eliminar CFDIs')); return next(new AppError(403, 'No tienes permisos para eliminar CFDIs'));
} }
await cfdiService.deleteCfdi(req.tenantSchema, req.params.id); await cfdiService.deleteCfdi(req.tenantSchema, String(req.params.id));
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
next(error); next(error);

View File

@@ -22,7 +22,7 @@ export async function getTenant(req: Request, res: Response, next: NextFunction)
throw new AppError(403, 'Solo administradores pueden ver detalles de clientes'); throw new AppError(403, 'Solo administradores pueden ver detalles de clientes');
} }
const tenant = await tenantsService.getTenantById(req.params.id); const tenant = await tenantsService.getTenantById(String(req.params.id));
if (!tenant) { if (!tenant) {
throw new AppError(404, 'Cliente no encontrado'); throw new AppError(404, 'Cliente no encontrado');
} }
@@ -65,7 +65,7 @@ export async function updateTenant(req: Request, res: Response, next: NextFuncti
throw new AppError(403, 'Solo administradores pueden editar clientes'); throw new AppError(403, 'Solo administradores pueden editar clientes');
} }
const { id } = req.params; const id = String(req.params.id);
const { nombre, rfc, plan, cfdiLimit, usersLimit, active } = req.body; const { nombre, rfc, plan, cfdiLimit, usersLimit, active } = req.body;
const tenant = await tenantsService.updateTenant(id, { const tenant = await tenantsService.updateTenant(id, {
@@ -89,7 +89,7 @@ export async function deleteTenant(req: Request, res: Response, next: NextFuncti
throw new AppError(403, 'Solo administradores pueden eliminar clientes'); throw new AppError(403, 'Solo administradores pueden eliminar clientes');
} }
await tenantsService.deleteTenant(req.params.id); await tenantsService.deleteTenant(String(req.params.id));
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
next(error); next(error);

View File

@@ -1,9 +1,9 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as alertasController from '../controllers/alertas.controller.js'; import * as alertasController from '../controllers/alertas.controller.js';
const router = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);
router.use(tenantMiddleware); router.use(tenantMiddleware);

View File

@@ -1,8 +1,8 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import * as authController from '../controllers/auth.controller.js'; import * as authController from '../controllers/auth.controller.js';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
const router = Router(); const router: IRouter = Router();
router.post('/register', authController.register); router.post('/register', authController.register);
router.post('/login', authController.login); router.post('/login', authController.login);

View File

@@ -1,9 +1,9 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as calendarioController from '../controllers/calendario.controller.js'; import * as calendarioController from '../controllers/calendario.controller.js';
const router = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);
router.use(tenantMiddleware); router.use(tenantMiddleware);

View File

@@ -1,9 +1,9 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as cfdiController from '../controllers/cfdi.controller.js'; import * as cfdiController from '../controllers/cfdi.controller.js';
const router = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);
router.use(tenantMiddleware); router.use(tenantMiddleware);
@@ -11,6 +11,7 @@ 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.get('/:id/xml', cfdiController.getXml);
router.post('/', cfdiController.createCfdi); router.post('/', cfdiController.createCfdi);
router.post('/bulk', cfdiController.createManyCfdis); router.post('/bulk', cfdiController.createManyCfdis);
router.delete('/:id', cfdiController.deleteCfdi); router.delete('/:id', cfdiController.deleteCfdi);

View File

@@ -1,9 +1,9 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as dashboardController from '../controllers/dashboard.controller.js'; import * as dashboardController from '../controllers/dashboard.controller.js';
const router = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);
router.use(tenantMiddleware); router.use(tenantMiddleware);

View File

@@ -1,9 +1,9 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as exportController from '../controllers/export.controller.js'; import * as exportController from '../controllers/export.controller.js';
const router = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);
router.use(tenantMiddleware); router.use(tenantMiddleware);

View File

@@ -1,8 +1,8 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import * as fielController from '../controllers/fiel.controller.js'; import * as fielController from '../controllers/fiel.controller.js';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
const router = Router(); const router: IRouter = Router();
// Todas las rutas requieren autenticación // Todas las rutas requieren autenticación
router.use(authenticate); router.use(authenticate);

View File

@@ -1,9 +1,9 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as impuestosController from '../controllers/impuestos.controller.js'; import * as impuestosController from '../controllers/impuestos.controller.js';
const router = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);
router.use(tenantMiddleware); router.use(tenantMiddleware);

View File

@@ -1,9 +1,9 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as reportesController from '../controllers/reportes.controller.js'; import * as reportesController from '../controllers/reportes.controller.js';
const router = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);
router.use(tenantMiddleware); router.use(tenantMiddleware);

View File

@@ -1,8 +1,8 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import * as satController from '../controllers/sat.controller.js'; import * as satController from '../controllers/sat.controller.js';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
const router = Router(); const router: IRouter = Router();
// Todas las rutas requieren autenticación // Todas las rutas requieren autenticación
router.use(authenticate); router.use(authenticate);

View File

@@ -1,8 +1,8 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
import * as tenantsController from '../controllers/tenants.controller.js'; import * as tenantsController from '../controllers/tenants.controller.js';
const router = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);

View File

@@ -1,8 +1,8 @@
import { Router } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate } from '../middlewares/auth.middleware.js';
import * as usuariosController from '../controllers/usuarios.controller.js'; import * as usuariosController from '../controllers/usuarios.controller.js';
const router = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);

View File

@@ -147,52 +147,56 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
} }
export async function refreshTokens(token: string): Promise<{ accessToken: string; refreshToken: string }> { export async function refreshTokens(token: string): Promise<{ accessToken: string; refreshToken: string }> {
const storedToken = await prisma.refreshToken.findUnique({ // Use a transaction to prevent race conditions
where: { token }, return await prisma.$transaction(async (tx) => {
}); const storedToken = await tx.refreshToken.findUnique({
where: { token },
});
if (!storedToken) { if (!storedToken) {
throw new AppError(401, 'Token inválido'); throw new AppError(401, 'Token inválido');
} }
if (storedToken.expiresAt < new Date()) { if (storedToken.expiresAt < new Date()) {
await prisma.refreshToken.delete({ where: { id: storedToken.id } }); await tx.refreshToken.deleteMany({ where: { id: storedToken.id } });
throw new AppError(401, 'Token expirado'); throw new AppError(401, 'Token expirado');
} }
const payload = verifyToken(token); const payload = verifyToken(token);
const user = await prisma.user.findUnique({ const user = await tx.user.findUnique({
where: { id: payload.userId }, where: { id: payload.userId },
include: { tenant: true }, include: { tenant: true },
}); });
if (!user || !user.active) { if (!user || !user.active) {
throw new AppError(401, 'Usuario no encontrado o desactivado'); throw new AppError(401, 'Usuario no encontrado o desactivado');
} }
await prisma.refreshToken.delete({ where: { id: storedToken.id } }); // Use deleteMany to avoid error if already deleted (race condition)
await tx.refreshToken.deleteMany({ where: { id: storedToken.id } });
const newTokenPayload = { const newTokenPayload = {
userId: user.id,
email: user.email,
role: user.role,
tenantId: user.tenantId,
schemaName: user.tenant.schemaName,
};
const accessToken = generateAccessToken(newTokenPayload);
const refreshToken = generateRefreshToken(newTokenPayload);
await prisma.refreshToken.create({
data: {
userId: user.id, userId: user.id,
token: refreshToken, email: user.email,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), role: user.role,
}, tenantId: user.tenantId,
}); schemaName: user.tenant.schemaName,
};
return { accessToken, refreshToken }; const accessToken = generateAccessToken(newTokenPayload);
const refreshToken = generateRefreshToken(newTokenPayload);
await tx.refreshToken.create({
data: {
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return { accessToken, refreshToken };
});
} }
export async function logout(token: string): Promise<void> { export async function logout(token: string): Promise<void> {

View File

@@ -86,6 +86,7 @@ export async function getCfdiById(schema: string, id: string): Promise<Cfdi | nu
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago", tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
forma_pago as "formaPago", uso_cfdi as "usoCfdi", forma_pago as "formaPago", uso_cfdi as "usoCfdi",
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl", estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
xml_original as "xmlOriginal",
created_at as "createdAt" created_at as "createdAt"
FROM "${schema}".cfdis FROM "${schema}".cfdis
WHERE id = $1 WHERE id = $1
@@ -94,6 +95,14 @@ export async function getCfdiById(schema: string, id: string): Promise<Cfdi | nu
return result[0] || null; return result[0] || null;
} }
export async function getXmlById(schema: string, id: string): Promise<string | null> {
const result = await prisma.$queryRawUnsafe<[{ xml_original: string | null }]>(`
SELECT xml_original FROM "${schema}".cfdis WHERE id = $1
`, id);
return result[0]?.xml_original || null;
}
export interface CreateCfdiData { export interface CreateCfdiData {
uuidFiscal: string; uuidFiscal: string;
tipo: 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago'; tipo: 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago';

View File

@@ -96,7 +96,7 @@ async function saveCfdis(
estado = $22, estado = $22,
xml_original = $23, xml_original = $23,
last_sat_sync = NOW(), last_sat_sync = NOW(),
sat_sync_job_id = $24, sat_sync_job_id = $24::uuid,
updated_at = NOW() updated_at = NOW()
WHERE uuid_fiscal = $1`, WHERE uuid_fiscal = $1`,
cfdi.uuidFiscal, cfdi.uuidFiscal,
@@ -137,7 +137,7 @@ async function saveCfdis(
) VALUES ( ) VALUES (
gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22,
$23, 'sat', $24, NOW(), NOW() $23, 'sat', $24::uuid, NOW(), NOW()
)`, )`,
cfdi.uuidFiscal, cfdi.uuidFiscal,
cfdi.tipo, cfdi.tipo,
@@ -278,11 +278,18 @@ async function processDateRange(
} }
/** /**
* Ejecuta sincronización inicial (últimos 10 años) * Ejecuta sincronización inicial o por rango personalizado
*/ */
async function processInitialSync(ctx: SyncContext, jobId: string): Promise<void> { async function processInitialSync(
ctx: SyncContext,
jobId: string,
customDateFrom?: Date,
customDateTo?: Date
): Promise<void> {
const ahora = new Date(); const ahora = new Date();
const inicioHistorico = new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1); // Usar fechas personalizadas si se proporcionan, sino calcular desde YEARS_TO_SYNC
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
const fechaFin = customDateTo || ahora;
let totalFound = 0; let totalFound = 0;
let totalDownloaded = 0; let totalDownloaded = 0;
@@ -292,9 +299,9 @@ async function processInitialSync(ctx: SyncContext, jobId: string): Promise<void
// Procesar por meses para evitar límites del SAT // Procesar por meses para evitar límites del SAT
let currentDate = new Date(inicioHistorico); let currentDate = new Date(inicioHistorico);
while (currentDate < ahora) { while (currentDate < fechaFin) {
const monthEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0, 23, 59, 59); const monthEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0, 23, 59, 59);
const rangeEnd = monthEnd > ahora ? ahora : monthEnd; const rangeEnd = monthEnd > fechaFin ? fechaFin : monthEnd;
// Procesar emitidos // Procesar emitidos
try { try {
@@ -446,7 +453,7 @@ export async function startSync(
(async () => { (async () => {
try { try {
if (type === 'initial') { if (type === 'initial') {
await processInitialSync(ctx, job.id); await processInitialSync(ctx, job.id, dateFrom, dateTo);
} else { } else {
await processDailySync(ctx, job.id); await processDailySync(ctx, job.id);
} }

View File

@@ -1,17 +1,19 @@
import jwt from 'jsonwebtoken'; import jwt, { type SignOptions } from 'jsonwebtoken';
import type { JWTPayload } from '@horux/shared'; import type { JWTPayload } from '@horux/shared';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string { export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
return jwt.sign(payload, env.JWT_SECRET, { const options: SignOptions = {
expiresIn: env.JWT_EXPIRES_IN, expiresIn: env.JWT_EXPIRES_IN as SignOptions['expiresIn'],
}); };
return jwt.sign(payload, env.JWT_SECRET, options);
} }
export function generateRefreshToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string { export function generateRefreshToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
return jwt.sign(payload, env.JWT_SECRET, { const options: SignOptions = {
expiresIn: env.JWT_REFRESH_EXPIRES_IN, expiresIn: env.JWT_REFRESH_EXPIRES_IN as SignOptions['expiresIn'],
}); };
return jwt.sign(payload, env.JWT_SECRET, options);
} }
export function verifyToken(token: string): JWTPayload { export function verifyToken(token: string): JWTPayload {

View File

@@ -9,9 +9,11 @@ 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 { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi'; import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
import { createManyCfdis } from '@/lib/api/cfdi'; import { createManyCfdis } from '@/lib/api/cfdi';
import type { CfdiFilters, TipoCfdi } from '@horux/shared'; import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
import type { CreateCfdiData } from '@/lib/api/cfdi'; import type { CreateCfdiData } from '@/lib/api/cfdi';
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'; import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2, Eye } from 'lucide-react';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { getCfdiById } from '@/lib/api/cfdi';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useTenantViewStore } from '@/stores/tenant-view-store'; import { useTenantViewStore } from '@/stores/tenant-view-store';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
@@ -255,6 +257,23 @@ export default function CfdiPage() {
const createCfdi = useCreateCfdi(); const createCfdi = useCreateCfdi();
const deleteCfdi = useDeleteCfdi(); const deleteCfdi = useDeleteCfdi();
// CFDI Viewer state
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
const [loadingCfdi, setLoadingCfdi] = useState<string | null>(null);
const handleViewCfdi = async (id: string) => {
setLoadingCfdi(id);
try {
const cfdi = await getCfdiById(id);
setViewingCfdi(cfdi);
} catch (error) {
console.error('Error loading CFDI:', error);
alert('Error al cargar el CFDI');
} finally {
setLoadingCfdi(null);
}
};
const canEdit = user?.role === 'admin' || user?.role === 'contador'; const canEdit = user?.role === 'admin' || user?.role === 'contador';
const handleSearch = () => { const handleSearch = () => {
@@ -1067,6 +1086,7 @@ export default function CfdiPage() {
<th className="pb-3 font-medium">Receptor</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>
<th className="pb-3 font-medium"></th>
{canEdit && <th className="pb-3 font-medium"></th>} {canEdit && <th className="pb-3 font-medium"></th>}
</tr> </tr>
</thead> </thead>
@@ -1122,6 +1142,21 @@ export default function CfdiPage() {
{cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado'} {cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado'}
</span> </span>
</td> </td>
<td className="py-3">
<Button
variant="ghost"
size="icon"
onClick={() => handleViewCfdi(cfdi.id)}
disabled={loadingCfdi === cfdi.id}
title="Ver factura"
>
{loadingCfdi === cfdi.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</td>
{canEdit && ( {canEdit && (
<td className="py-3"> <td className="py-3">
<Button <Button
@@ -1174,6 +1209,12 @@ export default function CfdiPage() {
</CardContent> </CardContent>
</Card> </Card>
</main> </main>
<CfdiViewerModal
cfdi={viewingCfdi}
open={viewingCfdi !== null}
onClose={() => setViewingCfdi(null)}
/>
</> </>
); );
} }

View File

@@ -0,0 +1,230 @@
'use client';
import { forwardRef } from 'react';
import type { Cfdi } from '@horux/shared';
interface CfdiConcepto {
descripcion: string;
cantidad: number;
valorUnitario: number;
importe: number;
claveUnidad?: string;
claveProdServ?: string;
}
interface CfdiInvoiceProps {
cfdi: Cfdi;
conceptos?: CfdiConcepto[];
}
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
const formatDate = (dateString: string) =>
new Date(dateString).toLocaleDateString('es-MX', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
const tipoLabels: Record<string, string> = {
ingreso: 'Ingreso',
egreso: 'Egreso',
traslado: 'Traslado',
pago: 'Pago',
nomina: 'Nomina',
};
const formaPagoLabels: Record<string, string> = {
'01': 'Efectivo',
'02': 'Cheque nominativo',
'03': 'Transferencia electrónica',
'04': 'Tarjeta de crédito',
'28': 'Tarjeta de débito',
'99': 'Por definir',
};
const metodoPagoLabels: Record<string, string> = {
PUE: 'Pago en una sola exhibición',
PPD: 'Pago en parcialidades o diferido',
};
export const CfdiInvoice = forwardRef<HTMLDivElement, CfdiInvoiceProps>(
({ cfdi, conceptos }, ref) => {
return (
<div
ref={ref}
className="bg-white text-black p-8 max-w-[800px] mx-auto text-sm"
style={{ fontFamily: 'Arial, sans-serif' }}
>
{/* Header */}
<div className="flex justify-between items-start border-b-2 border-gray-800 pb-4 mb-4">
<div className="w-32 h-20 bg-gray-200 flex items-center justify-center text-gray-500 text-xs">
[LOGO]
</div>
<div className="text-right">
<h1 className="text-2xl font-bold text-gray-800">FACTURA</h1>
<p className="text-gray-600">
{cfdi.serie && `Serie: ${cfdi.serie} `}
{cfdi.folio && `Folio: ${cfdi.folio}`}
</p>
<p className="text-gray-600">Fecha: {formatDate(cfdi.fechaEmision)}</p>
<span
className={`inline-block px-2 py-1 text-xs font-semibold rounded mt-1 ${
cfdi.estado === 'vigente'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{cfdi.estado === 'vigente' ? 'VIGENTE' : 'CANCELADO'}
</span>
</div>
</div>
{/* Emisor / Receptor */}
<div className="grid grid-cols-2 gap-6 mb-6">
<div className="border border-gray-300 p-4 rounded">
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
EMISOR
</h3>
<p className="font-semibold">{cfdi.nombreEmisor}</p>
<p className="text-gray-600">RFC: {cfdi.rfcEmisor}</p>
</div>
<div className="border border-gray-300 p-4 rounded">
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
RECEPTOR
</h3>
<p className="font-semibold">{cfdi.nombreReceptor}</p>
<p className="text-gray-600">RFC: {cfdi.rfcReceptor}</p>
{cfdi.usoCfdi && (
<p className="text-gray-600">Uso CFDI: {cfdi.usoCfdi}</p>
)}
</div>
</div>
{/* Datos del Comprobante */}
<div className="border border-gray-300 p-4 rounded mb-6">
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
DATOS DEL COMPROBANTE
</h3>
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500">Tipo:</span>
<p className="font-medium">{tipoLabels[cfdi.tipo] || cfdi.tipo}</p>
</div>
<div>
<span className="text-gray-500">Método de Pago:</span>
<p className="font-medium">
{cfdi.metodoPago ? metodoPagoLabels[cfdi.metodoPago] || cfdi.metodoPago : '-'}
</p>
</div>
<div>
<span className="text-gray-500">Forma de Pago:</span>
<p className="font-medium">
{cfdi.formaPago ? formaPagoLabels[cfdi.formaPago] || cfdi.formaPago : '-'}
</p>
</div>
<div>
<span className="text-gray-500">Moneda:</span>
<p className="font-medium">
{cfdi.moneda}
{cfdi.tipoCambio !== 1 && ` (TC: ${cfdi.tipoCambio})`}
</p>
</div>
</div>
</div>
{/* Conceptos */}
{conceptos && conceptos.length > 0 && (
<div className="mb-6">
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
CONCEPTOS
</h3>
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-100">
<th className="text-left p-2 border">Descripción</th>
<th className="text-center p-2 border w-20">Cant.</th>
<th className="text-right p-2 border w-28">P. Unit.</th>
<th className="text-right p-2 border w-28">Importe</th>
</tr>
</thead>
<tbody>
{conceptos.map((concepto, idx) => (
<tr key={idx} className="border-b">
<td className="p-2 border">{concepto.descripcion}</td>
<td className="text-center p-2 border">{concepto.cantidad}</td>
<td className="text-right p-2 border">
{formatCurrency(concepto.valorUnitario)}
</td>
<td className="text-right p-2 border">
{formatCurrency(concepto.importe)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Totales */}
<div className="flex justify-end mb-6">
<div className="w-64">
<div className="flex justify-between py-1 border-b">
<span className="text-gray-600">Subtotal:</span>
<span>{formatCurrency(cfdi.subtotal)}</span>
</div>
{cfdi.descuento > 0 && (
<div className="flex justify-between py-1 border-b">
<span className="text-gray-600">Descuento:</span>
<span className="text-red-600">-{formatCurrency(cfdi.descuento)}</span>
</div>
)}
{cfdi.iva > 0 && (
<div className="flex justify-between py-1 border-b">
<span className="text-gray-600">IVA (16%):</span>
<span>{formatCurrency(cfdi.iva)}</span>
</div>
)}
{cfdi.ivaRetenido > 0 && (
<div className="flex justify-between py-1 border-b">
<span className="text-gray-600">IVA Retenido:</span>
<span className="text-red-600">-{formatCurrency(cfdi.ivaRetenido)}</span>
</div>
)}
{cfdi.isrRetenido > 0 && (
<div className="flex justify-between py-1 border-b">
<span className="text-gray-600">ISR Retenido:</span>
<span className="text-red-600">-{formatCurrency(cfdi.isrRetenido)}</span>
</div>
)}
<div className="flex justify-between py-2 font-bold text-lg border-t-2 border-gray-800 mt-1">
<span>TOTAL:</span>
<span>{formatCurrency(cfdi.total)}</span>
</div>
</div>
</div>
{/* Timbre Fiscal */}
<div className="border-t-2 border-gray-800 pt-4">
<h3 className="font-bold text-gray-700 mb-2">TIMBRE FISCAL DIGITAL</h3>
<div className="grid grid-cols-2 gap-4 text-xs">
<div>
<p className="text-gray-500">UUID:</p>
<p className="font-mono break-all">{cfdi.uuidFiscal}</p>
</div>
<div>
<p className="text-gray-500">Fecha de Timbrado:</p>
<p>{cfdi.fechaTimbrado}</p>
</div>
</div>
</div>
</div>
);
}
);
CfdiInvoice.displayName = 'CfdiInvoice';

View File

@@ -0,0 +1,169 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import type { Cfdi } from '@horux/shared';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { CfdiInvoice } from './cfdi-invoice';
import { getCfdiXml } from '@/lib/api/cfdi';
import { Download, FileText, Loader2 } from 'lucide-react';
interface CfdiConcepto {
descripcion: string;
cantidad: number;
valorUnitario: number;
importe: number;
}
interface CfdiViewerModalProps {
cfdi: Cfdi | null;
open: boolean;
onClose: () => void;
}
function parseConceptosFromXml(xmlString: string): CfdiConcepto[] {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlString, 'text/xml');
const conceptos: CfdiConcepto[] = [];
// Find all Concepto elements
const elements = doc.getElementsByTagName('*');
for (let i = 0; i < elements.length; i++) {
if (elements[i].localName === 'Concepto') {
const el = elements[i];
conceptos.push({
descripcion: el.getAttribute('Descripcion') || '',
cantidad: parseFloat(el.getAttribute('Cantidad') || '1'),
valorUnitario: parseFloat(el.getAttribute('ValorUnitario') || '0'),
importe: parseFloat(el.getAttribute('Importe') || '0'),
});
}
}
return conceptos;
} catch {
return [];
}
}
export function CfdiViewerModal({ cfdi, open, onClose }: CfdiViewerModalProps) {
const invoiceRef = useRef<HTMLDivElement>(null);
const [conceptos, setConceptos] = useState<CfdiConcepto[]>([]);
const [downloading, setDownloading] = useState<'pdf' | 'xml' | null>(null);
const [xmlContent, setXmlContent] = useState<string | null>(null);
useEffect(() => {
if (cfdi?.xmlOriginal) {
setXmlContent(cfdi.xmlOriginal);
setConceptos(parseConceptosFromXml(cfdi.xmlOriginal));
} else {
setXmlContent(null);
setConceptos([]);
}
}, [cfdi]);
const handleDownloadPdf = async () => {
if (!invoiceRef.current || !cfdi) return;
setDownloading('pdf');
try {
const html2pdf = (await import('html2pdf.js')).default;
const opt = {
margin: 10,
filename: `factura-${cfdi.uuidFiscal.substring(0, 8)}.pdf`,
image: { type: 'jpeg' as const, quality: 0.98 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: 'mm' as const, format: 'a4' as const, orientation: 'portrait' as const },
};
await html2pdf().set(opt).from(invoiceRef.current).save();
} catch (error) {
console.error('Error generating PDF:', error);
alert('Error al generar el PDF');
} finally {
setDownloading(null);
}
};
const handleDownloadXml = async () => {
if (!cfdi) return;
setDownloading('xml');
try {
let xml = xmlContent;
if (!xml) {
xml = await getCfdiXml(cfdi.id);
}
if (!xml) {
alert('No hay XML disponible para este CFDI');
return;
}
const blob = new Blob([xml], { type: 'application/xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `cfdi-${cfdi.uuidFiscal.substring(0, 8)}.xml`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading XML:', error);
alert('Error al descargar el XML');
} finally {
setDownloading(null);
}
};
if (!cfdi) return null;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle>Vista de Factura</DialogTitle>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleDownloadPdf}
disabled={downloading !== null}
>
{downloading === 'pdf' ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Download className="h-4 w-4 mr-1" />
)}
PDF
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDownloadXml}
disabled={downloading !== null || !xmlContent}
title={!xmlContent ? 'XML no disponible' : 'Descargar XML'}
>
{downloading === 'xml' ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<FileText className="h-4 w-4 mr-1" />
)}
XML
</Button>
</div>
</div>
</DialogHeader>
<div className="border rounded-lg overflow-hidden bg-gray-50 p-4">
<CfdiInvoice ref={invoiceRef} cfdi={cfdi} conceptos={conceptos} />
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,4 +1,3 @@
import { Sidebar } from './sidebar';
import { Header } from './header'; import { Header } from './header';
interface DashboardShellProps { interface DashboardShellProps {
@@ -8,13 +7,12 @@ interface DashboardShellProps {
} }
export function DashboardShell({ children, title, headerContent }: DashboardShellProps) { export function DashboardShell({ children, title, headerContent }: DashboardShellProps) {
// Navigation is handled by the parent layout.tsx which respects theme settings
// DashboardShell only provides Header and content wrapper
return ( return (
<div className="min-h-screen bg-background"> <>
<Sidebar /> <Header title={title}>{headerContent}</Header>
<div className="pl-64"> <main className="p-6">{children}</main>
<Header title={title}>{headerContent}</Header> </>
<main className="p-6">{children}</main>
</div>
</div>
); );
} }

View File

@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth-store';
/** /**
* Onboarding persistence key. * Onboarding persistence key.
@@ -11,6 +12,7 @@ const STORAGE_KEY = 'horux360:onboarding_seen_v1';
export default function OnboardingScreen() { export default function OnboardingScreen() {
const router = useRouter(); const router = useRouter();
const { isAuthenticated, _hasHydrated } = useAuthStore();
const [isNewUser, setIsNewUser] = useState(true); const [isNewUser, setIsNewUser] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -21,6 +23,13 @@ export default function OnboardingScreen() {
router.push(path); router.push(path);
}; };
// Redirect to login if not authenticated
useEffect(() => {
if (_hasHydrated && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, _hasHydrated, router]);
useEffect(() => { useEffect(() => {
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1'; const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
@@ -46,6 +55,20 @@ export default function OnboardingScreen() {
const headerStatus = useMemo(() => (isNewUser ? 'Onboarding' : 'Redirección'), [isNewUser]); const headerStatus = useMemo(() => (isNewUser ? 'Onboarding' : 'Redirección'), [isNewUser]);
// Show loading while store hydrates
if (!_hasHydrated) {
return (
<div className="min-h-screen flex items-center justify-center bg-white">
<div className="animate-pulse text-slate-500">Cargando...</div>
</div>
);
}
// Don't render if not authenticated
if (!isAuthenticated) {
return null;
}
return ( return (
<main className="min-h-screen relative overflow-hidden bg-white"> <main className="min-h-screen relative overflow-hidden bg-white">
{/* Grid tech claro */} {/* Grid tech claro */}
@@ -160,9 +183,6 @@ export default function OnboardingScreen() {
</div> </div>
</div> </div>
<p className="mt-4 text-center text-xs text-slate-400">
Demo UI sin backend Persistencia local: localStorage
</p>
</div> </div>
</div> </div>
</main> </main>

View File

@@ -3,6 +3,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { getSyncStatus, startSync } from '@/lib/api/sat'; import { getSyncStatus, startSync } from '@/lib/api/sat';
import type { SatSyncStatusResponse } from '@horux/shared'; import type { SatSyncStatusResponse } from '@horux/shared';
@@ -30,6 +32,9 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [startingSync, setStartingSync] = useState(false); const [startingSync, setStartingSync] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [showCustomDate, setShowCustomDate] = useState(false);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const fetchStatus = async () => { const fetchStatus = async () => {
try { try {
@@ -53,12 +58,21 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
} }
}, [fielConfigured]); }, [fielConfigured]);
const handleStartSync = async (type: 'initial' | 'daily') => { const handleStartSync = async (type: 'initial' | 'daily', customDates?: boolean) => {
setStartingSync(true); setStartingSync(true);
setError(''); setError('');
try { try {
await startSync({ type }); const params: { type: 'initial' | 'daily'; dateFrom?: string; dateTo?: string } = { type };
if (customDates && dateFrom && dateTo) {
// Convertir a formato completo con hora
params.dateFrom = `${dateFrom}T00:00:00`;
params.dateTo = `${dateTo}T23:59:59`;
}
await startSync(params);
await fetchStatus(); await fetchStatus();
setShowCustomDate(false);
onSyncStarted?.(); onSyncStarted?.();
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.error || 'Error al iniciar sincronizacion'); setError(err.response?.data?.error || 'Error al iniciar sincronizacion');
@@ -162,6 +176,49 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
<p className="text-sm text-red-500">{error}</p> <p className="text-sm text-red-500">{error}</p>
)} )}
{/* Formulario de fechas personalizadas */}
{showCustomDate && (
<div className="p-4 bg-gray-50 rounded-lg space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="dateFrom">Fecha inicio</Label>
<Input
id="dateFrom"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
max={dateTo || undefined}
/>
</div>
<div>
<Label htmlFor="dateTo">Fecha fin</Label>
<Input
id="dateTo"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
min={dateFrom || undefined}
/>
</div>
</div>
<div className="flex gap-2">
<Button
disabled={startingSync || status?.hasActiveSync || !dateFrom || !dateTo}
onClick={() => handleStartSync('initial', true)}
className="flex-1"
>
{startingSync ? 'Iniciando...' : 'Sincronizar periodo'}
</Button>
<Button
variant="outline"
onClick={() => setShowCustomDate(false)}
>
Cancelar
</Button>
</div>
</div>
)}
<div className="flex gap-3"> <div className="flex gap-3">
<Button <Button
variant="outline" variant="outline"
@@ -169,18 +226,27 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
onClick={() => handleStartSync('daily')} onClick={() => handleStartSync('daily')}
className="flex-1" className="flex-1"
> >
{startingSync ? 'Iniciando...' : 'Sincronizar ahora'} {startingSync ? 'Iniciando...' : 'Sincronizar mes actual'}
</Button>
<Button
variant="outline"
disabled={startingSync || status?.hasActiveSync}
onClick={() => setShowCustomDate(!showCustomDate)}
className="flex-1"
>
Periodo personalizado
</Button> </Button>
{!status?.lastCompletedJob && (
<Button
disabled={startingSync || status?.hasActiveSync}
onClick={() => handleStartSync('initial')}
className="flex-1"
>
{startingSync ? 'Iniciando...' : 'Sincronizacion inicial (10 anos)'}
</Button>
)}
</div> </div>
{!status?.lastCompletedJob && (
<Button
disabled={startingSync || status?.hasActiveSync}
onClick={() => handleStartSync('initial')}
className="w-full"
>
{startingSync ? 'Iniciando...' : 'Sincronizacion inicial (6 anos)'}
</Button>
)}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -0,0 +1,122 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -89,3 +89,10 @@ export async function createManyCfdis(
export async function deleteCfdi(id: string): Promise<void> { export async function deleteCfdi(id: string): Promise<void> {
await apiClient.delete(`/cfdi/${id}`); await apiClient.delete(`/cfdi/${id}`);
} }
export async function getCfdiXml(id: string): Promise<string> {
const response = await apiClient.get<string>(`/cfdi/${id}/xml`, {
responseType: 'text'
});
return response.data;
}

View File

@@ -11,7 +11,7 @@
"dependencies": { "dependencies": {
"@horux/shared": "workspace:*", "@horux/shared": "workspace:*",
"@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.0", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.0", "@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.0", "@radix-ui/react-select": "^2.1.0",
@@ -26,12 +26,13 @@
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"html2pdf.js": "^0.14.0",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
"next": "^14.2.0", "next": "^14.2.0",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"recharts": "^2.12.0",
"react-hook-form": "^7.53.0", "react-hook-form": "^7.53.0",
"recharts": "^2.12.0",
"tailwind-merge": "^2.5.0", "tailwind-merge": "^2.5.0",
"zod": "^3.23.0", "zod": "^3.23.0",
"zustand": "^5.0.0" "zustand": "^5.0.0"

View File

@@ -0,0 +1,298 @@
# Implementación de Sincronización SAT
## Resumen
Sistema de sincronización automática de CFDIs con el SAT (Servicio de Administración Tributaria de México) para Horux360.
## Componentes Implementados
### 1. Backend (API)
#### Servicios
| Archivo | Descripción |
|---------|-------------|
| `src/services/fiel.service.ts` | Gestión de credenciales FIEL (e.firma) |
| `src/services/sat/sat-client.service.ts` | Cliente para el servicio web del SAT |
| `src/services/sat/sat.service.ts` | Lógica principal de sincronización |
| `src/services/sat/sat-crypto.service.ts` | Encriptación AES-256-GCM para credenciales |
| `src/services/sat/sat-parser.service.ts` | Parser de XMLs de CFDI |
#### Controladores
| Archivo | Descripción |
|---------|-------------|
| `src/controllers/fiel.controller.ts` | Endpoints para gestión de FIEL |
| `src/controllers/sat.controller.ts` | Endpoints para sincronización SAT |
#### Job Programado
| Archivo | Descripción |
|---------|-------------|
| `src/jobs/sat-sync.job.ts` | Cron job para sincronización diaria (3:00 AM) |
### 2. Frontend (Web)
#### Componentes
| Archivo | Descripción |
|---------|-------------|
| `components/sat/FielUploadModal.tsx` | Modal para subir certificado y llave FIEL |
| `components/sat/SyncStatus.tsx` | Estado de sincronización con selector de fechas |
| `components/sat/SyncHistory.tsx` | Historial de sincronizaciones |
#### Página
| Archivo | Descripción |
|---------|-------------|
| `app/(dashboard)/configuracion/sat/page.tsx` | Página de configuración SAT |
### 3. Base de Datos
#### Tabla Principal (schema public)
```sql
-- sat_sync_jobs: Almacena los trabajos de sincronización
CREATE TABLE sat_sync_jobs (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
type VARCHAR(20) NOT NULL, -- 'initial' | 'daily'
status VARCHAR(20) NOT NULL, -- 'pending' | 'running' | 'completed' | 'failed'
date_from TIMESTAMP NOT NULL,
date_to TIMESTAMP NOT NULL,
cfdi_type VARCHAR(20),
sat_request_id VARCHAR(100),
sat_package_ids TEXT[],
cfdis_found INTEGER DEFAULT 0,
cfdis_downloaded INTEGER DEFAULT 0,
cfdis_inserted INTEGER DEFAULT 0,
cfdis_updated INTEGER DEFAULT 0,
progress_percent INTEGER DEFAULT 0,
error_message TEXT,
started_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
retry_count INTEGER DEFAULT 0
);
-- fiel_credentials: Almacena las credenciales FIEL encriptadas
CREATE TABLE fiel_credentials (
id UUID PRIMARY KEY,
tenant_id UUID UNIQUE NOT NULL,
rfc VARCHAR(13) NOT NULL,
cer_data BYTEA NOT NULL,
key_data BYTEA NOT NULL,
key_password_encrypted BYTEA NOT NULL,
encryption_iv BYTEA NOT NULL,
encryption_tag BYTEA NOT NULL,
serial_number VARCHAR(100),
valid_from TIMESTAMP NOT NULL,
valid_until TIMESTAMP NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
#### Columnas agregadas a tabla cfdis (por tenant)
```sql
ALTER TABLE tenant_xxx.cfdis ADD COLUMN xml_original TEXT;
ALTER TABLE tenant_xxx.cfdis ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE tenant_xxx.cfdis ADD COLUMN last_sat_sync TIMESTAMP;
ALTER TABLE tenant_xxx.cfdis ADD COLUMN sat_sync_job_id UUID;
ALTER TABLE tenant_xxx.cfdis ADD COLUMN source VARCHAR(20) DEFAULT 'manual';
```
## Dependencias
```json
{
"@nodecfdi/sat-ws-descarga-masiva": "^2.0.0",
"@nodecfdi/credentials": "^2.0.0",
"@nodecfdi/cfdi-core": "^1.0.1"
}
```
## Flujo de Sincronización
```
1. Usuario configura FIEL (certificado .cer + llave .key + contraseña)
2. Sistema valida y encripta credenciales (AES-256-GCM)
3. Usuario inicia sincronización (manual o automática 3:00 AM)
4. Sistema desencripta FIEL y crea cliente SAT
5. Por cada mes en el rango:
a. Solicitar CFDIs emitidos al SAT
b. Esperar respuesta (polling cada 30s)
c. Descargar paquetes ZIP
d. Extraer y parsear XMLs
e. Guardar en BD del tenant
f. Repetir para CFDIs recibidos
6. Marcar job como completado
```
## API Endpoints
### FIEL
| Método | Ruta | Descripción |
|--------|------|-------------|
| GET | `/api/fiel/status` | Estado de la FIEL configurada |
| POST | `/api/fiel/upload` | Subir nueva FIEL |
| DELETE | `/api/fiel` | Eliminar FIEL |
### Sincronización SAT
| Método | Ruta | Descripción |
|--------|------|-------------|
| POST | `/api/sat/sync` | Iniciar sincronización |
| GET | `/api/sat/sync/status` | Estado actual |
| GET | `/api/sat/sync/history` | Historial de syncs |
| GET | `/api/sat/sync/:id` | Detalle de un job |
| POST | `/api/sat/sync/:id/retry` | Reintentar job fallido |
### Parámetros de sincronización
```typescript
interface StartSyncRequest {
type?: 'initial' | 'daily'; // default: 'daily'
dateFrom?: string; // ISO date, ej: "2025-01-01T00:00:00"
dateTo?: string; // ISO date, ej: "2025-12-31T23:59:59"
}
```
## Configuración
### Variables de entorno
```env
# Clave para encriptar credenciales FIEL (32 bytes hex)
FIEL_ENCRYPTION_KEY=tu_clave_de_32_bytes_en_hexadecimal
# Zona horaria para el cron
TZ=America/Mexico_City
```
### Límites del SAT
- **Antigüedad máxima**: 6 años
- **Solicitudes por día**: Limitadas (se reinicia cada 24h)
- **Tamaño de paquete**: Variable
## Errores Comunes del SAT
| Código | Mensaje | Solución |
|--------|---------|----------|
| 5000 | Solicitud Aceptada | OK - esperar verificación |
| 5002 | Límite de solicitudes agotado | Esperar 24 horas |
| 5004 | No se encontraron CFDIs | Normal si no hay facturas en el rango |
| 5005 | Solicitud duplicada | Ya existe una solicitud pendiente |
| - | Información mayor a 6 años | Ajustar rango de fechas |
| - | No se permite descarga de cancelados | Facturas canceladas no disponibles |
## Seguridad
1. **Encriptación de credenciales**: AES-256-GCM con IV único
2. **Almacenamiento seguro**: Certificado, llave y contraseña encriptados
3. **Autenticación**: JWT con tenantId embebido
4. **Aislamiento**: Cada tenant tiene su propio schema en PostgreSQL
## Servicios Systemd
```bash
# API Backend
systemctl status horux-api
# Web Frontend
systemctl status horux-web
```
## Comandos Útiles
```bash
# Ver logs de sincronización SAT
journalctl -u horux-api -f | grep "\[SAT\]"
# Estado de jobs
psql -U postgres -d horux360 -c "SELECT * FROM sat_sync_jobs ORDER BY created_at DESC LIMIT 5;"
# CFDIs sincronizados por tenant
psql -U postgres -d horux360 -c "SELECT COUNT(*) FROM tenant_xxx.cfdis WHERE source = 'sat';"
```
## Changelog
### 2026-01-25
- Implementación inicial de sincronización SAT
- Integración con librería @nodecfdi/sat-ws-descarga-masiva
- Soporte para fechas personalizadas en sincronización
- Corrección de cast UUID en queries SQL
- Agregadas columnas faltantes a tabla cfdis
- UI para selección de periodo personalizado
- Cambio de servicio web a modo producción (next start)
## Estado Actual (2026-01-25)
### Completado
- [x] Servicio de encriptación de credenciales FIEL
- [x] Integración con @nodecfdi/sat-ws-descarga-masiva
- [x] Parser de XMLs de CFDI
- [x] UI para subir FIEL
- [x] UI para ver estado de sincronización
- [x] UI para seleccionar periodo personalizado
- [x] Cron job para sincronización diaria (3:00 AM)
- [x] Soporte para fechas personalizadas
- [x] Corrección de cast UUID en queries
- [x] Columnas adicionales en tabla cfdis de todos los tenants
### Pendiente por probar
El SAT bloqueó las solicitudes por exceso de pruebas. **Esperar 24 horas** y luego:
1. Ir a **Configuración > SAT**
2. Clic en **"Periodo personalizado"**
3. Seleccionar: **2025-01-01** a **2025-12-31**
4. Clic en **"Sincronizar periodo"**
### Tenant de prueba
- **RFC**: CAS2408138W2
- **Schema**: `tenant_cas2408138w2`
- **Nota**: Los CFDIs "recibidos" de este tenant están cancelados (SAT no permite descargarlos)
### Comandos para verificar después de 24h
```bash
# Ver estado del sync
PGPASSWORD=postgres psql -h localhost -U postgres -d horux360 -c \
"SELECT status, cfdis_found, cfdis_downloaded, cfdis_inserted FROM sat_sync_jobs ORDER BY created_at DESC LIMIT 1;"
# Ver logs en tiempo real
journalctl -u horux-api -f | grep "\[SAT\]"
# Contar CFDIs sincronizados
PGPASSWORD=postgres psql -h localhost -U postgres -d horux360 -c \
"SELECT COUNT(*) as total FROM tenant_cas2408138w2.cfdis WHERE source = 'sat';"
```
### Problemas conocidos
1. **"Se han agotado las solicitudes de por vida"**: Límite de SAT alcanzado, esperar 24h
2. **"No se permite la descarga de xml que se encuentren cancelados"**: Normal para facturas canceladas
3. **"Información mayor a 6 años"**: SAT solo permite descargar últimos 6 años
## Próximos Pasos
- [ ] Probar sincronización completa después de 24h
- [ ] Verificar que los CFDIs se guarden correctamente
- [ ] Implementar reintentos automáticos para errores temporales
- [ ] Notificaciones por email al completar sincronización
- [ ] Dashboard con estadísticas de CFDIs por periodo
- [ ] Soporte para filtros adicionales (RFC emisor/receptor, tipo de comprobante)

View File

@@ -0,0 +1,126 @@
# Diseño: Visor de CFDI
**Fecha:** 2026-02-17
**Estado:** Aprobado
## Resumen
Agregar funcionalidad para visualizar facturas CFDI en formato PDF-like, recreando la representación visual desde el XML almacenado. Incluye descarga de PDF y XML.
## Decisiones de Diseño
- **Tipo de vista:** PDF-like (representación visual similar a factura impresa)
- **Acceso:** Botón "Ver" (icono ojo) en cada fila de la tabla
- **Acciones:** Descargar PDF, Descargar XML
- **Enfoque técnico:** Componente React + html2pdf.js para generación de PDF en cliente
## Arquitectura de Componentes
```
CfdiPage (existente)
├── Tabla de CFDIs
│ └── Botón "Ver" (Eye icon) → abre modal
└── CfdiViewerModal (NUEVO)
├── Header: Título + Botones (PDF, XML, Cerrar)
└── CfdiInvoice (NUEVO)
├── Encabezado (Emisor + Receptor)
├── Datos del comprobante
├── Tabla de conceptos (parseados del XML)
├── Totales e impuestos
└── Timbre fiscal (UUID, fechas)
```
## Componentes Nuevos
| Componente | Ubicación | Responsabilidad |
|------------|-----------|-----------------|
| `CfdiViewerModal` | `components/cfdi/cfdi-viewer-modal.tsx` | Modal con visor y botones de acción |
| `CfdiInvoice` | `components/cfdi/cfdi-invoice.tsx` | Renderiza la factura estilo PDF |
## Diseño Visual
```
┌──────────────────────────────────────────────────────────────┐
│ ┌─────────────────┐ FACTURA │
│ │ [LOGO] │ Serie: A Folio: 001 │
│ │ placeholder │ Fecha: 15/Ene/2025 │
│ └─────────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ EMISOR │ RECEPTOR │
│ Empresa Emisora SA de CV │ Cliente SA de CV │
│ RFC: XAXX010101000 │ RFC: XAXX010101001 │
│ │ Uso CFDI: G03 │
├──────────────────────────────────────────────────────────────┤
│ DATOS DEL COMPROBANTE │
│ Tipo: Ingreso Método: PUE Forma: 03 - Transferencia │
│ Moneda: MXN Tipo Cambio: 1.00 │
├──────────────────────────────────────────────────────────────┤
│ CONCEPTOS │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Descripción │ Cant │ P. Unit │ Importe │ │
│ ├──────────────────────────────────────────────────────┤ │
│ │ Servicio consultoría │ 1 │ 10,000 │ 10,000.00 │ │
│ └──────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ Subtotal: $10,000.00 │
│ IVA 16%: $1,600.00 │
│ TOTAL: $11,600.00 │
├──────────────────────────────────────────────────────────────┤
│ TIMBRE FISCAL DIGITAL │
│ UUID: 12345678-1234-1234-1234-123456789012 │
│ Fecha Timbrado: 2025-01-15T12:30:45 │
└──────────────────────────────────────────────────────────────┘
```
## Flujo de Datos
1. Usuario hace clic en "Ver" (Eye icon)
2. Se abre CfdiViewerModal con el CFDI seleccionado
3. Si existe xmlOriginal:
- Parsear XML para extraer conceptos
- Mostrar factura completa
4. Si no existe XML:
- Mostrar factura con datos de BD (sin conceptos)
5. Acciones disponibles:
- Descargar PDF (html2pdf genera PDF)
- Descargar XML (si existe)
## Cambios en Backend
### Nuevo Endpoint
```
GET /api/cfdi/:id/xml
```
Retorna el XML original del CFDI.
### Modificar Endpoint Existente
```
GET /api/cfdi/:id
```
Agregar campo `xmlOriginal` a la respuesta.
## Dependencias
```json
{
"html2pdf.js": "^0.10.1"
}
```
## Archivos a Crear/Modificar
### Nuevos
- `apps/web/components/cfdi/cfdi-viewer-modal.tsx`
- `apps/web/components/cfdi/cfdi-invoice.tsx`
- `apps/api/src/controllers/cfdi.controller.ts` (nuevo método getXml)
### Modificar
- `apps/web/app/(dashboard)/cfdi/page.tsx` (agregar botón Ver y modal)
- `apps/api/src/routes/cfdi.routes.ts` (agregar ruta /xml)
- `apps/api/src/services/cfdi.service.ts` (agregar método getXmlById)
- `packages/shared/src/types/cfdi.ts` (agregar xmlOriginal a Cfdi)

View File

@@ -0,0 +1,816 @@
# CFDI Viewer Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add PDF-like invoice visualization for CFDIs with PDF and XML download capabilities.
**Architecture:** React modal component with invoice renderer. Backend returns XML via new endpoint. html2pdf.js generates PDF client-side from rendered HTML.
**Tech Stack:** React, TypeScript, html2pdf.js, Tailwind CSS
---
## Task 1: Install html2pdf.js Dependency
**Files:**
- Modify: `apps/web/package.json`
**Step 1: Install the dependency**
Run:
```bash
cd /root/Horux/apps/web && pnpm add html2pdf.js
```
**Step 2: Verify installation**
Run:
```bash
grep html2pdf apps/web/package.json
```
Expected: `"html2pdf.js": "^0.10.x"`
**Step 3: Commit**
```bash
git add apps/web/package.json apps/web/pnpm-lock.yaml
git commit -m "chore: add html2pdf.js for PDF generation"
```
---
## Task 2: Add xmlOriginal to Cfdi Type
**Files:**
- Modify: `packages/shared/src/types/cfdi.ts:4-31`
**Step 1: Add xmlOriginal field to Cfdi interface**
In `packages/shared/src/types/cfdi.ts`, add after line 29 (`pdfUrl: string | null;`):
```typescript
xmlOriginal: string | null;
```
**Step 2: Verify types compile**
Run:
```bash
cd /root/Horux && pnpm build --filter=@horux/shared
```
Expected: Build succeeds
**Step 3: Commit**
```bash
git add packages/shared/src/types/cfdi.ts
git commit -m "feat(types): add xmlOriginal field to Cfdi interface"
```
---
## Task 3: Update Backend Service to Return XML
**Files:**
- Modify: `apps/api/src/services/cfdi.service.ts:77-95`
**Step 1: Update getCfdiById to include xml_original**
Replace the `getCfdiById` function:
```typescript
export async function getCfdiById(schema: string, id: string): Promise<Cfdi | null> {
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
SELECT
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",
xml_original as "xmlOriginal",
created_at as "createdAt"
FROM "${schema}".cfdis
WHERE id = $1
`, id);
return result[0] || null;
}
```
**Step 2: Add getXmlById function**
Add after `getCfdiById`:
```typescript
export async function getXmlById(schema: string, id: string): Promise<string | null> {
const result = await prisma.$queryRawUnsafe<[{ xml_original: string | null }]>(`
SELECT xml_original FROM "${schema}".cfdis WHERE id = $1
`, id);
return result[0]?.xml_original || null;
}
```
**Step 3: Verify API compiles**
Run:
```bash
cd /root/Horux/apps/api && pnpm build
```
Expected: Build succeeds
**Step 4: Commit**
```bash
git add apps/api/src/services/cfdi.service.ts
git commit -m "feat(api): add xmlOriginal to getCfdiById and add getXmlById"
```
---
## Task 4: Add XML Download Endpoint
**Files:**
- Modify: `apps/api/src/controllers/cfdi.controller.ts`
- Modify: `apps/api/src/routes/cfdi.routes.ts`
**Step 1: Add getXml controller function**
Add to `apps/api/src/controllers/cfdi.controller.ts` after `getCfdiById`:
```typescript
export async function getXml(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
}
const xml = await cfdiService.getXmlById(req.tenantSchema, String(req.params.id));
if (!xml) {
return next(new AppError(404, 'XML no encontrado para este CFDI'));
}
res.set('Content-Type', 'application/xml');
res.set('Content-Disposition', `attachment; filename="cfdi-${req.params.id}.xml"`);
res.send(xml);
} catch (error) {
next(error);
}
}
```
**Step 2: Add route for XML download**
In `apps/api/src/routes/cfdi.routes.ts`, add after line 13 (`router.get('/:id', ...)`):
```typescript
router.get('/:id/xml', cfdiController.getXml);
```
**Step 3: Verify API compiles**
Run:
```bash
cd /root/Horux/apps/api && pnpm build
```
Expected: Build succeeds
**Step 4: Commit**
```bash
git add apps/api/src/controllers/cfdi.controller.ts apps/api/src/routes/cfdi.routes.ts
git commit -m "feat(api): add GET /cfdi/:id/xml endpoint"
```
---
## Task 5: Add API Client Function for XML Download
**Files:**
- Modify: `apps/web/lib/api/cfdi.ts`
**Step 1: Add getCfdiXml function**
Add at the end of `apps/web/lib/api/cfdi.ts`:
```typescript
export async function getCfdiXml(id: string): Promise<string> {
const response = await apiClient.get<string>(`/cfdi/${id}/xml`, {
responseType: 'text'
});
return response.data;
}
```
**Step 2: Commit**
```bash
git add apps/web/lib/api/cfdi.ts
git commit -m "feat(web): add getCfdiXml API function"
```
---
## Task 6: Create CfdiInvoice Component
**Files:**
- Create: `apps/web/components/cfdi/cfdi-invoice.tsx`
**Step 1: Create the component**
Create `apps/web/components/cfdi/cfdi-invoice.tsx`:
```typescript
'use client';
import { forwardRef } from 'react';
import type { Cfdi } from '@horux/shared';
interface CfdiConcepto {
descripcion: string;
cantidad: number;
valorUnitario: number;
importe: number;
claveUnidad?: string;
claveProdServ?: string;
}
interface CfdiInvoiceProps {
cfdi: Cfdi;
conceptos?: CfdiConcepto[];
}
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
const formatDate = (dateString: string) =>
new Date(dateString).toLocaleDateString('es-MX', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
const tipoLabels: Record<string, string> = {
ingreso: 'Ingreso',
egreso: 'Egreso',
traslado: 'Traslado',
pago: 'Pago',
nomina: 'Nomina',
};
const formaPagoLabels: Record<string, string> = {
'01': 'Efectivo',
'02': 'Cheque nominativo',
'03': 'Transferencia electrónica',
'04': 'Tarjeta de crédito',
'28': 'Tarjeta de débito',
'99': 'Por definir',
};
const metodoPagoLabels: Record<string, string> = {
PUE: 'Pago en una sola exhibición',
PPD: 'Pago en parcialidades o diferido',
};
export const CfdiInvoice = forwardRef<HTMLDivElement, CfdiInvoiceProps>(
({ cfdi, conceptos }, ref) => {
return (
<div
ref={ref}
className="bg-white text-black p-8 max-w-[800px] mx-auto text-sm"
style={{ fontFamily: 'Arial, sans-serif' }}
>
{/* Header */}
<div className="flex justify-between items-start border-b-2 border-gray-800 pb-4 mb-4">
<div className="w-32 h-20 bg-gray-200 flex items-center justify-center text-gray-500 text-xs">
[LOGO]
</div>
<div className="text-right">
<h1 className="text-2xl font-bold text-gray-800">FACTURA</h1>
<p className="text-gray-600">
{cfdi.serie && `Serie: ${cfdi.serie} `}
{cfdi.folio && `Folio: ${cfdi.folio}`}
</p>
<p className="text-gray-600">Fecha: {formatDate(cfdi.fechaEmision)}</p>
<span
className={`inline-block px-2 py-1 text-xs font-semibold rounded mt-1 ${
cfdi.estado === 'vigente'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{cfdi.estado === 'vigente' ? 'VIGENTE' : 'CANCELADO'}
</span>
</div>
</div>
{/* Emisor / Receptor */}
<div className="grid grid-cols-2 gap-6 mb-6">
<div className="border border-gray-300 p-4 rounded">
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
EMISOR
</h3>
<p className="font-semibold">{cfdi.nombreEmisor}</p>
<p className="text-gray-600">RFC: {cfdi.rfcEmisor}</p>
</div>
<div className="border border-gray-300 p-4 rounded">
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
RECEPTOR
</h3>
<p className="font-semibold">{cfdi.nombreReceptor}</p>
<p className="text-gray-600">RFC: {cfdi.rfcReceptor}</p>
{cfdi.usoCfdi && (
<p className="text-gray-600">Uso CFDI: {cfdi.usoCfdi}</p>
)}
</div>
</div>
{/* Datos del Comprobante */}
<div className="border border-gray-300 p-4 rounded mb-6">
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
DATOS DEL COMPROBANTE
</h3>
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500">Tipo:</span>
<p className="font-medium">{tipoLabels[cfdi.tipo] || cfdi.tipo}</p>
</div>
<div>
<span className="text-gray-500">Método de Pago:</span>
<p className="font-medium">
{cfdi.metodoPago ? metodoPagoLabels[cfdi.metodoPago] || cfdi.metodoPago : '-'}
</p>
</div>
<div>
<span className="text-gray-500">Forma de Pago:</span>
<p className="font-medium">
{cfdi.formaPago ? formaPagoLabels[cfdi.formaPago] || cfdi.formaPago : '-'}
</p>
</div>
<div>
<span className="text-gray-500">Moneda:</span>
<p className="font-medium">
{cfdi.moneda}
{cfdi.tipoCambio !== 1 && ` (TC: ${cfdi.tipoCambio})`}
</p>
</div>
</div>
</div>
{/* Conceptos */}
{conceptos && conceptos.length > 0 && (
<div className="mb-6">
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
CONCEPTOS
</h3>
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-100">
<th className="text-left p-2 border">Descripción</th>
<th className="text-center p-2 border w-20">Cant.</th>
<th className="text-right p-2 border w-28">P. Unit.</th>
<th className="text-right p-2 border w-28">Importe</th>
</tr>
</thead>
<tbody>
{conceptos.map((concepto, idx) => (
<tr key={idx} className="border-b">
<td className="p-2 border">{concepto.descripcion}</td>
<td className="text-center p-2 border">{concepto.cantidad}</td>
<td className="text-right p-2 border">
{formatCurrency(concepto.valorUnitario)}
</td>
<td className="text-right p-2 border">
{formatCurrency(concepto.importe)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Totales */}
<div className="flex justify-end mb-6">
<div className="w-64">
<div className="flex justify-between py-1 border-b">
<span className="text-gray-600">Subtotal:</span>
<span>{formatCurrency(cfdi.subtotal)}</span>
</div>
{cfdi.descuento > 0 && (
<div className="flex justify-between py-1 border-b">
<span className="text-gray-600">Descuento:</span>
<span className="text-red-600">-{formatCurrency(cfdi.descuento)}</span>
</div>
)}
{cfdi.iva > 0 && (
<div className="flex justify-between py-1 border-b">
<span className="text-gray-600">IVA (16%):</span>
<span>{formatCurrency(cfdi.iva)}</span>
</div>
)}
{cfdi.ivaRetenido > 0 && (
<div className="flex justify-between py-1 border-b">
<span className="text-gray-600">IVA Retenido:</span>
<span className="text-red-600">-{formatCurrency(cfdi.ivaRetenido)}</span>
</div>
)}
{cfdi.isrRetenido > 0 && (
<div className="flex justify-between py-1 border-b">
<span className="text-gray-600">ISR Retenido:</span>
<span className="text-red-600">-{formatCurrency(cfdi.isrRetenido)}</span>
</div>
)}
<div className="flex justify-between py-2 font-bold text-lg border-t-2 border-gray-800 mt-1">
<span>TOTAL:</span>
<span>{formatCurrency(cfdi.total)}</span>
</div>
</div>
</div>
{/* Timbre Fiscal */}
<div className="border-t-2 border-gray-800 pt-4">
<h3 className="font-bold text-gray-700 mb-2">TIMBRE FISCAL DIGITAL</h3>
<div className="grid grid-cols-2 gap-4 text-xs">
<div>
<p className="text-gray-500">UUID:</p>
<p className="font-mono break-all">{cfdi.uuidFiscal}</p>
</div>
<div>
<p className="text-gray-500">Fecha de Timbrado:</p>
<p>{cfdi.fechaTimbrado}</p>
</div>
</div>
</div>
</div>
);
}
);
CfdiInvoice.displayName = 'CfdiInvoice';
```
**Step 2: Commit**
```bash
git add apps/web/components/cfdi/cfdi-invoice.tsx
git commit -m "feat(web): add CfdiInvoice component for PDF-like rendering"
```
---
## Task 7: Create CfdiViewerModal Component
**Files:**
- Create: `apps/web/components/cfdi/cfdi-viewer-modal.tsx`
**Step 1: Create the modal component**
Create `apps/web/components/cfdi/cfdi-viewer-modal.tsx`:
```typescript
'use client';
import { useRef, useState, useEffect } from 'react';
import type { Cfdi } from '@horux/shared';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { CfdiInvoice } from './cfdi-invoice';
import { getCfdiXml } from '@/lib/api/cfdi';
import { Download, FileText, X, Loader2 } from 'lucide-react';
interface CfdiConcepto {
descripcion: string;
cantidad: number;
valorUnitario: number;
importe: number;
}
interface CfdiViewerModalProps {
cfdi: Cfdi | null;
open: boolean;
onClose: () => void;
}
function parseConceptosFromXml(xmlString: string): CfdiConcepto[] {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlString, 'text/xml');
const conceptos: CfdiConcepto[] = [];
// Find all Concepto elements
const elements = doc.getElementsByTagName('*');
for (let i = 0; i < elements.length; i++) {
if (elements[i].localName === 'Concepto') {
const el = elements[i];
conceptos.push({
descripcion: el.getAttribute('Descripcion') || '',
cantidad: parseFloat(el.getAttribute('Cantidad') || '1'),
valorUnitario: parseFloat(el.getAttribute('ValorUnitario') || '0'),
importe: parseFloat(el.getAttribute('Importe') || '0'),
});
}
}
return conceptos;
} catch {
return [];
}
}
export function CfdiViewerModal({ cfdi, open, onClose }: CfdiViewerModalProps) {
const invoiceRef = useRef<HTMLDivElement>(null);
const [conceptos, setConceptos] = useState<CfdiConcepto[]>([]);
const [downloading, setDownloading] = useState<'pdf' | 'xml' | null>(null);
const [xmlContent, setXmlContent] = useState<string | null>(null);
useEffect(() => {
if (cfdi?.xmlOriginal) {
setXmlContent(cfdi.xmlOriginal);
setConceptos(parseConceptosFromXml(cfdi.xmlOriginal));
} else {
setXmlContent(null);
setConceptos([]);
}
}, [cfdi]);
const handleDownloadPdf = async () => {
if (!invoiceRef.current || !cfdi) return;
setDownloading('pdf');
try {
const html2pdf = (await import('html2pdf.js')).default;
const opt = {
margin: 10,
filename: `factura-${cfdi.uuidFiscal.substring(0, 8)}.pdf`,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
};
await html2pdf().set(opt).from(invoiceRef.current).save();
} catch (error) {
console.error('Error generating PDF:', error);
alert('Error al generar el PDF');
} finally {
setDownloading(null);
}
};
const handleDownloadXml = async () => {
if (!cfdi) return;
setDownloading('xml');
try {
let xml = xmlContent;
if (!xml) {
xml = await getCfdiXml(cfdi.id);
}
if (!xml) {
alert('No hay XML disponible para este CFDI');
return;
}
const blob = new Blob([xml], { type: 'application/xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `cfdi-${cfdi.uuidFiscal.substring(0, 8)}.xml`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading XML:', error);
alert('Error al descargar el XML');
} finally {
setDownloading(null);
}
};
if (!cfdi) return null;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle>Vista de Factura</DialogTitle>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleDownloadPdf}
disabled={downloading !== null}
>
{downloading === 'pdf' ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Download className="h-4 w-4 mr-1" />
)}
PDF
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDownloadXml}
disabled={downloading !== null || !xmlContent}
title={!xmlContent ? 'XML no disponible' : 'Descargar XML'}
>
{downloading === 'xml' ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<FileText className="h-4 w-4 mr-1" />
)}
XML
</Button>
</div>
</div>
</DialogHeader>
<div className="border rounded-lg overflow-hidden bg-gray-50 p-4">
<CfdiInvoice ref={invoiceRef} cfdi={cfdi} conceptos={conceptos} />
</div>
</DialogContent>
</Dialog>
);
}
```
**Step 2: Commit**
```bash
git add apps/web/components/cfdi/cfdi-viewer-modal.tsx
git commit -m "feat(web): add CfdiViewerModal with PDF and XML download"
```
---
## Task 8: Integrate Viewer into CFDI Page
**Files:**
- Modify: `apps/web/app/(dashboard)/cfdi/page.tsx`
**Step 1: Add imports at top of file**
Add after the existing imports (around line 14):
```typescript
import { Eye } from 'lucide-react';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { getCfdiById } from '@/lib/api/cfdi';
```
**Step 2: Add state for viewer modal**
Inside `CfdiPage` component, after line 255 (`const deleteCfdi = useDeleteCfdi();`), add:
```typescript
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
const [loadingCfdi, setLoadingCfdi] = useState<string | null>(null);
const handleViewCfdi = async (id: string) => {
setLoadingCfdi(id);
try {
const cfdi = await getCfdiById(id);
setViewingCfdi(cfdi);
} catch (error) {
console.error('Error loading CFDI:', error);
alert('Error al cargar el CFDI');
} finally {
setLoadingCfdi(null);
}
};
```
**Step 3: Add import for Cfdi type**
Update the import from `@horux/shared` at line 12 to include `Cfdi`:
```typescript
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
```
**Step 4: Add View button in table**
In the table header (around line 1070), add a new column header before the delete column:
```typescript
<th className="pb-3 font-medium"></th>
```
In the table body (inside the map, around line 1125), add before the delete button:
```typescript
<td className="py-3">
<Button
variant="ghost"
size="icon"
onClick={() => handleViewCfdi(cfdi.id)}
disabled={loadingCfdi === cfdi.id}
title="Ver factura"
>
{loadingCfdi === cfdi.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</td>
```
**Step 5: Add modal component**
At the end of the return statement, just before the closing `</>`, add:
```typescript
<CfdiViewerModal
cfdi={viewingCfdi}
open={viewingCfdi !== null}
onClose={() => setViewingCfdi(null)}
/>
```
**Step 6: Verify build**
Run:
```bash
cd /root/Horux/apps/web && pnpm build
```
Expected: Build succeeds
**Step 7: Commit**
```bash
git add apps/web/app/\(dashboard\)/cfdi/page.tsx
git commit -m "feat(web): integrate CFDI viewer modal into CFDI page"
```
---
## Task 9: Build and Test
**Step 1: Build all packages**
Run:
```bash
cd /root/Horux && pnpm build
```
Expected: All packages build successfully
**Step 2: Restart services**
Run:
```bash
systemctl restart horux-api horux-web
```
**Step 3: Manual verification**
1. Open browser to CFDI page
2. Click Eye icon on any CFDI row
3. Verify modal opens with invoice preview
4. Click PDF button - verify PDF downloads
5. Click XML button (if XML exists) - verify XML downloads
**Step 4: Final commit with all changes**
```bash
git add -A
git status
# If any uncommitted changes:
git commit -m "feat: complete CFDI viewer implementation"
```
---
## Summary of Changes
| File | Change |
|------|--------|
| `apps/web/package.json` | Added html2pdf.js dependency |
| `packages/shared/src/types/cfdi.ts` | Added xmlOriginal field |
| `apps/api/src/services/cfdi.service.ts` | Updated getCfdiById, added getXmlById |
| `apps/api/src/controllers/cfdi.controller.ts` | Added getXml controller |
| `apps/api/src/routes/cfdi.routes.ts` | Added GET /:id/xml route |
| `apps/web/lib/api/cfdi.ts` | Added getCfdiXml function |
| `apps/web/components/cfdi/cfdi-invoice.tsx` | NEW - Invoice renderer |
| `apps/web/components/cfdi/cfdi-viewer-modal.tsx` | NEW - Modal wrapper |
| `apps/web/app/(dashboard)/cfdi/page.tsx` | Integrated viewer |

34
ecosystem.config.js Normal file
View File

@@ -0,0 +1,34 @@
module.exports = {
apps: [
{
name: 'horux-api',
cwd: '/root/Horux/apps/api',
script: 'pnpm',
args: 'dev',
interpreter: 'none',
watch: false,
autorestart: true,
restart_delay: 5000,
max_restarts: 5,
env: {
NODE_ENV: 'development',
PORT: 4000
}
},
{
name: 'horux-web',
cwd: '/root/Horux/apps/web',
script: 'pnpm',
args: 'dev',
interpreter: 'none',
watch: false,
autorestart: true,
restart_delay: 5000,
max_restarts: 5,
env: {
NODE_ENV: 'development',
PORT: 3000
}
}
]
};

View File

@@ -27,6 +27,7 @@ export interface Cfdi {
estado: EstadoCfdi; estado: EstadoCfdi;
xmlUrl: string | null; xmlUrl: string | null;
pdfUrl: string | null; pdfUrl: string | null;
xmlOriginal: string | null;
createdAt: string; createdAt: string;
} }

175
pnpm-lock.yaml generated
View File

@@ -112,7 +112,7 @@ importers:
specifier: ^1.1.0 specifier: ^1.1.0
version: 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-dialog': '@radix-ui/react-dialog':
specifier: ^1.1.0 specifier: ^1.1.15
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-dropdown-menu': '@radix-ui/react-dropdown-menu':
specifier: ^2.1.0 specifier: ^2.1.0
@@ -156,6 +156,9 @@ importers:
date-fns: date-fns:
specifier: ^3.6.0 specifier: ^3.6.0
version: 3.6.0 version: 3.6.0
html2pdf.js:
specifier: ^0.14.0
version: 0.14.0
lucide-react: lucide-react:
specifier: ^0.460.0 specifier: ^0.460.0
version: 0.460.0(react@18.3.1) version: 0.460.0(react@18.3.1)
@@ -1069,12 +1072,18 @@ packages:
'@types/node@22.19.7': '@types/node@22.19.7':
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==} resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
'@types/pako@2.0.4':
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
'@types/prop-types@15.7.15': '@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/qs@6.14.0': '@types/qs@6.14.0':
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
'@types/raf@3.4.3':
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
'@types/range-parser@1.2.7': '@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
@@ -1092,6 +1101,9 @@ packages:
'@types/serve-static@2.2.0': '@types/serve-static@2.2.0':
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@vilic/node-forge@1.3.2-5': '@vilic/node-forge@1.3.2-5':
resolution: {integrity: sha512-8GVr3S/nmLKL7QI7RYhVIcz3PuT/fxfkQLuh/F1CaT+/3QgI14RqiJkcKIni7h9u4ySbQGiGvm4XbNxRBJin4g==} resolution: {integrity: sha512-8GVr3S/nmLKL7QI7RYhVIcz3PuT/fxfkQLuh/F1CaT+/3QgI14RqiJkcKIni7h9u4ySbQGiGvm4XbNxRBJin4g==}
engines: {node: '>= 6.13.0'} engines: {node: '>= 6.13.0'}
@@ -1156,6 +1168,10 @@ packages:
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
base64-js@1.5.1: base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -1242,6 +1258,10 @@ packages:
caniuse-lite@1.0.30001765: caniuse-lite@1.0.30001765:
resolution: {integrity: sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==} resolution: {integrity: sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==}
canvg@3.0.11:
resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
engines: {node: '>=10.0.0'}
chainsaw@0.1.0: chainsaw@0.1.0:
resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==}
@@ -1289,6 +1309,9 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
core-js@3.48.0:
resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==}
core-util-is@1.0.3: core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -1305,6 +1328,9 @@ packages:
resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
cssesc@3.0.0: cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -1398,6 +1424,9 @@ packages:
dom-helpers@5.2.1: dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dompurify@3.3.1:
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
dotenv@17.2.3: dotenv@17.2.3:
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -1480,6 +1509,9 @@ packages:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'} engines: {node: '>=8.6.0'}
fast-png@6.4.0:
resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==}
fast-xml-parser@5.3.3: fast-xml-parser@5.3.3:
resolution: {integrity: sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==} resolution: {integrity: sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==}
hasBin: true hasBin: true
@@ -1496,6 +1528,9 @@ packages:
picomatch: picomatch:
optional: true optional: true
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
fill-range@7.1.1: fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1597,6 +1632,13 @@ packages:
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
html2pdf.js@0.14.0:
resolution: {integrity: sha512-yvNJgE/8yru2UeGflkPdjW8YEY+nDH5X7/2WG4uiuSCwYiCp8PZ8EKNiTAa6HxJ1NjC51fZSIEq6xld5CADKBQ==}
http-errors@2.0.1: http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -1622,6 +1664,9 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'} engines: {node: '>=12'}
iobuffer@5.4.0:
resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==}
ipaddr.js@1.9.1: ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@@ -1660,6 +1705,9 @@ packages:
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'} engines: {node: '>=12', npm: '>=6'}
jspdf@4.1.0:
resolution: {integrity: sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==}
jszip@3.10.1: jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
@@ -1880,6 +1928,9 @@ packages:
pako@1.0.11: pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
pako@2.1.0:
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
parseurl@1.3.3: parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -1894,6 +1945,9 @@ packages:
path-to-regexp@0.1.12: path-to-regexp@0.1.12:
resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==}
performance-now@2.1.0:
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1989,6 +2043,9 @@ packages:
queue-microtask@1.2.3: queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
raf@3.4.1:
resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
range-parser@1.2.1: range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -2087,6 +2144,9 @@ packages:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
regenerator-runtime@0.13.11:
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
resolve-pkg-maps@1.0.0: resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -2099,6 +2159,10 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'} engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rgbcolor@1.0.1:
resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
engines: {node: '>= 0.8.15'}
rimraf@2.7.1: rimraf@2.7.1:
resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
deprecated: Rimraf versions prior to v4 are no longer supported deprecated: Rimraf versions prior to v4 are no longer supported
@@ -2162,6 +2226,10 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
stackblur-canvas@2.7.0:
resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
engines: {node: '>=0.1.14'}
statuses@2.0.2: statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -2201,6 +2269,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
svg-pathdata@6.0.3:
resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
engines: {node: '>=12.0.0'}
tailwind-merge@2.6.0: tailwind-merge@2.6.0:
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
@@ -2213,6 +2285,9 @@ packages:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
text-segmentation@1.0.3:
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
thenify-all@1.6.0: thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@@ -2347,6 +2422,9 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'} engines: {node: '>= 0.4.0'}
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
uuid@8.3.2: uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true hasBin: true
@@ -3139,10 +3217,15 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/pako@2.0.4': {}
'@types/prop-types@15.7.15': {} '@types/prop-types@15.7.15': {}
'@types/qs@6.14.0': {} '@types/qs@6.14.0': {}
'@types/raf@3.4.3':
optional: true
'@types/range-parser@1.2.7': {} '@types/range-parser@1.2.7': {}
'@types/react-dom@18.3.7(@types/react@18.3.27)': '@types/react-dom@18.3.7(@types/react@18.3.27)':
@@ -3163,6 +3246,9 @@ snapshots:
'@types/http-errors': 2.0.5 '@types/http-errors': 2.0.5
'@types/node': 22.19.7 '@types/node': 22.19.7
'@types/trusted-types@2.0.7':
optional: true
'@vilic/node-forge@1.3.2-5': {} '@vilic/node-forge@1.3.2-5': {}
'@xmldom/xmldom@0.9.8': {} '@xmldom/xmldom@0.9.8': {}
@@ -3248,6 +3334,8 @@ snapshots:
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
base64-arraybuffer@1.0.2: {}
base64-js@1.5.1: {} base64-js@1.5.1: {}
baseline-browser-mapping@2.9.17: {} baseline-browser-mapping@2.9.17: {}
@@ -3342,6 +3430,18 @@ snapshots:
caniuse-lite@1.0.30001765: {} caniuse-lite@1.0.30001765: {}
canvg@3.0.11:
dependencies:
'@babel/runtime': 7.28.6
'@types/raf': 3.4.3
core-js: 3.48.0
raf: 3.4.1
regenerator-runtime: 0.13.11
rgbcolor: 1.0.1
stackblur-canvas: 2.7.0
svg-pathdata: 6.0.3
optional: true
chainsaw@0.1.0: chainsaw@0.1.0:
dependencies: dependencies:
traverse: 0.3.9 traverse: 0.3.9
@@ -3391,6 +3491,9 @@ snapshots:
cookie@0.7.2: {} cookie@0.7.2: {}
core-js@3.48.0:
optional: true
core-util-is@1.0.3: {} core-util-is@1.0.3: {}
cors@2.8.5: cors@2.8.5:
@@ -3405,6 +3508,10 @@ snapshots:
crc-32: 1.2.2 crc-32: 1.2.2
readable-stream: 3.6.2 readable-stream: 3.6.2
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
cssesc@3.0.0: {} cssesc@3.0.0: {}
csstype@3.2.3: {} csstype@3.2.3: {}
@@ -3474,6 +3581,10 @@ snapshots:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.28.6
csstype: 3.2.3 csstype: 3.2.3
dompurify@3.3.1:
optionalDependencies:
'@types/trusted-types': 2.0.7
dotenv@17.2.3: {} dotenv@17.2.3: {}
dunder-proto@1.0.1: dunder-proto@1.0.1:
@@ -3615,6 +3726,12 @@ snapshots:
merge2: 1.4.1 merge2: 1.4.1
micromatch: 4.0.8 micromatch: 4.0.8
fast-png@6.4.0:
dependencies:
'@types/pako': 2.0.4
iobuffer: 5.4.0
pako: 2.1.0
fast-xml-parser@5.3.3: fast-xml-parser@5.3.3:
dependencies: dependencies:
strnum: 2.1.2 strnum: 2.1.2
@@ -3627,6 +3744,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 picomatch: 4.0.3
fflate@0.8.2: {}
fill-range@7.1.1: fill-range@7.1.1:
dependencies: dependencies:
to-regex-range: 5.0.1 to-regex-range: 5.0.1
@@ -3732,6 +3851,17 @@ snapshots:
helmet@8.1.0: {} helmet@8.1.0: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
text-segmentation: 1.0.3
html2pdf.js@0.14.0:
dependencies:
dompurify: 3.3.1
html2canvas: 1.4.1
jspdf: 4.1.0
http-errors@2.0.1: http-errors@2.0.1:
dependencies: dependencies:
depd: 2.0.0 depd: 2.0.0
@@ -3757,6 +3887,8 @@ snapshots:
internmap@2.0.3: {} internmap@2.0.3: {}
iobuffer@5.4.0: {}
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
is-binary-path@2.1.0: is-binary-path@2.1.0:
@@ -3794,6 +3926,17 @@ snapshots:
ms: 2.1.3 ms: 2.1.3
semver: 7.7.3 semver: 7.7.3
jspdf@4.1.0:
dependencies:
'@babel/runtime': 7.28.6
fast-png: 6.4.0
fflate: 0.8.2
optionalDependencies:
canvg: 3.0.11
core-js: 3.48.0
dompurify: 3.3.1
html2canvas: 1.4.1
jszip@3.10.1: jszip@3.10.1:
dependencies: dependencies:
lie: 3.3.0 lie: 3.3.0
@@ -3974,6 +4117,8 @@ snapshots:
pako@1.0.11: {} pako@1.0.11: {}
pako@2.1.0: {}
parseurl@1.3.3: {} parseurl@1.3.3: {}
path-is-absolute@1.0.1: {} path-is-absolute@1.0.1: {}
@@ -3982,6 +4127,9 @@ snapshots:
path-to-regexp@0.1.12: {} path-to-regexp@0.1.12: {}
performance-now@2.1.0:
optional: true
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@2.3.1: {} picomatch@2.3.1: {}
@@ -4063,6 +4211,11 @@ snapshots:
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
raf@3.4.1:
dependencies:
performance-now: 2.1.0
optional: true
range-parser@1.2.1: {} range-parser@1.2.1: {}
raw-body@2.5.3: raw-body@2.5.3:
@@ -4179,6 +4332,9 @@ snapshots:
tiny-invariant: 1.3.3 tiny-invariant: 1.3.3
victory-vendor: 36.9.2 victory-vendor: 36.9.2
regenerator-runtime@0.13.11:
optional: true
resolve-pkg-maps@1.0.0: {} resolve-pkg-maps@1.0.0: {}
resolve@1.22.11: resolve@1.22.11:
@@ -4189,6 +4345,9 @@ snapshots:
reusify@1.1.0: {} reusify@1.1.0: {}
rgbcolor@1.0.1:
optional: true
rimraf@2.7.1: rimraf@2.7.1:
dependencies: dependencies:
glob: 7.2.3 glob: 7.2.3
@@ -4274,6 +4433,9 @@ snapshots:
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
stackblur-canvas@2.7.0:
optional: true
statuses@2.0.2: {} statuses@2.0.2: {}
streamsearch@1.1.0: {} streamsearch@1.1.0: {}
@@ -4305,6 +4467,9 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
svg-pathdata@6.0.3:
optional: true
tailwind-merge@2.6.0: {} tailwind-merge@2.6.0: {}
tailwindcss@3.4.19(tsx@4.21.0): tailwindcss@3.4.19(tsx@4.21.0):
@@ -4343,6 +4508,10 @@ snapshots:
inherits: 2.0.4 inherits: 2.0.4
readable-stream: 3.6.2 readable-stream: 3.6.2
text-segmentation@1.0.3:
dependencies:
utrie: 1.0.2
thenify-all@1.6.0: thenify-all@1.6.0:
dependencies: dependencies:
thenify: 3.3.1 thenify: 3.3.1
@@ -4461,6 +4630,10 @@ snapshots:
utils-merge@1.0.1: {} utils-merge@1.0.1: {}
utrie@1.0.2:
dependencies:
base64-arraybuffer: 1.0.2
uuid@8.3.2: {} uuid@8.3.2: {}
vary@1.1.2: {} vary@1.1.2: {}