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:
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user