Registro de horas: estado de pago por partida (por pagar / pagada)
Permite separar las horas ya cobradas de las pendientes para que los totales no se
acumulen y poder emitir la nota por pagar al cliente y conservar el recibo de lo pagado.
- Modelo: RegistroHoras.estadoPago ("por_pagar" default | "pagada") + fechaPago
(se sella al marcar pagada, se limpia al revertir). Migración idempotente.
- API: PATCH /api/cotizaciones/[id]/horas/[registroId] para marcar por pagar/pagada.
- calculators: ESTADOS_PAGO_HORAS + resumenPagoHoras() que suma por separado
pendiente vs pagado.
- Panel: resumen "Por pagar" vs "Pagado" siempre visible (con IVA si aplica),
badge/toggle de estado por fila, filtro de estado (Por pagar/Pagadas/Todas) que
además define el documento: "Nota de horas por pagar", "Recibo de horas pagadas"
o "Estado de cuenta de horas".
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8b1e420d4a
commit
d83a80c85a
@@ -0,0 +1,10 @@
|
||||
-- Estado de cobro por partida de horas: separa lo pendiente ("por_pagar") de lo ya
|
||||
-- cobrado ("pagada") para poder emitir notas por pagar y conservar recibos pagados sin
|
||||
-- que los totales se mezclen. Idempotente (IF NOT EXISTS).
|
||||
|
||||
-- AlterTable: RegistroHoras.estadoPago / fechaPago
|
||||
ALTER TABLE "RegistroHoras" ADD COLUMN IF NOT EXISTS "estadoPago" TEXT NOT NULL DEFAULT 'por_pagar';
|
||||
ALTER TABLE "RegistroHoras" ADD COLUMN IF NOT EXISTS "fechaPago" TIMESTAMP(3);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "RegistroHoras_estadoPago_idx" ON "RegistroHoras"("estadoPago");
|
||||
@@ -199,6 +199,11 @@ model RegistroHoras {
|
||||
horas Float
|
||||
tarifaHora Float @default(0)
|
||||
descripcion String
|
||||
// Estado de cobro de la partida de horas: "por_pagar" (default) o "pagada".
|
||||
// Permite separar lo pendiente (nota por pagar al cliente) de lo ya cobrado
|
||||
// (recibo/historial) sin que los totales se mezclen. fechaPago = cuando se marco pagada.
|
||||
estadoPago String @default("por_pagar")
|
||||
fechaPago DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -206,6 +211,7 @@ model RegistroHoras {
|
||||
|
||||
@@index([cotizacionId])
|
||||
@@index([fecha])
|
||||
@@index([estadoPago])
|
||||
}
|
||||
|
||||
model Configuracion {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Clock, Plus, Trash2, Pencil, Printer, Eye, X } from "lucide-react";
|
||||
import { Clock, Plus, Trash2, Pencil, Printer, Eye, X, CheckCircle2, CircleDashed } from "lucide-react";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatFechaRegistro,
|
||||
periodoAgrupacion,
|
||||
calcularHorasRango,
|
||||
resumenPagoHoras,
|
||||
esPagada,
|
||||
MODOS_AGRUPACION,
|
||||
IVA_RATE,
|
||||
type ModoAgrupacion,
|
||||
@@ -20,6 +22,8 @@ interface Registro {
|
||||
horas: number;
|
||||
tarifaHora: number;
|
||||
descripcion: string;
|
||||
estadoPago: string; // "por_pagar" | "pagada"
|
||||
fechaPago?: string | null;
|
||||
}
|
||||
|
||||
interface Branding {
|
||||
@@ -41,6 +45,21 @@ interface RegistroHorasPanelProps {
|
||||
registrosIniciales: Registro[];
|
||||
}
|
||||
|
||||
type FiltroEstado = "todas" | "por_pagar" | "pagada";
|
||||
|
||||
const FILTROS_ESTADO: { value: FiltroEstado; label: string }[] = [
|
||||
{ value: "por_pagar", label: "Por pagar" },
|
||||
{ value: "pagada", label: "Pagadas" },
|
||||
{ value: "todas", label: "Todas" },
|
||||
];
|
||||
|
||||
// Titulo del documento segun el filtro de estado activo.
|
||||
const TITULO_NOTA: Record<FiltroEstado, string> = {
|
||||
por_pagar: "Nota de horas por pagar",
|
||||
pagada: "Recibo de horas pagadas",
|
||||
todas: "Estado de cuenta de horas",
|
||||
};
|
||||
|
||||
function hoyISO(): string {
|
||||
const d = new Date();
|
||||
const p2 = (n: number) => String(n).padStart(2, "0");
|
||||
@@ -72,9 +91,11 @@ export function RegistroHorasPanel({
|
||||
const [form, setForm] = useState(formVacio(tarifaSugerida));
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [modo, setModo] = useState<ModoAgrupacion>("detalle");
|
||||
const [estadoFiltro, setEstadoFiltro] = useState<FiltroEstado>("todas");
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// HTML de la nota congelado al abrir la previsualización (null = modal cerrado).
|
||||
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
|
||||
@@ -83,7 +104,9 @@ export function RegistroHorasPanel({
|
||||
// Horas en vivo del rango actual del formulario (feedback al asesor).
|
||||
const horasPreview = calcularHorasRango(form.horaInicio, form.horaFin);
|
||||
|
||||
const filtrados = useMemo(() => {
|
||||
// Registros acotados solo por fecha (base para el resumen por-pagar / pagado que
|
||||
// se muestra SIEMPRE, independientemente del filtro de estado en pantalla).
|
||||
const porFecha = useMemo(() => {
|
||||
return registros
|
||||
.filter((r) => {
|
||||
const d = r.fecha.slice(0, 10);
|
||||
@@ -98,6 +121,17 @@ export function RegistroHorasPanel({
|
||||
);
|
||||
}, [registros, from, to]);
|
||||
|
||||
// Resumen separado: pendiente vs pagado (nunca se mezclan).
|
||||
const resumen = useMemo(() => resumenPagoHoras(porFecha), [porFecha]);
|
||||
|
||||
// Lo que se ve en la tabla y entra en la nota: filtro de fecha + estado.
|
||||
const filtrados = useMemo(() => {
|
||||
if (estadoFiltro === "todas") return porFecha;
|
||||
return porFecha.filter((r) =>
|
||||
estadoFiltro === "pagada" ? esPagada(r.estadoPago) : !esPagada(r.estadoPago)
|
||||
);
|
||||
}, [porFecha, estadoFiltro]);
|
||||
|
||||
const grupos = useMemo(() => {
|
||||
if (modo === "detalle") return [];
|
||||
const map = new Map<string, { etiqueta: string; horas: number; importe: number; count: number }>();
|
||||
@@ -114,6 +148,9 @@ export function RegistroHorasPanel({
|
||||
.map(([clave, g]) => ({ clave, ...g }));
|
||||
}, [filtrados, modo]);
|
||||
|
||||
const ivaFactor = incluirIva ? 1 + IVA_RATE : 1;
|
||||
|
||||
// Totales de la seleccion actual (lo que se imprime en la nota).
|
||||
const totalHoras = filtrados.reduce((a, r) => a + r.horas, 0);
|
||||
const subtotal = filtrados.reduce((a, r) => a + r.horas * r.tarifaHora, 0);
|
||||
const iva = incluirIva ? subtotal * IVA_RATE : 0;
|
||||
@@ -190,6 +227,28 @@ export function RegistroHorasPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// Alterna el estado de cobro de una partida (por_pagar <-> pagada). Optimista.
|
||||
const handleToggleEstado = async (r: Registro) => {
|
||||
const nuevo = esPagada(r.estadoPago) ? "por_pagar" : "pagada";
|
||||
const prev = registros;
|
||||
setTogglingId(r.id);
|
||||
setRegistros((rs) => rs.map((x) => (x.id === r.id ? { ...x, estadoPago: nuevo } : x)));
|
||||
try {
|
||||
const res = await fetch(`/api/cotizaciones/${cotizacionId}/horas/${r.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ estadoPago: nuevo }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) setRegistros(prev);
|
||||
else setRegistros((rs) => rs.map((x) => (x.id === r.id ? data : x)));
|
||||
} catch {
|
||||
setRegistros(prev);
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Congela el HTML de la nota (con branding) y abre el modal de previsualización.
|
||||
const abrirPreview = () => setPreviewHtml(construirHTMLNota());
|
||||
|
||||
@@ -201,6 +260,20 @@ export function RegistroHorasPanel({
|
||||
w.print();
|
||||
};
|
||||
|
||||
const badgeEstado = (estadoPago: string) => {
|
||||
const pagada = esPagada(estadoPago);
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
pagada ? "bg-green-100 text-green-800" : "bg-amber-100 text-amber-800"
|
||||
}`}
|
||||
>
|
||||
{pagada ? <CheckCircle2 className="w-3 h-3" /> : <CircleDashed className="w-3 h-3" />}
|
||||
{pagada ? "Pagada" : "Por pagar"}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- HTML autocontenido de la nota de pago (para imprimir / guardar como PDF) ----
|
||||
const construirHTMLNota = () => {
|
||||
const PRIMARY = branding.colorPrimario || "#2563eb";
|
||||
@@ -217,12 +290,17 @@ export function RegistroHorasPanel({
|
||||
: "Periodo: todos los registros";
|
||||
|
||||
const modoLabel = MODOS_AGRUPACION.find((m) => m.value === modo)?.label ?? "";
|
||||
const titulo = TITULO_NOTA[estadoFiltro];
|
||||
// Solo el estado de cuenta (todas) muestra la columna Estado por partida.
|
||||
const mostrarColEstado = estadoFiltro === "todas" && modo === "detalle";
|
||||
|
||||
const cuerpo =
|
||||
modo === "detalle"
|
||||
? `<thead><tr>
|
||||
<th>Fecha</th><th>Horario</th><th class="r">Horas</th>
|
||||
<th class="r">Tarifa</th><th class="r">Importe</th><th>Descripcion</th>
|
||||
<th class="r">Tarifa</th><th class="r">Importe</th><th>Descripcion</th>${
|
||||
mostrarColEstado ? "<th>Estado</th>" : ""
|
||||
}
|
||||
</tr></thead>
|
||||
<tbody>${filtrados
|
||||
.map(
|
||||
@@ -232,7 +310,11 @@ export function RegistroHorasPanel({
|
||||
<td class="r">${r.horas.toFixed(2)}</td>
|
||||
<td class="r">${formatCurrency(r.tarifaHora)}</td>
|
||||
<td class="r">${formatCurrency(r.horas * r.tarifaHora)}</td>
|
||||
<td>${escapeHtml(r.descripcion)}</td>
|
||||
<td>${escapeHtml(r.descripcion)}</td>${
|
||||
mostrarColEstado
|
||||
? `<td>${esPagada(r.estadoPago) ? "Pagada" : "Por pagar"}</td>`
|
||||
: ""
|
||||
}
|
||||
</tr>`
|
||||
)
|
||||
.join("")}</tbody>`
|
||||
@@ -251,16 +333,19 @@ export function RegistroHorasPanel({
|
||||
)
|
||||
.join("")}</tbody>`;
|
||||
|
||||
const etiquetaTotal =
|
||||
estadoFiltro === "pagada" ? "Total pagado" : estadoFiltro === "todas" ? "Total" : "Total a pagar";
|
||||
|
||||
const totalesHTML = `
|
||||
<table class="tot">
|
||||
<tr><td>Total de horas</td><td class="r">${totalHoras.toFixed(2)} h</td></tr>
|
||||
<tr><td>Subtotal</td><td class="r">${formatCurrency(subtotal)}</td></tr>
|
||||
${incluirIva ? `<tr><td>IVA (16%)</td><td class="r">${formatCurrency(iva)}</td></tr>` : ""}
|
||||
<tr class="grand"><td>Total a pagar</td><td class="r">${formatCurrency(total)}</td></tr>
|
||||
<tr class="grand"><td>${etiquetaTotal}</td><td class="r">${formatCurrency(total)}</td></tr>
|
||||
</table>`;
|
||||
|
||||
return `<!doctype html><html lang="es"><head><meta charset="utf-8">
|
||||
<title>Nota de pago ${escapeHtml(numero)}</title>
|
||||
<title>${escapeHtml(titulo)} ${escapeHtml(numero)}</title>
|
||||
<style>
|
||||
*{box-sizing:border-box} body{font-family:Arial,Helvetica,sans-serif;color:#1f2937;margin:40px;font-size:13px}
|
||||
h1{font-size:20px;margin:0 0 4px;color:${DARK}} .muted{color:#6b7280}
|
||||
@@ -282,7 +367,7 @@ export function RegistroHorasPanel({
|
||||
<div class="brand">
|
||||
${logoSrc ? `<img src="${logoSrc}" alt="Logo" class="logo">` : ""}
|
||||
<div>
|
||||
<h1>Nota de pago</h1>
|
||||
<h1>${escapeHtml(titulo)}</h1>
|
||||
<div class="muted">Cotizacion ${escapeHtml(numero)} · ${escapeHtml(proyecto)}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,10 +397,42 @@ export function RegistroHorasPanel({
|
||||
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
<Eye className="w-4 h-4 text-blue-600" />
|
||||
Previsualizar nota
|
||||
{estadoFiltro === "pagada"
|
||||
? "Previsualizar recibo"
|
||||
: estadoFiltro === "todas"
|
||||
? "Previsualizar estado de cuenta"
|
||||
: "Previsualizar nota por pagar"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Resumen separado: pendiente vs pagado (nunca se acumulan). */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50/60 p-3">
|
||||
<div className="flex items-center gap-2 text-amber-800 text-sm font-medium">
|
||||
<CircleDashed className="w-4 h-4" /> Por pagar
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold text-amber-900">
|
||||
{formatCurrency(resumen.porPagar.importe * ivaFactor)}
|
||||
</div>
|
||||
<div className="text-xs text-amber-800/80">
|
||||
{resumen.porPagar.horas.toFixed(2)} h · {resumen.porPagar.count} registro(s)
|
||||
{incluirIva ? " · IVA incluido" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-green-200 bg-green-50/60 p-3">
|
||||
<div className="flex items-center gap-2 text-green-800 text-sm font-medium">
|
||||
<CheckCircle2 className="w-4 h-4" /> Pagado
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold text-green-900">
|
||||
{formatCurrency(resumen.pagada.importe * ivaFactor)}
|
||||
</div>
|
||||
<div className="text-xs text-green-800/80">
|
||||
{resumen.pagada.horas.toFixed(2)} h · {resumen.pagada.count} registro(s)
|
||||
{incluirIva ? " · IVA incluido" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formulario de alta / edicion */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
@@ -402,8 +519,22 @@ export function RegistroHorasPanel({
|
||||
{error && <p className="md:col-span-12 text-sm text-red-600">{error}</p>}
|
||||
</form>
|
||||
|
||||
{/* Filtros y agrupacion */}
|
||||
{/* Filtros: estado de pago, periodo y agrupacion */}
|
||||
<div className="flex items-end gap-3 flex-wrap mb-3">
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1">Estado</label>
|
||||
<select
|
||||
value={estadoFiltro}
|
||||
onChange={(e) => setEstadoFiltro(e.target.value as FiltroEstado)}
|
||||
className={INPUT}
|
||||
>
|
||||
{FILTROS_ESTADO.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1">Desde</label>
|
||||
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className={INPUT} />
|
||||
@@ -442,7 +573,12 @@ export function RegistroHorasPanel({
|
||||
{/* Tabla */}
|
||||
{filtrados.length === 0 ? (
|
||||
<p className="text-muted text-sm py-6 text-center">
|
||||
Aún no hay registros de horas{from || to ? " en este periodo" : ""}.
|
||||
{estadoFiltro === "pagada"
|
||||
? "No hay horas marcadas como pagadas"
|
||||
: estadoFiltro === "por_pagar"
|
||||
? "No hay horas pendientes de pago"
|
||||
: "Aún no hay registros de horas"}
|
||||
{from || to ? " en este periodo" : ""}.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
@@ -457,6 +593,7 @@ export function RegistroHorasPanel({
|
||||
<th className="py-2 pr-3 font-medium text-right">Tarifa</th>
|
||||
<th className="py-2 pr-3 font-medium text-right">Importe</th>
|
||||
<th className="py-2 pr-3 font-medium">Descripcion</th>
|
||||
<th className="py-2 pr-3 font-medium">Estado</th>
|
||||
<th className="py-2 font-medium"></th>
|
||||
</>
|
||||
) : (
|
||||
@@ -472,7 +609,10 @@ export function RegistroHorasPanel({
|
||||
<tbody>
|
||||
{modo === "detalle"
|
||||
? filtrados.map((r) => (
|
||||
<tr key={r.id} className="border-b border-border/60">
|
||||
<tr
|
||||
key={r.id}
|
||||
className={`border-b border-border/60 ${esPagada(r.estadoPago) ? "bg-green-50/40" : ""}`}
|
||||
>
|
||||
<td className="py-2 pr-3 whitespace-nowrap">{formatFechaRegistro(new Date(r.fecha))}</td>
|
||||
<td className="py-2 pr-3 whitespace-nowrap">
|
||||
{r.horaInicio}–{r.horaFin}
|
||||
@@ -481,6 +621,16 @@ export function RegistroHorasPanel({
|
||||
<td className="py-2 pr-3 text-right">{formatCurrency(r.tarifaHora)}</td>
|
||||
<td className="py-2 pr-3 text-right">{formatCurrency(r.horas * r.tarifaHora)}</td>
|
||||
<td className="py-2 pr-3">{r.descripcion}</td>
|
||||
<td className="py-2 pr-3 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => handleToggleEstado(r)}
|
||||
disabled={togglingId === r.id}
|
||||
title={esPagada(r.estadoPago) ? "Marcar como por pagar" : "Marcar como pagada"}
|
||||
className="disabled:opacity-50"
|
||||
>
|
||||
{badgeEstado(r.estadoPago)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2 text-right whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => handleEdit(r)}
|
||||
@@ -512,12 +662,14 @@ export function RegistroHorasPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Totales */}
|
||||
{/* Totales de la seleccion actual */}
|
||||
{filtrados.length > 0 && (
|
||||
<div className="mt-4 pt-3 border-t border-border flex justify-end">
|
||||
<div className="w-full max-w-xs space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted">Total de horas</span>
|
||||
<span className="text-muted">
|
||||
Total de horas{estadoFiltro !== "todas" ? ` (${FILTROS_ESTADO.find((f) => f.value === estadoFiltro)?.label.toLowerCase()})` : ""}
|
||||
</span>
|
||||
<span className="font-medium">{totalHoras.toFixed(2)} h</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
@@ -531,7 +683,13 @@ export function RegistroHorasPanel({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between font-bold pt-2 border-t border-border">
|
||||
<span>Total a pagar</span>
|
||||
<span>
|
||||
{estadoFiltro === "pagada"
|
||||
? "Total pagado"
|
||||
: estadoFiltro === "todas"
|
||||
? "Total general"
|
||||
: "Total a pagar"}
|
||||
</span>
|
||||
<span className="text-primary">{formatCurrency(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -548,7 +706,7 @@ export function RegistroHorasPanel({
|
||||
className="flex items-center justify-between gap-4 px-4 py-3 bg-white border-b border-border"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="font-semibold text-sm truncate">Nota de pago — {numero}</h3>
|
||||
<h3 className="font-semibold text-sm truncate">{TITULO_NOTA[estadoFiltro]} — {numero}</h3>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={imprimirDesdePreview}
|
||||
|
||||
@@ -218,6 +218,8 @@ export default async function CotizacionDetailPage({
|
||||
horas: r.horas,
|
||||
tarifaHora: r.tarifaHora,
|
||||
descripcion: r.descripcion,
|
||||
estadoPago: r.estadoPago,
|
||||
fechaPago: r.fechaPago ? r.fechaPago.toISOString() : null,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { registroHorasSchema } from "@/lib/schemas";
|
||||
import { registroHorasSchema, registroHorasEstadoSchema } from "@/lib/schemas";
|
||||
import { calcularHorasRango, fechaRegistroDesdeISO } from "@/lib/calculators";
|
||||
|
||||
// PUT /api/cotizaciones/:id/horas/:registroId
|
||||
@@ -54,6 +54,46 @@ export async function PUT(
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/cotizaciones/:id/horas/:registroId
|
||||
// Marca la partida como "por_pagar" o "pagada" sin tocar el resto. Al marcarla pagada
|
||||
// se sella la fechaPago; al regresarla a por_pagar se limpia.
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; registroId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id, registroId } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
const parsed = registroHorasEstadoSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues.map((i) => i.message).join(", ") },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { estadoPago } = parsed.data;
|
||||
|
||||
const existing = await prisma.registroHoras.findUnique({ where: { id: registroId } });
|
||||
if (!existing || existing.cotizacionId !== id) {
|
||||
return NextResponse.json({ error: "Registro no encontrado" }, { status: 404 });
|
||||
}
|
||||
|
||||
const registro = await prisma.registroHoras.update({
|
||||
where: { id: registroId },
|
||||
data: {
|
||||
estadoPago,
|
||||
fechaPago: estadoPago === "pagada" ? (existing.fechaPago ?? new Date()) : null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(registro);
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : "Error interno";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/cotizaciones/:id/horas/:registroId
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
|
||||
@@ -242,6 +242,50 @@ export function periodoAgrupacion(
|
||||
return { clave: `${y}-${p2(m + 1)}-${p2(d)}`, etiqueta: formatFechaRegistro(fecha) };
|
||||
}
|
||||
|
||||
// Estado de cobro de una partida de horas registrada. "por_pagar" es pendiente de
|
||||
// cobro (entra en la nota por pagar al cliente); "pagada" ya se cobro (se conserva
|
||||
// como recibo/historial y NO se suma al pendiente).
|
||||
export const ESTADOS_PAGO_HORAS = ["por_pagar", "pagada"] as const;
|
||||
export type EstadoPagoHoras = (typeof ESTADOS_PAGO_HORAS)[number];
|
||||
|
||||
export const ESTADOS_PAGO_HORAS_LABEL: Record<EstadoPagoHoras, string> = {
|
||||
por_pagar: "Por pagar",
|
||||
pagada: "Pagada",
|
||||
};
|
||||
|
||||
// Normaliza el estado de pago: cualquier valor distinto de "pagada" cuenta como pendiente.
|
||||
export function esPagada(estadoPago?: string | null): boolean {
|
||||
return estadoPago === "pagada";
|
||||
}
|
||||
|
||||
// Suma horas e importe de un conjunto de registros separando por estado de pago, para
|
||||
// que lo pagado y lo pendiente nunca se mezclen en un solo total.
|
||||
export function resumenPagoHoras(
|
||||
registros: Array<{ horas: number; tarifaHora: number; estadoPago?: string | null }>
|
||||
): {
|
||||
porPagar: { horas: number; importe: number; count: number };
|
||||
pagada: { horas: number; importe: number; count: number };
|
||||
total: { horas: number; importe: number; count: number };
|
||||
} {
|
||||
const acc = () => ({ horas: 0, importe: 0, count: 0 });
|
||||
const r = { porPagar: acc(), pagada: acc(), total: acc() };
|
||||
for (const x of registros) {
|
||||
const importe = (x.horas || 0) * (x.tarifaHora || 0);
|
||||
const bucket = esPagada(x.estadoPago) ? r.pagada : r.porPagar;
|
||||
bucket.horas += x.horas || 0;
|
||||
bucket.importe += importe;
|
||||
bucket.count += 1;
|
||||
r.total.horas += x.horas || 0;
|
||||
r.total.importe += importe;
|
||||
r.total.count += 1;
|
||||
}
|
||||
for (const k of ["porPagar", "pagada", "total"] as const) {
|
||||
r[k].horas = Math.round(r[k].horas * 100) / 100;
|
||||
r[k].importe = Math.round(r[k].importe * 100) / 100;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
export function bucefaloPrecio(nivel: string): number {
|
||||
return PLANES_BUCEFALO.find((p) => p.nivel === nivel)?.precio ?? 0;
|
||||
}
|
||||
|
||||
@@ -102,6 +102,12 @@ export const registroHorasSchema = z.object({
|
||||
tarifaHora: z.number().min(0).optional(),
|
||||
});
|
||||
|
||||
// PATCH ligero para marcar una partida de horas como "por_pagar" o "pagada" sin
|
||||
// reenviar todo el registro.
|
||||
export const registroHorasEstadoSchema = z.object({
|
||||
estadoPago: z.enum(["por_pagar", "pagada"]),
|
||||
});
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email("Email invalido"),
|
||||
password: z.string().min(1, "Password es requerido"),
|
||||
|
||||
Reference in New Issue
Block a user