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
|
horas Float
|
||||||
tarifaHora Float @default(0)
|
tarifaHora Float @default(0)
|
||||||
descripcion String
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -206,6 +211,7 @@ model RegistroHoras {
|
|||||||
|
|
||||||
@@index([cotizacionId])
|
@@index([cotizacionId])
|
||||||
@@index([fecha])
|
@@index([fecha])
|
||||||
|
@@index([estadoPago])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Configuracion {
|
model Configuracion {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useRef, useState } from "react";
|
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 {
|
import {
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
formatFechaRegistro,
|
formatFechaRegistro,
|
||||||
periodoAgrupacion,
|
periodoAgrupacion,
|
||||||
calcularHorasRango,
|
calcularHorasRango,
|
||||||
|
resumenPagoHoras,
|
||||||
|
esPagada,
|
||||||
MODOS_AGRUPACION,
|
MODOS_AGRUPACION,
|
||||||
IVA_RATE,
|
IVA_RATE,
|
||||||
type ModoAgrupacion,
|
type ModoAgrupacion,
|
||||||
@@ -20,6 +22,8 @@ interface Registro {
|
|||||||
horas: number;
|
horas: number;
|
||||||
tarifaHora: number;
|
tarifaHora: number;
|
||||||
descripcion: string;
|
descripcion: string;
|
||||||
|
estadoPago: string; // "por_pagar" | "pagada"
|
||||||
|
fechaPago?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Branding {
|
interface Branding {
|
||||||
@@ -41,6 +45,21 @@ interface RegistroHorasPanelProps {
|
|||||||
registrosIniciales: Registro[];
|
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 {
|
function hoyISO(): string {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
const p2 = (n: number) => String(n).padStart(2, "0");
|
const p2 = (n: number) => String(n).padStart(2, "0");
|
||||||
@@ -72,9 +91,11 @@ export function RegistroHorasPanel({
|
|||||||
const [form, setForm] = useState(formVacio(tarifaSugerida));
|
const [form, setForm] = useState(formVacio(tarifaSugerida));
|
||||||
const [editId, setEditId] = useState<string | null>(null);
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
const [modo, setModo] = useState<ModoAgrupacion>("detalle");
|
const [modo, setModo] = useState<ModoAgrupacion>("detalle");
|
||||||
|
const [estadoFiltro, setEstadoFiltro] = useState<FiltroEstado>("todas");
|
||||||
const [from, setFrom] = useState("");
|
const [from, setFrom] = useState("");
|
||||||
const [to, setTo] = useState("");
|
const [to, setTo] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// HTML de la nota congelado al abrir la previsualización (null = modal cerrado).
|
// HTML de la nota congelado al abrir la previsualización (null = modal cerrado).
|
||||||
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
|
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).
|
// Horas en vivo del rango actual del formulario (feedback al asesor).
|
||||||
const horasPreview = calcularHorasRango(form.horaInicio, form.horaFin);
|
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
|
return registros
|
||||||
.filter((r) => {
|
.filter((r) => {
|
||||||
const d = r.fecha.slice(0, 10);
|
const d = r.fecha.slice(0, 10);
|
||||||
@@ -98,6 +121,17 @@ export function RegistroHorasPanel({
|
|||||||
);
|
);
|
||||||
}, [registros, from, to]);
|
}, [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(() => {
|
const grupos = useMemo(() => {
|
||||||
if (modo === "detalle") return [];
|
if (modo === "detalle") return [];
|
||||||
const map = new Map<string, { etiqueta: string; horas: number; importe: number; count: number }>();
|
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 }));
|
.map(([clave, g]) => ({ clave, ...g }));
|
||||||
}, [filtrados, modo]);
|
}, [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 totalHoras = filtrados.reduce((a, r) => a + r.horas, 0);
|
||||||
const subtotal = filtrados.reduce((a, r) => a + r.horas * r.tarifaHora, 0);
|
const subtotal = filtrados.reduce((a, r) => a + r.horas * r.tarifaHora, 0);
|
||||||
const iva = incluirIva ? subtotal * IVA_RATE : 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.
|
// Congela el HTML de la nota (con branding) y abre el modal de previsualización.
|
||||||
const abrirPreview = () => setPreviewHtml(construirHTMLNota());
|
const abrirPreview = () => setPreviewHtml(construirHTMLNota());
|
||||||
|
|
||||||
@@ -201,6 +260,20 @@ export function RegistroHorasPanel({
|
|||||||
w.print();
|
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) ----
|
// ---- HTML autocontenido de la nota de pago (para imprimir / guardar como PDF) ----
|
||||||
const construirHTMLNota = () => {
|
const construirHTMLNota = () => {
|
||||||
const PRIMARY = branding.colorPrimario || "#2563eb";
|
const PRIMARY = branding.colorPrimario || "#2563eb";
|
||||||
@@ -217,12 +290,17 @@ export function RegistroHorasPanel({
|
|||||||
: "Periodo: todos los registros";
|
: "Periodo: todos los registros";
|
||||||
|
|
||||||
const modoLabel = MODOS_AGRUPACION.find((m) => m.value === modo)?.label ?? "";
|
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 =
|
const cuerpo =
|
||||||
modo === "detalle"
|
modo === "detalle"
|
||||||
? `<thead><tr>
|
? `<thead><tr>
|
||||||
<th>Fecha</th><th>Horario</th><th class="r">Horas</th>
|
<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>
|
</tr></thead>
|
||||||
<tbody>${filtrados
|
<tbody>${filtrados
|
||||||
.map(
|
.map(
|
||||||
@@ -232,7 +310,11 @@ export function RegistroHorasPanel({
|
|||||||
<td class="r">${r.horas.toFixed(2)}</td>
|
<td class="r">${r.horas.toFixed(2)}</td>
|
||||||
<td class="r">${formatCurrency(r.tarifaHora)}</td>
|
<td class="r">${formatCurrency(r.tarifaHora)}</td>
|
||||||
<td class="r">${formatCurrency(r.horas * 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>`
|
</tr>`
|
||||||
)
|
)
|
||||||
.join("")}</tbody>`
|
.join("")}</tbody>`
|
||||||
@@ -251,16 +333,19 @@ export function RegistroHorasPanel({
|
|||||||
)
|
)
|
||||||
.join("")}</tbody>`;
|
.join("")}</tbody>`;
|
||||||
|
|
||||||
|
const etiquetaTotal =
|
||||||
|
estadoFiltro === "pagada" ? "Total pagado" : estadoFiltro === "todas" ? "Total" : "Total a pagar";
|
||||||
|
|
||||||
const totalesHTML = `
|
const totalesHTML = `
|
||||||
<table class="tot">
|
<table class="tot">
|
||||||
<tr><td>Total de horas</td><td class="r">${totalHoras.toFixed(2)} h</td></tr>
|
<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>
|
<tr><td>Subtotal</td><td class="r">${formatCurrency(subtotal)}</td></tr>
|
||||||
${incluirIva ? `<tr><td>IVA (16%)</td><td class="r">${formatCurrency(iva)}</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>`;
|
</table>`;
|
||||||
|
|
||||||
return `<!doctype html><html lang="es"><head><meta charset="utf-8">
|
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>
|
<style>
|
||||||
*{box-sizing:border-box} body{font-family:Arial,Helvetica,sans-serif;color:#1f2937;margin:40px;font-size:13px}
|
*{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}
|
h1{font-size:20px;margin:0 0 4px;color:${DARK}} .muted{color:#6b7280}
|
||||||
@@ -282,7 +367,7 @@ export function RegistroHorasPanel({
|
|||||||
<div class="brand">
|
<div class="brand">
|
||||||
${logoSrc ? `<img src="${logoSrc}" alt="Logo" class="logo">` : ""}
|
${logoSrc ? `<img src="${logoSrc}" alt="Logo" class="logo">` : ""}
|
||||||
<div>
|
<div>
|
||||||
<h1>Nota de pago</h1>
|
<h1>${escapeHtml(titulo)}</h1>
|
||||||
<div class="muted">Cotizacion ${escapeHtml(numero)} · ${escapeHtml(proyecto)}</div>
|
<div class="muted">Cotizacion ${escapeHtml(numero)} · ${escapeHtml(proyecto)}</div>
|
||||||
</div>
|
</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"
|
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" />
|
<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>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Formulario de alta / edicion */}
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
@@ -402,8 +519,22 @@ export function RegistroHorasPanel({
|
|||||||
{error && <p className="md:col-span-12 text-sm text-red-600">{error}</p>}
|
{error && <p className="md:col-span-12 text-sm text-red-600">{error}</p>}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Filtros y agrupacion */}
|
{/* Filtros: estado de pago, periodo y agrupacion */}
|
||||||
<div className="flex items-end gap-3 flex-wrap mb-3">
|
<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>
|
<div>
|
||||||
<label className="block text-xs text-muted mb-1">Desde</label>
|
<label className="block text-xs text-muted mb-1">Desde</label>
|
||||||
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className={INPUT} />
|
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className={INPUT} />
|
||||||
@@ -442,7 +573,12 @@ export function RegistroHorasPanel({
|
|||||||
{/* Tabla */}
|
{/* Tabla */}
|
||||||
{filtrados.length === 0 ? (
|
{filtrados.length === 0 ? (
|
||||||
<p className="text-muted text-sm py-6 text-center">
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<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">Tarifa</th>
|
||||||
<th className="py-2 pr-3 font-medium text-right">Importe</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">Descripcion</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">Estado</th>
|
||||||
<th className="py-2 font-medium"></th>
|
<th className="py-2 font-medium"></th>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -472,7 +609,10 @@ export function RegistroHorasPanel({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{modo === "detalle"
|
{modo === "detalle"
|
||||||
? filtrados.map((r) => (
|
? 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">{formatFechaRegistro(new Date(r.fecha))}</td>
|
||||||
<td className="py-2 pr-3 whitespace-nowrap">
|
<td className="py-2 pr-3 whitespace-nowrap">
|
||||||
{r.horaInicio}–{r.horaFin}
|
{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.tarifaHora)}</td>
|
||||||
<td className="py-2 pr-3 text-right">{formatCurrency(r.horas * 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">{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">
|
<td className="py-2 text-right whitespace-nowrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(r)}
|
onClick={() => handleEdit(r)}
|
||||||
@@ -512,12 +662,14 @@ export function RegistroHorasPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Totales */}
|
{/* Totales de la seleccion actual */}
|
||||||
{filtrados.length > 0 && (
|
{filtrados.length > 0 && (
|
||||||
<div className="mt-4 pt-3 border-t border-border flex justify-end">
|
<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="w-full max-w-xs space-y-1 text-sm">
|
||||||
<div className="flex justify-between">
|
<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>
|
<span className="font-medium">{totalHoras.toFixed(2)} h</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
@@ -531,7 +683,13 @@ export function RegistroHorasPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between font-bold pt-2 border-t border-border">
|
<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>
|
<span className="text-primary">{formatCurrency(total)}</span>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
className="flex items-center justify-between gap-4 px-4 py-3 bg-white border-b border-border"
|
||||||
onClick={(e) => e.stopPropagation()}
|
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">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={imprimirDesdePreview}
|
onClick={imprimirDesdePreview}
|
||||||
|
|||||||
@@ -218,6 +218,8 @@ export default async function CotizacionDetailPage({
|
|||||||
horas: r.horas,
|
horas: r.horas,
|
||||||
tarifaHora: r.tarifaHora,
|
tarifaHora: r.tarifaHora,
|
||||||
descripcion: r.descripcion,
|
descripcion: r.descripcion,
|
||||||
|
estadoPago: r.estadoPago,
|
||||||
|
fechaPago: r.fechaPago ? r.fechaPago.toISOString() : null,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { registroHorasSchema } from "@/lib/schemas";
|
import { registroHorasSchema, registroHorasEstadoSchema } from "@/lib/schemas";
|
||||||
import { calcularHorasRango, fechaRegistroDesdeISO } from "@/lib/calculators";
|
import { calcularHorasRango, fechaRegistroDesdeISO } from "@/lib/calculators";
|
||||||
|
|
||||||
// PUT /api/cotizaciones/:id/horas/:registroId
|
// 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
|
// DELETE /api/cotizaciones/:id/horas/:registroId
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
|
|||||||
@@ -242,6 +242,50 @@ export function periodoAgrupacion(
|
|||||||
return { clave: `${y}-${p2(m + 1)}-${p2(d)}`, etiqueta: formatFechaRegistro(fecha) };
|
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 {
|
export function bucefaloPrecio(nivel: string): number {
|
||||||
return PLANES_BUCEFALO.find((p) => p.nivel === nivel)?.precio ?? 0;
|
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(),
|
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({
|
export const loginSchema = z.object({
|
||||||
email: z.string().email("Email invalido"),
|
email: z.string().email("Email invalido"),
|
||||||
password: z.string().min(1, "Password es requerido"),
|
password: z.string().min(1, "Password es requerido"),
|
||||||
|
|||||||
Reference in New Issue
Block a user