mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 20:23: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>
346 lines
12 KiB
Vue
346 lines
12 KiB
Vue
<template>
|
|
<div class="detail-section">
|
|
<Message v-if="freshnessRequired" severity="warn" :closable="false" class="mb-3">
|
|
Reingreso o pendiente de alta IMSS: CURP, NSS, RFC y domicilio deben haberse subido en los últimos 3 meses. Quien recibe el expediente revisa si el documento en sí es vigente.
|
|
</Message>
|
|
<Message v-else-if="imssReady" severity="info" :closable="false" class="mb-3">
|
|
Expediente listo para alta IMSS.
|
|
<Button v-if="!readonly" label="Registrar alta" link class="p-0 ml-1" @click="emit('registerAlta')" />
|
|
</Message>
|
|
<Message v-else-if="inProjectWithoutImss" severity="error" :closable="false" class="mb-3">
|
|
Está en obra sin alta IMSS.
|
|
<Button v-if="!readonly" label="Registrar alta" link class="p-0 ml-1" @click="emit('registerAlta')" />
|
|
</Message>
|
|
|
|
<div class="document-progress">
|
|
<div>
|
|
<div class="section-title">Expediente obligatorio</div>
|
|
<div class="muted">{{ requiredDone }} de {{ requiredItems.length }} documentos válidos</div>
|
|
</div>
|
|
<Tag :value="`${requiredProgress}%`" :severity="requiredProgress === 100 ? 'success' : 'warn'" />
|
|
</div>
|
|
<ProgressBar :value="requiredProgress" :show-value="false" class="mt-2" />
|
|
|
|
<div class="document-checklist">
|
|
<div
|
|
v-for="item in requiredItems"
|
|
:key="item.code"
|
|
class="document-check"
|
|
:class="{ complete: item.present && item.valid }"
|
|
>
|
|
<i :class="item.present && item.valid ? 'pi pi-check-circle' : 'pi pi-circle'" />
|
|
<span>{{ item.label }}</span>
|
|
<small>{{ validityLabel(item.validity_status) }}</small>
|
|
</div>
|
|
</div>
|
|
|
|
<Panel v-if="uploadedOptionalItems.length" header="También en el expediente" toggleable collapsed class="mt-3">
|
|
<div class="document-checklist">
|
|
<div
|
|
v-for="item in uploadedOptionalItems"
|
|
:key="item.code"
|
|
class="document-check"
|
|
:class="{ complete: item.present && item.valid }"
|
|
>
|
|
<i :class="item.present && item.valid ? 'pi pi-check-circle' : 'pi pi-circle'" />
|
|
<span>{{ item.label }}</span>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
|
|
<Fieldset v-if="!readonly" legend="Subir o reemplazar documento" class="mt-3">
|
|
<FormField label="Tipo de documento">
|
|
<Select
|
|
v-model="documentType"
|
|
:options="groupedTypes"
|
|
option-label="label"
|
|
option-value="code"
|
|
option-group-label="label"
|
|
option-group-children="items"
|
|
class="w-full"
|
|
/>
|
|
</FormField>
|
|
<FileUpload
|
|
ref="fileUploadRef"
|
|
mode="basic"
|
|
:auto="true"
|
|
choose-label="Seleccionar archivo"
|
|
accept=".jpg,.jpeg,.png,.pdf,.webp"
|
|
custom-upload
|
|
class="mt-2"
|
|
:disabled="uploading"
|
|
@uploader="uploadDocument"
|
|
/>
|
|
<div class="muted mt-2">{{ uploadHint }}</div>
|
|
</Fieldset>
|
|
|
|
<Tabs v-model:value="docsTab" class="mt-3 document-list-tabs">
|
|
<TabList>
|
|
<Tab value="current">Vigentes ({{ currentDocuments.length }})</Tab>
|
|
<Tab value="history">Histórico ({{ historyDocuments.length }})</Tab>
|
|
</TabList>
|
|
<TabPanels>
|
|
<TabPanel value="current">
|
|
<DataTable :value="currentDocuments" size="small" empty-message="No hay documentos vigentes">
|
|
<Column header="Tipo">
|
|
<template #body="{ data }">{{ typeLabel(data.type_code) }}</template>
|
|
</Column>
|
|
<Column field="original_name" header="Archivo" />
|
|
<Column header="Vigencia">
|
|
<template #body="{ data }">
|
|
<Tag
|
|
:value="validityLabel(statusForDoc(data))"
|
|
:severity="validitySeverity(statusForDoc(data))"
|
|
/>
|
|
</template>
|
|
</Column>
|
|
<Column header="">
|
|
<template #body="{ data }">
|
|
<div class="flex gap-1">
|
|
<Button icon="pi pi-eye" text rounded size="small" aria-label="Ver documento" @click="openDocument(data)" />
|
|
<Button
|
|
v-if="!readonly && canDeleteDoc"
|
|
icon="pi pi-trash"
|
|
text
|
|
rounded
|
|
size="small"
|
|
severity="danger"
|
|
aria-label="Eliminar documento"
|
|
@click="confirmDelete(data)"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
</DataTable>
|
|
</TabPanel>
|
|
<TabPanel value="history">
|
|
<DataTable :value="historyDocuments" size="small" empty-message="Sin versiones anteriores">
|
|
<Column header="Tipo">
|
|
<template #body="{ data }">{{ typeLabel(data.type_code) }}</template>
|
|
</Column>
|
|
<Column field="original_name" header="Archivo" />
|
|
<Column header="Fecha">
|
|
<template #body="{ data }">{{ formatDate(data.uploaded_at) }}</template>
|
|
</Column>
|
|
<Column header="">
|
|
<template #body="{ data }">
|
|
<div class="flex gap-1">
|
|
<Button icon="pi pi-eye" text rounded size="small" aria-label="Ver documento" @click="openDocument(data)" />
|
|
<Button
|
|
v-if="!readonly && canDeleteDoc"
|
|
icon="pi pi-trash"
|
|
text
|
|
rounded
|
|
size="small"
|
|
severity="danger"
|
|
aria-label="Eliminar documento"
|
|
@click="confirmDelete(data)"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
</DataTable>
|
|
</TabPanel>
|
|
</TabPanels>
|
|
</Tabs>
|
|
|
|
<DocumentPreviewDialog
|
|
v-model="previewOpen"
|
|
:path="previewPath"
|
|
:name="previewName"
|
|
:mime="previewMime"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import FormField from "./FormField.vue";
|
|
import DocumentPreviewDialog from "~/components/DocumentPreviewDialog.vue";
|
|
import type { ChecklistItem, CompanyOption, ValidityStatus } from "~/types/padron";
|
|
import { groupedDocumentOptions } from "~/types/padron";
|
|
|
|
type DocumentRow = {
|
|
id: number;
|
|
type_code: string;
|
|
original_name: string;
|
|
uploaded_at: string;
|
|
is_current: number;
|
|
mime?: string;
|
|
issued_at?: string | null;
|
|
expires_at?: string | null;
|
|
};
|
|
|
|
const props = defineProps<{
|
|
workerId: number;
|
|
documents: DocumentRow[];
|
|
checklist: ChecklistItem[];
|
|
companies?: CompanyOption[];
|
|
initialType?: string;
|
|
readonly?: boolean;
|
|
freshnessRequired?: boolean;
|
|
imssReady?: boolean;
|
|
inProjectWithoutImss?: boolean;
|
|
}>();
|
|
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"
|
|
? props.initialType
|
|
: "ine",
|
|
);
|
|
const uploading = ref(false);
|
|
const docsTab = ref("current");
|
|
const previewOpen = ref(false);
|
|
const previewPath = ref("");
|
|
const previewName = ref("");
|
|
const previewMime = ref<string | null>(null);
|
|
|
|
const documentTypes = computed(() =>
|
|
props.checklist
|
|
.filter((item) => item.code !== "alta_imss" && item.code !== "baja_imss")
|
|
.map((item) => ({
|
|
code: item.code,
|
|
label: item.label,
|
|
category: item.category,
|
|
requires_issued_at: !!item.requires_issued_at,
|
|
requires_expires_at: !!item.requires_expires_at,
|
|
validity_mode: item.validity_mode,
|
|
})),
|
|
);
|
|
const groupedTypes = computed(() => groupedDocumentOptions(documentTypes.value));
|
|
const selectedPolicy = computed(() => documentTypes.value.find((item) => item.code === documentType.value));
|
|
const requiredItems = computed(() => props.checklist.filter((item) => item.required));
|
|
const uploadedOptionalItems = computed(() =>
|
|
props.checklist.filter((item) => !item.required && item.present && item.code !== "alta_imss" && item.code !== "baja_imss"),
|
|
);
|
|
const requiredDone = computed(() => requiredItems.value.filter((item) => item.present && item.valid !== false).length);
|
|
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) => isDocCurrent(doc) && doc.type_code !== "alta_imss" && doc.type_code !== "baja_imss"),
|
|
);
|
|
const historyDocuments = computed(() =>
|
|
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);
|
|
const inProjectWithoutImss = computed(() => !!props.inProjectWithoutImss);
|
|
|
|
const uploadHint = computed(() => {
|
|
if (selectedPolicy.value?.validity_mode === "freshness") {
|
|
return "Solo sube el archivo. Si hay alta o reingreso pendiente, la vigencia de 3 meses cuenta desde la fecha de carga.";
|
|
}
|
|
return "Elige el tipo y el archivo se sube solo. Quien recibe el expediente revisa si el documento es válido. El catálogo extra no bloquea gafete ni alta IMSS.";
|
|
});
|
|
|
|
function typeLabel(code: string) {
|
|
return props.checklist.find((item) => item.code === code)?.label || code;
|
|
}
|
|
function statusForDoc(row: DocumentRow): ValidityStatus {
|
|
const item = props.checklist.find((entry) => entry.code === row.type_code);
|
|
return (item?.validity_status as ValidityStatus) || (row.is_current ? "ok" : "ok");
|
|
}
|
|
function validityLabel(status?: ValidityStatus | string) {
|
|
switch (status) {
|
|
case "ok":
|
|
return "Vigente";
|
|
case "missing":
|
|
return "Falta";
|
|
case "expired":
|
|
return "Caducado";
|
|
case "stale_for_alta":
|
|
return "Desactualizado para alta";
|
|
case "missing_dates":
|
|
return "Por refrescar";
|
|
default:
|
|
return status || "—";
|
|
}
|
|
}
|
|
function validitySeverity(status?: ValidityStatus | string) {
|
|
switch (status) {
|
|
case "ok":
|
|
return "success";
|
|
case "expired":
|
|
case "stale_for_alta":
|
|
return "danger";
|
|
case "missing":
|
|
case "missing_dates":
|
|
return "warn";
|
|
default:
|
|
return "secondary";
|
|
}
|
|
}
|
|
function formatDate(value: string) {
|
|
if (!value) return "—";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return date.toLocaleDateString("es-MX", { day: "2-digit", month: "short", year: "numeric" });
|
|
}
|
|
async function uploadDocument(event: { files: File[] }) {
|
|
const file = event.files?.[0];
|
|
if (!file || uploading.value) return;
|
|
uploading.value = true;
|
|
try {
|
|
const body = new FormData();
|
|
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");
|
|
} catch (error: unknown) {
|
|
toast.add({
|
|
severity: "error",
|
|
summary: error instanceof Error ? error.message : "No se pudo subir el archivo",
|
|
life: 3500,
|
|
});
|
|
} finally {
|
|
uploading.value = false;
|
|
}
|
|
}
|
|
function openDocument(row: DocumentRow) {
|
|
previewPath.value = `/v1/workers/${props.workerId}/documents/${row.id}`;
|
|
previewName.value = row.original_name;
|
|
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;
|
|
});
|
|
</script>
|