feat: Introduce bot-digitador service for automated data entry in Integración Total. Add CLI and GUI interfaces for CSV processing, implement auto-start functionality on Windows, and enhance README with setup instructions and usage details. Include necessary configurations in docker-compose for service integration.

This commit is contained in:
erik
2026-08-09 21:26:56 -05:00
parent a3b4c7fc47
commit e9b766281a
74 changed files with 11177 additions and 115 deletions
+10
View File
@@ -5,6 +5,7 @@ import {
Cpu,
Download,
Hash,
Keyboard,
Link2,
Package,
Settings,
@@ -35,6 +36,7 @@ import "./modules/extraction/ExtractionModule";
import "./modules/cleaning/CleaningModule";
import "./modules/poblacion_apta/PoblacionAptaModule";
import "./modules/downloads/DownloadsModule";
import "./modules/digitador/DigitadorModule";
const BOT_PACKAGE_URL =
"http://s3.builker.xyz/apps-versions/BotDescargaPYSCuentasMedicas/latest/bot_descarga.zip";
@@ -403,6 +405,14 @@ function App() {
<Users size={17} />
<span>Población apta</span>
</button>
<button
type="button"
className={jobSection === "digitador" ? "active" : ""}
onClick={() => handleActivateSection("digitador")}
>
<Keyboard size={17} />
<span>Bot digitador</span>
</button>
<button
type="button"
className={jobSection === "results" ? "active" : ""}
@@ -28,7 +28,11 @@ interface Props {
function jobLabel(r: DownloadJob): string {
const title = r.title || r.original_filename || r.id.slice(0, 8);
return `${title} · ${r.status} · ${r.worker_id}`;
const bots =
r.shard_count && Number(r.shard_count) > 1
? `${r.shard_count} bots`
: r.worker_id || "—";
return `${title} · ${r.status} · ${bots}`;
}
export function MinioDownloadSourcePicker({
@@ -9,6 +9,10 @@ import type {
CountSyncResponse,
CreateCountJobPayload,
CreateJobPayload,
DigitadorJob,
DigitadorJobEvent,
DigitadorJobFilesResponse,
DigitadorWorker,
DownloadJob,
DownloadJobEvent,
DownloadJobFilesResponse,
@@ -87,7 +91,8 @@ export class ApiClient {
path.startsWith("/cleaning/jobs/from-csv") ||
path.startsWith("/poblacion-apta/jobs/from-csv") ||
path.startsWith("/poblacion-apta/jobs/from-cleaning") ||
path === "/bot-descarga/jobs"
path === "/bot-descarga/jobs" ||
path === "/bot-digitador/jobs"
);
}
@@ -310,6 +315,83 @@ export class ApiClient {
return this.handle<DownloadJob>(response);
}
// ── Bot Digitador (control remoto) ─────────────────────────────────────────
async listDigitadorWorkers(): Promise<DigitadorWorker[]> {
const response = await fetchWithTimeout(
this.url("/bot-digitador/workers"),
{ headers: this.botHeaders() },
this.timeoutFor("/bot-digitador/workers"),
);
return this.handle<DigitadorWorker[]>(response);
}
async createDigitadorJob(form: FormData): Promise<DigitadorJob> {
const response = await fetchWithTimeout(
this.url("/bot-digitador/jobs"),
{ method: "POST", headers: this.botHeaders(), body: form },
this.timeoutFor("/bot-digitador/jobs"),
);
return this.handle<DigitadorJob>(response);
}
async listDigitadorJobs(limit = 50, parentJobId?: string): Promise<DigitadorJob[]> {
const qs = new URLSearchParams({ limit: String(limit) });
if (parentJobId) qs.set("job_id", parentJobId);
const response = await fetchWithTimeout(
this.url(`/bot-digitador/jobs?${qs.toString()}`),
{ headers: this.botHeaders() },
);
return this.handle<DigitadorJob[]>(response);
}
async getDigitadorJob(jobId: string): Promise<DigitadorJob> {
const response = await fetchWithTimeout(
this.url(`/bot-digitador/jobs/${encodeURIComponent(jobId)}`),
{ headers: this.botHeaders() },
);
return this.handle<DigitadorJob>(response);
}
async listDigitadorJobFiles(jobId: string): Promise<DigitadorJobFilesResponse> {
const response = await fetchWithTimeout(
this.url(`/bot-digitador/jobs/${encodeURIComponent(jobId)}/files`),
{ headers: this.botHeaders() },
);
return this.handle<DigitadorJobFilesResponse>(response);
}
/** Descarga bytes de un reporte CSV/log/json del resultado MinIO (proxy bot-digitador). */
async fetchDigitadorObject(jobId: string, key: string): Promise<ArrayBuffer> {
const qs = new URLSearchParams({ key });
const response = await fetchWithTimeout(
this.url(`/bot-digitador/jobs/${encodeURIComponent(jobId)}/object?${qs.toString()}`),
{ headers: this.botHeaders() },
120_000,
);
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(text || `Error ${response.status} al leer objeto MinIO`);
}
return response.arrayBuffer();
}
async getDigitadorEvents(jobId: string, since = 0): Promise<{ events: DigitadorJobEvent[] }> {
const response = await fetchWithTimeout(
this.url(`/bot-digitador/jobs/${encodeURIComponent(jobId)}/events?since=${since}`),
{ headers: this.botHeaders() },
);
return this.handle<{ events: DigitadorJobEvent[] }>(response);
}
async cancelDigitadorJob(jobId: string): Promise<DigitadorJob> {
const response = await fetchWithTimeout(
this.url(`/bot-digitador/jobs/${encodeURIComponent(jobId)}/cancel`),
{ method: "POST", headers: this.botHeaders() },
);
return this.handle<DigitadorJob>(response);
}
// ── Count Jobs ─────────────────────────────────────────────────────────────
/** Envía PDFs al servicio de conteo (síncrono). */
@@ -456,7 +538,7 @@ export class ApiClient {
async poblacionAptaExportCsv(
jobId: string,
subset: "aptos" | "no_aptos" = "aptos",
subset: "aptos" | "no_aptos" | "todos" = "aptos",
): Promise<Blob> {
const params = new URLSearchParams({ subset });
const path = `/poblacion-apta/jobs/${encodeURIComponent(jobId)}/export.csv?${params}`;
@@ -0,0 +1,18 @@
import type { ModuleDescriptor } from "../registry";
import { registerModule } from "../registry";
import type { Task } from "../../../shared/types";
import { DigitadorWizardModule } from "./DigitadorWizardModule";
import { DigitadorResultsPanel } from "./DigitadorResultsPanel";
const DigitadorModule: ModuleDescriptor = {
type: "digitador",
label: "Bot digitador",
iconName: "Keyboard",
hasTask: (tasks: Task[]) => tasks.some((t) => t.type === "digitador"),
Wizard: DigitadorWizardModule as unknown as ModuleDescriptor["Wizard"],
Results: DigitadorResultsPanel as unknown as ModuleDescriptor["Results"],
};
registerModule(DigitadorModule);
export { DigitadorModule };
@@ -0,0 +1,400 @@
import {
AlertCircle,
CheckCircle2,
Download,
Keyboard,
Loader2,
Plus,
RefreshCw,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type {
DigitadorJob,
DigitadorJobEvent,
DigitadorJobFile,
Job,
Task,
} from "../../../shared/types";
import type { ApiClient } from "../../lib/apiClient";
declare const desktopApi: import("../../../shared/types").DesktopBridge | undefined;
interface Props {
client: ApiClient;
job: Job;
tasks: Task[];
onStartDigitador: () => void;
}
function reportBasename(file: DigitadorJobFile): string {
const named = (file.name || "").trim();
if (named) return named;
return file.key.split("/").pop() || "reporte.csv";
}
function extensionFilters(filename: string): Array<{ name: string; extensions: string[] }> {
const lower = filename.toLowerCase();
if (lower.endsWith(".csv")) return [{ name: "CSV", extensions: ["csv"] }];
if (lower.endsWith(".json")) return [{ name: "JSON", extensions: ["json"] }];
if (lower.endsWith(".log")) return [{ name: "Log", extensions: ["log"] }];
return [{ name: "Archivo", extensions: ["*"] }];
}
async function saveReportAs(filename: string, data: ArrayBuffer): Promise<boolean> {
const safeName = reportBasename({ key: filename, name: filename });
if (typeof desktopApi?.saveFileDialog === "function" && typeof desktopApi?.writeBytesFile === "function") {
const dest = await desktopApi.saveFileDialog(safeName, extensionFilters(safeName));
if (!dest) return false;
await desktopApi.writeBytesFile(dest, data);
return true;
}
const blob = new Blob([data]);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = safeName;
a.click();
URL.revokeObjectURL(url);
return true;
}
export function DigitadorResultsPanel({ client, job, tasks, onStartDigitador }: Props) {
const [runs, setRuns] = useState<DigitadorJob[]>([]);
const [selectedId, setSelectedId] = useState<string>("");
const [events, setEvents] = useState<DigitadorJobEvent[]>([]);
const [files, setFiles] = useState<DigitadorJobFile[]>([]);
const [filesError, setFilesError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dlBusyKey, setDlBusyKey] = useState<string | null>(null);
const [dlStatus, setDlStatus] = useState<string | null>(null);
const digitadorTasks = tasks
.filter((t) => t.type === "digitador")
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
const loadRuns = useCallback(async () => {
setLoading(true);
setError(null);
try {
const list = await client.listDigitadorJobs(100, job.id);
setRuns(list);
setSelectedId((prev) => {
if (prev && list.some((r) => r.id === prev)) return prev;
return list[0]?.id ?? "";
});
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [client, job.id]);
useEffect(() => {
void loadRuns();
}, [loadRuns, digitadorTasks.length]);
const selected = runs.find((r) => r.id === selectedId) ?? null;
const running = Boolean(
selected && !["completed", "failed", "cancelled"].includes(selected.status),
);
useEffect(() => {
if (!selectedId) {
setEvents([]);
setFiles([]);
setFilesError(null);
return;
}
let cancelled = false;
async function loadDetail() {
try {
const [{ events: ev }, fresh] = await Promise.all([
client.getDigitadorEvents(selectedId, 0),
client.getDigitadorJob(selectedId),
]);
if (cancelled) return;
setEvents(ev);
setRuns((prev) => prev.map((r) => (r.id === fresh.id ? fresh : r)));
try {
const fileResp = await client.listDigitadorJobFiles(selectedId);
if (cancelled) return;
setFiles(fileResp.files);
setFilesError(null);
} catch (e) {
if (cancelled) return;
setFiles([]);
setFilesError(e instanceof Error ? e.message : String(e));
}
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
}
}
void loadDetail();
if (!running) return;
const t = setInterval(() => void loadDetail(), 3000);
return () => {
cancelled = true;
clearInterval(t);
};
}, [client, selectedId, running]);
const result = selected?.result ?? {};
const exitosos = Number(result.exitosos ?? 0);
const fallidos = Number(result.fallidos ?? 0);
const existentes = Number(result.existentes ?? 0);
const timeouts = Number(result.timeouts ?? 0);
const total = Number(result.total ?? 0);
const bulkBusy = Boolean(dlBusyKey);
async function downloadOne(file: DigitadorJobFile) {
if (!selectedId || bulkBusy) return;
const busyId = `file:${file.key}`;
setDlBusyKey(busyId);
setDlStatus(null);
setError(null);
try {
const data = await client.fetchDigitadorObject(selectedId, file.key);
const name = reportBasename(file);
const ok = await saveReportAs(name, data);
if (ok) setDlStatus(`Guardado: ${name}`);
} catch (e) {
setError(e instanceof Error ? e.message : "No se pudo descargar el reporte.");
} finally {
setDlBusyKey(null);
}
}
if (runs.length === 0 && !loading) {
return (
<div className="results-empty">
<Keyboard size={28} />
<p>Aún no hay corridas de bot digitador en este trabajo.</p>
<button type="button" className="btn btn-primary" onClick={onStartDigitador}>
<Plus size={15} /> Nueva digitación
</button>
</div>
);
}
return (
<div className="results-section">
<div className="results-section-header">
<div className="results-section-header-left">
<label className="wiz-field-label">Corrida</label>
<select
className="form-input"
value={selectedId}
onChange={(e) => {
setSelectedId(e.target.value);
setDlStatus(null);
}}
style={{ minWidth: 280 }}
>
{runs.map((r) => (
<option key={r.id} value={r.id}>
{r.title || r.original_filename || r.id.slice(0, 8)} · {r.status}
{r.shard_count && Number(r.shard_count) > 1
? ` · ${r.shard_count} bots`
: r.worker_id
? ` · ${r.worker_id}`
: ""}
</option>
))}
</select>
{selected && (
<span className={`status-badge status-badge--${selected.status}`}>
{selected.status}
</span>
)}
</div>
<div className="results-section-header-right">
<button type="button" className="btn btn-sm btn-ghost" onClick={() => void loadRuns()} disabled={loading}>
{loading ? <Loader2 size={13} className="spin" /> : <RefreshCw size={13} />}
Actualizar
</button>
<button type="button" className="btn btn-sm btn-primary" onClick={onStartDigitador}>
<Plus size={13} /> Nueva digitación
</button>
</div>
</div>
{error && (
<div className="wiz-toast wiz-toast--error" style={{ position: "static", marginBottom: 12 }}>
<AlertCircle size={15} />
<span>{error}</span>
</div>
)}
{selected?.error && (
<div className="wiz-toast wiz-toast--error" style={{ position: "static", marginBottom: 12 }}>
<AlertCircle size={15} />
<span>{selected.error}</span>
</div>
)}
{dlStatus && (
<div className="wiz-toast" style={{ position: "static", marginBottom: 12 }}>
<CheckCircle2 size={15} />
<span>{dlStatus}</span>
</div>
)}
{selected && (
<>
<div className="wiz-stats-row" style={{ marginBottom: 16, flexShrink: 0 }}>
<div className="wiz-stat">
<span className="wiz-stat-value">{exitosos}</span>
<span className="wiz-stat-label">OK</span>
</div>
<div className="wiz-stat">
<span className="wiz-stat-value">{fallidos}</span>
<span className="wiz-stat-label">Fallidos</span>
</div>
<div className="wiz-stat">
<span className="wiz-stat-value">{existentes}</span>
<span className="wiz-stat-label">Existentes</span>
</div>
<div className="wiz-stat">
<span className="wiz-stat-value">{timeouts}</span>
<span className="wiz-stat-label">Timeouts</span>
</div>
<div className="wiz-stat">
<span className="wiz-stat-value">{total || "—"}</span>
<span className="wiz-stat-label">Total</span>
</div>
</div>
<p className="wiz-hint" style={{ marginBottom: 12, flexShrink: 0 }}>
{selected.shard_count && Number(selected.shard_count) > 1
? `${selected.shard_count} bots (distribuido)`
: (
<>
Worker <strong>{selected.worker_id ?? "—"}</strong>
</>
)}
{selected.original_filename ? ` · ${selected.original_filename}` : ""}
{selected.reports_prefix ? (
<>
{" "}
· Prefijo <code className="dl-prefix-code">{selected.reports_prefix}</code>
</>
) : null}
</p>
{(() => {
const shardList = Array.isArray(selected.shards)
? selected.shards
: Array.isArray(result.shards)
? (result.shards as Array<{
id?: string;
worker_id?: string | null;
status?: string;
shard_index?: number | null;
}>)
: [];
if (shardList.length === 0) return null;
return (
<div className="wiz-section" style={{ marginBottom: 12, flexShrink: 0 }}>
<h3 className="wiz-section-title">Estado por bot</h3>
<div className="wiz-review-grid" style={{ marginTop: 8 }}>
{shardList.map((s, i) => (
<div key={s.id || String(i)} className="wiz-review-item">
<div>
<span className="wiz-review-label">
Shard {(s.shard_index ?? i) + 1}
</span>
<span className="wiz-review-value">{s.worker_id || "—"}</span>
</div>
<div>
<span className="wiz-review-label">Estado</span>
<span className="wiz-review-value">{s.status || "—"}</span>
</div>
</div>
))}
</div>
</div>
);
})()}
<div className="wiz-section dl-files-section">
<div className="dl-files-header">
<h3 className="wiz-section-title" style={{ margin: 0 }}>
Reportes en MinIO
{files.length > 0 ? ` · ${files.length} archivo${files.length === 1 ? "" : "s"}` : ""}
</h3>
</div>
{filesError && (
<div className="wiz-toast wiz-toast--error" style={{ position: "static", marginBottom: 12 }}>
<AlertCircle size={15} />
<span>No se pudo listar MinIO: {filesError}</span>
</div>
)}
{!filesError && files.length === 0 ? (
<p className="wiz-hint">Sin reportes listados aún (RESULTADOS / OMITIDOS).</p>
) : (
<table className="dl-files-table">
<thead>
<tr>
<th>Archivo</th>
<th className="dl-files-table-actions"> </th>
</tr>
</thead>
<tbody>
{files.map((f) => {
const fileBusy = dlBusyKey === `file:${f.key}`;
const name = reportBasename(f);
return (
<tr key={f.key}>
<td className="dl-col-file" title={f.key}>
{name}
</td>
<td className="dl-files-table-actions">
<button
type="button"
className="btn btn-sm btn-ghost"
disabled={bulkBusy}
onClick={() => void downloadOne(f)}
title="Descargar reporte"
>
{fileBusy ? (
<Loader2 size={13} className="spin" />
) : (
<Download size={13} />
)}
Descargar
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
<div className="wiz-section">
<h3 className="wiz-section-title">Log</h3>
<div className="wiz-estimate-box dl-log-box">
{events.length === 0 ? (
<span className="wiz-hint">Sin eventos.</span>
) : (
events.map((ev) => (
<div key={ev.id}>
[{ev.timestamp}] {ev.message}
</div>
))
)}
</div>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,760 @@
import {
AlertCircle,
Bot,
CheckCircle2,
FileSpreadsheet,
Keyboard,
Loader2,
Play,
RefreshCw,
Square,
Upload,
Users,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ApiClient } from "../../lib/apiClient";
import type {
DigitadorJob,
DigitadorJobEvent,
DigitadorWorker,
Job,
PoblacionAptaJob,
} from "../../../shared/types";
export type DigitadorSourceMode = "aptos" | "archivo";
export type DigitadorInitialSource = {
poblacionAptaJobId: string;
};
interface Props {
client: ApiClient;
job: Job;
initialSource?: DigitadorInitialSource | null;
}
type DigitadorStep = 1 | 2;
type ShardInfo = {
id?: string;
worker_id?: string | null;
status?: string;
shard_index?: number | null;
result?: Record<string, unknown>;
error?: string | null;
};
export function DigitadorWizardModule({ client, job, initialSource = null }: Props) {
const [step, setStep] = useState<DigitadorStep>(1);
const [workers, setWorkers] = useState<DigitadorWorker[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [useAllOnline, setUseAllOnline] = useState(true);
const [sourceMode, setSourceMode] = useState<DigitadorSourceMode>(
initialSource?.poblacionAptaJobId ? "aptos" : "aptos",
);
const [paJobs, setPaJobs] = useState<PoblacionAptaJob[]>([]);
const [selectedPaId, setSelectedPaId] = useState(initialSource?.poblacionAptaJobId ?? "");
const [loadingPa, setLoadingPa] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [busy, setBusy] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeJob, setActiveJob] = useState<DigitadorJob | null>(null);
const [events, setEvents] = useState<DigitadorJobEvent[]>([]);
const sinceRef = useRef(0);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const initializedSelectRef = useRef(false);
const appliedInitialRef = useRef<string | null>(null);
const onlineWorkers = useMemo(
() => workers.filter((w) => w.status === "online" || w.status === "busy"),
[workers],
);
useEffect(() => {
if (!initialSource?.poblacionAptaJobId) return;
const key = initialSource.poblacionAptaJobId;
if (appliedInitialRef.current === key) return;
appliedInitialRef.current = key;
setSourceMode("aptos");
setSelectedPaId(initialSource.poblacionAptaJobId);
setStep(1);
}, [initialSource?.poblacionAptaJobId]);
const refreshWorkers = useCallback(async () => {
setRefreshing(true);
try {
const list = await client.listDigitadorWorkers();
setWorkers(list);
setError(null);
const online = list.filter((w) => w.status === "online" || w.status === "busy");
if (!initializedSelectRef.current && online.length > 0) {
initializedSelectRef.current = true;
setUseAllOnline(online.length >= 2);
setSelectedIds(online.map((w) => w.id));
} else if (useAllOnline) {
setSelectedIds(online.map((w) => w.id));
} else {
setSelectedIds((prev) => prev.filter((id) => online.some((w) => w.id === id)));
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setRefreshing(false);
}
}, [client, useAllOnline]);
useEffect(() => {
void refreshWorkers();
const t = setInterval(() => void refreshWorkers(), 5000);
return () => clearInterval(t);
}, [refreshWorkers]);
const loadPaJobs = useCallback(async () => {
setLoadingPa(true);
try {
const list = await client.poblacionAptaJobs(job.id);
const completed = list
.filter((j) => j.status === "completed")
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
setPaJobs(completed);
setSelectedPaId((prev) => {
if (prev && completed.some((j) => j.id === prev)) return prev;
return completed[0]?.id ?? "";
});
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoadingPa(false);
}
}, [client, job.id]);
useEffect(() => {
if (sourceMode !== "aptos") return;
void loadPaJobs();
}, [sourceMode, loadPaJobs]);
useEffect(() => {
if (!activeJob || ["completed", "failed", "cancelled"].includes(activeJob.status)) {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
return;
}
async function poll() {
if (!activeJob) return;
try {
const nextJob = await client.getDigitadorJob(activeJob.id);
setActiveJob(nextJob);
const { events: next } = await client.getDigitadorEvents(activeJob.id, sinceRef.current);
if (next.length) {
setEvents((prev) => [...prev, ...next]);
sinceRef.current = next[next.length - 1].id;
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}
void poll();
pollRef.current = setInterval(() => void poll(), 3000);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [activeJob?.id, activeJob?.status, client]);
function toggleWorker(id: string) {
if (useAllOnline) return;
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
}
const selectedPa = paJobs.find((j) => j.id === selectedPaId) ?? null;
const aptosCount = Number(selectedPa?.summary?.paso_bot ?? 0);
async function startJob() {
const targets = useAllOnline ? onlineWorkers.map((w) => w.id) : selectedIds;
if (targets.length === 0) {
setError("Necesitas al menos un bot online.");
return;
}
if (sourceMode === "archivo") {
if (!file) {
setError("Selecciona un CSV.");
return;
}
} else {
if (!selectedPaId) {
setError("Selecciona una corrida de población apta.");
return;
}
if (aptosCount <= 0) {
setError("Esa corrida no tiene aptos (PASO_BOT) para digitar.");
return;
}
}
setBusy(true);
setError(null);
try {
const form = new FormData();
form.append("job_id", job.id);
if (sourceMode === "archivo" && file) {
form.append("file", file);
} else {
form.append("poblacion_apta_job_id", selectedPaId);
form.append("subset", "aptos");
}
if (title.trim()) form.append("title", title.trim());
if (targets.length === 1) {
form.append("worker_id", targets[0]);
} else {
form.append("worker_ids", JSON.stringify(targets));
}
const created = await client.createDigitadorJob(form);
sinceRef.current = 0;
setEvents([]);
setActiveJob(created);
setStep(2);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
async function cancelJob() {
if (!activeJob) return;
setBusy(true);
try {
const updated = await client.cancelDigitadorJob(activeJob.id);
setActiveJob(updated);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
function resetWizard() {
setStep(1);
setActiveJob(null);
setEvents([]);
setFile(null);
setTitle("");
setError(null);
sinceRef.current = 0;
if (fileInputRef.current) fileInputRef.current.value = "";
}
const result = activeJob?.result ?? {};
const processed = Number(result.processed ?? result.exitosos ?? 0);
const total = Number(result.total ?? result.total_rows ?? 0);
const pct = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0;
const running = Boolean(
activeJob && !["completed", "failed", "cancelled"].includes(activeJob.status),
);
const isDistributed =
Boolean(activeJob?.shard_count && Number(activeJob.shard_count) > 1) ||
result.mode === "distributed";
const shards: ShardInfo[] = Array.isArray(activeJob?.shards)
? (activeJob!.shards as ShardInfo[])
: Array.isArray(result.shards)
? (result.shards as ShardInfo[])
: [];
const effectiveTargets = useAllOnline ? onlineWorkers.map((w) => w.id) : selectedIds;
const sourceReady =
sourceMode === "archivo" ? Boolean(file) : Boolean(selectedPaId) && aptosCount > 0;
const canStart = effectiveTargets.length > 0 && sourceReady && !busy;
return (
<div className="wiz">
<nav className="stepper" aria-label="Pasos del bot digitador">
<div className="stepper-item">
<button
type="button"
className={`stepper-dot ${step === 1 ? "active" : step > 1 ? "done" : "todo"}`}
onClick={() => !running && setStep(1)}
disabled={running}
>
1
</button>
<span className={`stepper-label ${step === 1 ? "active" : step > 1 ? "done" : "todo"}`}>
Configurar
</span>
<div className={`stepper-connector ${step > 1 ? "done" : ""}`} />
</div>
<div className="stepper-item">
<button
type="button"
className={`stepper-dot ${step === 2 ? "active" : "todo"}`}
onClick={() => activeJob && setStep(2)}
disabled={!activeJob}
>
2
</button>
<span className={`stepper-label ${step === 2 ? "active" : "todo"}`}>Ejecución</span>
</div>
</nav>
<div className="wiz-body">
{step === 1 && (
<div className="wiz-step">
<div className="wiz-header" style={{ marginBottom: 4 }}>
<h2 style={{ display: "flex", alignItems: "center", gap: 8, margin: 0 }}>
<Keyboard size={18} />
Bot digitador
</h2>
<p className="wiz-desc" style={{ marginTop: 6 }}>
Digita en Integración Total solo los <strong>aptos (PASO_BOT)</strong>, o un CSV
propio con el mismo formato.
</p>
</div>
{/* 1. Origen */}
<div className="wiz-section">
<h3 className="wiz-section-title">1. Origen</h3>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 14 }}>
<button
type="button"
className={`btn${sourceMode === "aptos" ? " btn-primary" : ""}`}
onClick={() => setSourceMode("aptos")}
>
<Users size={14} /> Aptos de población apta
</button>
<button
type="button"
className={`btn${sourceMode === "archivo" ? " btn-primary" : ""}`}
onClick={() => setSourceMode("archivo")}
>
<Upload size={14} /> Subir CSV
</button>
</div>
<label className="wiz-field-label">Título (opcional)</label>
<input
className="form-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ej. Digitación lote mayo 2026"
style={{ width: "100%", marginBottom: 14 }}
/>
{sourceMode === "aptos" ? (
<>
<div className="wiz-section-header" style={{ marginBottom: 8 }}>
<label className="wiz-field-label" style={{ margin: 0 }}>
Corrida de población apta
</label>
<button
type="button"
className="btn btn-sm btn-ghost"
onClick={() => void loadPaJobs()}
disabled={loadingPa}
>
{loadingPa ? <Loader2 size={13} className="spin" /> : <RefreshCw size={13} />}
Actualizar
</button>
</div>
{loadingPa && paJobs.length === 0 ? (
<p className="wiz-hint">
<Loader2 size={13} className="spin" /> Cargando corridas…
</p>
) : paJobs.length === 0 ? (
<div className="wiz-estimate-box" style={{ textAlign: "left" }}>
<p className="wiz-desc" style={{ margin: 0 }}>
No hay corridas de población apta completadas en este trabajo.
</p>
<p className="wiz-hint" style={{ marginTop: 6, marginBottom: 0 }}>
Ejecuta primero Población apta, o cambia a «Subir CSV».
</p>
</div>
) : (
<>
<select
className="form-input"
value={selectedPaId}
onChange={(e) => setSelectedPaId(e.target.value)}
style={{ width: "100%" }}
>
{paJobs.map((pa) => {
const n = Number(pa.summary?.paso_bot ?? 0);
return (
<option key={pa.id} value={pa.id}>
{pa.id.slice(0, 8)} · {n} aptos · {pa.summary?.total ?? 0} total
</option>
);
})}
</select>
{selectedPa && (
<div
className="wiz-estimate-box"
style={{
marginTop: 12,
display: "flex",
alignItems: "center",
gap: 12,
textAlign: "left",
}}
>
{aptosCount > 0 ? (
<CheckCircle2 size={20} style={{ color: "var(--success, #16a34a)", flexShrink: 0 }} />
) : (
<AlertCircle size={20} style={{ color: "var(--danger, #dc2626)", flexShrink: 0 }} />
)}
<div>
<div style={{ fontWeight: 600 }}>
{aptosCount > 0
? `${aptosCount} registros aptos listos para digitar`
: "Sin aptos en esta corrida"}
</div>
<div className="wiz-hint" style={{ margin: 0 }}>
Solo se envían filas PASO_BOT. Los no aptos no se digitan.
</div>
</div>
</div>
)}
</>
)}
</>
) : (
<>
<label className="wiz-field-label">Archivo CSV</label>
<div className="folder-picker">
<div className="folder-picker-display">
{file ? (
<>
<FileSpreadsheet size={16} className="folder-picker-icon" />
<span className="folder-picker-path">{file.name}</span>
</>
) : (
<span className="folder-picker-placeholder">Ningún archivo seleccionado</span>
)}
</div>
<button
type="button"
className="btn btn-primary"
onClick={() => fileInputRef.current?.click()}
>
<FileSpreadsheet size={15} />
Seleccionar CSV
</button>
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
hidden
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</div>
<p className="wiz-hint" style={{ marginTop: 8 }}>
Columnas: radicado, documento, ingreso, total_cargos, autorizacion,
tipo_documento, tipo_servicio, copago, cuota_moderadora (también acepta
headers IT).
</p>
</>
)}
</div>
{/* 2. Bots */}
<div className="wiz-section">
<div className="wiz-section-header">
<h3 className="wiz-section-title">
2. Bots
{onlineWorkers.length > 0 && (
<span className="wiz-count-badge">{onlineWorkers.length} online</span>
)}
</h3>
<button
type="button"
className="btn btn-sm btn-ghost"
onClick={() => void refreshWorkers()}
disabled={refreshing}
>
{refreshing ? <Loader2 size={13} className="spin" /> : <RefreshCw size={13} />}
Actualizar
</button>
</div>
<p className="wiz-desc">
El agent debe estar encendido en la PC con Integración Total. Con varios bots, las
filas se reparte automáticamente.
</p>
{onlineWorkers.length >= 2 && (
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginTop: 12,
marginBottom: 8,
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={useAllOnline}
onChange={(e) => {
const on = e.target.checked;
setUseAllOnline(on);
if (on) setSelectedIds(onlineWorkers.map((w) => w.id));
}}
/>
<span>Usar todos los bots online ({onlineWorkers.length})</span>
</label>
)}
{workers.length === 0 ? (
<div className="wiz-estimate-box" style={{ marginTop: 12, textAlign: "left" }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
<Bot size={18} style={{ flexShrink: 0, marginTop: 2 }} />
<div>
<div style={{ fontWeight: 600 }}>Ningún bot conectado</div>
<p className="wiz-hint" style={{ margin: "4px 0 0" }}>
En la PC del digitador ejecuta <code>run_gui.py</code> del agent y conéctalo
al gateway.
</p>
</div>
</div>
</div>
) : (
<div className="wiz-review-grid" style={{ marginTop: 12 }}>
{workers.map((w) => {
const online = w.status === "online" || w.status === "busy";
const selected = effectiveTargets.includes(w.id);
return (
<button
key={w.id}
type="button"
className={`wiz-review-item${selected ? " wiz-review-item--selected" : ""}`}
disabled={!online || useAllOnline}
onClick={() => online && toggleWorker(w.id)}
style={{
textAlign: "left",
cursor: online && !useAllOnline ? "pointer" : "not-allowed",
opacity: online ? 1 : 0.45,
borderColor: selected ? "var(--accent, #3b8ed0)" : undefined,
}}
>
<div>
<span className="wiz-review-label">Worker</span>
<span className="wiz-review-value">{w.id}</span>
</div>
<div>
<span className="wiz-review-label">Estado</span>
<span className="wiz-review-value">
{w.status}
{w.hostname ? ` · ${w.hostname}` : ""}
{selected ? " · seleccionado" : ""}
</span>
</div>
</button>
);
})}
</div>
)}
{onlineWorkers.length === 0 && workers.length > 0 && (
<p className="wiz-hint" style={{ marginTop: 8 }}>
Hay workers registrados pero ninguno online. Revisa el agent.
</p>
)}
{effectiveTargets.length > 1 && (
<p className="wiz-hint" style={{ marginTop: 8 }}>
Se repartirá entre {effectiveTargets.length} bots.
</p>
)}
</div>
</div>
)}
{step === 2 && activeJob && (
<div className="wiz-step wiz-step-centered">
<div className="wiz-hero">
<div className={`wiz-hero-icon ${running ? "wiz-hero-icon--go" : ""}`}>
{running ? (
<Loader2 size={28} className="spin" />
) : activeJob.status === "completed" ? (
<Keyboard size={28} />
) : activeJob.status === "failed" ? (
<AlertCircle size={28} />
) : (
<Bot size={28} />
)}
</div>
<h3 className="wiz-hero-title">
{running
? isDistributed
? "Digitación distribuida en curso"
: "Digitación en curso"
: activeJob.status === "completed"
? "Digitación completada"
: activeJob.status === "cancelled"
? "Digitación cancelada"
: activeJob.status === "failed"
? "Digitación fallida"
: "Digitación finalizada"}
</h3>
<p className="wiz-desc">
{isDistributed
? `${Number(activeJob.shard_count) || shards.length || "?"} bots`
: `Bot ${activeJob.worker_id ?? "—"}`}
{activeJob.original_filename ? ` · ${activeJob.original_filename}` : ""}
</p>
</div>
{activeJob.error && (
<div className="wiz-toast wiz-toast--error" style={{ position: "static", marginBottom: 12 }}>
<AlertCircle size={15} />
<span>{activeJob.error}</span>
</div>
)}
<div className="wiz-progress">
<div className="wiz-progress-header">
<span>
{Number(result.exitosos ?? processed)}/{total || "?"} filas
</span>
<span>{pct}%</span>
</div>
<div className="wiz-progress-track">
<div className="wiz-progress-fill" style={{ width: `${pct}%` }} />
</div>
</div>
<div className="wiz-stats-row">
<div className="wiz-stat">
<span className="wiz-stat-value">{String(result.exitosos ?? processed)}</span>
<span className="wiz-stat-label">OK</span>
</div>
<div className="wiz-stat">
<span className="wiz-stat-value">{String(result.fallidos ?? 0)}</span>
<span className="wiz-stat-label">Fallidos</span>
</div>
<div className="wiz-stat">
<span className="wiz-stat-value">{String(result.existentes ?? 0)}</span>
<span className="wiz-stat-label">Existentes</span>
</div>
<div className="wiz-stat">
<span className="wiz-stat-value">{String(result.timeouts ?? 0)}</span>
<span className="wiz-stat-label">Timeouts</span>
</div>
</div>
{shards.length > 0 && (
<div className="wiz-section" style={{ marginTop: 16, width: "100%", maxWidth: 640 }}>
<h3 className="wiz-section-title">Shards por bot</h3>
<div className="wiz-review-grid" style={{ marginTop: 8 }}>
{shards.map((s, i) => (
<div key={s.id || String(i)} className="wiz-review-item">
<div>
<span className="wiz-review-label">
Shard {(s.shard_index ?? i) + 1}
</span>
<span className="wiz-review-value">{s.worker_id || "—"}</span>
</div>
<div>
<span className="wiz-review-label">Estado</span>
<span className="wiz-review-value">{s.status || "—"}</span>
</div>
</div>
))}
</div>
</div>
)}
<div className="wiz-section" style={{ marginTop: 16, width: "100%", maxWidth: 640 }}>
<h3 className="wiz-section-title">Log del agent</h3>
<div
className="wiz-estimate-box"
style={{
maxHeight: 220,
overflow: "auto",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: 12,
textAlign: "left",
}}
>
{events.length === 0 ? (
<span className="wiz-hint">Esperando eventos…</span>
) : (
events.map((ev) => (
<div key={ev.id}>
[{ev.timestamp}] {ev.message}
</div>
))
)}
</div>
</div>
</div>
)}
</div>
{error && (
<div className="wiz-toast wiz-toast--error">
<AlertCircle size={15} />
<span>{error}</span>
</div>
)}
<footer className="wiz-nav">
{step === 2 ? (
<button type="button" className="btn" onClick={resetWizard} disabled={running}>
Nueva digitación
</button>
) : (
<span />
)}
<div className="wiz-nav-dots">
<button
type="button"
className={`wiz-nav-dot ${step === 1 ? "active" : ""}`}
onClick={() => !running && setStep(1)}
disabled={running}
aria-label="Paso 1"
/>
<button
type="button"
className={`wiz-nav-dot ${step === 2 ? "active" : ""}`}
onClick={() => activeJob && setStep(2)}
disabled={!activeJob}
aria-label="Paso 2"
/>
</div>
{step === 1 && (
<button
type="button"
className="btn btn-primary"
disabled={!canStart}
onClick={() => void startJob()}
>
{busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />}
{effectiveTargets.length > 1
? `Digitar con ${effectiveTargets.length} bots`
: sourceMode === "aptos" && aptosCount > 0
? `Digitar ${aptosCount} aptos`
: "Iniciar digitación"}
</button>
)}
{step === 2 && running && (
<button type="button" className="btn" disabled={busy} onClick={() => void cancelJob()}>
<Square size={14} /> Cancelar
</button>
)}
{step === 2 && !running && (
<button type="button" className="btn btn-primary" onClick={resetWizard}>
<Play size={15} /> Otra digitación
</button>
)}
</footer>
</div>
);
}
@@ -1,4 +1,4 @@
import { Download, Loader2, RefreshCw, Users } from "lucide-react";
import { Download, Keyboard, Loader2, RefreshCw, Users } from "lucide-react";
import { useEffect, useState } from "react";
import type { PoblacionAptaJob } from "../../../shared/types";
import { PoblacionAptaResultsTable } from "../../components/PoblacionAptaResultsTable";
@@ -7,7 +7,7 @@ import type { ModuleResultsProps } from "../registry";
type Row = Record<string, unknown>;
export function PoblacionAptaResults({ client, task, job }: ModuleResultsProps) {
export function PoblacionAptaResults({ client, task, job, onActivateModule }: ModuleResultsProps) {
const [paJob, setPaJob] = useState<PoblacionAptaJob | null>(null);
const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(true);
@@ -60,6 +60,16 @@ export function PoblacionAptaResults({ client, task, job }: ModuleResultsProps)
downloadCsv(buildExportCsvFilename("poblacion_apta_no_aptos", job.title), text);
}
async function exportTodos() {
const paJobId = String(
(task.result as Record<string, unknown>)?.poblacion_apta_job_id ?? "",
);
if (!paJobId) return;
const blob = await client.poblacionAptaExportCsv(paJobId, "todos");
const text = await blob.text();
downloadCsv(buildExportCsvFilename("poblacion_apta_todos", job.title), text);
}
if (loading) {
return (
<div className="module-loading">
@@ -88,6 +98,10 @@ export function PoblacionAptaResults({ client, task, job }: ModuleResultsProps)
}
const summary = paJob?.summary ?? {};
const paJobId = String(
(task.result as Record<string, unknown>)?.poblacion_apta_job_id ?? paJob?.id ?? "",
);
const canDigitador = paJob?.status === "completed" && Boolean(paJobId) && Boolean(onActivateModule);
return (
<div className="module-results">
@@ -108,6 +122,23 @@ export function PoblacionAptaResults({ client, task, job }: ModuleResultsProps)
<button type="button" className="btn btn-sm" onClick={() => void exportNoAptos()}>
<Download size={13} /> CSV no aptos ({summary.con_error ?? 0})
</button>
<button type="button" className="btn btn-sm" onClick={() => void exportTodos()}>
<Download size={13} /> CSV todos ({summary.total ?? 0})
</button>
{canDigitador && (
<button
type="button"
className="btn btn-sm btn-primary"
disabled={Number(summary.paso_bot ?? 0) === 0}
onClick={() =>
onActivateModule?.("digitador", {
poblacionAptaJobId: paJobId,
})
}
>
<Keyboard size={13} /> Digitar aptos
</button>
)}
</div>
</div>
@@ -3,6 +3,7 @@ import {
CheckSquare,
Download,
FileSpreadsheet,
Keyboard,
Loader2,
Square,
Upload,
@@ -235,6 +236,13 @@ export function PoblacionAptaWizard({
downloadCsv(buildExportCsvFilename("poblacion_apta_no_aptos", job.title), text);
}
async function exportTodos() {
if (!resultJob) return;
const blob = await client.poblacionAptaExportCsv(resultJob.id, "todos");
const text = await blob.text();
downloadCsv(buildExportCsvFilename("poblacion_apta_todos", job.title), text);
}
function reset() {
setDone(false);
setResultJob(null);
@@ -271,6 +279,23 @@ export function PoblacionAptaWizard({
<button type="button" className="btn btn-sm" onClick={() => void exportNoAptos()}>
<Download size={13} /> CSV no aptos ({summary.con_error ?? 0})
</button>
<button type="button" className="btn btn-sm" onClick={() => void exportTodos()}>
<Download size={13} /> CSV todos ({summary.total ?? 0})
</button>
{onActivateModule && (
<button
type="button"
className="btn btn-sm btn-primary"
disabled={Number(summary.paso_bot ?? 0) === 0}
onClick={() =>
onActivateModule("digitador", {
poblacionAptaJobId: resultJob.id,
})
}
>
<Keyboard size={13} /> Digitar aptos
</button>
)}
<button type="button" className="btn btn-sm" onClick={reset}>
Nueva corrida
</button>
@@ -4,4 +4,5 @@ export type JobSection =
| "cleaning"
| "poblacion_apta"
| "downloads"
| "digitador"
| "results";
@@ -5,6 +5,7 @@ import {
Cpu,
Download,
Hash,
Keyboard,
Loader2,
Plus,
RefreshCw,
@@ -29,8 +30,9 @@ import type { ApiClient } from "../lib/apiClient";
import { downloadCsv, toCsv, buildExportCsvFilename } from "../lib/csv";
import { taskTimingLabel, timingDuration } from "../lib/time";
import { DownloadsResultsPanel } from "../modules/downloads/DownloadsResultsPanel";
import { DigitadorResultsPanel } from "../modules/digitador/DigitadorResultsPanel";
type ResultTab = "counting" | "extraction" | "cleaning" | "poblacion_apta" | "downloads";
type ResultTab = "counting" | "extraction" | "cleaning" | "poblacion_apta" | "downloads" | "digitador";
interface Props {
client: ApiClient;
@@ -46,11 +48,15 @@ interface Props {
onStartCleaning: (ctx?: Record<string, unknown>) => void;
onStartPoblacionApta: () => void;
onStartDownload: () => void;
onStartDigitador: () => void;
onContinueToCounting?: (ctx: {
downloadJobId: string;
downloadsPrefix?: string;
files?: DownloadJobFile[];
}) => void;
onContinueToDigitador?: (ctx: {
poblacionAptaJobId: string;
}) => void;
}
export function JobResultsView({
@@ -67,7 +73,9 @@ export function JobResultsView({
onStartCleaning,
onStartPoblacionApta,
onStartDownload,
onStartDigitador,
onContinueToCounting,
onContinueToDigitador,
}: Props) {
const [activeTab, setActiveTab] = useState<ResultTab>("counting");
const [refreshing, setRefreshing] = useState(false);
@@ -82,6 +90,8 @@ export function JobResultsView({
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
const downloadTasks = tasks.filter((t) => t.type === "downloads")
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
const digitadorTasks = tasks.filter((t) => t.type === "digitador")
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
async function refresh() {
setRefreshing(true);
@@ -164,6 +174,17 @@ export function JobResultsView({
<span className="job-results-tab-count">{downloadTasks.length}</span>
)}
</button>
<button
type="button"
className={`job-results-tab${activeTab === "digitador" ? " job-results-tab--active" : ""}`}
onClick={() => setActiveTab("digitador")}
>
<Keyboard size={15} />
Bot digitador
{digitadorTasks.length > 0 && (
<span className="job-results-tab-count">{digitadorTasks.length}</span>
)}
</button>
<div style={{ flex: 1 }} />
<button
type="button"
@@ -216,6 +237,7 @@ export function JobResultsView({
tasks={poblacionAptaTasks}
liveProgress={poblacionAptaProgress}
onStartPoblacionApta={onStartPoblacionApta}
onContinueToDigitador={onContinueToDigitador}
/>
)}
{activeTab === "downloads" && (
@@ -227,6 +249,14 @@ export function JobResultsView({
onContinueToCounting={onContinueToCounting}
/>
)}
{activeTab === "digitador" && (
<DigitadorResultsPanel
client={client}
job={job}
tasks={digitadorTasks}
onStartDigitador={onStartDigitador}
/>
)}
</div>
</div>
);
@@ -780,12 +810,16 @@ function PoblacionAptaResultsSection({
tasks,
liveProgress,
onStartPoblacionApta,
onContinueToDigitador,
}: {
client: ApiClient;
job: Job;
tasks: Task[];
liveProgress?: BackgroundProgress | null;
onStartPoblacionApta: () => void;
onContinueToDigitador?: (ctx: {
poblacionAptaJobId: string;
}) => void;
}) {
const [selectedTask, setSelectedTask] = useState<Task | null>(tasks[0] ?? null);
const [paJob, setPaJob] = useState<PoblacionAptaJob | null>(null);
@@ -853,6 +887,16 @@ function PoblacionAptaResultsSection({
downloadCsv(buildExportCsvFilename("poblacion_apta_no_aptos", job.title), text);
}
async function exportTodos() {
const paJobId = String(
(selectedTask?.result as Record<string, unknown>)?.poblacion_apta_job_id ?? "",
);
if (!paJobId) return;
const blob = await client.poblacionAptaExportCsv(paJobId, "todos");
const text = await blob.text();
downloadCsv(buildExportCsvFilename("poblacion_apta_todos", job.title), text);
}
if (tasks.length === 0 && !liveProgress?.running) {
return (
<div className="job-results-empty">
@@ -874,6 +918,13 @@ function PoblacionAptaResultsSection({
}
const summary = paJob?.summary ?? {};
const paJobId = String(
(selectedTask?.result as Record<string, unknown>)?.poblacion_apta_job_id ?? paJob?.id ?? "",
);
const canDigitador =
Boolean(onContinueToDigitador) &&
paJob?.status === "completed" &&
Boolean(paJobId);
return (
<div className="results-section">
@@ -917,6 +968,21 @@ function PoblacionAptaResultsSection({
<button type="button" className="btn btn-sm" onClick={() => void exportNoAptos()}>
<Download size={13} /> CSV no aptos ({summary.con_error ?? 0})
</button>
<button type="button" className="btn btn-sm" onClick={() => void exportTodos()}>
<Download size={13} /> CSV todos ({summary.total ?? 0})
</button>
{canDigitador && (
<button
type="button"
className="btn btn-sm btn-primary"
onClick={() =>
onContinueToDigitador?.({ poblacionAptaJobId: paJobId })
}
disabled={Number(summary.paso_bot ?? 0) === 0}
>
<Keyboard size={13} /> Digitar aptos
</button>
)}
<button type="button" className="btn btn-sm btn-ghost" onClick={onStartPoblacionApta}>
<Plus size={13} /> Nueva corrida
</button>
@@ -9,6 +9,7 @@ import { CleaningWizardModule } from "../modules/cleaning/CleaningWizardModule";
import { PoblacionAptaWizardModule } from "../modules/poblacion_apta/PoblacionAptaWizardModule";
import { JobExtractionWizard } from "../modules/extraction/JobExtractionWizard";
import { DownloadsWizardModule } from "../modules/downloads/DownloadsWizardModule";
import { DigitadorWizardModule } from "../modules/digitador/DigitadorWizardModule";
import { JobResultsView } from "./JobResultsView";
interface Props {
@@ -127,6 +128,7 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
{section === "cleaning" && "Limpieza de datos"}
{section === "poblacion_apta" && "Población apta"}
{section === "downloads" && "Bot de descarga"}
{section === "digitador" && "Bot digitador"}
{section === "results" && "Resultados del trabajo"}
</div>
</header>
@@ -201,6 +203,19 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
<DownloadsWizardModule client={client} job={job} />
</div>
<div className={`job-section-pane${section === "digitador" ? "" : " job-section-pane--hidden"}`}>
<DigitadorWizardModule
key={`digitador-${String(sectionContext.poblacionAptaJobId ?? "default")}`}
client={client}
job={job}
initialSource={
typeof sectionContext.poblacionAptaJobId === "string"
? { poblacionAptaJobId: sectionContext.poblacionAptaJobId }
: null
}
/>
</div>
<div className={`job-section-pane${section === "results" ? "" : " job-section-pane--hidden"}`}>
<JobResultsView
client={client}
@@ -216,7 +231,9 @@ export function JobView({ client, job, section, sectionContext, onActivateSectio
onStartCleaning={(ctx?: Record<string, unknown>) => onActivateSection("cleaning", ctx)}
onStartPoblacionApta={() => onActivateSection("poblacion_apta")}
onStartDownload={() => onActivateSection("downloads")}
onStartDigitador={() => onActivateSection("digitador")}
onContinueToCounting={(ctx) => onActivateSection("counting", ctx)}
onContinueToDigitador={(ctx) => onActivateSection("digitador", ctx)}
/>
</div>
</div>
+63 -1
View File
@@ -373,7 +373,7 @@ export interface DownloadWorker {
export interface DownloadJob {
id: string;
worker_id: string;
worker_id: string | null;
status: "pending" | "running" | "completed" | "failed" | "cancelled" | string;
title: string;
excel_key: string;
@@ -389,6 +389,11 @@ export interface DownloadJob {
job_id?: string | null;
/** Task type=downloads */
task_id?: string | null;
parent_download_job_id?: string | null;
shard_index?: number | null;
shard_count?: number | null;
/** Presente en GET de padre distribuido */
shards?: DownloadJob[];
}
export interface DownloadJobFile {
@@ -417,6 +422,63 @@ export interface DownloadJobEvent {
metadata?: Record<string, unknown>;
}
/** Worker del bot digitador (control remoto). */
export interface DigitadorWorker {
id: string;
hostname: string;
max_concurrent: number;
status: "online" | "offline" | "busy" | string;
last_heartbeat: string;
current_job_id: string | null;
}
export interface DigitadorJob {
id: string;
worker_id: string | null;
status: "pending" | "running" | "completed" | "failed" | "cancelled" | string;
title: string;
csv_key: string;
reports_prefix: string;
original_filename: string;
cancel_requested: boolean;
result: Record<string, unknown>;
error: string | null;
created_at: string;
started_at: string | null;
finished_at: string | null;
job_id?: string | null;
task_id?: string | null;
parent_digitador_job_id?: string | null;
shard_index?: number | null;
shard_count?: number | null;
shards?: DigitadorJob[];
}
export interface DigitadorJobFile {
key: string;
name?: string | null;
}
export interface DigitadorJobFilesResponse {
job_id: string;
prefix: string;
files: DigitadorJobFile[];
count: number;
}
export interface DigitadorJobEvent {
id: number;
job_id: string;
event_type: string;
message: string;
timestamp: string;
radicado?: string | null;
nit?: string | null;
index?: number | null;
total?: number | null;
metadata?: Record<string, unknown>;
}
export interface DesktopBridge {
getRuntimeConfig(): Promise<RuntimeConfig>;
selectDirectory(): Promise<string | null>;