panels-origin/web-panel/components/padron/WorkerDocuments.vue
Cursor Agent 17d9393b9e
fix(padron): vista previa inline, eliminar y reemplazar documentos
- Sirve documentos con Content-Type real e inline por defecto (query disposition)
- DocumentPreviewDialog reconstruye blob con MIME correcto para PDF/imagen
- RPC y DELETE para eliminar documentos del expediente; botón en UI
- Permiso IAM documents.delete; store archiva todas las versiones previas del tipo
- Limpia FileUpload tras subir; is_current robusto en listas vigente/histórico

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-16 21:11:11 +00:00

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>