mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 10:43:18 +00:00
merge: #17 documentos sin fechas de vigencia
Añade 032-document-validity-from-upload junto a los demás 032. Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
commit
5e4d9a6710
8 changed files with 155 additions and 85 deletions
|
|
@ -82,18 +82,10 @@ export function evaluateDocumentValidity(
|
|||
|
||||
const issuedAt = doc.issued_at;
|
||||
const expiresAt = doc.expires_at;
|
||||
const uploadedAt = doc.uploaded_at;
|
||||
const needFresh = freshnessRequired(ctx);
|
||||
|
||||
if (policy.validity_mode === "expiry") {
|
||||
if (policy.requires_expires_at && !expiresAt) {
|
||||
return {
|
||||
present: true,
|
||||
valid: false,
|
||||
validity_status: "missing_dates",
|
||||
issued_at: issuedAt,
|
||||
expires_at: expiresAt,
|
||||
};
|
||||
}
|
||||
const exp = parseDay(expiresAt);
|
||||
const todayDate = parseDay(today);
|
||||
if (exp && todayDate && exp < todayDate) {
|
||||
|
|
@ -115,20 +107,11 @@ export function evaluateDocumentValidity(
|
|||
}
|
||||
|
||||
if (policy.validity_mode === "freshness") {
|
||||
if (policy.requires_issued_at && !issuedAt) {
|
||||
return {
|
||||
present: true,
|
||||
valid: false,
|
||||
validity_status: "missing_dates",
|
||||
issued_at: issuedAt,
|
||||
expires_at: expiresAt,
|
||||
};
|
||||
}
|
||||
if (needFresh && issuedAt) {
|
||||
const issued = parseDay(issuedAt);
|
||||
if (needFresh) {
|
||||
const uploaded = parseDay(uploadedAt);
|
||||
const days = policy.freshness_days ?? 90;
|
||||
const todayForFreshness = parseDay(today) ?? new Date();
|
||||
if (issued && daysBetween(issued, todayForFreshness) > days) {
|
||||
if (uploaded && daysBetween(uploaded, todayForFreshness) > days) {
|
||||
return {
|
||||
present: true,
|
||||
valid: false,
|
||||
|
|
@ -170,11 +153,11 @@ export type CatalogDocType = {
|
|||
/** Núcleo = required 1. El resto es catálogo: se sube si se tiene, no cuenta como faltante. */
|
||||
export const DOCUMENT_TYPE_SEED: CatalogDocType[] = [
|
||||
{ code: "foto", label: "Foto", required: 1, category: "identidad", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
|
||||
{ code: "ine", label: "INE", required: 1, category: "identidad", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 1 },
|
||||
{ code: "curp", label: "Constancia CURP", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 },
|
||||
{ code: "nss", label: "Constancia NSS", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 },
|
||||
{ code: "rfc", label: "Constancia RFC", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 },
|
||||
{ code: "domicilio", label: "Comprobante de domicilio", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 },
|
||||
{ code: "ine", label: "INE", required: 1, category: "identidad", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
|
||||
{ code: "curp", label: "Constancia CURP", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 0, requires_expires_at: 0 },
|
||||
{ code: "nss", label: "Constancia NSS", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 0, requires_expires_at: 0 },
|
||||
{ code: "rfc", label: "Constancia RFC", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 0, requires_expires_at: 0 },
|
||||
{ code: "domicilio", label: "Comprobante de domicilio", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 0, requires_expires_at: 0 },
|
||||
{ code: "alta_imss", label: "Alta IMSS", required: 0, category: "imss", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
|
||||
{ code: "baja_imss", label: "Baja IMSS", required: 0, category: "imss", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
|
||||
{ code: "contrato", label: "Contrato laboral", required: 0, category: "laboral", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const curpPolicy: DocTypePolicy = {
|
|||
required: true,
|
||||
validity_mode: "freshness",
|
||||
freshness_days: 90,
|
||||
requires_issued_at: true,
|
||||
requires_issued_at: false,
|
||||
requires_expires_at: false,
|
||||
};
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ const inePolicy: DocTypePolicy = {
|
|||
validity_mode: "expiry",
|
||||
freshness_days: null,
|
||||
requires_issued_at: false,
|
||||
requires_expires_at: true,
|
||||
requires_expires_at: false,
|
||||
};
|
||||
|
||||
Deno.test("freshnessRequired: sin alta exige frescor", () => {
|
||||
|
|
@ -69,7 +69,7 @@ Deno.test("freshnessRequired: rehire con alta posterior no exige frescor", () =>
|
|||
);
|
||||
});
|
||||
|
||||
Deno.test("CURP stale when freshness required", () => {
|
||||
Deno.test("CURP stale when freshness required and upload is older than 90 days", () => {
|
||||
const ctx: WorkerImssContext = {
|
||||
imss_status: "sin_alta",
|
||||
last_rehire_at: null,
|
||||
|
|
@ -85,6 +85,22 @@ Deno.test("CURP stale when freshness required", () => {
|
|||
assertEquals(result.valid, false);
|
||||
});
|
||||
|
||||
Deno.test("CURP recent upload is fresh even if issued_at is old", () => {
|
||||
const ctx: WorkerImssContext = {
|
||||
imss_status: "sin_alta",
|
||||
last_rehire_at: null,
|
||||
current_alta_at: null,
|
||||
};
|
||||
const result = evaluateDocumentValidity(
|
||||
curpPolicy,
|
||||
{ type_code: "curp", issued_at: "2024-01-01", expires_at: null, uploaded_at: "2026-08-01" },
|
||||
ctx,
|
||||
"2026-08-20",
|
||||
);
|
||||
assertEquals(result.validity_status, "ok");
|
||||
assertEquals(result.valid, true);
|
||||
});
|
||||
|
||||
Deno.test("CURP old but ok when already alta", () => {
|
||||
const ctx: WorkerImssContext = {
|
||||
imss_status: "alta",
|
||||
|
|
@ -117,13 +133,26 @@ Deno.test("INE expired always invalid", () => {
|
|||
assertEquals(result.valid, false);
|
||||
});
|
||||
|
||||
Deno.test("missing issued_at for freshness", () => {
|
||||
Deno.test("freshness without issued_at uses uploaded_at", () => {
|
||||
const result = evaluateDocumentValidity(
|
||||
curpPolicy,
|
||||
{ type_code: "curp", issued_at: null, expires_at: null, uploaded_at: "2026-08-01" },
|
||||
{ imss_status: "sin_alta", last_rehire_at: null, current_alta_at: null },
|
||||
"2026-08-20",
|
||||
);
|
||||
assertEquals(result.validity_status, "missing_dates");
|
||||
assertEquals(result.validity_status, "ok");
|
||||
assertEquals(result.valid, true);
|
||||
});
|
||||
|
||||
Deno.test("INE without expires_at is valid; reviewer checks the file", () => {
|
||||
const result = evaluateDocumentValidity(
|
||||
inePolicy,
|
||||
{ type_code: "ine", issued_at: null, expires_at: null, uploaded_at: "2026-08-01" },
|
||||
{ imss_status: "sin_alta", last_rehire_at: null, current_alta_at: null },
|
||||
"2026-08-20",
|
||||
);
|
||||
assertEquals(result.validity_status, "ok");
|
||||
assertEquals(result.valid, true);
|
||||
});
|
||||
|
||||
Deno.test("núcleo de persona no se agranda", () => {
|
||||
|
|
|
|||
13
api/main.ts
13
api/main.ts
|
|
@ -1213,11 +1213,7 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, requirePermission("doc
|
|||
{},
|
||||
{ route },
|
||||
);
|
||||
const policy = (catEnv.data?.document_types ?? []).find((t) => String(t.code) === type) as {
|
||||
validity_mode: string;
|
||||
requires_issued_at: boolean;
|
||||
requires_expires_at: boolean;
|
||||
} | undefined;
|
||||
const policy = (catEnv.data?.document_types ?? []).find((t) => String(t.code) === type);
|
||||
if (!policy) {
|
||||
return respondApiError(c, "VALIDATION", `Tipo de documento no válido en ${route}: ${type}`, {
|
||||
route,
|
||||
|
|
@ -1231,13 +1227,6 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, requirePermission("doc
|
|||
const imssCompanyId = Number(form.get("imss_company_id") || 0) || null;
|
||||
const imssAltaAt = String(form.get("imss_alta_at") || "").trim() || null;
|
||||
const imssBajaAt = String(form.get("imss_baja_at") || "").trim() || null;
|
||||
|
||||
if (policy.requires_issued_at && !issuedAt) {
|
||||
return respondApiError(c, "VALIDATION", `Indique la fecha de emisión del documento en ${route}`, { route, worker_id: id });
|
||||
}
|
||||
if (policy.requires_expires_at && !expiresAt) {
|
||||
return respondApiError(c, "VALIDATION", `Indique la fecha de vigencia / vencimiento en ${route}`, { route, worker_id: id });
|
||||
}
|
||||
if (type === "alta_imss" && !imssCompanyId) {
|
||||
return respondApiError(c, "VALIDATION", `Seleccione la empresa patrón del alta IMSS en ${route}`, { route, worker_id: id });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
<include file="changesets/031-project-doc-types-technical.sql" relativeToChangelogFile="true"/>
|
||||
<include file="changesets/032-rpc-document-get-id.sql" relativeToChangelogFile="true"/>
|
||||
<include file="changesets/032-fix-work-program-jsonb-object-length.sql" relativeToChangelogFile="true"/>
|
||||
<include file="changesets/032-document-validity-from-upload.sql" relativeToChangelogFile="true"/>
|
||||
<include file="changesets/033-catalog-suppliers-units.sql" relativeToChangelogFile="true"/>
|
||||
<include file="changesets/034-fn-expense-update-reclassify.sql" relativeToChangelogFile="true"/>
|
||||
<include file="changesets/035-warehouse-materials-catalog.sql" relativeToChangelogFile="true"/>
|
||||
|
|
|
|||
98
db/core/changesets/032-document-validity-from-upload.sql
Normal file
98
db/core/changesets/032-document-validity-from-upload.sql
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
--liquibase formatted sql
|
||||
-- PANELS · core · vigencia documental desde la fecha de carga
|
||||
|
||||
--changeset panel:core-032a-doc-types-no-required-dates endDelimiter:; splitStatements:true
|
||||
UPDATE core.document_types
|
||||
SET requires_issued_at = false,
|
||||
requires_expires_at = false;
|
||||
|
||||
--changeset panel:core-032b-fn-worker-eval-doc-validity splitStatements:false
|
||||
CREATE OR REPLACE FUNCTION core._worker_eval_doc_validity(
|
||||
p_policy jsonb,
|
||||
p_doc jsonb,
|
||||
p_ctx jsonb,
|
||||
p_today date
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_mode text := COALESCE(p_policy->>'validity_mode', 'none');
|
||||
v_issued date;
|
||||
v_expires date;
|
||||
v_uploaded date;
|
||||
v_need_fresh boolean;
|
||||
v_days integer;
|
||||
BEGIN
|
||||
IF p_doc IS NULL THEN
|
||||
RETURN jsonb_build_object(
|
||||
'present', false,
|
||||
'valid', false,
|
||||
'validity_status', 'missing',
|
||||
'issued_at', NULL,
|
||||
'expires_at', NULL
|
||||
);
|
||||
END IF;
|
||||
v_issued := NULLIF(p_doc->>'issued_at', '')::date;
|
||||
v_expires := NULLIF(p_doc->>'expires_at', '')::date;
|
||||
BEGIN
|
||||
v_uploaded := COALESCE(
|
||||
NULLIF(left(p_doc->>'uploaded_at', 10), '')::date,
|
||||
NULL
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_uploaded := NULL;
|
||||
END;
|
||||
v_need_fresh := core._worker_freshness_required(p_ctx);
|
||||
IF v_mode = 'expiry' THEN
|
||||
IF v_expires IS NOT NULL AND v_expires < p_today THEN
|
||||
RETURN jsonb_build_object(
|
||||
'present', true,
|
||||
'valid', false,
|
||||
'validity_status', 'expired',
|
||||
'issued_at', v_issued,
|
||||
'expires_at', v_expires
|
||||
);
|
||||
END IF;
|
||||
RETURN jsonb_build_object(
|
||||
'present', true,
|
||||
'valid', true,
|
||||
'validity_status', 'ok',
|
||||
'issued_at', v_issued,
|
||||
'expires_at', v_expires
|
||||
);
|
||||
END IF;
|
||||
IF v_mode = 'freshness' THEN
|
||||
IF v_need_fresh AND v_uploaded IS NOT NULL THEN
|
||||
v_days := COALESCE((p_policy->>'freshness_days')::integer, 90);
|
||||
IF (p_today - v_uploaded) > v_days THEN
|
||||
RETURN jsonb_build_object(
|
||||
'present', true,
|
||||
'valid', false,
|
||||
'validity_status', 'stale_for_alta',
|
||||
'issued_at', v_issued,
|
||||
'expires_at', v_expires
|
||||
);
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN jsonb_build_object(
|
||||
'present', true,
|
||||
'valid', true,
|
||||
'validity_status', 'ok',
|
||||
'issued_at', v_issued,
|
||||
'expires_at', v_expires
|
||||
);
|
||||
END IF;
|
||||
RETURN jsonb_build_object(
|
||||
'present', true,
|
||||
'valid', true,
|
||||
'validity_status', 'ok',
|
||||
'issued_at', v_issued,
|
||||
'expires_at', v_expires
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-032c-grants endDelimiter:; splitStatements:true
|
||||
GRANT EXECUTE ON FUNCTION core._worker_eval_doc_validity(jsonb, jsonb, jsonb, date) TO panels_core_app;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="detail-section">
|
||||
<Message v-if="freshnessRequired" severity="warn" :closable="false" class="mb-3">
|
||||
Reingreso o pendiente de alta IMSS: documentos CURP, NSS, RFC y domicilio deben tener emisión de máximo 3 meses.
|
||||
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.
|
||||
|
|
@ -60,12 +60,6 @@
|
|||
class="w-full"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField v-if="selectedPolicy?.requires_issued_at || selectedPolicy?.validity_mode === 'freshness'" label="Fecha de emisión" class="mt-2">
|
||||
<DatePicker v-model="issuedAt" date-format="yy-mm-dd" show-icon class="w-full" />
|
||||
</FormField>
|
||||
<FormField v-if="selectedPolicy?.requires_expires_at || selectedPolicy?.validity_mode === 'expiry'" label="Vigencia / vencimiento" class="mt-2">
|
||||
<DatePicker v-model="expiresAt" date-format="yy-mm-dd" show-icon class="w-full" />
|
||||
</FormField>
|
||||
<FileUpload
|
||||
mode="basic"
|
||||
:auto="true"
|
||||
|
|
@ -73,7 +67,7 @@
|
|||
accept=".jpg,.jpeg,.png,.pdf,.webp"
|
||||
custom-upload
|
||||
class="mt-2"
|
||||
:disabled="uploading || !canUpload"
|
||||
:disabled="uploading"
|
||||
@uploader="uploadDocument"
|
||||
/>
|
||||
<div class="muted mt-2">{{ uploadHint }}</div>
|
||||
|
|
@ -173,8 +167,6 @@ const documentType = ref(
|
|||
);
|
||||
const uploading = ref(false);
|
||||
const docsTab = ref("current");
|
||||
const issuedAt = ref<Date | null>(null);
|
||||
const expiresAt = ref<Date | null>(null);
|
||||
const previewOpen = ref(false);
|
||||
const previewPath = ref("");
|
||||
const previewName = ref("");
|
||||
|
|
@ -212,17 +204,11 @@ const freshnessRequired = computed(() => !!props.freshnessRequired);
|
|||
const imssReady = computed(() => !!props.imssReady);
|
||||
const inProjectWithoutImss = computed(() => !!props.inProjectWithoutImss);
|
||||
|
||||
const canUpload = computed(() => {
|
||||
if (selectedPolicy.value?.requires_issued_at && !issuedAt.value) return false;
|
||||
if (selectedPolicy.value?.requires_expires_at && !expiresAt.value) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const uploadHint = computed(() => {
|
||||
if (selectedPolicy.value?.requires_issued_at) return "Indica la fecha de emisión del documento (máx. 3 meses si aplica alta/reingreso).";
|
||||
if (selectedPolicy.value?.requires_expires_at) return "Indica la vigencia impresa en la INE.";
|
||||
if (selectedPolicy.value?.validity_mode === "expiry") return "Si el documento tiene vencimiento, indícalo. No es obligatorio para operar.";
|
||||
return "Elige el tipo y el archivo se sube solo. El catálogo extra no bloquea gafete ni alta IMSS.";
|
||||
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) {
|
||||
|
|
@ -243,7 +229,7 @@ function validityLabel(status?: ValidityStatus | string) {
|
|||
case "stale_for_alta":
|
||||
return "Desactualizado para alta";
|
||||
case "missing_dates":
|
||||
return "Sin fecha";
|
||||
return "Por refrescar";
|
||||
default:
|
||||
return status || "—";
|
||||
}
|
||||
|
|
@ -268,29 +254,17 @@ function formatDate(value: string) {
|
|||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleDateString("es-MX", { day: "2-digit", month: "short", year: "numeric" });
|
||||
}
|
||||
function toIsoDate(value: Date | null) {
|
||||
if (!value) return "";
|
||||
const y = value.getFullYear();
|
||||
const m = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(value.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
async function uploadDocument(event: { files: File[] }) {
|
||||
const file = event.files?.[0];
|
||||
if (!file || uploading.value || !canUpload.value) return;
|
||||
if (!file || uploading.value) return;
|
||||
uploading.value = true;
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append("type", documentType.value);
|
||||
body.append("file", file);
|
||||
if (issuedAt.value) body.append("issued_at", toIsoDate(issuedAt.value));
|
||||
if (expiresAt.value) body.append("expires_at", toIsoDate(expiresAt.value));
|
||||
await api(`/v1/workers/${props.workerId}/documents`, { method: "POST", body });
|
||||
toast.add({ severity: "success", summary: "Documento cifrado y guardado", life: 2200 });
|
||||
docsTab.value = "current";
|
||||
issuedAt.value = null;
|
||||
expiresAt.value = null;
|
||||
emit("changed");
|
||||
} catch (error: unknown) {
|
||||
toast.add({
|
||||
|
|
@ -312,8 +286,4 @@ function openDocument(row: DocumentRow) {
|
|||
watch(() => props.initialType, (value) => {
|
||||
if (value && value !== "alta_imss" && value !== "baja_imss") documentType.value = value;
|
||||
});
|
||||
watch(documentType, () => {
|
||||
issuedAt.value = null;
|
||||
expiresAt.value = null;
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -406,7 +406,7 @@ function buildHomeAlerts(input: {
|
|||
key: "home_frescos",
|
||||
severity: "warn",
|
||||
title: `${freshness} con documentos por refrescar`,
|
||||
detail: "Reingreso o alta pendiente: constancias con más de 3 meses.",
|
||||
detail: "Reingreso o alta pendiente: constancias subidas hace más de 3 meses.",
|
||||
route: "/padron",
|
||||
filter: "freshness",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -265,7 +265,7 @@ function baja(worker: Worker) {
|
|||
function reactivate(worker: Worker) {
|
||||
confirm.require({
|
||||
header: "Reactivar persona",
|
||||
message: `¿Reactivar a ${worker.first_name} ${worker.last_name_p}? Se pedirán documentos frescos (máx. 3 meses) para una nueva alta IMSS.`,
|
||||
message: `¿Reactivar a ${worker.first_name} ${worker.last_name_p}? Se pedirán documentos subidos en los últimos 3 meses para una nueva alta IMSS.`,
|
||||
icon: "pi pi-user-plus",
|
||||
acceptLabel: "Reactivar",
|
||||
rejectLabel: "Cancelar",
|
||||
|
|
@ -274,7 +274,7 @@ function reactivate(worker: Worker) {
|
|||
toast.add({
|
||||
severity: "info",
|
||||
summary: "Reingreso",
|
||||
detail: "Actualiza documentos IMSS (máx. 3 meses) antes del alta.",
|
||||
detail: "Vuelve a subir CURP, NSS, RFC y domicilio (máx. 3 meses desde la carga) antes del alta.",
|
||||
life: 4000,
|
||||
});
|
||||
await loadWorkers(worker.id);
|
||||
|
|
@ -310,7 +310,7 @@ function syncStatusMessages() {
|
|||
key: "docs_frescos",
|
||||
severity: "warn" as const,
|
||||
title: `${freshnessCount.value} con documentos por refrescar`,
|
||||
detail: "Reingreso o pendiente de alta: CURP, NSS, RFC o domicilio con más de 3 meses.",
|
||||
detail: "Reingreso o pendiente de alta: CURP, NSS, RFC o domicilio subidos hace más de 3 meses.",
|
||||
route: "/padron",
|
||||
filter: "freshness",
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue