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

366 lines
16 KiB
Vue

<template>
<div class="ficha worker-detail">
<div v-if="!workerId" class="detail-empty">
<i class="pi pi-file detail-empty-icon" />
<strong>Seleccione un registro</strong>
<span>La ficha aparecerá en este panel.</span>
</div>
<div v-else-if="loading" class="detail-loading"><ProgressSpinner /></div>
<div v-else-if="data" class="ficha-body worker-record">
<div class="worker-record-code">{{ recordCode }}</div>
<div class="worker-photo worker-record-photo">
<img v-if="photoOk" :src="photoSrc" :alt="title" @error="photoOk = false" />
<Avatar v-else :label="initials(data.worker.first_name, data.worker.last_name_p)" shape="circle" size="xlarge" />
</div>
<div class="worker-record-lead">
<div class="worker-detail-name">{{ title }}</div>
<div class="muted">{{ data.worker.position }}</div>
</div>
<div class="profile-list worker-record-fields">
<div v-for="row in recordRows" :key="row.label">
<span>{{ row.label }}</span>
<strong :class="{ identifier: row.identifier }">{{ row.value }}</strong>
</div>
</div>
<div class="worker-record-status">
<span>Estatus</span>
<div class="ficha-tags">
<Tag :value="pipelineLabel(data.worker.pipeline_status)" :severity="pipelineSeverity(data.worker.pipeline_status)" />
<Tag :value="riskLevelLabel(data.worker.risk_code)" :severity="riskLevelSeverity(data.worker.risk_code)" />
<Tag v-if="empresaTag" :value="empresaTag" :severity="hireSeverity(empresaTag)" />
<Tag v-else value="Sin patrón" severity="secondary" />
<Tag :value="imssStatusLabel" :severity="imssStatusSeverity" />
</div>
</div>
<div class="worker-imss-block">
<div class="worker-imss-head">
<div class="worker-imss-title">IMSS</div>
<div class="worker-imss-actions">
<Button
v-if="data.worker.imss_status !== 'alta'"
label="Registrar alta"
icon="pi pi-file-pdf"
size="small"
@click="openImss('alta')"
/>
<template v-else>
<Button
label="Reemplazar alta"
icon="pi pi-upload"
size="small"
outlined
@click="openImss('alta')"
/>
<Button
label="Registrar baja"
icon="pi pi-times-circle"
size="small"
severity="warn"
outlined
@click="openImss('baja')"
/>
</template>
<Button
label="Historial"
icon="pi pi-history"
size="small"
text
@click="openImss('historial')"
/>
</div>
</div>
<div class="profile-list worker-record-fields">
<div><span>Estado</span><strong>{{ imssStatusLabel }}</strong></div>
<div v-if="data.worker.imss_status === 'alta'">
<span>Alta desde</span>
<strong>{{ formatDay(data.worker.imss_alta_at) }}</strong>
</div>
<div v-if="data.worker.imss_status === 'baja_imss' && data.worker.imss_baja_at">
<span>Baja desde</span>
<strong>{{ formatDay(data.worker.imss_baja_at) }}</strong>
</div>
<div v-if="data.worker.imss_company_name || data.worker.imss_company_code">
<span>Patrón</span>
<strong>{{ data.worker.imss_company_name || data.worker.imss_company_code }}</strong>
</div>
<div v-if="data.worker.imss_registro_patronal">
<span>Registro patronal</span>
<strong class="identifier">{{ data.worker.imss_registro_patronal }}</strong>
</div>
</div>
</div>
<Button label="Editar registro" icon="pi pi-pencil" outlined class="w-full worker-record-edit" @click="emit('edit')" />
<div class="worker-mini-stats">
<div><strong>{{ requiredDone }}/{{ requiredItems.length }}</strong><span>Documentos</span></div>
<div><strong>{{ activeAssignments }}</strong><span>Proyectos</span></div>
<div><strong>{{ money(loanBalance) }}</strong><span>Préstamos</span></div>
</div>
<Tabs v-model:value="tab" class="mt-3 detail-tabs">
<TabList>
<Tab value="docs">Expediente</Tab>
<Tab value="proyectos">Proyectos</Tab>
<Tab value="nomina">Nómina</Tab>
</TabList>
<TabPanels>
<TabPanel value="docs">
<WorkerDocuments
:worker-id="workerId"
:documents="data.documents"
:checklist="data.checklist"
:companies="companies"
:initial-type="doc"
:freshness-required="data.freshness_required || data.worker.freshness_required"
:imss-ready="data.worker.imss_ready"
:in-project-without-imss="data.worker.in_project_without_imss"
@changed="refreshRelated"
@register-alta="openImss('alta')"
/>
</TabPanel>
<TabPanel value="proyectos">
<WorkerAssignments
:worker-id="workerId"
:assignments="data.assignments"
:projects="projects"
@changed="refreshRelated"
/>
</TabPanel>
<TabPanel value="nomina">
<Message :closable="false" class="nomina-jornal-msg">Jornal {{ money(Number(data.worker.daily_wage)) }} · {{ data.worker.work_type === "D" ? "Destajo" : "Normal" }}</Message>
<Fieldset legend="Registrar préstamo" class="nomina-loan-fieldset">
<FormField label="Monto a entregar">
<InputNumber v-model="loanAmount" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
</FormField>
<div class="loan-switch">
<ToggleSwitch v-model="loanCommissionOn" />
<span>Comisión {{ loanCommissionPct }}%</span>
</div>
<FormField v-if="loanCommissionOn" label="Porcentaje" class="mt-2">
<InputNumber v-model="loanCommissionPct" suffix=" %" :min="0" :max="100" :max-fraction-digits="2" class="w-full" />
</FormField>
<Message v-if="loanIsSmall" severity="secondary" :closable="false" class="mt-2">
Préstamo chico (tope {{ money(loanSmallMax) }}): 1 exhibición el sábado de la semana siguiente. Recibe {{ money(loanAmount) }}; se descuenta {{ money(loanAmount + (loanCommissionOn ? loanAmount * loanCommissionPct / 100 : 0)) }}.
</Message>
<template v-else>
<FormField label="Plan" class="mt-2">
<Select v-model="loanPlan" :options="planOpts" option-label="label" option-value="value" class="w-full" />
</FormField>
<FormField v-if="loanPlan === 'installments'" label="Cuotas" class="mt-2">
<InputNumber v-model="loanInstallments" :min="2" :max="24" class="w-full" />
</FormField>
</template>
<Button label="Registrar préstamo" icon="pi pi-plus" class="w-full mt-2" :disabled="loanAmount <= 0" @click="addLoan" />
</Fieldset>
<DataTable :value="data.loans" size="small" class="mt-3" empty-message="Sin préstamos">
<Column header="Entregado"><template #body="{ data: row }">{{ money(Number(row.delivered || row.amount)) }}</template></Column>
<Column header="Saldo"><template #body="{ data: row }">{{ money(Number(row.balance)) }}</template></Column>
<Column header="Vence"><template #body="{ data: row }">{{ row.first_due || "—" }}</template></Column>
<Column header="">
<template #body="{ data: row }">
<Button icon="pi pi-file-pdf" text rounded size="small" aria-label="Recibo" @click="downloadLoan(row.id)" />
</template>
</Column>
</DataTable>
</TabPanel>
</TabPanels>
</Tabs>
<AltaImssDialog
v-if="workerId"
v-model="showAltaImss"
:worker-id="workerId"
:companies="companies"
:documents="data.documents"
:default-company-id="data.worker.company_id"
:imss-status="data.worker.imss_status"
:initial-tab="imssDialogTab"
@saved="onImssSaved"
/>
</div>
</div>
</template>
<script setup lang="ts">
import WorkerDocuments from "~/components/padron/WorkerDocuments.vue";
import WorkerAssignments from "~/components/padron/WorkerAssignments.vue";
import AltaImssDialog from "~/components/padron/AltaImssDialog.vue";
import FormField from "~/components/padron/FormField.vue";
import type { ChecklistItem, CompanyOption, ProjectOption, Worker } from "~/types/padron";
type DetailData = {
worker: Worker & { full_name: string };
documents: {
id: number;
type_code: string;
original_name: string;
uploaded_at: string;
is_current: number;
mime?: string;
issued_at?: string | null;
expires_at?: string | null;
imss_company_id?: number | null;
imss_alta_at?: string | null;
}[];
assignments: { id: number; project_id: number; project_name: string; project_code: string; active: number; start_date: string; end_date?: string | null }[];
loans: { id: number; amount: number; delivered?: number; balance: number; weekly_payment: number; first_due?: string | null }[];
checklist: ChecklistItem[];
freshness_required?: boolean;
};
const props = defineProps<{ workerId?: number; doc?: string }>();
const emit = defineEmits<{ edit: [] }>();
const { pipelineLabel, pipelineSeverity, initials, riskLevelLabel, riskLevelSeverity, hireSeverity } = useUi();
const { api, download } = useApi();
const toast = useToast();
const desk = useDesktop();
const data = ref<DetailData | null>(null);
const projects = ref<ProjectOption[]>([]);
const companies = ref<CompanyOption[]>([]);
const loading = ref(false);
const tab = ref("docs");
const photoOk = ref(true);
const showAltaImss = ref(false);
const imssDialogTab = ref<"alta" | "baja" | "historial">("alta");
const loanAmount = ref(0);
const loanCommissionOn = ref(true);
const loanCommissionPct = ref(10);
const loanSmallMax = ref(500);
const loanPlan = ref<"single" | "installments">("single");
const loanInstallments = ref(4);
const planOpts = [
{ label: "1 exhibición", value: "single" },
{ label: "Varias cuotas", value: "installments" },
];
const loanIsSmall = computed(() => loanAmount.value > 0 && loanAmount.value <= loanSmallMax.value);
const title = computed(() => data.value ? [data.value.worker.first_name, data.value.worker.middle_name, data.value.worker.last_name_p, data.value.worker.last_name_m].filter(Boolean).join(" ") : "Expediente");
const recordCode = computed(() => data.value ? `PER-${String(data.value.worker.id).padStart(4, "0")}` : "PER-0000");
const photoSrc = computed(() => `/v1/workers/${props.workerId}/photo?v=${desk.detailRev.value}`);
const requiredItems = computed(() => data.value?.checklist.filter((item) => item.required) || []);
const requiredDone = computed(() => requiredItems.value.filter((item) => item.present && item.valid !== false).length);
const activeAssignments = computed(() => data.value?.assignments.filter((assignment) => assignment.active).length || 0);
const activeProjectNames = computed(() => data.value?.assignments.filter((assignment) => assignment.active).map((assignment) => assignment.project_name).join(", ") || "Sin asignar");
const loanBalance = computed(() => data.value?.loans.reduce((sum, loan) => sum + Math.max(0, Number(loan.balance)), 0) || 0);
const imssStatusLabel = computed(() => {
const status = data.value?.worker.imss_status;
if (status === "alta") return "Alta IMSS";
if (status === "baja_imss") return "Baja IMSS";
return "Sin alta IMSS";
});
const imssStatusSeverity = computed(() => {
const status = data.value?.worker.imss_status;
if (status === "alta") return "success";
if (data.value?.worker.in_project_without_imss) return "danger";
return "warn";
});
const empresaTag = computed(() => {
const worker = data.value?.worker;
if (!worker || worker.imss_status !== "alta") return "";
return worker.imss_company_code || worker.imss_company_name || worker.company_name || worker.hire_type || "";
});
const recordRows = computed(() => {
const worker = data.value?.worker;
if (!worker) return [];
return [
{ label: "Empresa", value: empresaTag.value || "Sin patrón" },
{ label: "Proyecto", value: activeProjectNames.value },
{ label: "CURP", value: worker.curp, identifier: true },
{ label: "RFC", value: worker.rfc, identifier: true },
{ label: "NSS", value: worker.nss, identifier: true },
{ label: "Teléfono", value: worker.phone },
{ label: "Correo", value: worker.email },
{ label: "Dirección", value: worker.address },
{ label: "Tipo de sangre", value: worker.blood_type || "—" },
{ label: "Jornal", value: money(Number(worker.daily_wage)) },
];
});
function money(value: number) {
return value.toLocaleString("es-MX", { style: "currency", currency: "MXN", maximumFractionDigits: 0 });
}
function formatDay(value?: string | null) {
if (!value) return "—";
const date = new Date(`${String(value).slice(0, 10)}T12:00:00`);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString("es-MX", { day: "2-digit", month: "short", year: "numeric" });
}
async function load() {
if (!props.workerId) { data.value = null; return; }
loading.value = true;
try {
const [detail, projectResponse, companyResponse, payrollSettings] = await Promise.all([
api<DetailData>(`/v1/workers/${props.workerId}`),
api<{ projects: ProjectOption[] }>("/v1/projects?status=activo"),
api<{ companies: CompanyOption[] }>("/v1/companies"),
api<{ settings: { loan_commission_enabled: number; loan_commission_pct: number; loan_small_max: number } }>("/v1/payroll/settings"),
]);
data.value = detail;
projects.value = projectResponse.projects;
companies.value = companyResponse.companies.filter((c) => c.status === "activo");
photoOk.value = true;
loanCommissionOn.value = Boolean(payrollSettings.settings.loan_commission_enabled);
loanCommissionPct.value = Number(payrollSettings.settings.loan_commission_pct);
loanSmallMax.value = Number(payrollSettings.settings.loan_small_max);
} finally {
loading.value = false;
}
}
function refreshRelated() {
desk.bumpDetail();
}
function openImss(tab: "alta" | "baja" | "historial") {
imssDialogTab.value = tab;
showAltaImss.value = true;
}
function onImssSaved(kind: "alta" | "baja") {
toast.add({
severity: "success",
summary: kind === "baja" ? "Baja IMSS registrada" : "Alta IMSS registrada",
life: 2200,
});
refreshRelated();
}
async function addLoan() {
if (!props.workerId || loanAmount.value <= 0) return;
const created = await api<{ id: number }>("/v1/loans", {
method: "POST",
json: {
worker_id: props.workerId,
delivered: loanAmount.value,
commission_enabled: loanCommissionOn.value,
commission_pct: loanCommissionPct.value,
plan: loanIsSmall.value ? "single" : loanPlan.value,
installments_n: loanIsSmall.value ? 1 : loanInstallments.value,
},
});
loanAmount.value = 0;
toast.add({ severity: "success", summary: "Préstamo registrado", life: 1800 });
refreshRelated();
if (created.id) download(`/v1/loans/${created.id}/recibo`, `prestamo-aceptacion-${created.id}.pdf`);
}
function downloadLoan(id: number) {
download(`/v1/loans/${id}/recibo`, `prestamo-aceptacion-${id}.pdf`);
}
watch(() => [props.workerId, props.doc, desk.detailRev.value] as const, ([, doc]) => {
if (doc) tab.value = "docs";
load();
}, { immediate: true });
</script>
<style scoped>
.loan-switch {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
font-size: 0.9rem;
}
</style>