Files
IPSExtractor/services/desktop_app/src/renderer/App.tsx
T

626 lines
22 KiB
TypeScript

import {
BarChart2,
Briefcase,
ChevronDown,
Cpu,
Download,
Hash,
Keyboard,
Coins,
Link2,
Package,
Settings,
Sparkles,
Users,
Wallet,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { Job } from "../shared/types";
import {
APP_RELEASE_DATE,
APP_VERSION,
formatReleaseDateFlexible,
formatVersionLabel,
} from "../shared/appVersion";
import { ApiClient } from "./lib/apiClient";
import { loadApiBaseUrl, migrateApiBaseUrlInStorage } from "./lib/apiDefaults";
import { HomeView } from "./views/HomeView";
import { JobView } from "./views/JobView";
import { SettingsView, type SettingsSection } from "./views/SettingsView";
import type { JobSection } from "./types/jobSection";
import appIcon from "./assets/app-icon.png";
import { UpdatePrompt, CheckUpdatesButton, useAutoUpdateCheck } from "./components/UpdatePrompt";
// Registrar módulos al cargar la app
import "./modules/counting/CountingModule";
import "./modules/extraction/ExtractionModule";
import "./modules/cleaning/CleaningModule";
import "./modules/poblacion_apta/PoblacionAptaModule";
import "./modules/downloads/DownloadsModule";
import "./modules/digitador/DigitadorModule";
const DOWNLOAD_BOT_PACKAGE_URL =
"http://s3.builker.xyz/apps-versions/BotDescargaPYSCuentasMedicas/latest/bot_descarga.zip";
const DIGITADOR_BOT_PACKAGE_URL =
"http://s3.builker.xyz/apps-versions/BotDigitadorPYSCuentasMedicas/latest/bot_digitador.zip";
type Status = "idle" | "ok" | "error" | "busy";
type View = "home" | "job" | "settings";
type OpenAiBalanceState = {
status: Status;
available: number | null;
currency: string;
error: string | null;
};
export type { JobSection } from "./types/jobSection";
function formatUsd(amount: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
function formatOpenaiBalanceError(error: string | null): string {
const msg = (error || "").toLowerCase();
if (!msg) return "No disponible";
if (msg.includes("mock-key") || msg.includes("incorrect api key")) return "Key inválida";
if (msg.includes("no configurada") || msg.includes("api key de openai no")) return "Sin API key";
if (
msg.includes("session key") ||
msg.includes("navegador") ||
msg.includes("panel web") ||
msg.includes("no permite consultar")
) {
return "Solo en web";
}
if (msg.includes("sin conexión") || msg.includes("connection refused")) return "Sin red";
return "No disponible";
}
function App() {
const [view, setView] = useState<View>("home");
const [settingsReturnView, setSettingsReturnView] = useState<"home" | "job">("home");
const [settingsSection, setSettingsSection] = useState<SettingsSection>("connection");
const [activeJob, setActiveJob] = useState<Job | null>(null);
const [jobSection, setJobSection] = useState<JobSection>("downloads");
const [jobSectionContext, setJobSectionContext] = useState<Record<string, unknown>>({});
const [apiBaseUrl, setApiBaseUrl] = useState(loadApiBaseUrl);
const [connectionStatus, setConnectionStatus] = useState<Status>("idle");
const [connectionMessage, setConnectionMessage] = useState("Pulsa «Verificar conexión» para comprobar el gateway.");
const [healthJson, setHealthJson] = useState<unknown>(null);
const [configJson, setConfigJson] = useState<unknown>(null);
const [modelsJson, setModelsJson] = useState<unknown>(null);
const [openaiBalance, setOpenaiBalance] = useState<OpenAiBalanceState>({
status: "idle",
available: null,
currency: "USD",
error: null,
});
const [busy, setBusy] = useState(false);
const [releaseDate, setReleaseDate] = useState(APP_RELEASE_DATE);
const [downloadsMenuOpen, setDownloadsMenuOpen] = useState(true);
const [digitadorMenuOpen, setDigitadorMenuOpen] = useState(false);
const [botDownloadBusy, setBotDownloadBusy] = useState(false);
const [botDownloadError, setBotDownloadError] = useState<string | null>(null);
const [digitadorDownloadBusy, setDigitadorDownloadBusy] = useState(false);
const [digitadorDownloadError, setDigitadorDownloadError] = useState<string | null>(null);
const update = useAutoUpdateCheck();
useEffect(() => {
const api = window.desktopApi;
if (!api?.getReleaseMeta) return;
let cancelled = false;
void api.getReleaseMeta().then((meta) => {
if (cancelled || !meta) return;
const next = meta.releaseDate || meta.releaseDateDisplay;
if (next) setReleaseDate(next);
});
return () => {
cancelled = true;
};
}, []);
const client = useMemo(() => new ApiClient(apiBaseUrl), [apiBaseUrl]);
useEffect(() => {
migrateApiBaseUrlInStorage();
const normalized = loadApiBaseUrl();
if (normalized !== apiBaseUrl) {
setApiBaseUrl(normalized);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
localStorage.setItem("apiBaseUrl", apiBaseUrl);
}, [apiBaseUrl]);
const refreshOpenaiBalance = useCallback(async () => {
setOpenaiBalance((prev) => ({ ...prev, status: "busy", error: null }));
try {
const balance = await client.openaiBalance();
if (balance.ok && typeof balance.available === "number") {
setOpenaiBalance({
status: "ok",
available: balance.available,
currency: balance.currency || "USD",
error: null,
});
} else {
setOpenaiBalance({
status: "error",
available: null,
currency: balance.currency || "USD",
error: balance.error || "No se pudo obtener el crédito de OpenAI.",
});
}
} catch (err) {
setOpenaiBalance({
status: "error",
available: null,
currency: "USD",
error: err instanceof Error ? err.message : "No se pudo obtener el crédito de OpenAI.",
});
}
}, [client]);
const verifyConnection = useCallback(async () => {
setConnectionStatus("busy");
setConnectionMessage("Comprobando gateway y servicios…");
setBusy(true);
try {
const [health, config, models] = await Promise.all([
client.health(),
client.config(),
client.models(),
]);
setHealthJson(health);
setConfigJson(config);
setModelsJson(models);
setConnectionStatus("ok");
setConnectionMessage("Gateway respondió correctamente.");
void refreshOpenaiBalance();
} catch (err) {
setHealthJson(null);
setConnectionStatus("error");
setConnectionMessage(err instanceof Error ? err.message : "No se pudo conectar");
setOpenaiBalance({
status: "error",
available: null,
currency: "USD",
error: "Sin conexión al gateway.",
});
} finally {
setBusy(false);
}
}, [client, refreshOpenaiBalance]);
useEffect(() => {
void verifyConnection();
}, [verifyConnection]);
useEffect(() => {
if (connectionStatus !== "ok") return;
const timer = window.setInterval(() => {
void refreshOpenaiBalance();
}, 5 * 60_000);
return () => window.clearInterval(timer);
}, [connectionStatus, refreshOpenaiBalance]);
function openJob(job: Job) {
setActiveJob(job);
setJobSection("downloads");
setDownloadsMenuOpen(true);
setDigitadorMenuOpen(false);
setJobSectionContext({});
setView("job");
}
function handleActivateSection(section: JobSection, context: Record<string, unknown> = {}) {
setJobSection(section);
setJobSectionContext(context);
setDownloadsMenuOpen(section === "downloads");
setDigitadorMenuOpen(section === "digitador");
}
async function downloadBotPackage() {
setBotDownloadBusy(true);
setBotDownloadError(null);
try {
const api = window.desktopApi;
if (typeof api?.downloadUrl === "function") {
await api.downloadUrl(DOWNLOAD_BOT_PACKAGE_URL);
return;
}
const a = document.createElement("a");
a.href = DOWNLOAD_BOT_PACKAGE_URL;
a.download = "bot_descarga.zip";
a.rel = "noopener noreferrer";
a.target = "_blank";
document.body.appendChild(a);
a.click();
a.remove();
} catch (err) {
setBotDownloadError(err instanceof Error ? err.message : "No se pudo iniciar la descarga del bot.");
} finally {
setBotDownloadBusy(false);
}
}
async function downloadDigitadorPackage() {
setDigitadorDownloadBusy(true);
setDigitadorDownloadError(null);
try {
const api = window.desktopApi;
if (typeof api?.downloadUrl === "function") {
await api.downloadUrl(DIGITADOR_BOT_PACKAGE_URL);
return;
}
const a = document.createElement("a");
a.href = DIGITADOR_BOT_PACKAGE_URL;
a.download = "bot_digitador.zip";
a.rel = "noopener noreferrer";
a.target = "_blank";
document.body.appendChild(a);
a.click();
a.remove();
} catch (err) {
setDigitadorDownloadError(err instanceof Error ? err.message : "No se pudo iniciar la descarga del bot digitador.");
} finally {
setDigitadorDownloadBusy(false);
}
}
function openSettings(section: SettingsSection = "connection") {
setSettingsReturnView(view === "job" ? "job" : "home");
setSettingsSection(section);
setView("settings");
}
function leaveSettings() {
if (settingsReturnView === "job" && activeJob) {
setView("job");
} else {
setView("home");
}
}
const isJobOpen = view === "job" && activeJob;
const isSettingsOpen = view === "settings";
const openaiBalanceLabel =
openaiBalance.status === "busy"
? "Consultando…"
: openaiBalance.status === "ok" && openaiBalance.available != null
? formatUsd(openaiBalance.available)
: openaiBalance.status === "error"
? formatOpenaiBalanceError(openaiBalance.error)
: "—";
const openaiBalanceTitle =
openaiBalance.status === "ok" && openaiBalance.available != null
? `Crédito OpenAI disponible: ${formatUsd(openaiBalance.available)}`
: openaiBalance.error || "Crédito disponible de la API de OpenAI";
return (
<main className="app-frame">
<aside className="sidebar">
<div className="brand">
<img className="brand-mark" src={appIcon} alt="Flujo IPS" />
<div className="brand-text">
<strong>Flujo IPS</strong>
<span>Desktop</span>
</div>
</div>
{isSettingsOpen ? (
/* ── Submenú de configuración ── */
<>
<button type="button" className="side-job-back" onClick={leaveSettings}>
← {settingsReturnView === "job" && activeJob ? "Trabajo" : "Trabajos"}
</button>
<div className="side-job-name">Configuración</div>
<div className="side-section-divider" />
<nav className="side-nav" aria-label="Secciones de configuración">
<button
type="button"
className={settingsSection === "connection" ? "active" : ""}
onClick={() => setSettingsSection("connection")}
>
<Link2 size={17} />
<span>Conexión</span>
</button>
<button
type="button"
className={settingsSection === "cleaning" ? "active" : ""}
onClick={() => setSettingsSection("cleaning")}
>
<Sparkles size={17} />
<span>Limpieza</span>
</button>
<button
type="button"
className={settingsSection === "backend" ? "active" : ""}
onClick={() => setSettingsSection("backend")}
>
<Cpu size={17} />
<span>Backend</span>
</button>
<button
type="button"
className={settingsSection === "updates" ? "active" : ""}
onClick={() => setSettingsSection("updates")}
>
<Download size={17} />
<span>Actualizaciones</span>
</button>
</nav>
</>
) : isJobOpen ? (
/* ── Submenú del job activo ── */
<>
<button type="button" className="side-job-back" onClick={() => setView("home")}>
← Trabajos
</button>
<div className="side-job-name" title={activeJob.title ?? ""}>
{activeJob.title ?? "Sin título"}
</div>
<div className="side-section-divider" />
<nav className="side-nav" aria-label="Módulos del trabajo">
<div className="side-nav-group">
<button
type="button"
className={jobSection === "downloads" ? "active" : ""}
aria-expanded={downloadsMenuOpen}
onClick={() => {
if (jobSection === "downloads") {
setDownloadsMenuOpen((open) => !open);
} else {
handleActivateSection("downloads");
}
}}
>
<Download size={17} />
<span>Bot de descarga</span>
<ChevronDown
size={14}
className={`side-nav-chevron${downloadsMenuOpen ? " side-nav-chevron--open" : ""}`}
aria-hidden
/>
</button>
{downloadsMenuOpen && (
<div className="side-nav-sub" role="group" aria-label="Opciones del bot de descarga">
<button
type="button"
disabled={botDownloadBusy}
title={botDownloadError || "Descargar el paquete del bot"}
onClick={() => void downloadBotPackage()}
>
<Package size={15} />
<span>{botDownloadBusy ? "Descargando…" : "Descargar bot"}</span>
</button>
</div>
)}
</div>
<button
type="button"
className={jobSection === "counting" ? "active" : ""}
onClick={() => handleActivateSection("counting")}
>
<Hash size={17} />
<span>Conteo</span>
</button>
<button
type="button"
className={jobSection === "extraction" ? "active" : ""}
onClick={() => handleActivateSection("extraction")}
>
<Cpu size={17} />
<span>Extracción</span>
</button>
<button
type="button"
className={jobSection === "cleaning" ? "active" : ""}
onClick={() => handleActivateSection("cleaning")}
>
<Sparkles size={17} />
<span>Limpieza</span>
</button>
<button
type="button"
className={jobSection === "poblacion_apta" ? "active" : ""}
onClick={() => handleActivateSection("poblacion_apta")}
>
<Users size={17} />
<span>Población apta</span>
</button>
<div className="side-nav-group">
<button
type="button"
className={jobSection === "digitador" ? "active" : ""}
aria-expanded={digitadorMenuOpen}
onClick={() => {
if (jobSection === "digitador") {
setDigitadorMenuOpen((open) => !open);
} else {
handleActivateSection("digitador");
}
}}
>
<Keyboard size={17} />
<span>Bot digitador</span>
<ChevronDown
size={14}
className={`side-nav-chevron${digitadorMenuOpen ? " side-nav-chevron--open" : ""}`}
aria-hidden
/>
</button>
{digitadorMenuOpen && (
<div className="side-nav-sub" role="group" aria-label="Opciones del bot digitador">
<button
type="button"
disabled={digitadorDownloadBusy}
title={digitadorDownloadError || "Descargar el paquete del bot digitador"}
onClick={() => void downloadDigitadorPackage()}
>
<Package size={15} />
<span>{digitadorDownloadBusy ? "Descargando…" : "Descargar bot"}</span>
</button>
</div>
)}
</div>
<button
type="button"
className={jobSection === "costs" ? "active" : ""}
onClick={() => handleActivateSection("costs")}
>
<Coins size={17} />
<span>Costos</span>
</button>
<button
type="button"
className={jobSection === "results" ? "active" : ""}
onClick={() => handleActivateSection("results")}
>
<BarChart2 size={17} />
<span>Resultados</span>
</button>
</nav>
<nav className="side-nav side-nav--bottom">
<div className="side-section-divider" />
<button type="button" onClick={() => openSettings()}>
<Settings size={17} />
<span>Configuración</span>
</button>
</nav>
</>
) : (
/* ── Menú principal ── */
<nav className="side-nav" aria-label="Principal">
<button
type="button"
className={view === "home" ? "active" : ""}
onClick={() => setView("home")}
>
<Briefcase size={18} />
<span>Trabajos</span>
</button>
<button type="button" onClick={() => openSettings()}>
<Settings size={18} />
<span>Configuración</span>
</button>
</nav>
)}
<div className="sidebar-footer">
<button
type="button"
className={`sidebar-openai ${openaiBalance.status}`}
title={openaiBalanceTitle}
onClick={() => void refreshOpenaiBalance()}
disabled={openaiBalance.status === "busy"}
>
<Wallet size={14} />
<div className="sidebar-openai-text">
<span className="sidebar-openai-label">OpenAI</span>
<span className="sidebar-openai-value">{openaiBalanceLabel}</span>
</div>
</button>
<div className={`sidebar-gateway ${connectionStatus}`}>
<div className={`gateway-dot ${connectionStatus}`} />
<span className="gateway-label">
{connectionStatus === "ok"
? "Conectado"
: connectionStatus === "busy"
? "Verificando..."
: connectionStatus === "error"
? "Sin conexión"
: "Sin verificar"}
</span>
</div>
<div className="sidebar-version" title={formatVersionLabel(releaseDate)}>
<span>v{APP_VERSION}</span>
<span>{formatReleaseDateFlexible(releaseDate)}</span>
</div>
</div>
</aside>
<section className={`workspace workspace--full${isSettingsOpen ? " workspace--settings" : ""}`}>
<header className="app-topbar">
<span className="app-version-badge">{formatVersionLabel(releaseDate)}</span>
<CheckUpdatesButton
busy={update.checkStatus === "checking" || update.installing}
onClick={() => void update.checkForUpdates()}
/>
</header>
{view === "home" && (
<HomeView
client={client}
onOpenJob={openJob}
onJobDeleted={(jobId) => {
if (activeJob?.id === jobId) {
setActiveJob(null);
setView("home");
}
}}
/>
)}
{view === "job" && activeJob && (
<JobView
client={client}
job={activeJob}
section={jobSection}
sectionContext={jobSectionContext}
onActivateSection={handleActivateSection}
onJobUpdated={(updated) => setActiveJob(updated)}
/>
)}
{view === "settings" && (
<SettingsView
section={settingsSection}
apiBaseUrl={apiBaseUrl}
onApiBaseUrlChange={setApiBaseUrl}
connectionStatus={connectionStatus}
connectionMessage={connectionMessage}
healthJson={healthJson}
verifyConnection={verifyConnection}
busy={busy}
configJson={configJson}
modelsJson={modelsJson}
releaseDate={releaseDate}
updateCheckStatus={update.checkStatus}
updateCheckMessage={update.checkMessage}
updateInstalling={update.installing}
onCheckUpdates={() => void update.checkForUpdates()}
onInstallVersion={(manifest) =>
update.promptInstall(manifest, update.currentVersion || APP_VERSION)
}
/>
)}
</section>
{update.manifest && (
<UpdatePrompt
open={update.open}
currentVersion={update.currentVersion || APP_VERSION}
manifest={update.manifest}
onClose={update.close}
onConfirm={() => void update.confirmInstall()}
installing={update.installing}
progress={update.progress}
error={update.error}
forceInstall={update.forceInstall}
/>
)}
</main>
);
}
export default App;