Files
jorgecuadros-platform/apps/web/src/app/operaciones/page.tsx
T
rmancinas 27b3bd9efc
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m40s
Build and Push Images / Build jorgecuadros-api (push) Successful in 3m13s
feat: expand admin and data sync workflows
2026-07-23 22:00:08 -07:00

530 lines
18 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { useCan } from "@/lib/abilities";
import {
OPS_KIND_LABELS,
OPS_STATUS_LABELS,
formatBytes,
formatDateTime,
} from "@/lib/labels";
import {
backupDownloadUrl,
deleteBackup,
deleteIngest,
getOpsJob,
listBackups,
listIngest,
listOpsJobs,
startOpsJob,
uploadIngest,
} from "@/lib/api";
import type {
BackupFile,
IngestFile,
OpsJob,
OpsJobKind,
} from "@/lib/types";
const INGEST_MAX_BYTES = 2 * 1024 * 1024 * 1024;
export default function OperacionesPage() {
return (
<AppShell>
<Operaciones />
</AppShell>
);
}
type ConfirmState =
| { kind: "REIMPORT" }
| { kind: "SYNC" }
| { kind: "RESTORE"; file: string }
| null;
function Operaciones() {
const allowed = useCan("db:manage");
const [ingest, setIngest] = useState<IngestFile[] | null>(null);
const [backups, setBackups] = useState<BackupFile[] | null>(null);
const [jobs, setJobs] = useState<OpsJob[] | null>(null);
const [activeJob, setActiveJob] = useState<OpsJob | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [confirm, setConfirm] = useState<ConfirmState>(null);
const [confirmText, setConfirmText] = useState("");
const [uploading, setUploading] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
const fileInputs = useRef<Record<string, HTMLInputElement | null>>({});
const refreshLists = useCallback(() => {
listIngest().then(setIngest).catch(() => setIngest([]));
listBackups().then(setBackups).catch(() => setBackups([]));
listOpsJobs()
.then((rows) => {
setJobs(rows);
const running = rows.find((j) => j.status === "RUNNING");
if (running) setActiveJob(running);
})
.catch(() => setJobs([]));
}, []);
useEffect(() => {
if (allowed) refreshLists();
}, [allowed, refreshLists]);
// Poll the active job while it runs; refresh everything when it finishes.
useEffect(() => {
if (!activeJob || activeJob.status !== "RUNNING") return;
const id = activeJob.id;
const timer = setInterval(() => {
getOpsJob(id)
.then((job) => {
setActiveJob(job);
if (job.status !== "RUNNING") {
clearInterval(timer);
refreshLists();
setNotice(
job.status === "SUCCESS"
? `${OPS_KIND_LABELS[job.kind]} completada.`
: `${OPS_KIND_LABELS[job.kind]} terminó con error. Revise el registro.`,
);
}
})
.catch(() => {
/* transient — keep polling */
});
}, 1500);
return () => clearInterval(timer);
}, [activeJob, refreshLists]);
if (!allowed) {
return (
<div className="page-head">
<h1 className="page-title">Operaciones</h1>
<div className="state-box state-error">
No tiene permisos para administrar la base de datos.
</div>
</div>
);
}
const jobRunning = activeJob?.status === "RUNNING";
async function handleUpload(name: string, file: File | undefined) {
if (!file) return;
setError(null);
setNotice(null);
setUploading(name);
try {
await uploadIngest(name, file);
setNotice(`${name} cargado.`);
refreshLists();
} catch (e) {
setError((e as Error)?.message ?? "No se pudo cargar el archivo.");
} finally {
setUploading(null);
const input = fileInputs.current[name];
if (input) input.value = "";
}
}
async function handleDeleteIngest(name: string) {
setError(null);
try {
await deleteIngest(name);
refreshLists();
} catch (e) {
setError((e as Error)?.message ?? "No se pudo eliminar.");
}
}
async function handleDeleteBackup(name: string) {
setError(null);
try {
await deleteBackup(name);
refreshLists();
} catch (e) {
setError((e as Error)?.message ?? "No se pudo eliminar el respaldo.");
}
}
async function start(kind: OpsJobKind, file?: string) {
setError(null);
setNotice(null);
setStarting(true);
try {
const job = await startOpsJob(kind, file);
setActiveJob(job);
setJobs((prev) => (prev ? [job, ...prev] : [job]));
} catch (e) {
setError((e as Error)?.message ?? "No se pudo iniciar la operación.");
} finally {
setStarting(false);
}
}
function askConfirm(state: ConfirmState) {
setConfirm(state);
setConfirmText("");
setError(null);
setNotice(null);
}
async function runConfirmed() {
if (!confirm) return;
const c = confirm;
setConfirm(null);
if (c.kind === "REIMPORT") await start("REIMPORT");
else if (c.kind === "SYNC") await start("SYNC");
else await start("RESTORE", c.file);
}
const ingestReady = (ingest ?? []).every((f) => f.present);
return (
<>
<div className="page-head">
<h1 className="page-title">Operaciones de base de datos</h1>
</div>
{error && <div className="state-box state-error">{error}</div>}
{notice && <div className="state-box">{notice}</div>}
{/* Active / running job with live log */}
{activeJob && (
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<div className="row-actions" style={{ justifyContent: "space-between" }}>
<h2 className="section-title" style={{ margin: 0 }}>
{OPS_KIND_LABELS[activeJob.kind]}{" "}
<span
className={`badge ${
activeJob.status === "SUCCESS"
? "badge-positive"
: activeJob.status === "FAILED"
? "badge-negative"
: "badge-neutral"
}`}
>
{jobRunning && <span className="spinner" aria-hidden style={{ marginRight: 6 }} />}
{OPS_STATUS_LABELS[activeJob.status]}
</span>
</h2>
{!jobRunning && (
<button className="btn btn-ghost" type="button" onClick={() => setActiveJob(null)}>
Ocultar
</button>
)}
</div>
<pre className="ops-log">{activeJob.log || "Iniciando…"}</pre>
</div>
)}
{/* Ingest folder */}
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<h2 className="section-title">Carpeta de ingesta</h2>
<p className="inline-form-note">
Los cuatro archivos originales de Access. La reimportación y la
sincronización leen de aquí. Tamaño máximo por archivo: {formatBytes(INGEST_MAX_BYTES)}.
</p>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Archivo</th>
<th>Estado</th>
<th className="num">Tamaño</th>
<th>Modificado</th>
<th className="num">Acciones</th>
</tr>
</thead>
<tbody>
{(ingest ?? []).map((f) => (
<tr key={f.name}>
<td className="mono">{f.name}</td>
<td>
<span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}>
{f.present ? "Presente" : "Falta"}
</span>
</td>
<td className="num">{formatBytes(f.size)}</td>
<td>{formatDateTime(f.modifiedAt)}</td>
<td>
<div className="row-actions">
<input
ref={(el) => {
fileInputs.current[f.name] = el;
}}
type="file"
style={{ display: "none" }}
onChange={(e) => handleUpload(f.name, e.target.files?.[0])}
/>
<button
className="btn btn-outline"
type="button"
disabled={uploading === f.name}
onClick={() => fileInputs.current[f.name]?.click()}
>
{uploading === f.name ? "Cargando…" : f.present ? "Reemplazar" : "Cargar"}
</button>
{f.present && (
<button
className="btn btn-ghost"
type="button"
onClick={() => handleDeleteIngest(f.name)}
>
Eliminar
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Operations */}
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<h2 className="section-title">Operaciones</h2>
<div className="form-grid">
<OpTile
title="Respaldo"
desc="Genera un volcado comprimido de la base de datos actual."
action="Crear respaldo"
tone="primary"
disabled={jobRunning || starting}
onClick={() => start("BACKUP")}
/>
<OpTile
title="Reimportar (purga)"
desc="Respalda, borra TODO y reconstruye desde los archivos de ingesta. Se pierden los datos capturados manualmente."
action="Reimportar"
tone="danger"
disabled={jobRunning || starting || !ingestReady}
onClick={() => askConfirm({ kind: "REIMPORT" })}
/>
<OpTile
title="Sincronizar"
desc="Respalda, luego importa lo nuevo del legado. Borra del sistema los registros del legado que ya no aparecen en los archivos de ingesta. Se conservan los datos capturados a mano."
action="Sincronizar"
tone="primary"
disabled={jobRunning || starting || !ingestReady}
onClick={() => askConfirm({ kind: "SYNC" })}
/>
</div>
{!ingestReady && (
<p className="inline-form-note" style={{ marginTop: 12 }}>
La reimportación requiere que los cuatro archivos estén presentes.
</p>
)}
</div>
{/* Backups */}
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<h2 className="section-title">Respaldos</h2>
<p className="inline-form-note">
Restaurar sobreescribe la base de datos completa con el respaldo elegido.
</p>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Archivo</th>
<th className="num">Tamaño</th>
<th>Creado</th>
<th className="num">Acciones</th>
</tr>
</thead>
<tbody>
{backups === null ? (
<tr>
<td colSpan={4}>
<span className="spinner" aria-label="Cargando" />
</td>
</tr>
) : backups.length === 0 ? (
<tr>
<td colSpan={4} className="muted">
Sin respaldos.
</td>
</tr>
) : (
backups.map((b) => (
<tr key={b.name}>
<td className="mono">{b.name}</td>
<td className="num">{formatBytes(b.size)}</td>
<td>{formatDateTime(b.createdAt)}</td>
<td>
<div className="row-actions">
<a className="btn btn-outline" href={backupDownloadUrl(b.name)}>
Descargar
</a>
<button
className="btn btn-outline"
type="button"
disabled={jobRunning || starting}
onClick={() => askConfirm({ kind: "RESTORE", file: b.name })}
>
Restaurar
</button>
<button
className="btn btn-ghost"
type="button"
onClick={() => handleDeleteBackup(b.name)}
>
Eliminar
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Recent jobs */}
<div className="card" style={{ padding: 20 }}>
<h2 className="section-title">Historial</h2>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Operación</th>
<th>Estado</th>
<th>Inicio</th>
<th>Fin</th>
<th className="num"></th>
</tr>
</thead>
<tbody>
{(jobs ?? []).map((j) => (
<tr key={j.id}>
<td>{OPS_KIND_LABELS[j.kind]}</td>
<td>
<span
className={`badge ${
j.status === "SUCCESS"
? "badge-positive"
: j.status === "FAILED"
? "badge-negative"
: "badge-neutral"
}`}
>
{OPS_STATUS_LABELS[j.status]}
</span>
</td>
<td>{formatDateTime(j.startedAt)}</td>
<td>{formatDateTime(j.finishedAt)}</td>
<td className="num">
<button
className="btn btn-ghost"
type="button"
onClick={() => setActiveJob(j)}
>
Ver registro
</button>
</td>
</tr>
))}
{jobs && jobs.length === 0 && (
<tr>
<td colSpan={5} className="muted">
Sin operaciones registradas.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Destructive-op confirm */}
{confirm && (
<div className="modal-backdrop" role="dialog" aria-modal="true">
<div className="card" style={{ padding: 24, maxWidth: 480 }}>
<h2 className="section-title" style={{ marginTop: 0 }}>
{confirm.kind === "REIMPORT"
? "Confirmar reimportación"
: confirm.kind === "SYNC"
? "Confirmar sincronización"
: "Confirmar restauración"}
</h2>
<p className="inline-form-note">
{confirm.kind === "REIMPORT"
? "Esto BORRA todos los datos actuales (incluidos los capturados a mano) y reconstruye desde los archivos de ingesta. Se creará un respaldo previo automático."
: confirm.kind === "SYNC"
? "Se creará un respaldo previo automático. Luego se importarán al sistema los registros nuevos del legado y se eliminarán los del legado que ya no aparezcan en los archivos de ingesta. Los datos capturados a mano NO se borran."
: `Esto sobreescribe la base de datos completa con “${confirm.file}”. Se recomienda crear un respaldo antes.`}
</p>
<label className="field">
<span className="field-label">Escriba CONFIRMAR para continuar</span>
<input
className="input"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
autoFocus
/>
</label>
<div className="form-actions">
<button
className={confirm.kind === "SYNC" ? "btn btn-primary" : "btn btn-danger"}
type="button"
disabled={confirmText !== "CONFIRMAR" || starting}
onClick={runConfirmed}
>
{confirm.kind === "REIMPORT"
? "Reimportar"
: confirm.kind === "SYNC"
? "Sincronizar"
: "Restaurar"}
</button>
<button className="btn btn-outline" type="button" onClick={() => setConfirm(null)}>
Cancelar
</button>
</div>
</div>
</div>
)}
</>
);
}
function OpTile({
title,
desc,
action,
tone,
disabled,
onClick,
}: {
title: string;
desc: string;
action: string;
tone: "primary" | "danger" | "muted";
disabled: boolean;
onClick: () => void;
}) {
const btnClass =
tone === "danger" ? "btn btn-danger" : tone === "muted" ? "btn btn-outline" : "btn btn-primary";
return (
<div className="card" style={{ padding: 16 }}>
<h3 className="section-title" style={{ fontSize: 15, margin: "0 0 4px" }}>
{title}
</h3>
<p className="inline-form-note" style={{ minHeight: 48 }}>
{desc}
</p>
<button className={btnClass} type="button" disabled={disabled} onClick={onClick}>
{action}
</button>
</div>
);
}