Compare commits
11 Commits
3466ec740e
...
2bbab12627
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bbab12627 | ||
|
|
cdb6f0c94e | ||
|
|
3beee1c174 | ||
|
|
837831ccd4 | ||
|
|
f9d2161938 | ||
|
|
427c94fb9d | ||
|
|
266e547eb5 | ||
|
|
ebd099f596 | ||
|
|
8c0bc799d3 | ||
|
|
6109294811 | ||
|
|
67f74538b8 |
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
230
apps/web/components/cfdi/cfdi-invoice.tsx
Normal file
230
apps/web/components/cfdi/cfdi-invoice.tsx
Normal 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';
|
||||||
169
apps/web/components/cfdi/cfdi-viewer-modal.tsx
Normal file
169
apps/web/components/cfdi/cfdi-viewer-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
122
apps/web/components/ui/dialog.tsx
Normal file
122
apps/web/components/ui/dialog.tsx
Normal 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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
126
docs/plans/2026-02-17-cfdi-viewer-design.md
Normal file
126
docs/plans/2026-02-17-cfdi-viewer-design.md
Normal 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)
|
||||||
816
docs/plans/2026-02-17-cfdi-viewer-implementation.md
Normal file
816
docs/plans/2026-02-17-cfdi-viewer-implementation.md
Normal 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
34
ecosystem.config.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -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
175
pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user