chore: Bump version to 1.0.3 and enhance update handling in the desktop application. Introduce release date stamping before build, add version listing functionality, and improve update prompts with detailed version information. Update README and scripts for clarity on release date management and version publishing.
This commit is contained in:
@@ -116,7 +116,7 @@ El instalador queda en `release/` y se publica en:
|
||||
|
||||
`s3.builker.xyz/apps-versions/IPSExtractor/{version}/`
|
||||
|
||||
La fecha de release en `latest.json` / `DATA.txt` es **el día en que se publica**, salvo que pases `RELEASE_DATE=DD/MM/YYYY`. El script también actualiza `src/shared/version.json`.
|
||||
La fecha de release (UI, `latest.json`, `DATA.txt`) es **el día del build/publish**, salvo que pases `RELEASE_DATE=DD/MM/YYYY`. Se escribe en `version.json` antes de `vite build` (la UI la inyecta en compile-time).
|
||||
|
||||
Si solo quieres el `.exe` local **sin** subir a MinIO:
|
||||
|
||||
@@ -129,9 +129,9 @@ npm run dist:local
|
||||
| Campo | Archivo |
|
||||
|-------|---------|
|
||||
| Número (`1.0.0`) | `package.json` → `version` |
|
||||
| Fecha de release | Se escribe al publicar (`version.json`); override con `RELEASE_DATE` |
|
||||
| Fecha de release | MinIO (`latest.json` / `DATA.txt` de la versión); fallback local de `version.json` |
|
||||
|
||||
La versión se muestra en el menú lateral y en la esquina superior derecha.
|
||||
La versión se muestra en el menú lateral y en la esquina superior derecha. Al arrancar, la app lee la fecha desde MinIO para la versión instalada (si coincide con `latest.json`, o desde `{version}/DATA.txt`).
|
||||
|
||||
### Auto-update (MinIO)
|
||||
|
||||
@@ -146,6 +146,7 @@ Layout en el bucket `apps-versions`:
|
||||
```text
|
||||
IPSExtractor/
|
||||
latest.json
|
||||
versions.json ← catálogo de todas las versiones publicadas
|
||||
1.0.1/IPSExtractor-Desktop-1.0.1-Setup.exe
|
||||
1.0.1/DATA.txt ← fecha DD/MM/YYYY, ej. 06/07/2026
|
||||
```
|
||||
|
||||
@@ -215,10 +215,113 @@ async function downloadAndInstall(manifest, onProgress) {
|
||||
return { ok: true, destPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fecha de release de la versión instalada, leída desde MinIO
|
||||
* (latest.json si coincide la versión, si no DATA.txt de esa carpeta).
|
||||
* @returns {Promise<{ releaseDate?: string, releaseDateDisplay?: string } | null>}
|
||||
*/
|
||||
async function getCurrentReleaseMeta() {
|
||||
const base = LATEST_JSON_URL.replace(/\/latest\.json$/i, "");
|
||||
|
||||
try {
|
||||
const { buffer } = await download(LATEST_JSON_URL);
|
||||
if (buffer) {
|
||||
/** @type {UpdateManifest} */
|
||||
const manifest = JSON.parse(buffer.toString("utf8"));
|
||||
if (manifest?.version && compareSemver(CURRENT_VERSION, manifest.version) === 0) {
|
||||
if (!manifest.releaseDateDisplay) {
|
||||
try {
|
||||
const dataUrl = `${base}/${CURRENT_VERSION}/DATA.txt`;
|
||||
const { buffer: dataBuf } = await download(dataUrl);
|
||||
const display = dataBuf?.toString("utf8").trim().split(/\r?\n/)[0]?.trim();
|
||||
if (display) {
|
||||
manifest.releaseDateDisplay = display;
|
||||
const iso = display.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||
if (iso && !manifest.releaseDate) {
|
||||
manifest.releaseDate = `${iso[3]}-${iso[2].padStart(2, "0")}-${iso[1].padStart(2, "0")}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* opcional */
|
||||
}
|
||||
}
|
||||
if (manifest.releaseDate || manifest.releaseDateDisplay) {
|
||||
return {
|
||||
releaseDate: manifest.releaseDate,
|
||||
releaseDateDisplay: manifest.releaseDateDisplay,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* probar DATA.txt de la versión instalada */
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = `${base}/${CURRENT_VERSION}/DATA.txt`;
|
||||
const { buffer: dataBuf } = await download(dataUrl);
|
||||
const display = dataBuf?.toString("utf8").trim().split(/\r?\n/)[0]?.trim();
|
||||
if (!display) return null;
|
||||
const isoMatch = display.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||
return {
|
||||
releaseDateDisplay: display,
|
||||
releaseDate: isoMatch
|
||||
? `${isoMatch[3]}-${isoMatch[2].padStart(2, "0")}-${isoMatch[1].padStart(2, "0")}`
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todas las versiones publicadas (versions.json).
|
||||
* Si no existe, cae a [latest.json].
|
||||
* @returns {Promise<{ currentVersion: string, versions: UpdateManifest[], error?: string }>}
|
||||
*/
|
||||
async function listVersions() {
|
||||
const versionsUrl = LATEST_JSON_URL.replace(/\/latest\.json$/i, "/versions.json");
|
||||
try {
|
||||
const { buffer } = await download(versionsUrl);
|
||||
if (buffer) {
|
||||
const parsed = JSON.parse(buffer.toString("utf8"));
|
||||
const versions = Array.isArray(parsed?.versions)
|
||||
? parsed.versions.filter((v) => v?.version && v?.url && v?.fileName)
|
||||
: [];
|
||||
if (versions.length > 0) {
|
||||
versions.sort((a, b) => compareSemver(b.version, a.version));
|
||||
return { currentVersion: CURRENT_VERSION, versions };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fallback latest */
|
||||
}
|
||||
|
||||
try {
|
||||
const { buffer } = await download(LATEST_JSON_URL);
|
||||
if (!buffer) {
|
||||
return { currentVersion: CURRENT_VERSION, versions: [], error: "Respuesta vacía" };
|
||||
}
|
||||
const manifest = JSON.parse(buffer.toString("utf8"));
|
||||
if (!manifest?.version || !manifest?.url || !manifest?.fileName) {
|
||||
return { currentVersion: CURRENT_VERSION, versions: [], error: "Manifiesto inválido" };
|
||||
}
|
||||
return { currentVersion: CURRENT_VERSION, versions: [manifest] };
|
||||
} catch (err) {
|
||||
return {
|
||||
currentVersion: CURRENT_VERSION,
|
||||
versions: [],
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CURRENT_VERSION,
|
||||
LATEST_JSON_URL,
|
||||
compareSemver,
|
||||
checkForUpdates,
|
||||
downloadAndInstall,
|
||||
getCurrentReleaseMeta,
|
||||
listVersions,
|
||||
};
|
||||
|
||||
@@ -47,19 +47,20 @@ function sendUpdateProgress(payload) {
|
||||
}
|
||||
|
||||
ipcMain.handle("updater:check", async () => {
|
||||
if (!app.isPackaged) {
|
||||
return {
|
||||
updateAvailable: false,
|
||||
currentVersion: appVersion,
|
||||
error: "Auto-update desactivado en desarrollo",
|
||||
};
|
||||
}
|
||||
return autoUpdate.checkForUpdates();
|
||||
});
|
||||
|
||||
ipcMain.handle("updater:release-meta", async () => {
|
||||
return autoUpdate.getCurrentReleaseMeta();
|
||||
});
|
||||
|
||||
ipcMain.handle("updater:list-versions", async () => {
|
||||
return autoUpdate.listVersions();
|
||||
});
|
||||
|
||||
ipcMain.handle("updater:install", async (_event, manifest) => {
|
||||
if (!app.isPackaged) {
|
||||
throw new Error("Auto-update solo disponible en la app instalada");
|
||||
throw new Error("Instalar actualizaciones solo está disponible en la app instalada");
|
||||
}
|
||||
return autoUpdate.downloadAndInstall(manifest, sendUpdateProgress);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ contextBridge.exposeInMainWorld("desktopApi", {
|
||||
ipcRenderer.invoke("fs:index-folder", rootPath, structureMode, docTypes, keywords),
|
||||
readFile: (filePath) => ipcRenderer.invoke("fs:read-file", filePath),
|
||||
checkForUpdates: () => ipcRenderer.invoke("updater:check"),
|
||||
getReleaseMeta: () => ipcRenderer.invoke("updater:release-meta"),
|
||||
listVersions: () => ipcRenderer.invoke("updater:list-versions"),
|
||||
installUpdate: (manifest) => ipcRenderer.invoke("updater:install", manifest),
|
||||
onUpdateProgress: (callback) => {
|
||||
const handler = (_event, payload) => callback(payload);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "ipsextractor-desktop",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"private": true,
|
||||
"description": "Desktop extraction client for IPSExtractor microservices.",
|
||||
"main": "electron/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"vite --host 127.0.0.1\" \"wait-on tcp:5173 && electron .\"",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"stamp:date": "node scripts/stamp-release-date.mjs",
|
||||
"build": "npm run stamp:date && tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Publica el instalador NSIS en MinIO y actualiza latest.json + DATA.txt.
|
||||
* Publica el instalador NSIS en MinIO y actualiza latest.json, versions.json y DATA.txt.
|
||||
*
|
||||
* Uso:
|
||||
* set MINIO_ACCESS_KEY=...
|
||||
@@ -16,7 +16,7 @@ import { createHash } from "node:crypto";
|
||||
import { createReadStream, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, "..");
|
||||
@@ -113,6 +113,46 @@ async function upload(client, key, body, contentType) {
|
||||
console.log(` ↑ s3://${bucket}/${key}`);
|
||||
}
|
||||
|
||||
function compareSemver(a, b) {
|
||||
const pa = String(a).replace(/^v/i, "").split(".").map((x) => Number.parseInt(x, 10) || 0);
|
||||
const pb = String(b).replace(/^v/i, "").split(".").map((x) => Number.parseInt(x, 10) || 0);
|
||||
const len = Math.max(pa.length, pb.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = pa[i] ?? 0;
|
||||
const y = pb[i] ?? 0;
|
||||
if (x < y) return -1;
|
||||
if (x > y) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function streamToString(body) {
|
||||
if (!body) return "";
|
||||
if (typeof body.transformToString === "function") return body.transformToString();
|
||||
const chunks = [];
|
||||
for await (const chunk of body) chunks.push(chunk);
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
/** Lee versions.json existente del bucket (o [] si no existe). */
|
||||
async function loadExistingVersions(client) {
|
||||
try {
|
||||
const res = await client.send(
|
||||
new GetObjectCommand({ Bucket: bucket, Key: `${prefix}/versions.json` }),
|
||||
);
|
||||
const text = await streamToString(res.Body);
|
||||
const parsed = JSON.parse(text);
|
||||
return Array.isArray(parsed?.versions) ? parsed.versions : [];
|
||||
} catch (err) {
|
||||
const code = err?.name || err?.Code || err?.$metadata?.httpStatusCode;
|
||||
if (code === "NoSuchKey" || code === 404 || err?.$metadata?.httpStatusCode === 404) {
|
||||
return [];
|
||||
}
|
||||
console.warn("No se pudo leer versions.json previo:", err?.message || err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const releaseDateDisplay = resolveReleaseDateDisplay();
|
||||
const releaseDateIso = isoFromDisplay(releaseDateDisplay) || new Date().toISOString().slice(0, 10);
|
||||
@@ -123,9 +163,10 @@ async function main() {
|
||||
const objectKeyExe = `${prefix}/${version}/${setupName}`;
|
||||
const objectKeyData = `${prefix}/${version}/DATA.txt`;
|
||||
const objectKeyLatest = `${prefix}/latest.json`;
|
||||
const objectKeyVersions = `${prefix}/versions.json`;
|
||||
const publicUrl = `${publicBase}/${version}/${setupName}`;
|
||||
|
||||
const latest = {
|
||||
const entry = {
|
||||
version,
|
||||
releaseDate: releaseDateIso,
|
||||
releaseDateDisplay,
|
||||
@@ -152,20 +193,32 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
const existing = await loadExistingVersions(client);
|
||||
const byVersion = new Map(existing.map((v) => [String(v.version), v]));
|
||||
byVersion.set(version, entry);
|
||||
const versions = [...byVersion.values()].sort((a, b) => compareSemver(b.version, a.version));
|
||||
|
||||
console.log(`Publicando v${version} → ${endpoint}/${bucket}/${prefix}/`);
|
||||
await upload(client, objectKeyExe, createReadStream(setupPath), "application/octet-stream");
|
||||
await upload(client, objectKeyData, `${releaseDateDisplay}\n`, "text/plain; charset=utf-8");
|
||||
await upload(
|
||||
client,
|
||||
objectKeyLatest,
|
||||
Buffer.from(`${JSON.stringify(latest, null, 2)}\n`, "utf8"),
|
||||
Buffer.from(`${JSON.stringify(entry, null, 2)}\n`, "utf8"),
|
||||
"application/json",
|
||||
);
|
||||
await upload(
|
||||
client,
|
||||
objectKeyVersions,
|
||||
Buffer.from(`${JSON.stringify({ versions }, null, 2)}\n`, "utf8"),
|
||||
"application/json",
|
||||
);
|
||||
|
||||
console.log("\nListo.");
|
||||
console.log(` latest.json: ${publicBase}/latest.json`);
|
||||
console.log(` DATA.txt: ${publicBase}/${version}/DATA.txt → ${releaseDateDisplay}`);
|
||||
console.log(` Setup: ${publicUrl}`);
|
||||
console.log(` latest.json: ${publicBase}/latest.json`);
|
||||
console.log(` versions.json: ${publicBase}/versions.json (${versions.length} versiones)`);
|
||||
console.log(` DATA.txt: ${publicBase}/${version}/DATA.txt → ${releaseDateDisplay}`);
|
||||
console.log(` Setup: ${publicUrl}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Escribe src/shared/version.json con la fecha de release.
|
||||
* Default: hoy. Override: RELEASE_DATE=DD/MM/YYYY (env o .env.minio).
|
||||
*
|
||||
* Debe ejecutarse ANTES de vite build: la UI inyecta esta fecha en compile-time.
|
||||
*/
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, "..");
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!existsSync(filePath)) return;
|
||||
const text = readFileSync(filePath, "utf8");
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (!(key in process.env) || process.env[key] === "") {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile(join(root, ".env.minio"));
|
||||
loadEnvFile(join(root, ".env"));
|
||||
|
||||
function todayIso() {
|
||||
const d = new Date();
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const yyyy = d.getFullYear();
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function isoFromDisplay(display) {
|
||||
const m = String(display).trim().match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||
if (!m) return null;
|
||||
const [, dd, mm, yyyy] = m;
|
||||
return `${yyyy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const releaseDateIso = process.env.RELEASE_DATE
|
||||
? isoFromDisplay(process.env.RELEASE_DATE) || todayIso()
|
||||
: todayIso();
|
||||
|
||||
const outPath = join(root, "src/shared/version.json");
|
||||
writeFileSync(outPath, `${JSON.stringify({ releaseDate: releaseDateIso }, null, 2)}\n`, "utf8");
|
||||
console.log(`releaseDate → ${releaseDateIso} (${outPath})`);
|
||||
@@ -2,17 +2,18 @@ import {
|
||||
BarChart2,
|
||||
Briefcase,
|
||||
Cpu,
|
||||
Download,
|
||||
Hash,
|
||||
Link2,
|
||||
Settings,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { Job } from "../shared/types";
|
||||
import {
|
||||
APP_RELEASE_DATE,
|
||||
APP_VERSION,
|
||||
formatReleaseDate,
|
||||
formatReleaseDateFlexible,
|
||||
formatVersionLabel,
|
||||
} from "../shared/appVersion";
|
||||
import { ApiClient } from "./lib/apiClient";
|
||||
@@ -22,7 +23,7 @@ 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, useAutoUpdateCheck } from "./components/UpdatePrompt";
|
||||
import { UpdatePrompt, CheckUpdatesButton, useAutoUpdateCheck } from "./components/UpdatePrompt";
|
||||
|
||||
// Registrar módulos al cargar la app
|
||||
import "./modules/counting/CountingModule";
|
||||
@@ -47,8 +48,23 @@ function App() {
|
||||
const [configJson, setConfigJson] = useState<unknown>(null);
|
||||
const [modelsJson, setModelsJson] = useState<unknown>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [releaseDate, setReleaseDate] = useState(APP_RELEASE_DATE);
|
||||
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(() => {
|
||||
@@ -63,7 +79,7 @@ function App() {
|
||||
localStorage.setItem("apiBaseUrl", apiBaseUrl);
|
||||
}, [apiBaseUrl]);
|
||||
|
||||
async function verifyConnection() {
|
||||
const verifyConnection = useCallback(async () => {
|
||||
setConnectionStatus("busy");
|
||||
setConnectionMessage("Comprobando gateway y servicios…");
|
||||
setBusy(true);
|
||||
@@ -85,7 +101,11 @@ function App() {
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
void verifyConnection();
|
||||
}, [verifyConnection]);
|
||||
|
||||
function openJob(job: Job) {
|
||||
setActiveJob(job);
|
||||
@@ -160,6 +180,14 @@ function App() {
|
||||
<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 ? (
|
||||
@@ -245,19 +273,34 @@ function App() {
|
||||
: "Sin verificar"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="sidebar-version" title={formatVersionLabel()}>
|
||||
<div className="sidebar-version" title={formatVersionLabel(releaseDate)}>
|
||||
<span>v{APP_VERSION}</span>
|
||||
<span>{formatReleaseDate(APP_RELEASE_DATE)}</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()}</span>
|
||||
<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} />}
|
||||
{view === "home" && (
|
||||
<HomeView
|
||||
client={client}
|
||||
onOpenJob={openJob}
|
||||
onJobDeleted={(jobId) => {
|
||||
if (activeJob?.id === jobId) {
|
||||
setActiveJob(null);
|
||||
setView("home");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === "job" && activeJob && (
|
||||
<JobView
|
||||
@@ -282,6 +325,14 @@ function App() {
|
||||
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>
|
||||
@@ -289,13 +340,14 @@ function App() {
|
||||
{update.manifest && (
|
||||
<UpdatePrompt
|
||||
open={update.open}
|
||||
currentVersion={update.currentVersion}
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Download, Loader2, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Download, Loader2, RefreshCw, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { UpdateManifest, UpdateProgress } from "../../shared/types";
|
||||
|
||||
type Props = {
|
||||
@@ -11,6 +11,8 @@ type Props = {
|
||||
installing: boolean;
|
||||
progress: UpdateProgress | null;
|
||||
error: string | null;
|
||||
/** Si true, el título habla de instalar esa versión (no solo "nueva"). */
|
||||
forceInstall?: boolean;
|
||||
};
|
||||
|
||||
function formatBytes(n: number | undefined): string {
|
||||
@@ -20,6 +22,19 @@ function formatBytes(n: number | undefined): string {
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function compareSemver(a: string, b: string): number {
|
||||
const pa = a.replace(/^v/i, "").split(".").map((x) => Number.parseInt(x, 10) || 0);
|
||||
const pb = b.replace(/^v/i, "").split(".").map((x) => Number.parseInt(x, 10) || 0);
|
||||
const len = Math.max(pa.length, pb.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = pa[i] ?? 0;
|
||||
const y = pb[i] ?? 0;
|
||||
if (x < y) return -1;
|
||||
if (x > y) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function UpdatePrompt({
|
||||
open,
|
||||
currentVersion,
|
||||
@@ -29,11 +44,37 @@ export function UpdatePrompt({
|
||||
installing,
|
||||
progress,
|
||||
error,
|
||||
forceInstall = false,
|
||||
}: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
const dateLabel = manifest.releaseDateDisplay || manifest.releaseDate || "";
|
||||
const percent = progress?.percent ?? 0;
|
||||
const cmp = compareSemver(currentVersion, manifest.version);
|
||||
const isSame = cmp === 0;
|
||||
const isOlder = cmp > 0;
|
||||
const title = forceInstall || isSame || isOlder ? "Instalar versión" : "Nueva versión disponible";
|
||||
const body = isSame ? (
|
||||
<>
|
||||
Vas a reinstalar la <strong>v{manifest.version}</strong>
|
||||
{dateLabel ? <> (lanzamiento {dateLabel})</> : null}.
|
||||
</>
|
||||
) : isOlder ? (
|
||||
<>
|
||||
Vas a instalar una versión anterior: <strong>v{manifest.version}</strong>
|
||||
{dateLabel ? <> (lanzamiento {dateLabel})</> : null}.
|
||||
<br />
|
||||
Tienes instalada la <strong>v{currentVersion}</strong>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Hay una versión nueva: <strong>v{manifest.version}</strong>
|
||||
{dateLabel ? <> (lanzamiento {dateLabel})</> : null}.
|
||||
<br />
|
||||
Tienes instalada la <strong>v{currentVersion}</strong>.
|
||||
</>
|
||||
);
|
||||
|
||||
const phaseLabel =
|
||||
progress?.phase === "verifying"
|
||||
? "Verificando archivo…"
|
||||
@@ -47,7 +88,7 @@ export function UpdatePrompt({
|
||||
<div className="update-modal-backdrop" role="presentation">
|
||||
<div className="update-modal" role="dialog" aria-labelledby="update-modal-title">
|
||||
<div className="update-modal-header">
|
||||
<h2 id="update-modal-title">Nueva versión disponible</h2>
|
||||
<h2 id="update-modal-title">{title}</h2>
|
||||
{!installing && (
|
||||
<button type="button" className="btn btn-sm btn-ghost" onClick={onClose} aria-label="Cerrar">
|
||||
<X size={16} />
|
||||
@@ -55,12 +96,7 @@ export function UpdatePrompt({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="update-modal-body">
|
||||
Hay una versión nueva: <strong>v{manifest.version}</strong>
|
||||
{dateLabel ? <> (lanzamiento {dateLabel})</> : null}.
|
||||
<br />
|
||||
Tienes instalada la <strong>v{currentVersion}</strong>.
|
||||
</p>
|
||||
<p className="update-modal-body">{body}</p>
|
||||
|
||||
{manifest.size != null && manifest.size > 0 && (
|
||||
<p className="form-hint">Tamaño aproximado: {formatBytes(manifest.size)}</p>
|
||||
@@ -103,7 +139,9 @@ export function UpdatePrompt({
|
||||
);
|
||||
}
|
||||
|
||||
/** Comprueba actualizaciones al montar (solo si existe desktopApi). */
|
||||
export type UpdateCheckStatus = "idle" | "checking" | "up-to-date" | "available" | "error";
|
||||
|
||||
/** Comprueba actualizaciones al montar y expone check/install manual. */
|
||||
export function useAutoUpdateCheck() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [currentVersion, setCurrentVersion] = useState("");
|
||||
@@ -111,6 +149,58 @@ export function useAutoUpdateCheck() {
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [progress, setProgress] = useState<UpdateProgress | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [forceInstall, setForceInstall] = useState(false);
|
||||
const [checkStatus, setCheckStatus] = useState<UpdateCheckStatus>("idle");
|
||||
const [checkMessage, setCheckMessage] = useState("");
|
||||
|
||||
const openInstall = useCallback((next: UpdateManifest, current: string, force = false) => {
|
||||
setCurrentVersion(current);
|
||||
setManifest(next);
|
||||
setForceInstall(force);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
const checkForUpdates = useCallback(async (opts?: { silent?: boolean }) => {
|
||||
const api = window.desktopApi;
|
||||
if (!api?.checkForUpdates) {
|
||||
setCheckStatus("error");
|
||||
setCheckMessage("Actualizaciones no disponibles en este entorno.");
|
||||
return null;
|
||||
}
|
||||
setCheckStatus("checking");
|
||||
setCheckMessage("Buscando actualizaciones…");
|
||||
try {
|
||||
const result = await api.checkForUpdates();
|
||||
setCurrentVersion(result.currentVersion);
|
||||
if (result.error && !result.manifest) {
|
||||
setCheckStatus("error");
|
||||
setCheckMessage(result.error);
|
||||
return result;
|
||||
}
|
||||
if (result.updateAvailable && result.manifest) {
|
||||
setCheckStatus("available");
|
||||
setCheckMessage(`Hay una versión nueva: v${result.manifest.version}`);
|
||||
if (!opts?.silent) {
|
||||
openInstall(result.manifest, result.currentVersion, false);
|
||||
} else {
|
||||
setManifest(result.manifest);
|
||||
setOpen(true);
|
||||
setForceInstall(false);
|
||||
}
|
||||
} else {
|
||||
setCheckStatus("up-to-date");
|
||||
setCheckMessage(`Ya tienes la última versión (v${result.currentVersion}).`);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setCheckStatus("error");
|
||||
setCheckMessage(msg);
|
||||
return null;
|
||||
}
|
||||
}, [openInstall]);
|
||||
|
||||
useEffect(() => {
|
||||
const api = window.desktopApi;
|
||||
@@ -124,6 +214,9 @@ export function useAutoUpdateCheck() {
|
||||
if (cancelled || !result.updateAvailable || !result.manifest) return;
|
||||
setCurrentVersion(result.currentVersion);
|
||||
setManifest(result.manifest);
|
||||
setForceInstall(false);
|
||||
setCheckStatus("available");
|
||||
setCheckMessage(`Hay una versión nueva: v${result.manifest.version}`);
|
||||
setOpen(true);
|
||||
} catch {
|
||||
/* silencioso */
|
||||
@@ -150,13 +243,16 @@ export function useAutoUpdateCheck() {
|
||||
setProgress({ phase: "downloading", percent: 0 });
|
||||
try {
|
||||
await window.desktopApi.installUpdate(manifest);
|
||||
// La app debería cerrarse desde main; si no, mostramos mensaje.
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setInstalling(false);
|
||||
}
|
||||
}
|
||||
|
||||
function promptInstall(next: UpdateManifest, current: string) {
|
||||
openInstall(next, current, true);
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
currentVersion,
|
||||
@@ -164,9 +260,31 @@ export function useAutoUpdateCheck() {
|
||||
installing,
|
||||
progress,
|
||||
error,
|
||||
forceInstall,
|
||||
checkStatus,
|
||||
checkMessage,
|
||||
close: () => {
|
||||
if (!installing) setOpen(false);
|
||||
},
|
||||
confirmInstall,
|
||||
checkForUpdates,
|
||||
promptInstall,
|
||||
};
|
||||
}
|
||||
|
||||
export function CheckUpdatesButton({
|
||||
onClick,
|
||||
busy,
|
||||
className = "btn btn-outline btn-sm",
|
||||
}: {
|
||||
onClick: () => void;
|
||||
busy?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className={className} onClick={onClick} disabled={busy}>
|
||||
{busy ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
||||
{busy ? "Buscando…" : "Buscar actualización"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,6 +185,15 @@ export class ApiClient {
|
||||
return this.handle<Job>(response);
|
||||
}
|
||||
|
||||
async deleteJob(jobId: string): Promise<void> {
|
||||
const response = await fetchWithTimeout(
|
||||
this.url(`/jobs/${encodeURIComponent(jobId)}`),
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (response.status === 204 || response.ok) return;
|
||||
await this.handle(response);
|
||||
}
|
||||
|
||||
// ── Count Jobs ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Envía PDFs al servicio de conteo (síncrono). */
|
||||
|
||||
@@ -179,6 +179,7 @@ button:disabled {
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
height: 36px;
|
||||
justify-content: flex-end;
|
||||
padding: 0 20px;
|
||||
@@ -2356,6 +2357,17 @@ th {
|
||||
color: #354154;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #b91c1c;
|
||||
border-color: #b91c1c;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border: 1px dashed #c5cdd8;
|
||||
@@ -3403,6 +3415,12 @@ th {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.job-list-item {
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.job-card {
|
||||
align-items: center;
|
||||
background: var(--surface);
|
||||
@@ -3410,11 +3428,13 @@ th {
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
transition: box-shadow 0.15s, border-color 0.15s;
|
||||
width: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.job-card:hover {
|
||||
@@ -3422,6 +3442,23 @@ th {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.job-card-delete {
|
||||
align-self: center;
|
||||
border-style: solid;
|
||||
color: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.job-card-delete:hover:not(:disabled) {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.job-card-icon {
|
||||
align-items: center;
|
||||
background: var(--primary-ghost, rgba(59,130,246,0.08));
|
||||
@@ -4706,3 +4743,49 @@ th {
|
||||
height: 100%;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.updates-version-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.updates-version-row {
|
||||
align-items: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.updates-version-row.is-current {
|
||||
background: #f8fafc;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.updates-version-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.updates-version-meta strong {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.updates-version-badge {
|
||||
background: #e2e8f0;
|
||||
border-radius: 4px;
|
||||
color: #334155;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ beforeEach(() => {
|
||||
updateAvailable: false,
|
||||
currentVersion: "1.0.0",
|
||||
}),
|
||||
getReleaseMeta: vi.fn().mockResolvedValue(null),
|
||||
listVersions: vi.fn().mockResolvedValue({ currentVersion: "1.0.0", versions: [] }),
|
||||
installUpdate: vi.fn().mockResolvedValue({ ok: true }),
|
||||
onUpdateProgress: vi.fn().mockReturnValue(() => undefined),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { Briefcase, CalendarDays, ChevronRight, FolderOpen, Loader2, Plus, RefreshCw, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Briefcase,
|
||||
CalendarDays,
|
||||
ChevronRight,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState, type MouseEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ApiClient } from "../lib/apiClient";
|
||||
import { DEFAULT_API_BASE_URL } from "../lib/apiDefaults";
|
||||
@@ -10,6 +20,7 @@ declare const desktopApi: import("../../shared/types").DesktopBridge;
|
||||
interface Props {
|
||||
client: ApiClient;
|
||||
onOpenJob: (job: Job) => void;
|
||||
onJobDeleted?: (jobId: string) => void;
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
@@ -32,7 +43,7 @@ function statusLabel(s: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function HomeView({ client, onOpenJob }: Props) {
|
||||
export function HomeView({ client, onOpenJob, onJobDeleted }: Props) {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -41,6 +52,9 @@ export function HomeView({ client, onOpenJob }: Props) {
|
||||
const [formFolder, setFormFolder] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [jobToDelete, setJobToDelete] = useState<Job | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -64,18 +78,17 @@ export function HomeView({ client, onOpenJob }: Props) {
|
||||
useEffect(() => { loadJobs(); }, [loadJobs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showForm) return;
|
||||
if (!showForm && !jobToDelete) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, [showForm]);
|
||||
}, [showForm, jobToDelete]);
|
||||
|
||||
async function selectFolder() {
|
||||
const dir = await desktopApi.selectDirectory();
|
||||
if (dir) {
|
||||
setFormFolder(dir);
|
||||
if (!formTitle) {
|
||||
// Usar último segmento del path como título sugerido
|
||||
const parts = dir.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
setFormTitle(parts[parts.length - 1] ?? "");
|
||||
}
|
||||
@@ -102,6 +115,29 @@ export function HomeView({ client, onOpenJob }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteConfirm(job: Job, e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
setDeleteError(null);
|
||||
setJobToDelete(job);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!jobToDelete) return;
|
||||
setDeleting(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await client.deleteJob(jobToDelete.id);
|
||||
const deletedId = jobToDelete.id;
|
||||
setJobs((prev) => prev.filter((j) => j.id !== deletedId));
|
||||
setJobToDelete(null);
|
||||
onJobDeleted?.(deletedId);
|
||||
} catch (e) {
|
||||
setDeleteError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home-view">
|
||||
<header className="home-header">
|
||||
@@ -114,7 +150,6 @@ export function HomeView({ client, onOpenJob }: Props) {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* ── Modal crear trabajo (portal: evita que fixed quede anclado a .home-view) ── */}
|
||||
{showForm && createPortal(
|
||||
<div className="modal-backdrop" onClick={() => setShowForm(false)}>
|
||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -189,7 +224,60 @@ export function HomeView({ client, onOpenJob }: Props) {
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* ── Lista de trabajos ── */}
|
||||
{jobToDelete && createPortal(
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
onClick={() => { if (!deleting) setJobToDelete(null); }}
|
||||
>
|
||||
<div className="modal-card" onClick={(e) => e.stopPropagation()} role="dialog" aria-labelledby="delete-job-title">
|
||||
<div className="modal-header">
|
||||
<h2 id="delete-job-title" className="modal-title">Eliminar trabajo</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
onClick={() => setJobToDelete(null)}
|
||||
disabled={deleting}
|
||||
aria-label="Cerrar"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p>
|
||||
¿Eliminar <strong>{jobToDelete.title ?? "Sin título"}</strong>?
|
||||
</p>
|
||||
<p className="form-hint">
|
||||
Se borrarán de forma permanente los conteos, extracciones, limpiezas y resultados
|
||||
asociados. Los archivos en disco no se eliminan.
|
||||
</p>
|
||||
{deleteError && (
|
||||
<div className="wiz-toast wiz-toast--error">{deleteError}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => setJobToDelete(null)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? <Loader2 size={14} className="spin" /> : <Trash2 size={14} />}
|
||||
{deleting ? "Eliminando…" : "Eliminar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
<div className="home-body">
|
||||
{loading ? (
|
||||
<div className="home-loading">
|
||||
@@ -216,7 +304,7 @@ export function HomeView({ client, onOpenJob }: Props) {
|
||||
) : (
|
||||
<ul className="job-list">
|
||||
{jobs.map((job) => (
|
||||
<li key={job.id}>
|
||||
<li key={job.id} className="job-list-item">
|
||||
<button
|
||||
type="button"
|
||||
className="job-card"
|
||||
@@ -245,6 +333,15 @@ export function HomeView({ client, onOpenJob }: Props) {
|
||||
</div>
|
||||
<ChevronRight size={18} className="job-card-arrow" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost job-card-delete"
|
||||
aria-label={`Eliminar ${job.title ?? "trabajo"}`}
|
||||
title="Eliminar trabajo"
|
||||
onClick={(e) => openDeleteConfirm(job, e)}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import {
|
||||
CheckCircle2,
|
||||
Database,
|
||||
Download,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Server,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { UpdateManifest } from "../../shared/types";
|
||||
import { APP_VERSION, formatReleaseDateFlexible } from "../../shared/appVersion";
|
||||
import { DEFAULT_API_BASE_URL } from "../lib/apiDefaults";
|
||||
import {
|
||||
type CopModConfig,
|
||||
@@ -16,8 +20,9 @@ import {
|
||||
loadCopModConfig,
|
||||
saveCopModConfig,
|
||||
} from "../lib/cleaningSettings";
|
||||
import type { UpdateCheckStatus } from "../components/UpdatePrompt";
|
||||
|
||||
export type SettingsSection = "connection" | "cleaning" | "backend";
|
||||
export type SettingsSection = "connection" | "cleaning" | "backend" | "updates";
|
||||
type Status = "idle" | "ok" | "error" | "busy";
|
||||
type BackendTab = "config" | "models";
|
||||
|
||||
@@ -32,6 +37,12 @@ type SettingsViewProps = {
|
||||
busy: boolean;
|
||||
configJson: unknown;
|
||||
modelsJson: unknown;
|
||||
releaseDate: string;
|
||||
updateCheckStatus: UpdateCheckStatus;
|
||||
updateCheckMessage: string;
|
||||
updateInstalling: boolean;
|
||||
onCheckUpdates: () => void;
|
||||
onInstallVersion: (manifest: UpdateManifest) => void;
|
||||
};
|
||||
|
||||
const SERVICE_LABELS: Record<string, string> = {
|
||||
@@ -71,6 +82,12 @@ export function SettingsView({
|
||||
busy,
|
||||
configJson,
|
||||
modelsJson,
|
||||
releaseDate,
|
||||
updateCheckStatus,
|
||||
updateCheckMessage,
|
||||
updateInstalling,
|
||||
onCheckUpdates,
|
||||
onInstallVersion,
|
||||
}: SettingsViewProps) {
|
||||
if (section === "connection") {
|
||||
return (
|
||||
@@ -88,6 +105,18 @@ export function SettingsView({
|
||||
if (section === "cleaning") {
|
||||
return <CleaningSettings />;
|
||||
}
|
||||
if (section === "updates") {
|
||||
return (
|
||||
<UpdatesSettings
|
||||
releaseDate={releaseDate}
|
||||
checkStatus={updateCheckStatus}
|
||||
checkMessage={updateCheckMessage}
|
||||
installing={updateInstalling}
|
||||
onCheckUpdates={onCheckUpdates}
|
||||
onInstallVersion={onInstallVersion}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<BackendSettings
|
||||
connectionStatus={connectionStatus}
|
||||
@@ -99,6 +128,181 @@ export function SettingsView({
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(n: number | undefined): string {
|
||||
if (!n || n <= 0) return "—";
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function UpdatesSettings({
|
||||
releaseDate,
|
||||
checkStatus,
|
||||
checkMessage,
|
||||
installing,
|
||||
onCheckUpdates,
|
||||
onInstallVersion,
|
||||
}: {
|
||||
releaseDate: string;
|
||||
checkStatus: UpdateCheckStatus;
|
||||
checkMessage: string;
|
||||
installing: boolean;
|
||||
onCheckUpdates: () => void;
|
||||
onInstallVersion: (manifest: UpdateManifest) => void;
|
||||
}) {
|
||||
const [versions, setVersions] = useState<UpdateManifest[]>([]);
|
||||
const [listBusy, setListBusy] = useState(false);
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
const api = window.desktopApi;
|
||||
if (!api?.listVersions) {
|
||||
setListError("Listado de versiones no disponible.");
|
||||
return;
|
||||
}
|
||||
setListBusy(true);
|
||||
setListError(null);
|
||||
try {
|
||||
const result = await api.listVersions();
|
||||
if (result.error && result.versions.length === 0) {
|
||||
setListError(result.error);
|
||||
}
|
||||
setVersions(result.versions);
|
||||
} catch (e) {
|
||||
setListError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setListBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadVersions();
|
||||
}, [loadVersions]);
|
||||
|
||||
const statusTone =
|
||||
checkStatus === "available"
|
||||
? "ok"
|
||||
: checkStatus === "error"
|
||||
? "error"
|
||||
: checkStatus === "checking"
|
||||
? "busy"
|
||||
: checkStatus === "up-to-date"
|
||||
? "ok"
|
||||
: "idle";
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<header className="settings-page-header">
|
||||
<div className="settings-page-header-row">
|
||||
<div>
|
||||
<h2>Actualizaciones</h2>
|
||||
<p>Busca versiones nuevas o instala una publicada en el servidor.</p>
|
||||
</div>
|
||||
<div className="settings-page-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={onCheckUpdates}
|
||||
disabled={checkStatus === "checking" || installing}
|
||||
>
|
||||
{checkStatus === "checking" ? (
|
||||
<Loader2 size={15} className="spin" />
|
||||
) : (
|
||||
<RefreshCw size={15} />
|
||||
)}
|
||||
Buscar actualización
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="settings-page-body">
|
||||
<div className={`settings-status-card settings-status-card--${statusTone}`}>
|
||||
<div className={`settings-status-icon settings-status-icon--${statusTone}`}>
|
||||
{checkStatus === "checking" ? (
|
||||
<Loader2 size={20} className="spin" />
|
||||
) : checkStatus === "error" ? (
|
||||
<XCircle size={20} />
|
||||
) : checkStatus === "available" || checkStatus === "up-to-date" ? (
|
||||
<CheckCircle2 size={20} />
|
||||
) : (
|
||||
<Download size={20} />
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-status-text">
|
||||
<strong>
|
||||
Instalada: v{APP_VERSION}
|
||||
{releaseDate ? ` · ${formatReleaseDateFlexible(releaseDate)}` : ""}
|
||||
</strong>
|
||||
<span>
|
||||
{checkMessage ||
|
||||
(checkStatus === "idle"
|
||||
? "Pulsa «Buscar actualización» para comprobar si hay una versión nueva."
|
||||
: "")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-panel">
|
||||
<div className="settings-page-header-row" style={{ marginBottom: 12 }}>
|
||||
<h3 className="wiz-field-label" style={{ margin: 0 }}>
|
||||
Versiones publicadas
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={() => void loadVersions()}
|
||||
disabled={listBusy || installing}
|
||||
>
|
||||
{listBusy ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
||||
Actualizar lista
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{listError && <div className="wiz-toast wiz-toast--error">{listError}</div>}
|
||||
|
||||
{!listError && versions.length === 0 && !listBusy && (
|
||||
<p className="form-hint">
|
||||
No hay catálogo aún. Tras el próximo <code>npm run publish:minio</code> aparecerá{" "}
|
||||
<code>versions.json</code> en MinIO.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{versions.length > 0 && (
|
||||
<ul className="updates-version-list">
|
||||
{versions.map((v) => {
|
||||
const isCurrent = v.version === APP_VERSION;
|
||||
const dateLabel = v.releaseDateDisplay || v.releaseDate || "";
|
||||
return (
|
||||
<li key={v.version} className={`updates-version-row${isCurrent ? " is-current" : ""}`}>
|
||||
<div className="updates-version-meta">
|
||||
<strong>v{v.version}</strong>
|
||||
{isCurrent && <span className="updates-version-badge">Actual</span>}
|
||||
<span className="form-hint">
|
||||
{dateLabel ? formatReleaseDateFlexible(dateLabel) : "Sin fecha"}
|
||||
{" · "}
|
||||
{formatBytes(v.size)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline"
|
||||
disabled={installing}
|
||||
onClick={() => onInstallVersion(v)}
|
||||
>
|
||||
<Download size={14} />
|
||||
{isCurrent ? "Reinstalar" : "Descargar e instalar"}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionSettings({
|
||||
apiBaseUrl,
|
||||
onApiBaseUrlChange,
|
||||
|
||||
@@ -11,6 +11,18 @@ export function formatReleaseDate(isoDate: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
export function formatVersionLabel(): string {
|
||||
return `v${APP_VERSION} · ${formatReleaseDate(APP_RELEASE_DATE)}`;
|
||||
/** Acepta ISO (YYYY-MM-DD) o display (DD/MM/YYYY). */
|
||||
export function formatReleaseDateFlexible(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return formatReleaseDate(trimmed);
|
||||
const m = trimmed.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||
if (m) {
|
||||
const iso = `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`;
|
||||
return formatReleaseDate(iso);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function formatVersionLabel(releaseDate: string = APP_RELEASE_DATE): string {
|
||||
return `v${APP_VERSION} · ${formatReleaseDateFlexible(releaseDate)}`;
|
||||
}
|
||||
|
||||
@@ -284,6 +284,18 @@ export interface UpdateCheckResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Fecha de la versión instalada según MinIO (latest.json / DATA.txt). */
|
||||
export interface ReleaseMeta {
|
||||
releaseDate?: string;
|
||||
releaseDateDisplay?: string;
|
||||
}
|
||||
|
||||
export interface VersionsListResult {
|
||||
currentVersion: string;
|
||||
versions: UpdateManifest[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProgress {
|
||||
phase: "downloading" | "verifying" | "installing" | string;
|
||||
received?: number;
|
||||
@@ -305,6 +317,8 @@ export interface DesktopBridge {
|
||||
): Promise<ManifestEntry[]>;
|
||||
readFile(filePath: string): Promise<FileUploadData>;
|
||||
checkForUpdates(): Promise<UpdateCheckResult>;
|
||||
getReleaseMeta(): Promise<ReleaseMeta | null>;
|
||||
listVersions(): Promise<VersionsListResult>;
|
||||
installUpdate(manifest: UpdateManifest): Promise<{ ok: boolean; destPath?: string }>;
|
||||
onUpdateProgress(callback: (progress: UpdateProgress) => void): () => void;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ except ImportError:
|
||||
|
||||
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from api.desktop_ingest import mark_task_completed_if_done, process_desktop_ingest
|
||||
|
||||
@@ -28,6 +28,8 @@ from domain.estimator import Estimator
|
||||
from domain.service import InvoiceProcessingService
|
||||
from domain.strategies import OpenAICompatibleExtractor
|
||||
from infrastructure.database import (
|
||||
CleanedRow,
|
||||
CleaningJob,
|
||||
CountJobFile,
|
||||
ExtractionBatch,
|
||||
ExtractionRow,
|
||||
@@ -1044,6 +1046,53 @@ def get_job_tasks(job_id: str) -> list[dict[str, Any]]:
|
||||
return [serialize_task(t) for t in tasks]
|
||||
|
||||
|
||||
@app.delete("/jobs/{job_id}", status_code=204)
|
||||
def delete_job(job_id: str) -> None:
|
||||
"""Elimina un job y todos sus resultados (tasks, conteos, extracciones, limpiezas)."""
|
||||
with session_scope() as session:
|
||||
job = session.get(Job, job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="Job no encontrado")
|
||||
|
||||
tasks = session.scalars(select(Task).where(Task.job_id == job_id)).all()
|
||||
task_ids = [t.id for t in tasks]
|
||||
|
||||
batch_ids: list[str] = []
|
||||
if task_ids:
|
||||
batch_ids = list(
|
||||
session.scalars(
|
||||
select(ExtractionBatch.id).where(ExtractionBatch.job_module_id.in_(task_ids))
|
||||
).all()
|
||||
)
|
||||
|
||||
cleaning_ids = list(
|
||||
session.scalars(select(CleaningJob.id).where(CleaningJob.job_id == job_id)).all()
|
||||
)
|
||||
if batch_ids:
|
||||
cleaning_ids.extend(
|
||||
session.scalars(
|
||||
select(CleaningJob.id).where(CleaningJob.extraction_batch_id.in_(batch_ids))
|
||||
).all()
|
||||
)
|
||||
cleaning_ids = list(dict.fromkeys(cleaning_ids))
|
||||
|
||||
if cleaning_ids:
|
||||
session.execute(delete(CleanedRow).where(CleanedRow.cleaning_job_id.in_(cleaning_ids)))
|
||||
session.execute(delete(CleaningJob).where(CleaningJob.id.in_(cleaning_ids)))
|
||||
|
||||
if batch_ids:
|
||||
session.execute(delete(CleanedRow).where(CleanedRow.extraction_batch_id.in_(batch_ids)))
|
||||
session.execute(delete(ExtractionRow).where(ExtractionRow.batch_id.in_(batch_ids)))
|
||||
session.execute(delete(ExtractionBatch).where(ExtractionBatch.id.in_(batch_ids)))
|
||||
|
||||
if task_ids:
|
||||
session.execute(delete(CountJobFile).where(CountJobFile.job_module_id.in_(task_ids)))
|
||||
session.execute(delete(Task).where(Task.id.in_(task_ids)))
|
||||
|
||||
session.delete(job)
|
||||
session.flush()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Count Jobs — persistir y gestionar resultados del servicio de conteo
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user