626 lines
22 KiB
TypeScript
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;
|