mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 20:23:17 +00:00
El ORDER BY del listado de personas refería la tabla w fuera del subquery (42P01). El frente del gafete ya no imprime Construcciones Arctec/Zendala: usa el nombre comercial de la empresa del proyecto. Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
356 lines
12 KiB
Vue
356 lines
12 KiB
Vue
<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 { user } = useAuth();
|
||
const projects = ref<{ id: number; name: string; code?: string; company_name?: 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",
|
||
companyName: (project.value?.company_name || user.value?.company_name || "").toUpperCase(),
|
||
projectName: (project.value?.name || "Obra").toUpperCase(),
|
||
projectCode: (project.value?.code || "").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; company_name?: 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>
|