mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 16:13:17 +00:00
<!-- 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> <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> </div>
167 lines
4.6 KiB
Vue
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>
|