diff --git a/api/document_serve.ts b/api/document_serve.ts new file mode 100644 index 0000000..1f81a5d --- /dev/null +++ b/api/document_serve.ts @@ -0,0 +1,24 @@ +/** Cabeceras HTTP para bytes de documento cifrado ya descifrados. */ +export function documentServeHeaders( + doc: { original_name: string; mime?: string }, + opts: { inline?: boolean } = {}, +): Record { + const inline = opts.inline !== false; + const mime = (doc.mime || "").trim() || "application/octet-stream"; + const name = doc.original_name || "documento"; + const disposition = inline ? "inline" : "attachment"; + return { + "Content-Type": mime, + "X-Content-Type-Options": "nosniff", + "Content-Disposition": `${disposition}; filename="${encodeURIComponent(name)}"`, + "Cache-Control": "private, max-age=60", + }; +} + +export function wantsInlineDocumentDisposition(url: URL | string): boolean { + const u = typeof url === "string" ? new URL(url, "http://local") : url; + const d = (u.searchParams.get("disposition") || u.searchParams.get("inline") || "").toLowerCase(); + if (d === "attachment" || d === "download" || d === "0" || d === "false") return false; + if (d === "inline" || d === "1" || d === "true") return true; + return true; +} diff --git a/api/document_serve_test.ts b/api/document_serve_test.ts new file mode 100644 index 0000000..33ddc88 --- /dev/null +++ b/api/document_serve_test.ts @@ -0,0 +1,13 @@ +import { documentServeHeaders, wantsInlineDocumentDisposition } from "./document_serve.ts"; + +Deno.test("documentServeHeaders inline uses mime", () => { + const h = documentServeHeaders({ original_name: "a.pdf", mime: "application/pdf" }, { inline: true }); + if (h["Content-Type"] !== "application/pdf") throw new Error(h["Content-Type"]); + if (!h["Content-Disposition"].startsWith("inline;")) throw new Error(h["Content-Disposition"]); +}); + +Deno.test("wantsInlineDocumentDisposition", () => { + if (!wantsInlineDocumentDisposition("http://x/doc")) throw new Error("default inline"); + if (wantsInlineDocumentDisposition("http://x/doc?disposition=attachment")) throw new Error("attachment"); + if (!wantsInlineDocumentDisposition("http://x/doc?disposition=inline")) throw new Error("inline"); +}); diff --git a/api/excel.ts b/api/excel.ts index cf37498..39af560 100644 --- a/api/excel.ts +++ b/api/excel.ts @@ -247,6 +247,21 @@ export async function storeDocument( await refreshPipeline(db, workerId); } +export async function deleteWorkerDocument( + db: Db, + workerId: number, + docId: number, +): Promise<{ storage_name: string }> { + const env = await callCoreFn<{ storage_name?: string }>(db, "core.fn_worker_document_delete", { + worker_id: workerId, + doc_id: docId, + }); + if (!env.ok) throw new RpcCallError(env); + const storage = String(env.data?.storage_name ?? ""); + await refreshPipeline(db, workerId); + return { storage_name: storage }; +} + export async function storeProjectDocument( db: Db, projectId: number, diff --git a/api/main.ts b/api/main.ts index fe77bab..571e102 100644 --- a/api/main.ts +++ b/api/main.ts @@ -17,6 +17,7 @@ import { } from "./db.ts"; import { callCoreFn } from "./rpc.ts"; import { normalizeDocumentGetPayload } from "./document_rpc.ts"; +import { documentServeHeaders, wantsInlineDocumentDisposition } from "./document_serve.ts"; import { respondRpc, respondApiError, @@ -50,7 +51,7 @@ import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, upd import { smtpConfigured, testSmtp } from "./mail.ts"; import { saveSmtpSettings, smtpPublicView } from "./smtp.ts"; import { decryptBytes } from "./docs_crypto.ts"; -import { importExcel, storeDocument, storeProjectDocument, storeCompanyDocument, buildImportTemplate } from "./excel.ts"; +import { importExcel, storeDocument, storeProjectDocument, storeCompanyDocument, buildImportTemplate, deleteWorkerDocument } from "./excel.ts"; import { normalizeWorker, validateCurp, @@ -81,7 +82,7 @@ import { importBudgetExcel, listBudget, } from "./budget.ts"; -import { badgeJobPdfKey, companyDocKey, getObject, pingStorage, projectDocKey, workerDocKey } from "./storage.ts"; +import { badgeJobPdfKey, companyDocKey, deleteObject, getObject, pingStorage, projectDocKey, workerDocKey } from "./storage.ts"; import { cacheCore, cacheKeyCore } from "./cache.ts"; import { pingRedis } from "./redis.ts"; @@ -128,6 +129,17 @@ async function rpcDocumentForDownload( return { doc, envelope }; } +function applyDocumentResponseHeaders( + c: { header: (name: string, value: string) => void; req: { url: string } }, + doc: StorageDoc, + inline?: boolean, +) { + const useInline = inline ?? wantsInlineDocumentDisposition(c.req.url); + for (const [name, value] of Object.entries(documentServeHeaders(doc, { inline: useInline }))) { + c.header(name, value); + } +} + async function readExcelUpload( c: { req: { formData: () => Promise } }, ): Promise<{ file: File; bytes: Uint8Array } | { error: string }> { @@ -473,9 +485,7 @@ app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, requirePermiss if (!doc) return respondRpc(c, envelope); const enc = await getObject(companyDocKey(companyId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); - c.header("Content-Type", "application/octet-stream"); - c.header("X-Content-Type-Options", "nosniff"); - c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`); + applyDocumentResponseHeaders(c, doc); return c.body(plain.buffer as ArrayBuffer); }); @@ -717,9 +727,7 @@ app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, requirePermissi if (!await denyUnlessProjectDocView(c, db, typeCode)) return; const enc = await getObject(projectDocKey(projectId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); - c.header("Content-Type", "application/octet-stream"); - c.header("X-Content-Type-Options", "nosniff"); - c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`); + applyDocumentResponseHeaders(c, doc); return c.body(plain.buffer as ArrayBuffer); }); @@ -1271,12 +1279,38 @@ app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, requirePermissio if (!doc) return respondRpc(c, envelope); const enc = await getObject(workerDocKey(workerId, doc.storage_name)); const plain = await decryptBytes(doc.iv, enc); - c.header("Content-Type", "application/octet-stream"); - c.header("X-Content-Type-Options", "nosniff"); - c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`); + applyDocumentResponseHeaders(c, doc); return c.body(plain.buffer as ArrayBuffer); }); +app.delete("/v1/workers/:id/documents/:docId", ...requireCoreAuth, requirePermission("documents.delete"), async (c) => { + const workerId = Number(c.req.param("id")); + const docId = Number(c.req.param("docId")); + const db = c.get("db"); + const tid = tenantScope(c.get("user")); + const route = routeLabel(c); + const workerEnv = await callCoreFn(db, "core.fn_worker_get", { id: workerId, tenant_id: tid }, { route }); + if (!workerEnv.ok) return respondRpc(c, workerEnv); + try { + const { storage_name: storageName } = await deleteWorkerDocument(db, workerId, docId); + if (storageName) { + try { + await deleteObject(workerDocKey(workerId, storageName)); + } catch { + /* metadatos ya eliminados; objeto huérfano es aceptable */ + } + } + } catch (error) { + const message = error instanceof Error ? error.message : "No se pudo eliminar el documento"; + return respondApiError(c, "VALIDATION", message, { route, worker_id: workerId, doc_id: docId }); + } + return c.json({ + ok: true, + checklist: await checklistFor(db, workerId, tid), + ...await imssFlagsFor(db, workerId, tid), + }); +}); + app.get("/v1/workers/:id/photo", ...requireCoreAuth, requirePermission("workers.view"), async (c) => { const id = Number(c.req.param("id")); const db = c.get("db"); diff --git a/api/storage.ts b/api/storage.ts index 4ed3c3a..6701f41 100644 --- a/api/storage.ts +++ b/api/storage.ts @@ -158,6 +158,22 @@ export async function getObject(key: string): Promise { return await readFile(path); } +export async function deleteObject(key: string): Promise { + if (s3Configured()) { + await getClient().send( + new DeleteObjectCommand({ Bucket: config.s3Bucket, Key: key }), + ); + return; + } + const path = join(DATA_DIR, "local-objects", key); + try { + const { unlink } = await import("node:fs/promises"); + await unlink(path); + } catch (e) { + if ((e as NodeJS.ErrnoException)?.code !== "ENOENT") throw e; + } +} + // --- Convenciones de key por dominio (equivalentes a los antiguos // workerDir/projectDir/companyDir + Deno.readFile/writeFile) --- export function workerDocKey(workerId: number, storageName: string): string { diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml index 5a115c5..216d163 100644 --- a/db/core/changelog-master.xml +++ b/db/core/changelog-master.xml @@ -49,5 +49,7 @@ + + diff --git a/db/core/changesets/040-fn-worker-document-delete.sql b/db/core/changesets/040-fn-worker-document-delete.sql new file mode 100644 index 0000000..adbfbfe --- /dev/null +++ b/db/core/changesets/040-fn-worker-document-delete.sql @@ -0,0 +1,63 @@ +--liquibase formatted sql +-- PANELS · core · eliminar documento de expediente (trabajador) + +--changeset panel:core-040a-fn-worker-document-delete splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_worker_document_delete(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint; + v_doc_id bigint := NULLIF(payload->>'doc_id', '')::bigint; + v_doc documents%ROWTYPE; + v_next_id bigint; +BEGIN + IF v_worker_id IS NULL OR v_doc_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_document_delete: worker_id y doc_id son obligatorios', + jsonb_build_object('fn', 'fn_worker_document_delete', 'worker_id', v_worker_id, 'doc_id', v_doc_id) + ); + END IF; + SELECT * INTO v_doc FROM documents WHERE id = v_doc_id AND worker_id = v_worker_id; + IF NOT FOUND THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_worker_document_delete: documento id=%s no encontrado para trabajador %s', v_doc_id, v_worker_id), + jsonb_build_object('fn', 'fn_worker_document_delete', 'doc_id', v_doc_id, 'worker_id', v_worker_id) + ); + END IF; + + DELETE FROM documents WHERE id = v_doc_id AND worker_id = v_worker_id; + + IF v_doc.is_current THEN + SELECT id INTO v_next_id + FROM documents + WHERE worker_id = v_worker_id + AND type_code = v_doc.type_code + ORDER BY uploaded_at DESC, id DESC + LIMIT 1; + IF v_next_id IS NOT NULL THEN + UPDATE documents SET is_current = true WHERE id = v_next_id; + END IF; + END IF; + + RETURN core.rpc_ok( + jsonb_build_object( + 'deleted_id', v_doc_id, + 'worker_id', v_worker_id, + 'type_code', v_doc.type_code, + 'storage_name', v_doc.storage_name + ), + format('Documento id=%s eliminado del expediente del trabajador %s', v_doc_id, v_worker_id), + jsonb_build_object('fn', 'fn_worker_document_delete', 'doc_id', v_doc_id, 'worker_id', v_worker_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_worker_document_delete', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-040b-fn-worker-document-delete-grant endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core.fn_worker_document_delete(jsonb) TO panels_core_app; diff --git a/db/core/changesets/041-fn-worker-document-store-replace.sql b/db/core/changesets/041-fn-worker-document-store-replace.sql new file mode 100644 index 0000000..0f98976 --- /dev/null +++ b/db/core/changesets/041-fn-worker-document-store-replace.sql @@ -0,0 +1,153 @@ +--liquibase formatted sql +-- PANELS · core · reemplazo de documento por tipo (siempre archiva versiones previas) + +--changeset panel:core-041a-fn-worker-document-store-replace splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_worker_document_store(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint; + v_type_code text := btrim(COALESCE(payload->>'type_code', payload->>'type', '')); + v_original_name text := btrim(COALESCE(payload->>'original_name', payload->>'filename', '')); + v_mime text := btrim(COALESCE(payload->>'mime', '')); + v_size_bytes bigint := NULLIF(payload->>'size_bytes', '')::bigint; + v_sha256 text := btrim(COALESCE(payload->>'sha256', '')); + v_iv text := btrim(COALESCE(payload->>'iv', '')); + v_storage_name text := btrim(COALESCE(payload->>'storage_name', '')); + v_uploaded_by_id integer := NULLIF(payload->>'uploaded_by_id', '')::integer; + v_uploaded_by_name text := COALESCE(NULLIF(btrim(payload->>'uploaded_by_name'), ''), ''); + v_issued_at date := NULLIF(btrim(payload->>'issued_at'), '')::date; + v_expires_at date := NULLIF(btrim(payload->>'expires_at'), '')::date; + v_imss_company_id integer := NULLIF(payload->>'imss_company_id', '')::integer; + v_imss_alta_at date := NULLIF(btrim(payload->>'imss_alta_at'), '')::date; + v_imss_baja_at date := NULLIF(btrim(payload->>'imss_baja_at'), '')::date; + v_today date := current_date; + v_movement_date date; + v_doc_id bigint; + v_company record; +BEGIN + IF v_worker_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_document_store: worker_id es obligatorio', + jsonb_build_object('fn', 'fn_worker_document_store', 'field', 'worker_id') + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM workers WHERE id = v_worker_id) THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_worker_document_store: trabajador id=%s no encontrado', v_worker_id), + jsonb_build_object('fn', 'fn_worker_document_store', 'worker_id', v_worker_id) + ); + END IF; + IF v_type_code = '' THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_document_store: type_code es obligatorio', + jsonb_build_object('fn', 'fn_worker_document_store', 'field', 'type_code') + ); + END IF; + IF NOT EXISTS (SELECT 1 FROM document_types WHERE code = v_type_code) THEN + RETURN core.rpc_err( + 'VALIDATION', + format('fn_worker_document_store: tipo de documento no válido (%s)', v_type_code), + jsonb_build_object('fn', 'fn_worker_document_store', 'type_code', v_type_code) + ); + END IF; + IF v_original_name = '' OR v_mime = '' OR v_sha256 = '' OR v_iv = '' OR v_storage_name = '' THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_document_store: original_name, mime, sha256, iv y storage_name son obligatorios tras la carga a almacenamiento', + jsonb_build_object('fn', 'fn_worker_document_store') + ); + END IF; + IF v_size_bytes IS NULL OR v_size_bytes < 0 THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_document_store: size_bytes debe ser un entero no negativo', + jsonb_build_object('fn', 'fn_worker_document_store', 'field', 'size_bytes') + ); + END IF; + + v_imss_alta_at := COALESCE(v_imss_alta_at, CASE WHEN v_type_code = 'alta_imss' THEN v_today END); + v_imss_baja_at := COALESCE(v_imss_baja_at, CASE WHEN v_type_code = 'baja_imss' THEN v_today END); + v_movement_date := CASE WHEN v_type_code = 'baja_imss' THEN v_imss_baja_at ELSE v_imss_alta_at END; + + UPDATE documents + SET is_current = false + WHERE worker_id = v_worker_id + AND type_code = v_type_code; + + INSERT INTO documents ( + worker_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, + is_current, parse_status, issued_at, expires_at, imss_company_id, imss_alta_at, + uploaded_by_id, uploaded_by_name + ) VALUES ( + v_worker_id, v_type_code, v_original_name, v_mime, v_size_bytes, v_sha256, v_iv, v_storage_name, + true, 'manual', + COALESCE(v_issued_at, v_movement_date), + v_expires_at, + v_imss_company_id, + v_movement_date, + v_uploaded_by_id, + v_uploaded_by_name + ) + RETURNING id INTO v_doc_id; + + IF v_type_code = 'alta_imss' THEN + v_imss_company_id := COALESCE( + v_imss_company_id, + (SELECT company_id FROM workers WHERE id = v_worker_id) + ); + SELECT id, code INTO v_company FROM companies WHERE id = v_imss_company_id; + IF NOT FOUND THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_worker_document_store: empresa patrón no válida para alta IMSS', + jsonb_build_object('fn', 'fn_worker_document_store', 'worker_id', v_worker_id) + ); + END IF; + UPDATE workers SET + imss_status = 'alta', + imss_company_id = v_company.id, + imss_alta_at = v_imss_alta_at, + imss_baja_at = NULL, + company_id = v_company.id, + hire_type = v_company.code, + updated_at = now() + WHERE id = v_worker_id; + END IF; + + IF v_type_code = 'baja_imss' THEN + UPDATE workers SET + imss_status = 'baja_imss', + imss_baja_at = v_imss_baja_at, + imss_company_id = NULL, + company_id = NULL, + hire_type = '', + updated_at = now() + WHERE id = v_worker_id; + END IF; + + IF (SELECT status FROM workers WHERE id = v_worker_id) = 'baja' THEN + UPDATE workers SET pipeline_status = 'baja' WHERE id = v_worker_id; + END IF; + + RETURN core.rpc_created( + jsonb_build_object( + 'id', v_doc_id, + 'worker_id', v_worker_id, + 'type_code', v_type_code, + 'is_current', true, + 'storage_name', v_storage_name + ), + format('Metadatos del documento %s guardados para trabajador %s (versión actual)', v_type_code, v_worker_id), + jsonb_build_object('fn', 'fn_worker_document_store', 'id', v_doc_id, 'worker_id', v_worker_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_worker_document_store', SQLSTATE, SQLERRM); +END; +$$; diff --git a/db/iam/changelog-master.xml b/db/iam/changelog-master.xml index befbd1a..c677a13 100644 --- a/db/iam/changelog-master.xml +++ b/db/iam/changelog-master.xml @@ -11,5 +11,6 @@ + diff --git a/db/iam/changesets/006-documents-delete-permission.sql b/db/iam/changesets/006-documents-delete-permission.sql new file mode 100644 index 0000000..cd89372 --- /dev/null +++ b/db/iam/changesets/006-documents-delete-permission.sql @@ -0,0 +1,23 @@ +--liquibase formatted sql +-- PANELS · iam · permiso eliminar documentos de expediente + +--changeset panel:iam-006a-documents-delete endDelimiter:; splitStatements:true +INSERT INTO iam.permissions (code, label, module, verb, perm_group) VALUES + ('documents.delete', 'Eliminar documentos de expediente', 'documents', 'delete', NULL) +ON CONFLICT (code) DO UPDATE SET + label = EXCLUDED.label, + module = EXCLUDED.module, + verb = EXCLUDED.verb, + perm_group = EXCLUDED.perm_group; + +INSERT INTO iam._legacy_perm_map (legacy_code, v2_code) VALUES + ('manage_documents', 'documents.delete') +ON CONFLICT (legacy_code, v2_code) DO NOTHING; + +INSERT INTO iam.role_permissions (role_id, permission_code) +SELECT sr.id, 'documents.delete' + FROM iam.roles sr + WHERE sr.code = 'tenant_admin' + AND sr.is_system + AND sr.tenant_id IS NULL +ON CONFLICT DO NOTHING; diff --git a/web-panel/components/DocumentPreviewDialog.vue b/web-panel/components/DocumentPreviewDialog.vue index a36d09a..e868a63 100644 --- a/web-panel/components/DocumentPreviewDialog.vue +++ b/web-panel/components/DocumentPreviewDialog.vue @@ -60,6 +60,25 @@ const kind = computed<"image" | "pdf" | "other">(() => { return "other"; }); +function previewFetchPath(path: string) { + const sep = path.includes("?") ? "&" : "?"; + return `${path}${sep}disposition=inline`; +} + +function resolvedPreviewMime(raw: Blob): string { + const fromProp = (props.mime || "").trim(); + if (fromProp) return fromProp; + const fromBlob = (raw.type || "").trim(); + if (fromBlob && fromBlob !== "application/octet-stream") return fromBlob; + const name = props.name.toLowerCase(); + if (name.endsWith(".pdf")) return "application/pdf"; + if (/\.(jpe?g)$/.test(name)) return "image/jpeg"; + if (name.endsWith(".png")) return "image/png"; + if (name.endsWith(".webp")) return "image/webp"; + if (name.endsWith(".gif")) return "image/gif"; + return fromBlob || "application/octet-stream"; +} + async function loadPreview() { revoke(); error.value = ""; @@ -67,7 +86,9 @@ async function loadPreview() { if (!props.path) return; loading.value = true; try { - const blob = await api(props.path); + const raw = await api(previewFetchPath(props.path)); + const mime = resolvedPreviewMime(raw); + const blob = mime === raw.type ? raw : new Blob([raw], { type: mime }); blobRef.value = blob; url.value = URL.createObjectURL(blob); } catch (err: unknown) { diff --git a/web-panel/components/padron/WorkerDocuments.vue b/web-panel/components/padron/WorkerDocuments.vue index 0de7098..68cb879 100644 --- a/web-panel/components/padron/WorkerDocuments.vue +++ b/web-panel/components/padron/WorkerDocuments.vue @@ -61,6 +61,7 @@ /> @@ -111,7 +124,19 @@ @@ -158,7 +183,11 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ changed: []; registerAlta: [] }>(); const { api } = useApi(); +const { can } = usePermissions(); +const confirm = useConfirm(); const toast = useToast(); +const fileUploadRef = ref<{ clear?: () => void } | null>(null); +const canDeleteDoc = computed(() => can("documents.delete") || can("documents.create")); const documentType = ref( props.initialType && props.initialType !== "alta_imss" && props.initialType !== "baja_imss" @@ -194,11 +223,14 @@ const requiredDone = computed(() => requiredItems.value.filter((item) => item.pr const requiredProgress = computed(() => requiredItems.value.length ? Math.round((requiredDone.value / requiredItems.value.length) * 100) : 0, ); +function isDocCurrent(doc: DocumentRow) { + return doc.is_current === true || doc.is_current === 1; +} const currentDocuments = computed(() => - props.documents.filter((doc) => doc.is_current && doc.type_code !== "alta_imss" && doc.type_code !== "baja_imss"), + props.documents.filter((doc) => isDocCurrent(doc) && doc.type_code !== "alta_imss" && doc.type_code !== "baja_imss"), ); const historyDocuments = computed(() => - props.documents.filter((doc) => !doc.is_current && doc.type_code !== "alta_imss" && doc.type_code !== "baja_imss"), + props.documents.filter((doc) => !isDocCurrent(doc) && doc.type_code !== "alta_imss" && doc.type_code !== "baja_imss"), ); const freshnessRequired = computed(() => !!props.freshnessRequired); const imssReady = computed(() => !!props.imssReady); @@ -263,6 +295,7 @@ async function uploadDocument(event: { files: File[] }) { body.append("type", documentType.value); body.append("file", file); await api(`/v1/workers/${props.workerId}/documents`, { method: "POST", body }); + fileUploadRef.value?.clear?.(); toast.add({ severity: "success", summary: "Documento cifrado y guardado", life: 2200 }); docsTab.value = "current"; emit("changed"); @@ -282,6 +315,30 @@ function openDocument(row: DocumentRow) { previewMime.value = row.mime || null; previewOpen.value = true; } +function confirmDelete(row: DocumentRow) { + confirm.require({ + message: `¿Eliminar ${typeLabel(row.type_code)} (${row.original_name})? Esta acción no se puede deshacer.`, + header: "Eliminar documento", + icon: "pi pi-exclamation-triangle", + acceptLabel: "Eliminar", + rejectLabel: "Cancelar", + acceptClass: "p-button-danger", + accept: () => deleteDocument(row), + }); +} +async function deleteDocument(row: DocumentRow) { + try { + await api(`/v1/workers/${props.workerId}/documents/${row.id}`, { method: "DELETE" }); + toast.add({ severity: "success", summary: "Documento eliminado", life: 2200 }); + emit("changed"); + } catch (error: unknown) { + toast.add({ + severity: "error", + summary: error instanceof Error ? error.message : "No se pudo eliminar", + life: 3500, + }); + } +} watch(() => props.initialType, (value) => { if (value && value !== "alta_imss" && value !== "baja_imss") documentType.value = value;