panels-origin/web-panel/pages/gafetes.vue
2026-08-25 20:21:49 -05:00

354 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div>
<Toolbar>
<template #start>
<Select
v-model="workerId"
:options="workerOptions"
option-label="label"
option-value="id"
placeholder="Persona"
@change="onWorker"
/>
</template>
<template #end>
<SelectButton v-model="view" :options="viewOpts" option-label="label" option-value="value" />
</template>
</Toolbar>
<Tabs value="preview" class="mt-3">
<TabList>
<Tab value="preview">Vista previa</Tab>
<Tab value="jobs">Trabajos PDF</Tab>
</TabList>
<TabPanels>
<TabPanel value="preview">
<Toolbar>
<template #start>
<SelectButton
v-model="riskCode"
:options="risks"
option-label="label"
option-value="code"
:allow-empty="false"
/>
</template>
</Toolbar>
<Message severity="secondary" :closable="false" class="mt-3 mb-3">
El reverso en el PDF va girado 180° para el doblez de la carta. En “Hoja” se ve como sale a impresión.
</Message>
<div v-if="view === 'cards'" class="badge-pair">
<div>
<Tag value="Frente" class="mb-2" />
<BadgeCard v-bind="cardProps" side="front" />
</div>
<div>
<Tag value="Reverso (legible)" severity="info" class="mb-2" />
<BadgeCard v-bind="cardProps" side="back" />
</div>
<div>
<Tag value="Reverso impreso" severity="warn" class="mb-2" />
<BadgeCard v-bind="cardProps" side="back" rotated />
</div>
</div>
<div v-else class="letter-wrap">
<div class="letter-caption muted">Carta 11 × 8.5 in · 4 arriba (frente) · 4 abajo (reverso 180°)</div>
<div class="letter-sheet">
<div class="letter-row">
<BadgeCard v-for="n in 4" :key="'f'+n" v-bind="cardProps" side="front" fluid />
</div>
<div class="letter-row">
<BadgeCard v-for="n in 4" :key="'b'+n" v-bind="cardProps" side="back" rotated fluid />
</div>
</div>
</div>
</TabPanel>
<TabPanel value="jobs">
<div class="list-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar trabajo, proyecto o fecha">
<Chip :label="`${listedJobs.length} trabajos`" icon="pi pi-file-pdf" />
<Chip :label="`${pendingJobs} por entregar`" icon="pi pi-clock" />
</DeskTableBar>
<DataTable :value="listedJobs" context-menu paginator :rows="15" :rows-per-page-options="[15, 25, 50, 100]" empty-message="Aún no hay jobs" @row-contextmenu="onCtx">
<Column field="id" header="#" />
<Column field="project_name" header="Proyecto" />
<Column field="created_at" header="Fecha" />
<Column header="Personas">
<template #body="{ data }"><Badge :value="data.people" /></template>
</Column>
<Column header="Entregados">
<template #body="{ data }">
<Tag :value="String(data.delivered)" :severity="data.delivered ? 'success' : 'secondary'" />
</template>
</Column>
<Column header="">
<template #body="{ data }">
<ButtonGroup>
<Button label="PDF" icon="pi pi-file-pdf" size="small" outlined @click="dl(data.id)" />
<Button label="Entrega" icon="pi pi-check" size="small" @click="openPeople(data.id)" />
</ButtonGroup>
</template>
</Column>
</DataTable>
</div>
</TabPanel>
</TabPanels>
</Tabs>
<ContextMenu ref="cm" :model="[{ label: 'Descargar PDF', icon: 'pi pi-download', command: () => ctx && dl(ctx.id) }]" />
<Dialog v-model:visible="showPeople" header="Control de entrega" modal :style="{ width: '640px' }">
<DeskTableBar v-model="peopleQ" placeholder="Buscar persona">
<Chip :label="`${listedPeople.length} personas`" icon="pi pi-users" />
</DeskTableBar>
<DataTable :value="listedPeople" class="mt-3">
<Column field="last_name_p" header="Apellido" />
<Column field="first_name" header="Nombre" />
<Column header="Entregado">
<template #body="{ data }">
<ToggleSwitch :model-value="!!data.delivered" @update:model-value="(v: boolean) => mark(data, v)" />
</template>
</Column>
</DataTable>
</Dialog>
</div>
</template>
<script setup lang="ts">
type Worker = {
id: number;
first_name: string;
middle_name?: string | null;
last_name_p: string;
last_name_m: string;
curp: string;
nss: string;
blood_type?: string | null;
position: string;
risk_code: string;
risk_color: string;
risk_text?: string;
pipeline_status?: string;
};
const SAMPLE: Worker = {
id: 0,
first_name: "Juan",
last_name_p: "Pérez",
last_name_m: "López",
curp: "PELJ850101HDFRRN09",
nss: "12345678901",
blood_type: "O+",
position: "Ayudante general",
risk_code: "rojo",
risk_color: "#A20000",
risk_text: "#FFFFFF",
};
const { api, download } = useApi();
const desk = useDesktop();
const toast = useToast();
const projects = ref<{ id: number; name: string; code?: string }[]>([]);
const projectId = ref<number | null>(null);
const workers = ref<Worker[]>([]);
const workerId = ref(0);
const risks = ref<{ code: string; label: string; color: string; text_color: string }[]>([]);
const riskCode = ref("rojo");
const view = ref<"cards" | "sheet">("cards");
const viewOpts = [
{ label: "Frente y reverso", value: "cards" },
{ label: "Hoja carta", value: "sheet" },
];
const jobs = ref<Record<string, unknown>[]>([]);
const cm = ref();
const ctx = ref<{ id: number } | null>(null);
const showPeople = ref(false);
const people = ref<Record<string, unknown>[]>([]);
const jobId = ref(0);
const { tableQ, matches } = useTableSearch();
const peopleQ = ref("");
const listedJobs = computed(() =>
jobs.value.filter((job) => matches(job.id, job.project_name, job.created_at, job.people, job.delivered)),
);
const pendingJobs = computed(() => listedJobs.value.filter((job) => !job.delivered).length);
const listedPeople = computed(() => {
const q = peopleQ.value.trim().toLowerCase();
if (!q) return people.value;
return people.value.filter((row) =>
[row.last_name_p, row.first_name].some((part) => String(part ?? "").toLowerCase().includes(q)),
);
});
const workerOptions = computed(() => [
{ id: 0, label: "Muestra de diseño" },
...workers.value.map((w) => ({ id: w.id, label: `${w.last_name_p} ${w.first_name}` })),
]);
const project = computed(() => projects.value.find((p) => p.id === projectId.value));
const selected = computed(() => workers.value.find((w) => w.id === workerId.value) || SAMPLE);
const risk = computed(() => risks.value.find((r) => r.code === riskCode.value));
const cardProps = computed(() => {
const w = selected.value;
const r = risk.value;
return {
firstName: w.first_name.toUpperCase(),
lastName: w.last_name_p.toUpperCase(),
full: [w.first_name, w.middle_name, w.last_name_p, w.last_name_m].filter(Boolean).join(" ").toUpperCase(),
position: w.position.toUpperCase(),
curp: w.curp,
nss: w.nss,
blood: (w.blood_type || "").toUpperCase(),
riskColor: r?.color || w.risk_color || "#A20000",
riskText: r?.text_color || w.risk_text || "#FFFFFF",
projectName: (project.value?.name || "Zendala Cancún").toUpperCase(),
projectCode: (project.value?.code || "PRY-0001").toUpperCase(),
photoSrc: w.id ? `/v1/workers/${w.id}/photo` : "",
qrSrc: `/v1/badge-qr?curp=${encodeURIComponent(w.curp)}`,
};
});
function onCtx(e: { originalEvent: Event; data: { id: number } }) {
ctx.value = e.data;
cm.value.show(e.originalEvent);
}
function onWorker() {
const w = workers.value.find((x) => x.id === workerId.value);
if (w?.risk_code) riskCode.value = w.risk_code;
}
async function onProject() {
workerId.value = 0;
await loadWorkers();
}
async function loadWorkers() {
if (!projectId.value) {
workers.value = [];
return;
}
const w = await api<{ workers: Worker[] }>(`/v1/workers?status=activo&project_id=${projectId.value}`);
workers.value = w.workers;
}
async function load() {
const [p, j, c] = await Promise.all([
api<{ projects: { id: number; name: string; code?: string }[] }>("/v1/projects?status=activo"),
api<{ jobs: Record<string, unknown>[] }>("/v1/badge-jobs"),
api<{ risks: { code: string; label: string; color: string; text_color: string }[] }>("/v1/catalogs"),
]);
projects.value = p.projects;
if (!projectId.value && p.projects[0]) projectId.value = p.projects[0].id;
jobs.value = j.jobs;
risks.value = c.risks;
if (c.risks[0] && !risks.value.some((r) => r.code === riskCode.value)) riskCode.value = c.risks[0].code;
await loadWorkers();
syncChrome();
}
function syncChrome() {
const ready = workers.value.filter((w) => w.pipeline_status === "listo_gafete").length;
const messages = [];
if (ready > 0) {
messages.push({
key: "gaf_listos",
severity: "warn" as const,
title: `${ready} personas listas para gafete`,
detail: "Selecciona persona y genera PDF de impresión.",
route: "/gafetes",
});
}
if (pendingJobs.value > 0) {
messages.push({
key: "gaf_pendientes",
severity: "info" as const,
title: `${pendingJobs.value} trabajos PDF por entregar`,
detail: "Marca entrega cuando el gafete llegue a obra.",
route: "/gafetes",
});
}
desk.setStatusMessages(messages);
desk.configure({
tab: "Gafetes",
treeTitle: "Proyectos",
showTree: true,
showDetail: false,
treeKey: projectId.value ? `p:${projectId.value}` : "",
countLabel: project.value ? String(project.value.name) : "",
onTreeSelect: (key) => {
if (!key.startsWith("p:")) return;
projectId.value = Number(key.slice(2));
onProject();
},
tree: projects.value.map((p) => ({
key: `p:${p.id}`,
label: p.name,
icon: "pi pi-folder",
})),
ribbon: [
{
key: "acciones",
label: "Gafete",
items: [{ key: "print", label: "Generar", icon: "pi pi-print", command: generate }],
},
{
key: "vista",
label: "Vista",
items: [{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: load }],
},
],
});
}
async function generate() {
if (!projectId.value) {
toast.add({ severity: "warn", summary: "Seleccione un proyecto en el árbol", life: 2500 });
return;
}
try {
const r = await api<{ id: number; count: number }>(`/v1/projects/${projectId.value}/badge-jobs`, {
method: "POST",
json: {},
});
toast.add({ severity: "success", summary: `PDF ${r.id} (${r.count} gafetes)`, life: 2500 });
await load();
dl(r.id);
} catch (error: unknown) {
toast.add({
severity: "error",
summary: error instanceof Error ? error.message : "No se pudieron generar los gafetes",
life: 3500,
});
}
}
function dl(id: number) {
download(`/v1/badge-jobs/${id}/pdf`, `gafetes-${id}.pdf`).then(() => {
toast.add({ severity: "success", summary: `Descargado gafetes-${id}.pdf`, detail: "Ábrelo con el visor de PDF del equipo", life: 3500 });
}).catch((e: unknown) => {
toast.add({ severity: "error", summary: e instanceof Error ? e.message : "No se pudo descargar el PDF", life: 3500 });
});
}
async function openPeople(id: number) {
jobId.value = id;
const r = await api<{ people: Record<string, unknown>[] }>(`/v1/badge-jobs/${id}`);
people.value = r.people;
showPeople.value = true;
}
async function mark(row: Record<string, unknown>, delivered: boolean) {
await api(`/v1/badge-jobs/${jobId.value}/people/${row.worker_id}`, {
method: "PATCH",
json: { delivered },
});
await openPeople(jobId.value);
await load();
}
onMounted(load);
onBeforeUnmount(() => desk.clearStatusMessages());
</script>