255 lines
11 KiB
TypeScript
255 lines
11 KiB
TypeScript
import { Loader2, Pencil, X } from "lucide-react";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import type { ApiClient } from "../lib/apiClient";
|
|
import type { BackgroundProgress, Job, Task } from "../../shared/types";
|
|
import type { JobSection } from "../types/jobSection";
|
|
import { BackgroundProgressBanner } from "../components/BackgroundProgressBanner";
|
|
import { CountingWizardModule } from "../modules/counting/CountingWizardModule";
|
|
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";
|
|
import { JobCostsView } from "./JobCostsView";
|
|
|
|
interface Props {
|
|
client: ApiClient;
|
|
job: Job;
|
|
section: JobSection;
|
|
sectionContext: Record<string, unknown>;
|
|
onActivateSection: (section: JobSection, context?: Record<string, unknown>) => void;
|
|
onJobUpdated: (job: Job) => void;
|
|
}
|
|
|
|
export function JobView({ client, job, section, sectionContext, onActivateSection, onJobUpdated }: Props) {
|
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
const [editingTitle, setEditingTitle] = useState(false);
|
|
const [titleDraft, setTitleDraft] = useState(job.title ?? "");
|
|
const [savingTitle, setSavingTitle] = useState(false);
|
|
const [countingProgress, setCountingProgress] = useState<BackgroundProgress | null>(null);
|
|
const [extractionProgress, setExtractionProgress] = useState<BackgroundProgress | null>(null);
|
|
const [cleaningProgress, setCleaningProgress] = useState<BackgroundProgress | null>(null);
|
|
const [poblacionAptaProgress, setPoblacionAptaProgress] = useState<BackgroundProgress | null>(null);
|
|
const [costsRefreshing, setCostsRefreshing] = useState(false);
|
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
const backgroundRunning =
|
|
countingProgress?.running === true ||
|
|
extractionProgress?.running === true ||
|
|
cleaningProgress?.running === true ||
|
|
poblacionAptaProgress?.running === true;
|
|
|
|
const loadTasks = useCallback(async () => {
|
|
try {
|
|
const data = await client.jobTasks(job.id);
|
|
setTasks(data);
|
|
} catch { /* silent */ }
|
|
}, [client, job.id]);
|
|
|
|
const refreshCosts = useCallback(async () => {
|
|
setCostsRefreshing(true);
|
|
try { await loadTasks(); } finally { setCostsRefreshing(false); }
|
|
}, [loadTasks]);
|
|
|
|
useEffect(() => {
|
|
loadTasks();
|
|
return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
|
|
}, [loadTasks]);
|
|
|
|
// Polling más frecuente mientras hay trabajo en segundo plano
|
|
useEffect(() => {
|
|
if (pollingRef.current) clearInterval(pollingRef.current);
|
|
const intervalMs = backgroundRunning ? 3000 : 8000;
|
|
pollingRef.current = setInterval(loadTasks, intervalMs);
|
|
return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
|
|
}, [backgroundRunning, loadTasks]);
|
|
|
|
function handleTaskCreated(task: Task) {
|
|
setTasks((prev) => {
|
|
const idx = prev.findIndex((t) => t.id === task.id);
|
|
return idx >= 0
|
|
? prev.map((t, i) => (i === idx ? task : t))
|
|
: [...prev, task];
|
|
});
|
|
loadTasks();
|
|
}
|
|
|
|
async function saveTitle() {
|
|
if (!titleDraft.trim() || titleDraft === job.title) { setEditingTitle(false); return; }
|
|
setSavingTitle(true);
|
|
try {
|
|
const updated = await client.updateJob(job.id, { title: titleDraft.trim() });
|
|
onJobUpdated(updated);
|
|
} finally {
|
|
setSavingTitle(false);
|
|
setEditingTitle(false);
|
|
}
|
|
}
|
|
|
|
const activeBackground =
|
|
countingProgress?.running ? countingProgress
|
|
: extractionProgress?.running ? extractionProgress
|
|
: cleaningProgress?.running ? cleaningProgress
|
|
: poblacionAptaProgress?.running ? poblacionAptaProgress
|
|
: null;
|
|
|
|
return (
|
|
<div className="job-view">
|
|
<header className="job-content-header">
|
|
<div className="job-title-area">
|
|
{editingTitle ? (
|
|
<div className="job-title-edit">
|
|
<input
|
|
className="form-input job-title-input"
|
|
value={titleDraft}
|
|
onChange={(e) => setTitleDraft(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") saveTitle();
|
|
if (e.key === "Escape") setEditingTitle(false);
|
|
}}
|
|
autoFocus
|
|
/>
|
|
<button type="button" className="btn btn-sm btn-primary" onClick={saveTitle} disabled={savingTitle}>
|
|
{savingTitle ? <Loader2 size={13} className="spin" /> : "Guardar"}
|
|
</button>
|
|
<button type="button" className="btn btn-sm" onClick={() => setEditingTitle(false)}>
|
|
<X size={13} />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="job-title-btn"
|
|
onClick={() => { setTitleDraft(job.title ?? ""); setEditingTitle(true); }}
|
|
title="Editar nombre del trabajo"
|
|
>
|
|
<h1 className="job-title">{job.title ?? "Sin título"}</h1>
|
|
<Pencil size={13} className="job-title-edit-icon" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="job-content-header-section">
|
|
{section === "counting" && "Conteo de servicios"}
|
|
{section === "extraction" && "Extracción de datos"}
|
|
{section === "cleaning" && "Limpieza de datos"}
|
|
{section === "poblacion_apta" && "Población apta"}
|
|
{section === "downloads" && "Bot de descarga"}
|
|
{section === "digitador" && "Bot digitador"}
|
|
{section === "costs" && "Costos de API"}
|
|
{section === "results" && "Resultados del trabajo"}
|
|
</div>
|
|
</header>
|
|
|
|
{/* Banner global visible en cualquier sección mientras corre un proceso */}
|
|
{activeBackground && section !== "results" && (
|
|
<div className="job-background-banner-wrap">
|
|
<BackgroundProgressBanner progress={activeBackground} compact />
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-ghost"
|
|
onClick={() => onActivateSection("results")}
|
|
>
|
|
Ver progreso en Resultados →
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/*
|
|
Los módulos permanecen montados (solo ocultos) para que el conteo/subida
|
|
siga en segundo plano al cambiar de sección en el sidebar.
|
|
*/}
|
|
<div className="job-section-content">
|
|
<div className={`job-section-pane${section === "counting" ? "" : " job-section-pane--hidden"}`}>
|
|
<CountingWizardModule
|
|
client={client}
|
|
job={job}
|
|
context={sectionContext}
|
|
onTaskCreated={handleTaskCreated}
|
|
onProgress={(p) => setCountingProgress(p.running ? p : null)}
|
|
onActivateModule={(type, ctx) => onActivateSection(type as JobSection, ctx)}
|
|
/>
|
|
</div>
|
|
|
|
<div className={`job-section-pane${section === "extraction" ? "" : " job-section-pane--hidden"}`}>
|
|
<JobExtractionWizard
|
|
client={client}
|
|
job={job}
|
|
onTaskCreated={handleTaskCreated}
|
|
onProgress={(p) => setExtractionProgress(p.running ? p : null)}
|
|
onActivateModule={(type, ctx) => onActivateSection(type as JobSection, ctx)}
|
|
context={sectionContext}
|
|
countingTaskId={sectionContext.countingTaskId as string | undefined}
|
|
validCounts={sectionContext.validCounts as number[] | undefined}
|
|
validCount={sectionContext.validCount as number | undefined}
|
|
/>
|
|
</div>
|
|
|
|
<div className={`job-section-pane${section === "cleaning" ? "" : " job-section-pane--hidden"}`}>
|
|
<CleaningWizardModule
|
|
key={`cleaning-${String(sectionContext.extractionTaskId ?? sectionContext.extractionBatchId ?? "default")}`}
|
|
client={client}
|
|
job={job}
|
|
onTaskCreated={handleTaskCreated}
|
|
onProgress={(p) => setCleaningProgress(p.running ? p : null)}
|
|
onActivateModule={(type, ctx) => onActivateSection(type as JobSection, ctx)}
|
|
context={sectionContext}
|
|
/>
|
|
</div>
|
|
|
|
<div className={`job-section-pane${section === "poblacion_apta" ? "" : " job-section-pane--hidden"}`}>
|
|
<PoblacionAptaWizardModule
|
|
client={client}
|
|
job={job}
|
|
onTaskCreated={handleTaskCreated}
|
|
onProgress={(p) => setPoblacionAptaProgress(p.running ? p : null)}
|
|
onActivateModule={(type, ctx) => onActivateSection(type as JobSection, ctx)}
|
|
/>
|
|
</div>
|
|
|
|
<div className={`job-section-pane${section === "downloads" ? "" : " job-section-pane--hidden"}`}>
|
|
<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}
|
|
job={job}
|
|
tasks={tasks}
|
|
countingProgress={countingProgress}
|
|
extractionProgress={extractionProgress}
|
|
cleaningProgress={cleaningProgress}
|
|
poblacionAptaProgress={poblacionAptaProgress}
|
|
onRefresh={loadTasks}
|
|
onStartCounting={() => onActivateSection("counting")}
|
|
onStartExtraction={(ctx?: Record<string, unknown>) => onActivateSection("extraction", ctx)}
|
|
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 className={`job-section-pane${section === "costs" ? "" : " job-section-pane--hidden"}`}>
|
|
<JobCostsView tasks={tasks} refreshing={costsRefreshing} onRefresh={() => void refreshCosts()} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|