panels-origin/web-panel/components/SupplierFormDialog.vue
Alberto Martinez 90bcd48f95 Facturas, nómina, dispersión e IMSS (#28)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Resumen

Implementa las observaciones de facturas, nómina e IMSS sobre el catálogo de proveedores y la nómina semanal que ya existían.

## Qué queda disponible

- Proveedores: régimen fiscal, código postal, estatus activo/inactivo y filtro por nombre o RFC.
- Facturas recibidas y emitidas: Excel, UUID, revisión si el RFC no está en el catálogo, clasificación a obra/concepto y gasto `source = invoice` solo para la recibida clasificada.
- Nómina: bono, descuento con observación, horas y día festivo dentro del armado semanal. La semana pagada no acepta cambios.
- Préstamos: edición (libre sin pagos; con pagos solo nota y cuota) y estado de cuenta con saldo = entregado + comisión − pagos.
- Finiquitos y liquidaciones: conceptos, neto descontando descuentos vigentes y saldo de préstamo, y gasto al pagar.
- Dispersión: corte semanal, corrección antes de definitiva y comprobante.
- Historial de alta, baja y reingreso en la ficha, más movimientos IMSS.
- Reporte por periodo en Excel y PDF.
- Jornal IMSS distinto del jornal real y consulta de cuotas exportable a Excel.

## Verificación

`deno check` de la API pasó. En este entorno no hay Postgres, así que `api/pendientes_test.ts` queda listo para correr cuando la base tenga las migraciones 042–050 y el permiso IAM 007.

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-5e23dc3b-921e-4091-9650-7091d3a52bbf?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-5e23dc3b-921e-4091-9650-7091d3a52bbf&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
2026-09-25 03:26:30 +00:00

147 lines
4.3 KiB
Vue

<template>
<Dialog
v-model:visible="visible"
:header="supplierId ? 'Editar proveedor' : 'Nuevo proveedor'"
modal
:style="{ width: '640px' }"
>
<div class="form-grid form-grid-compact">
<FormField label="Nombre comercial" required>
<InputText v-model="form.name" class="w-full" placeholder="Home Depot" />
</FormField>
<FormField label="Razón social">
<InputText v-model="form.legal_name" class="w-full" />
</FormField>
<FormField label="RFC">
<InputText v-model="form.rfc" class="w-full identifier-input" maxlength="13" placeholder="XAXX010101000" />
</FormField>
<FormField label="Régimen fiscal">
<InputText v-model="form.regimen_fiscal" class="w-full identifier-input" maxlength="3" placeholder="601" />
</FormField>
<FormField label="C.P. fiscal">
<InputText v-model="form.postal_code" class="w-full identifier-input" maxlength="5" />
</FormField>
<FormField label="Estatus">
<Select v-model="form.status" :options="statusOpts" option-label="label" option-value="value" class="w-full" />
</FormField>
<FormField label="Teléfono">
<InputText v-model="form.phone" class="w-full" />
</FormField>
<FormField label="Correo">
<InputText v-model="form.email" class="w-full no-caps" />
</FormField>
<FormField label="Ciudad">
<InputText v-model="form.city" class="w-full" />
</FormField>
<FormField label="Domicilio" class="span-2">
<InputText v-model="form.address" class="w-full" />
</FormField>
<FormField label="Notas" class="span-2">
<Textarea v-model="form.notes" class="w-full" rows="2" auto-resize />
</FormField>
</div>
<Message v-if="error" severity="error" class="mt-3" :closable="false">{{ error }}</Message>
<template #footer>
<Button label="Cancelar" text @click="visible = false" />
<Button label="Guardar" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import { displayErrorMessage } from "~/composables/api-response";
export type Supplier = {
id: number;
name: string;
legal_name?: string;
rfc?: string;
email?: string;
phone?: string;
address?: string;
city?: string;
state?: string;
postal_code?: string;
regimen_fiscal?: string;
notes?: string;
status?: string;
};
const props = defineProps<{
modelValue: boolean;
supplier?: Supplier | null;
}>();
const emit = defineEmits<{
"update:modelValue": [boolean];
saved: [Supplier];
}>();
const { api } = useApi();
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
const empty = () => ({
name: "",
legal_name: "",
rfc: "",
email: "",
phone: "",
address: "",
city: "",
postal_code: "",
regimen_fiscal: "",
notes: "",
status: "activo",
});
const statusOpts = [
{ label: "Activo", value: "activo" },
{ label: "Inactivo", value: "inactivo" },
];
const form = reactive(empty());
const error = ref("");
const saving = ref(false);
const supplierId = computed(() => props.supplier?.id ?? null);
watch(
() => [props.modelValue, props.supplier] as const,
([open, supplier]) => {
if (!open) return;
error.value = "";
Object.assign(form, empty(), supplier || {});
},
);
async function save() {
error.value = "";
if (!form.name.trim()) {
error.value = "El nombre del proveedor es obligatorio";
return;
}
saving.value = true;
try {
const payload = { ...form, rfc: form.rfc.trim().toUpperCase() };
const r = supplierId.value
? await api<{ supplier?: Supplier; data?: { supplier?: Supplier } }>(`/v1/suppliers/${supplierId.value}`, {
method: "PATCH",
json: payload,
})
: await api<{ supplier?: Supplier; data?: { supplier?: Supplier } }>("/v1/suppliers", {
method: "POST",
json: payload,
});
const saved = r.supplier ?? r.data?.supplier;
if (!saved) throw new Error("No se recibió el proveedor guardado");
emit("saved", saved);
visible.value = false;
} catch (e) {
error.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
} finally {
saving.value = false;
}
}
</script>