diff --git a/apps/web/app/(dashboard)/configuracion/sat/page.tsx b/apps/web/app/(dashboard)/configuracion/sat/page.tsx new file mode 100644 index 0000000..f5b754f --- /dev/null +++ b/apps/web/app/(dashboard)/configuracion/sat/page.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { FielUploadModal } from '@/components/sat/FielUploadModal'; +import { SyncStatus } from '@/components/sat/SyncStatus'; +import { SyncHistory } from '@/components/sat/SyncHistory'; +import { getFielStatus, deleteFiel } from '@/lib/api/fiel'; +import type { FielStatus } from '@horux/shared'; + +export default function SatConfigPage() { + const [fielStatus, setFielStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [showUploadModal, setShowUploadModal] = useState(false); + const [deleting, setDeleting] = useState(false); + + const fetchFielStatus = async () => { + try { + const status = await getFielStatus(); + setFielStatus(status); + } catch (err) { + console.error('Error fetching FIEL status:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchFielStatus(); + }, []); + + const handleUploadSuccess = (status: FielStatus) => { + setFielStatus(status); + setShowUploadModal(false); + }; + + const handleDelete = async () => { + if (!confirm('Estas seguro de eliminar la FIEL? Se detendran las sincronizaciones automaticas.')) { + return; + } + + setDeleting(true); + try { + await deleteFiel(); + setFielStatus({ configured: false }); + } catch (err) { + console.error('Error deleting FIEL:', err); + } finally { + setDeleting(false); + } + }; + + if (loading) { + return ( +
+

Configuracion SAT

+

Cargando...

+
+ ); + } + + return ( +
+
+
+

Configuracion SAT

+

+ Gestiona tu FIEL y la sincronizacion automatica de CFDIs +

+
+
+ + {/* Estado de la FIEL */} + + + FIEL (e.firma) + + Tu firma electronica para autenticarte con el SAT + + + + {fielStatus?.configured ? ( +
+
+
+

RFC

+

{fielStatus.rfc}

+
+
+

No. Serie

+

{fielStatus.serialNumber}

+
+
+

Vigente hasta

+

+ {fielStatus.validUntil ? new Date(fielStatus.validUntil).toLocaleDateString('es-MX') : '-'} +

+
+
+

Estado

+

+ {fielStatus.isExpired ? 'Vencida' : `Valida (${fielStatus.daysUntilExpiration} dias)`} +

+
+
+ +
+ + +
+
+ ) : ( +
+

+ No tienes una FIEL configurada. Sube tu certificado y llave privada para habilitar + la sincronizacion automatica de CFDIs con el SAT. +

+ +
+ )} +
+
+ + {/* Estado de Sincronizacion */} + + + {/* Historial */} + + + {/* Modal de carga */} + {showUploadModal && ( + setShowUploadModal(false)} + /> + )} +
+ ); +} diff --git a/apps/web/components/sat/FielUploadModal.tsx b/apps/web/components/sat/FielUploadModal.tsx new file mode 100644 index 0000000..3c62ca7 --- /dev/null +++ b/apps/web/components/sat/FielUploadModal.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { uploadFiel } from '@/lib/api/fiel'; +import type { FielStatus } from '@horux/shared'; + +interface FielUploadModalProps { + onSuccess: (status: FielStatus) => void; + onClose: () => void; +} + +export function FielUploadModal({ onSuccess, onClose }: FielUploadModalProps) { + const [cerFile, setCerFile] = useState(null); + const [keyFile, setKeyFile] = useState(null); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const fileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + const result = reader.result as string; + // Remove data URL prefix (e.g., "data:application/x-x509-ca-cert;base64,") + const base64 = result.split(',')[1]; + resolve(base64); + }; + reader.onerror = reject; + }); + }; + + const handleSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (!cerFile || !keyFile || !password) { + setError('Todos los campos son requeridos'); + return; + } + + setLoading(true); + + try { + const cerBase64 = await fileToBase64(cerFile); + const keyBase64 = await fileToBase64(keyFile); + + const result = await uploadFiel({ + cerFile: cerBase64, + keyFile: keyBase64, + password, + }); + + if (result.status) { + onSuccess(result.status); + } + } catch (err: any) { + setError(err.response?.data?.error || 'Error al subir la FIEL'); + } finally { + setLoading(false); + } + }, [cerFile, keyFile, password, onSuccess]); + + return ( +
+ + + Configurar FIEL (e.firma) + + Sube tu certificado y llave privada para sincronizar CFDIs con el SAT + + + +
+
+ + setCerFile(e.target.files?.[0] || null)} + className="cursor-pointer" + /> +
+ +
+ + setKeyFile(e.target.files?.[0] || null)} + className="cursor-pointer" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Ingresa la contrasena de tu FIEL" + /> +
+ + {error && ( +

{error}

+ )} + +
+ + +
+
+
+
+
+ ); +} diff --git a/apps/web/components/sat/SyncHistory.tsx b/apps/web/components/sat/SyncHistory.tsx new file mode 100644 index 0000000..440ab64 --- /dev/null +++ b/apps/web/components/sat/SyncHistory.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { getSyncHistory, retrySync } from '@/lib/api/sat'; +import type { SatSyncJob } from '@horux/shared'; + +interface SyncHistoryProps { + fielConfigured: boolean; +} + +const statusLabels: Record = { + pending: 'Pendiente', + running: 'En progreso', + completed: 'Completado', + failed: 'Fallido', +}; + +const statusColors: Record = { + pending: 'bg-yellow-100 text-yellow-800', + running: 'bg-blue-100 text-blue-800', + completed: 'bg-green-100 text-green-800', + failed: 'bg-red-100 text-red-800', +}; + +const typeLabels: Record = { + initial: 'Inicial', + daily: 'Diaria', +}; + +export function SyncHistory({ fielConfigured }: SyncHistoryProps) { + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const limit = 10; + + const fetchHistory = async () => { + try { + const data = await getSyncHistory(page, limit); + setJobs(data.jobs); + setTotal(data.total); + } catch (err) { + console.error('Error fetching sync history:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (fielConfigured) { + fetchHistory(); + } else { + setLoading(false); + } + }, [fielConfigured, page]); + + const handleRetry = async (jobId: string) => { + try { + await retrySync(jobId); + fetchHistory(); + } catch (err) { + console.error('Error retrying job:', err); + } + }; + + if (!fielConfigured) { + return null; + } + + if (loading) { + return ( + + + Historial de Sincronizaciones + + +

Cargando historial...

+
+
+ ); + } + + if (jobs.length === 0) { + return ( + + + Historial de Sincronizaciones + + Registro de todas las sincronizaciones con el SAT + + + +

No hay sincronizaciones registradas.

+
+
+ ); + } + + const totalPages = Math.ceil(total / limit); + + return ( + + + Historial de Sincronizaciones + + Registro de todas las sincronizaciones con el SAT + + + +
+ {jobs.map((job) => ( +
+
+
+ + {statusLabels[job.status]} + + + {typeLabels[job.type]} + +
+

+ {job.startedAt ? new Date(job.startedAt).toLocaleString('es-MX') : 'No iniciado'} +

+

+ {job.cfdisInserted} nuevos, {job.cfdisUpdated} actualizados +

+ {job.errorMessage && ( +

{job.errorMessage}

+ )} +
+ {job.status === 'failed' && ( + + )} + {job.status === 'running' && ( +
+

{job.progressPercent}%

+

{job.cfdisDownloaded} descargados

+
+ )} +
+ ))} +
+ + {totalPages > 1 && ( +
+ + + Pagina {page} de {totalPages} + + +
+ )} +
+
+ ); +} diff --git a/apps/web/components/sat/SyncStatus.tsx b/apps/web/components/sat/SyncStatus.tsx new file mode 100644 index 0000000..c2c4cf4 --- /dev/null +++ b/apps/web/components/sat/SyncStatus.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { getSyncStatus, startSync } from '@/lib/api/sat'; +import type { SatSyncStatusResponse } from '@horux/shared'; + +interface SyncStatusProps { + fielConfigured: boolean; + onSyncStarted?: () => void; +} + +const statusLabels: Record = { + pending: 'Pendiente', + running: 'En progreso', + completed: 'Completado', + failed: 'Fallido', +}; + +const statusColors: Record = { + pending: 'bg-yellow-100 text-yellow-800', + running: 'bg-blue-100 text-blue-800', + completed: 'bg-green-100 text-green-800', + failed: 'bg-red-100 text-red-800', +}; + +export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [startingSync, setStartingSync] = useState(false); + const [error, setError] = useState(''); + + const fetchStatus = async () => { + try { + const data = await getSyncStatus(); + setStatus(data); + } catch (err) { + console.error('Error fetching sync status:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (fielConfigured) { + fetchStatus(); + // Actualizar cada 30 segundos si hay sync activo + const interval = setInterval(fetchStatus, 30000); + return () => clearInterval(interval); + } else { + setLoading(false); + } + }, [fielConfigured]); + + const handleStartSync = async (type: 'initial' | 'daily') => { + setStartingSync(true); + setError(''); + try { + await startSync({ type }); + await fetchStatus(); + onSyncStarted?.(); + } catch (err: any) { + setError(err.response?.data?.error || 'Error al iniciar sincronizacion'); + } finally { + setStartingSync(false); + } + }; + + if (!fielConfigured) { + return ( + + + Sincronizacion SAT + + Configura tu FIEL para habilitar la sincronizacion automatica + + + +

+ La sincronizacion con el SAT requiere una FIEL valida configurada. +

+
+
+ ); + } + + if (loading) { + return ( + + + Sincronizacion SAT + + +

Cargando estado...

+
+
+ ); + } + + return ( + + + Sincronizacion SAT + + Estado de la sincronizacion automatica de CFDIs + + + + {status?.hasActiveSync && status.currentJob && ( +
+
+ + {statusLabels[status.currentJob.status]} + + + {status.currentJob.type === 'initial' ? 'Sincronizacion inicial' : 'Sincronizacion diaria'} + +
+ {status.currentJob.status === 'running' && ( +
+
+
+ )} +

+ {status.currentJob.cfdisDownloaded} CFDIs descargados +

+
+ )} + + {status?.lastCompletedJob && !status.hasActiveSync && ( +
+
+ + Ultima sincronizacion exitosa + +
+

+ {new Date(status.lastCompletedJob.completedAt!).toLocaleString('es-MX')} +

+

+ {status.lastCompletedJob.cfdisInserted} CFDIs nuevos, {status.lastCompletedJob.cfdisUpdated} actualizados +

+
+ )} + +
+
+

{status?.totalCfdisSynced || 0}

+

CFDIs sincronizados

+
+
+

3:00 AM

+

Sincronizacion diaria

+
+
+ + {error && ( +

{error}

+ )} + +
+ + {!status?.lastCompletedJob && ( + + )} +
+ + + ); +} diff --git a/apps/web/lib/api/fiel.ts b/apps/web/lib/api/fiel.ts new file mode 100644 index 0000000..a3b25cb --- /dev/null +++ b/apps/web/lib/api/fiel.ts @@ -0,0 +1,16 @@ +import { apiClient } from './client'; +import type { FielStatus, FielUploadRequest } from '@horux/shared'; + +export async function uploadFiel(data: FielUploadRequest): Promise<{ message: string; status: FielStatus }> { + const response = await apiClient.post('/fiel/upload', data); + return response.data; +} + +export async function getFielStatus(): Promise { + const response = await apiClient.get('/fiel/status'); + return response.data; +} + +export async function deleteFiel(): Promise { + await apiClient.delete('/fiel'); +} diff --git a/apps/web/lib/api/sat.ts b/apps/web/lib/api/sat.ts new file mode 100644 index 0000000..f6af17f --- /dev/null +++ b/apps/web/lib/api/sat.ts @@ -0,0 +1,45 @@ +import { apiClient } from './client'; +import type { + SatSyncJob, + SatSyncStatusResponse, + SatSyncHistoryResponse, + StartSyncRequest, + StartSyncResponse, +} from '@horux/shared'; + +export async function startSync(data?: StartSyncRequest): Promise { + const response = await apiClient.post('/sat/sync', data || {}); + return response.data; +} + +export async function getSyncStatus(): Promise { + const response = await apiClient.get('/sat/sync/status'); + return response.data; +} + +export async function getSyncHistory(page: number = 1, limit: number = 10): Promise { + const response = await apiClient.get('/sat/sync/history', { + params: { page, limit }, + }); + return response.data; +} + +export async function getSyncJob(id: string): Promise { + const response = await apiClient.get(`/sat/sync/${id}`); + return response.data; +} + +export async function retrySync(id: string): Promise { + const response = await apiClient.post(`/sat/sync/${id}/retry`); + return response.data; +} + +export async function getCronInfo(): Promise<{ scheduled: boolean; expression: string; timezone: string }> { + const response = await apiClient.get('/sat/cron'); + return response.data; +} + +export async function runCron(): Promise<{ message: string }> { + const response = await apiClient.post('/sat/cron/run'); + return response.data; +}