feat: expand admin and data sync workflows
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m40s
Build and Push Images / Build jorgecuadros-api (push) Successful in 3m13s

This commit is contained in:
2026-07-23 22:00:08 -07:00
parent 70911e7e62
commit 27b3bd9efc
9 changed files with 127 additions and 13 deletions
+24 -10
View File
@@ -27,6 +27,8 @@ import type {
OpsJobKind,
} from "@/lib/types";
const INGEST_MAX_BYTES = 2 * 1024 * 1024 * 1024;
export default function OperacionesPage() {
return (
<AppShell>
@@ -37,6 +39,7 @@ export default function OperacionesPage() {
type ConfirmState =
| { kind: "REIMPORT" }
| { kind: "SYNC" }
| { kind: "RESTORE"; file: string }
| null;
@@ -176,6 +179,7 @@ function Operaciones() {
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);
}
@@ -224,7 +228,7 @@ function Operaciones() {
<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í.
sincronización leen de aquí. Tamaño máximo por archivo: {formatBytes(INGEST_MAX_BYTES)}.
</p>
<div className="tx-scroll">
<table className="tx-table">
@@ -306,11 +310,11 @@ function Operaciones() {
/>
<OpTile
title="Sincronizar"
desc="Conserva los datos actuales e importa solo lo nuevo del legado. Disponible en la Fase B."
action="Próximamente"
tone="muted"
disabled
onClick={() => {}}
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 && (
@@ -446,12 +450,18 @@ function Operaciones() {
<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" : "Confirmar restauración"}
{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."
: `Esto sobreescribe la base de datos completa con “${confirm.file}”. Se recomienda crear un respaldo antes.`}
: 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>
@@ -464,12 +474,16 @@ function Operaciones() {
</label>
<div className="form-actions">
<button
className="btn btn-danger"
className={confirm.kind === "SYNC" ? "btn btn-primary" : "btn btn-danger"}
type="button"
disabled={confirmText !== "CONFIRMAR" || starting}
onClick={runConfirmed}
>
{confirm.kind === "REIMPORT" ? "Reimportar" : "Restaurar"}
{confirm.kind === "REIMPORT"
? "Reimportar"
: confirm.kind === "SYNC"
? "Sincronizar"
: "Restaurar"}
</button>
<button className="btn btn-outline" type="button" onClick={() => setConfirm(null)}>
Cancelar
+30
View File
@@ -6,6 +6,7 @@ import { useAuth, useCan } from "@/lib/abilities";
import { ROLE_LABEL, ROLES_DESC } from "@/lib/labels";
import {
createUser,
deleteUser,
listUsers,
resetUserPassword,
updateUser,
@@ -133,6 +134,22 @@ function UsuariosAdmin() {
}
}
async function submitDelete(u: UserRow) {
if (!window.confirm(`¿Eliminar al usuario "${u.name}"? Esta acción no se puede deshacer.`)) {
return;
}
setError(null);
setNotice(null);
try {
await deleteUser(u.id);
if (editingId === u.id) startCreate();
setNotice("Usuario eliminado.");
refresh();
} catch (e) {
setError((e as Error)?.message ?? "No se pudo eliminar el usuario.");
}
}
return (
<>
<div className="page-head">
@@ -312,6 +329,19 @@ function UsuariosAdmin() {
>
Contraseña
</button>
<button
className="btn btn-ghost btn-danger"
type="button"
disabled={u.id === me?.id}
title={
u.id === me?.id
? "No puede eliminar su propia cuenta"
: "Eliminar usuario"
}
onClick={() => submitDelete(u)}
>
Eliminar
</button>
</div>
)}
</td>
+4
View File
@@ -655,6 +655,10 @@ export function resetUserPassword(id: string, password: string): Promise<UserRow
});
}
export function deleteUser(id: string): Promise<void> {
return apiFetch<void>(`/users/${id}`, { method: "DELETE" });
}
/* ------------------------------------------- DB operations (admin only) */
export function listIngest(): Promise<IngestFile[]> {