panels-origin/web-panel/components/DocumentPreviewDialog.vue
Alberto Martinez 34c0da30d8 fix(padron): vista previa de documentos, eliminar y reemplazar por tipo (#26)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Problema
En el expediente del padrón, al ver un documento el navegador lo trataba como descarga (cabeceras `application/octet-stream` + `attachment` y blob sin MIME). No había forma de eliminar archivos cargados y al subir otro del mismo tipo algunos entornos fallaban o no refrescaban bien la lista vigente.

## Cambios
- **API:** los GET de documentos (trabajador, proyecto, empresa) envían el `Content-Type` real del archivo y `Content-Disposition: inline` por defecto (`?disposition=attachment` para forzar descarga).
- **UI:** `DocumentPreviewDialog` pide `disposition=inline` y reconstruye el blob con MIME correcto para que PDF/imagen se vean en el diálogo.
- **Eliminar:** RPC `core.fn_worker_document_delete`, `DELETE /v1/workers/:id/documents/:docId`, permiso `documents.delete` (incluido en `manage_documents`), botón de papelera en vigentes e histórico.
- **Reemplazar:** `fn_worker_document_store` marca como no vigentes **todas** las filas del mismo tipo antes del INSERT; se limpia el `FileUpload` tras subir; filtro `is_current` tolera booleano o `1`.

## Migraciones
- Core: `040-fn-worker-document-delete.sql`, `041-fn-worker-document-store-replace.sql`
- IAM: `006-documents-delete-permission.sql`

## Verificación
Tras aplicar Liquibase, en un trabajador del padrón: abrir ojo en PDF/JPG (debe verse en modal), eliminar un doc de prueba, subir dos veces el mismo tipo (debe quedar una vigente y la anterior en histórico).
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-d8a5d34a-ead9-4912-8296-322cff799783?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-d8a5d34a-ead9-4912-8296-322cff799783&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
2026-09-16 21:17:39 +00:00

167 lines
4.6 KiB
Vue

<template>
<Dialog
v-model:visible="visible"
modal
:header="title"
class="document-preview-dialog"
:style="{ width: 'min(920px, 96vw)' }"
:content-style="{ padding: '0' }"
@hide="onHide"
>
<div class="document-preview-body">
<div v-if="loading" class="document-preview-state">
<ProgressSpinner stroke-width="4" />
<span class="muted">Cargando vista previa…</span>
</div>
<div v-else-if="error" class="document-preview-state">
<i class="pi pi-exclamation-triangle" />
<span>{{ error }}</span>
</div>
<img v-else-if="kind === 'image' && url" :src="url" :alt="title" class="document-preview-image" />
<iframe v-else-if="kind === 'pdf' && url" :src="url" class="document-preview-frame" title="Vista previa PDF" />
<div v-else class="document-preview-state">
<i class="pi pi-file" />
<span>Este archivo no tiene vista previa en el panel.</span>
<Button label="Descargar" icon="pi pi-download" outlined size="small" @click="downloadFile" />
</div>
</div>
<template #footer>
<Button label="Descargar" icon="pi pi-download" text :disabled="!url" @click="downloadFile" />
<Button label="Cerrar" severity="secondary" @click="visible = false" />
</template>
</Dialog>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: boolean;
path: string;
name: string;
mime?: string | null;
}>();
const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
const { api } = useApi();
const visible = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
const title = computed(() => props.name || "Documento");
const loading = ref(false);
const error = ref("");
const url = ref("");
const blobRef = ref<Blob | null>(null);
const kind = computed<"image" | "pdf" | "other">(() => {
const mime = (props.mime || blobRef.value?.type || "").toLowerCase();
const name = props.name.toLowerCase();
if (mime.startsWith("image/") || /\.(jpe?g|png|gif|webp|bmp)$/i.test(name)) return "image";
if (mime.includes("pdf") || name.endsWith(".pdf")) return "pdf";
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 = "";
blobRef.value = null;
if (!props.path) return;
loading.value = true;
try {
const raw = await api<Blob>(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) {
error.value = err instanceof Error ? err.message : "No se pudo abrir el documento";
} finally {
loading.value = false;
}
}
function downloadFile() {
if (!url.value) return;
const a = document.createElement("a");
a.href = url.value;
a.download = props.name || "documento";
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
}
function revoke() {
if (url.value) URL.revokeObjectURL(url.value);
url.value = "";
}
function onHide() {
revoke();
blobRef.value = null;
error.value = "";
}
watch(
() => [props.modelValue, props.path] as const,
([open]) => {
if (open) loadPreview();
else onHide();
},
);
</script>
<style scoped>
.document-preview-body {
min-height: 360px;
max-height: min(78vh, 760px);
display: flex;
align-items: center;
justify-content: center;
background: #f4f6f8;
}
.document-preview-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 28px 20px;
color: #5d6e84;
text-align: center;
}
.document-preview-state .pi {
font-size: 28px;
color: #8a96a8;
}
.document-preview-image {
display: block;
max-width: 100%;
max-height: min(78vh, 760px);
object-fit: contain;
background: #fff;
}
.document-preview-frame {
width: 100%;
height: min(78vh, 760px);
border: 0;
background: #fff;
}
</style>