Recuperar feature de registro de horas / cobro por tiempo (RegistroHoras)
La cotización UJ2606AG777 (aprobada, "Agentes IA") se cobra por hora bajo
demanda, pero la rama desplegada (main) no incluía la feature de registro de
horas que sí existía en desarrollo (rama master): el cargo de horas de junio no
se veía en la web y los proyectos por tiempo no mostraban sus cobros en el panel
individual.
Se porta la feature de forma quirúrgica (sin arrastrar cambios no relacionados):
- Modelo RegistroHoras + columnas Cotizacion.incluirIva, Cliente.rfc,
ServicioCotizado.beneficios (migración idempotente para la web).
- calculators.ts: modelo de cobro "demanda", helpers de horas
(calcularHorasRango, agrupación por día/semana/mes, notas de pago).
- RegistroHorasPanel: panel para registrar/editar/borrar horas y previsualizar
la nota de pago con branding. Solo aparece en cotizaciones aprobadas con cobro
por tiempo (horas/retainer/demanda).
- PreciosEditables: los servicios "demanda" muestran la tarifa/hr en vez de $0.
- API /api/cotizaciones/[id]/horas (+ /[registroId]) para el CRUD de registros.
- Handlers de cotizaciones: persisten incluirIva/rfc/beneficios, "demanda" nunca
suma al total, y fast-path para el cambio de estado (arregla CambiarEstado que
fallaba la validación al enviar solo { estado }).
- .megaignore para evitar que MegaSync corrompa node_modules/.next.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4d550c5963
commit
8b1e420d4a
@@ -0,0 +1,7 @@
|
||||
# Excluir de la sincronizacion de MegaSync.
|
||||
# Estas carpetas tienen junction points (symlinks de Windows) y artefactos
|
||||
# de build que cambian constantemente; sincronizarlas hace que MegaSync pelee
|
||||
# con Turbopack y provoca el panic "failed to create junction point (os error 145)".
|
||||
-:.next
|
||||
-:node_modules
|
||||
-:.turbo
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Feature "registro de horas / cobro por tiempo": recupera el modelo RegistroHoras y
|
||||
-- las columnas incluirIva / rfc / beneficios que existian en desarrollo pero no habian
|
||||
-- llegado a la rama desplegada. Idempotente (IF NOT EXISTS) para poder aplicarse tanto
|
||||
-- en la BD web (limpia) como en una BD que ya tenga estos objetos.
|
||||
|
||||
-- AlterTable: Cliente.rfc
|
||||
ALTER TABLE "Cliente" ADD COLUMN IF NOT EXISTS "rfc" TEXT;
|
||||
|
||||
-- AlterTable: Cotizacion.incluirIva
|
||||
ALTER TABLE "Cotizacion" ADD COLUMN IF NOT EXISTS "incluirIva" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- AlterTable: ServicioCotizado.beneficios
|
||||
ALTER TABLE "ServicioCotizado" ADD COLUMN IF NOT EXISTS "beneficios" JSONB NOT NULL DEFAULT '[]';
|
||||
|
||||
-- CreateTable: RegistroHoras
|
||||
CREATE TABLE IF NOT EXISTS "RegistroHoras" (
|
||||
"id" TEXT NOT NULL,
|
||||
"cotizacionId" TEXT NOT NULL,
|
||||
"fecha" TIMESTAMP(3) NOT NULL,
|
||||
"horaInicio" TEXT NOT NULL,
|
||||
"horaFin" TEXT NOT NULL,
|
||||
"horas" DOUBLE PRECISION NOT NULL,
|
||||
"tarifaHora" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"descripcion" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "RegistroHoras_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "RegistroHoras_cotizacionId_idx" ON "RegistroHoras"("cotizacionId");
|
||||
CREATE INDEX IF NOT EXISTS "RegistroHoras_fecha_idx" ON "RegistroHoras"("fecha");
|
||||
|
||||
-- AddForeignKey (guarda porque ADD CONSTRAINT no admite IF NOT EXISTS en PostgreSQL)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'RegistroHoras_cotizacionId_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "RegistroHoras"
|
||||
ADD CONSTRAINT "RegistroHoras_cotizacionId_fkey"
|
||||
FOREIGN KEY ("cotizacionId") REFERENCES "Cotizacion"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -25,6 +25,7 @@ model Cliente {
|
||||
empresa String?
|
||||
email String?
|
||||
telefono String?
|
||||
rfc String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -45,6 +46,7 @@ model Cotizacion {
|
||||
estado String @default("borrador")
|
||||
incluirBonos Boolean @default(false)
|
||||
incluirFinanciamiento Boolean @default(false)
|
||||
incluirIva Boolean @default(true)
|
||||
esDoble Boolean @default(false)
|
||||
opcionesMetadata Json?
|
||||
observaciones String?
|
||||
@@ -57,6 +59,7 @@ model Cotizacion {
|
||||
asesor User @relation("AsesorCotizaciones", fields: [asesorId], references: [id])
|
||||
servicios ServicioCotizado[]
|
||||
planBucefalo PlanBucefaloCotizacion?
|
||||
registrosHoras RegistroHoras[]
|
||||
|
||||
@@index([clienteId])
|
||||
@@index([asesorId])
|
||||
@@ -158,6 +161,7 @@ model ServicioCotizado {
|
||||
precio Float
|
||||
tiempoEntrega String
|
||||
entregables Json @default("[]")
|
||||
beneficios Json @default("[]")
|
||||
notas String?
|
||||
seleccionado Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
@@ -182,6 +186,28 @@ model PlanBucefaloCotizacion {
|
||||
cotizacion Cotizacion @relation(fields: [cotizacionId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
// Registro de horas trabajadas contra una cotizacion aprobada con cobro por tiempo
|
||||
// (modeloCobro horas/retainer/demanda). Se usa para emitir notas de pago al cliente:
|
||||
// cada entrada es un dia con un rango horario, una descripcion breve y una tarifa
|
||||
// (snapshot). `horas` se calcula del rango (calcularHorasRango) y se persiste.
|
||||
model RegistroHoras {
|
||||
id String @id @default(cuid())
|
||||
cotizacionId String
|
||||
fecha DateTime
|
||||
horaInicio String
|
||||
horaFin String
|
||||
horas Float
|
||||
tarifaHora Float @default(0)
|
||||
descripcion String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
cotizacion Cotizacion @relation(fields: [cotizacionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([cotizacionId])
|
||||
@@index([fecha])
|
||||
}
|
||||
|
||||
model Configuracion {
|
||||
id String @id @default(cuid())
|
||||
clave String @unique
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { formatCurrency, IVA_RATE } from "@/lib/calculators";
|
||||
import { formatCurrency, precioDisplay, IVA_RATE } from "@/lib/calculators";
|
||||
|
||||
interface ServicioItem {
|
||||
id: string;
|
||||
nombre: string;
|
||||
precio: number;
|
||||
modeloCobro?: string | null;
|
||||
tarifaHora?: number | null;
|
||||
}
|
||||
|
||||
interface PreciosEditablesProps {
|
||||
@@ -56,6 +58,33 @@ export function PreciosEditables({
|
||||
}
|
||||
};
|
||||
|
||||
// Las partidas "bajo demanda" no tienen un precio fijo (se facturan por consumo):
|
||||
// su monto guardado es 0 y lo relevante es la tarifa por hora cotizada, que se
|
||||
// muestra como texto en vez de un input editable de precio.
|
||||
const renderFila = (s: ServicioItem, tipo: "unico" | "mensual") => (
|
||||
<div key={s.id} className="flex items-center justify-between text-sm gap-2">
|
||||
<span className="flex-1 truncate">{s.nombre}</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{s.modeloCobro === "demanda" ? (
|
||||
<span className="text-muted">{precioDisplay(s)}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted text-xs">$</span>
|
||||
<input
|
||||
type="number"
|
||||
value={s.precio}
|
||||
onChange={(e) =>
|
||||
handlePrecioChange(s.id, parseFloat(e.target.value) || 0, tipo)
|
||||
}
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
{saving === s.id && <span className="text-xs text-muted">...</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-card-bg rounded-xl border border-border">
|
||||
@@ -67,25 +96,7 @@ export function PreciosEditables({
|
||||
<p className="text-muted text-sm">Sin servicios</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{unicos.map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between text-sm gap-2">
|
||||
<span className="flex-1 truncate">{s.nombre}</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-muted text-xs">$</span>
|
||||
<input
|
||||
type="number"
|
||||
value={s.precio}
|
||||
onChange={(e) =>
|
||||
handlePrecioChange(s.id, parseFloat(e.target.value) || 0, "unico")
|
||||
}
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
{saving === s.id && (
|
||||
<span className="text-xs text-muted">...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{unicos.map((s) => renderFila(s, "unico"))}
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-border mt-3 pt-3 space-y-1">
|
||||
@@ -115,25 +126,7 @@ export function PreciosEditables({
|
||||
<p className="text-muted text-sm">Sin servicios</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{mensuales.map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between text-sm gap-2">
|
||||
<span className="flex-1 truncate">{s.nombre}</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-muted text-xs">$</span>
|
||||
<input
|
||||
type="number"
|
||||
value={s.precio}
|
||||
onChange={(e) =>
|
||||
handlePrecioChange(s.id, parseFloat(e.target.value) || 0, "mensual")
|
||||
}
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
{saving === s.id && (
|
||||
<span className="text-xs text-muted">...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{mensuales.map((s) => renderFila(s, "mensual"))}
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-border mt-3 pt-3 space-y-1">
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Clock, Plus, Trash2, Pencil, Printer, Eye, X } from "lucide-react";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatFechaRegistro,
|
||||
periodoAgrupacion,
|
||||
calcularHorasRango,
|
||||
MODOS_AGRUPACION,
|
||||
IVA_RATE,
|
||||
type ModoAgrupacion,
|
||||
} from "@/lib/calculators";
|
||||
|
||||
interface Registro {
|
||||
id: string;
|
||||
fecha: string; // ISO (12:00 UTC)
|
||||
horaInicio: string;
|
||||
horaFin: string;
|
||||
horas: number;
|
||||
tarifaHora: number;
|
||||
descripcion: string;
|
||||
}
|
||||
|
||||
interface Branding {
|
||||
colorPrimario?: string;
|
||||
colorSecundario?: string;
|
||||
logoBase64?: string;
|
||||
logoMime?: string;
|
||||
}
|
||||
|
||||
interface RegistroHorasPanelProps {
|
||||
cotizacionId: string;
|
||||
numero: string;
|
||||
clienteNombre: string;
|
||||
clienteEmpresa: string | null;
|
||||
proyecto: string;
|
||||
tarifaSugerida: number;
|
||||
incluirIva: boolean;
|
||||
branding: Branding;
|
||||
registrosIniciales: Registro[];
|
||||
}
|
||||
|
||||
function hoyISO(): string {
|
||||
const d = new Date();
|
||||
const p2 = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`;
|
||||
}
|
||||
|
||||
const formVacio = (tarifa: number) => ({
|
||||
fecha: hoyISO(),
|
||||
horaInicio: "09:00",
|
||||
horaFin: "13:00",
|
||||
descripcion: "",
|
||||
tarifaHora: tarifa,
|
||||
});
|
||||
|
||||
const INPUT = "px-2 py-1.5 border border-border rounded text-sm focus:outline-none focus:ring-1 focus:ring-primary";
|
||||
|
||||
export function RegistroHorasPanel({
|
||||
cotizacionId,
|
||||
numero,
|
||||
clienteNombre,
|
||||
clienteEmpresa,
|
||||
proyecto,
|
||||
tarifaSugerida,
|
||||
incluirIva,
|
||||
branding,
|
||||
registrosIniciales,
|
||||
}: RegistroHorasPanelProps) {
|
||||
const [registros, setRegistros] = useState<Registro[]>(registrosIniciales);
|
||||
const [form, setForm] = useState(formVacio(tarifaSugerida));
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [modo, setModo] = useState<ModoAgrupacion>("detalle");
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
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);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// Horas en vivo del rango actual del formulario (feedback al asesor).
|
||||
const horasPreview = calcularHorasRango(form.horaInicio, form.horaFin);
|
||||
|
||||
const filtrados = useMemo(() => {
|
||||
return registros
|
||||
.filter((r) => {
|
||||
const d = r.fecha.slice(0, 10);
|
||||
if (from && d < from) return false;
|
||||
if (to && d > to) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) =>
|
||||
a.fecha === b.fecha
|
||||
? a.horaInicio.localeCompare(b.horaInicio)
|
||||
: a.fecha.localeCompare(b.fecha)
|
||||
);
|
||||
}, [registros, from, to]);
|
||||
|
||||
const grupos = useMemo(() => {
|
||||
if (modo === "detalle") return [];
|
||||
const map = new Map<string, { etiqueta: string; horas: number; importe: number; count: number }>();
|
||||
for (const r of filtrados) {
|
||||
const { clave, etiqueta } = periodoAgrupacion(new Date(r.fecha), modo);
|
||||
const g = map.get(clave) ?? { etiqueta, horas: 0, importe: 0, count: 0 };
|
||||
g.horas += r.horas;
|
||||
g.importe += r.horas * r.tarifaHora;
|
||||
g.count += 1;
|
||||
map.set(clave, g);
|
||||
}
|
||||
return [...map.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([clave, g]) => ({ clave, ...g }));
|
||||
}, [filtrados, modo]);
|
||||
|
||||
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;
|
||||
const total = subtotal + iva;
|
||||
|
||||
const resetForm = () => {
|
||||
setForm(formVacio(tarifaSugerida));
|
||||
setEditId(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (calcularHorasRango(form.horaInicio, form.horaFin) <= 0) {
|
||||
setError("El rango es invalido: la hora fin debe ser mayor a la de inicio.");
|
||||
return;
|
||||
}
|
||||
if (!form.descripcion.trim()) {
|
||||
setError("Agrega una descripcion breve de lo realizado.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const url = editId
|
||||
? `/api/cotizaciones/${cotizacionId}/horas/${editId}`
|
||||
: `/api/cotizaciones/${cotizacionId}/horas`;
|
||||
const res = await fetch(url, {
|
||||
method: editId ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || "No se pudo guardar el registro");
|
||||
return;
|
||||
}
|
||||
setRegistros((prev) =>
|
||||
editId ? prev.map((r) => (r.id === editId ? data : r)) : [...prev, data]
|
||||
);
|
||||
resetForm();
|
||||
} catch {
|
||||
setError("Error de conexion al guardar");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (r: Registro) => {
|
||||
setEditId(r.id);
|
||||
setError(null);
|
||||
setForm({
|
||||
fecha: r.fecha.slice(0, 10),
|
||||
horaInicio: r.horaInicio,
|
||||
horaFin: r.horaFin,
|
||||
descripcion: r.descripcion,
|
||||
tarifaHora: r.tarifaHora,
|
||||
});
|
||||
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("¿Eliminar este registro de horas?")) return;
|
||||
const prev = registros;
|
||||
setRegistros((rs) => rs.filter((r) => r.id !== id));
|
||||
if (editId === id) resetForm();
|
||||
try {
|
||||
const res = await fetch(`/api/cotizaciones/${cotizacionId}/horas/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) setRegistros(prev);
|
||||
} catch {
|
||||
setRegistros(prev);
|
||||
}
|
||||
};
|
||||
|
||||
// Congela el HTML de la nota (con branding) y abre el modal de previsualización.
|
||||
const abrirPreview = () => setPreviewHtml(construirHTMLNota());
|
||||
|
||||
// Imprime / guarda como PDF solo el contenido de la nota previsualizada.
|
||||
const imprimirDesdePreview = () => {
|
||||
const w = iframeRef.current?.contentWindow;
|
||||
if (!w) return;
|
||||
w.focus();
|
||||
w.print();
|
||||
};
|
||||
|
||||
// ---- HTML autocontenido de la nota de pago (para imprimir / guardar como PDF) ----
|
||||
const construirHTMLNota = () => {
|
||||
const PRIMARY = branding.colorPrimario || "#2563eb";
|
||||
const DARK = branding.colorSecundario || "#111827";
|
||||
const logoSrc = branding.logoBase64
|
||||
? `data:${branding.logoMime || "image/png"};base64,${branding.logoBase64}`
|
||||
: "";
|
||||
|
||||
const periodoTexto =
|
||||
from || to
|
||||
? `Periodo: ${from ? formatFechaRegistro(new Date(`${from}T12:00:00Z`)) : "inicio"} al ${
|
||||
to ? formatFechaRegistro(new Date(`${to}T12:00:00Z`)) : "hoy"
|
||||
}`
|
||||
: "Periodo: todos los registros";
|
||||
|
||||
const modoLabel = MODOS_AGRUPACION.find((m) => m.value === modo)?.label ?? "";
|
||||
|
||||
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>
|
||||
</tr></thead>
|
||||
<tbody>${filtrados
|
||||
.map(
|
||||
(r) => `<tr>
|
||||
<td>${formatFechaRegistro(new Date(r.fecha))}</td>
|
||||
<td>${r.horaInicio}–${r.horaFin}</td>
|
||||
<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>
|
||||
</tr>`
|
||||
)
|
||||
.join("")}</tbody>`
|
||||
: `<thead><tr>
|
||||
<th>${modoLabel}</th><th class="r">Registros</th>
|
||||
<th class="r">Horas</th><th class="r">Importe</th>
|
||||
</tr></thead>
|
||||
<tbody>${grupos
|
||||
.map(
|
||||
(g) => `<tr>
|
||||
<td>${escapeHtml(g.etiqueta)}</td>
|
||||
<td class="r">${g.count}</td>
|
||||
<td class="r">${g.horas.toFixed(2)}</td>
|
||||
<td class="r">${formatCurrency(g.importe)}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("")}</tbody>`;
|
||||
|
||||
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>
|
||||
</table>`;
|
||||
|
||||
return `<!doctype html><html lang="es"><head><meta charset="utf-8">
|
||||
<title>Nota de pago ${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}
|
||||
.head{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid ${PRIMARY};padding-bottom:12px;margin-bottom:16px}
|
||||
.brand{display:flex;align-items:center;gap:14px}
|
||||
.logo{height:52px;width:auto;max-width:220px;object-fit:contain}
|
||||
.meta{margin:12px 0;line-height:1.6}
|
||||
table{width:100%;border-collapse:collapse;margin-top:8px}
|
||||
th,td{border:1px solid #d1d5db;padding:6px 8px;text-align:left;vertical-align:top}
|
||||
th{background:#f3f4f6;font-size:11px;text-transform:uppercase;letter-spacing:.03em}
|
||||
.r{text-align:right;white-space:nowrap}
|
||||
.tot{width:auto;min-width:280px;margin-left:auto;margin-top:16px}
|
||||
.tot td{border:none;padding:4px 8px}
|
||||
.tot .grand td{border-top:2px solid ${PRIMARY};font-weight:bold;font-size:15px;padding-top:8px;color:${DARK}}
|
||||
.foot{margin-top:32px;color:#6b7280;font-size:11px;text-align:center}
|
||||
@media print{body{margin:16px}}
|
||||
</style></head><body>
|
||||
<div class="head">
|
||||
<div class="brand">
|
||||
${logoSrc ? `<img src="${logoSrc}" alt="Logo" class="logo">` : ""}
|
||||
<div>
|
||||
<h1>Nota de pago</h1>
|
||||
<div class="muted">Cotizacion ${escapeHtml(numero)} · ${escapeHtml(proyecto)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="muted" style="text-align:right">Emitida: ${formatFechaRegistro(new Date(new Date().toISOString().slice(0, 10) + "T12:00:00Z"))}</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div><b>Cliente:</b> ${escapeHtml(clienteEmpresa || clienteNombre)}</div>
|
||||
${clienteEmpresa ? `<div><b>Contacto:</b> ${escapeHtml(clienteNombre)}</div>` : ""}
|
||||
<div class="muted">${periodoTexto} · Desglose ${modoLabel.toLowerCase()}</div>
|
||||
</div>
|
||||
<table>${cuerpo}</table>
|
||||
${totalesHTML}
|
||||
<div class="foot">Documento generado desde el Cotizador. Conteo de horas trabajadas para cobro del proyecto.</div>
|
||||
</body></html>`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-card-bg rounded-xl border border-border p-5 mb-6">
|
||||
<div className="flex items-center justify-between mb-4 gap-3 flex-wrap">
|
||||
<h2 className="font-semibold text-lg flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-primary" />
|
||||
Registro de horas
|
||||
</h2>
|
||||
<button
|
||||
onClick={abrirPreview}
|
||||
disabled={filtrados.length === 0}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Formulario de alta / edicion */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-cols-1 md:grid-cols-12 gap-2 items-end bg-gray-50 rounded-lg p-3 mb-4"
|
||||
>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs text-muted mb-1">Fecha</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.fecha}
|
||||
onChange={(e) => setForm({ ...form, fecha: e.target.value })}
|
||||
className={`${INPUT} w-full`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-1">
|
||||
<label className="block text-xs text-muted mb-1">Inicio</label>
|
||||
<input
|
||||
type="time"
|
||||
value={form.horaInicio}
|
||||
onChange={(e) => setForm({ ...form, horaInicio: e.target.value })}
|
||||
className={`${INPUT} w-full`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-1">
|
||||
<label className="block text-xs text-muted mb-1">Fin</label>
|
||||
<input
|
||||
type="time"
|
||||
value={form.horaFin}
|
||||
onChange={(e) => setForm({ ...form, horaFin: e.target.value })}
|
||||
className={`${INPUT} w-full`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-1">
|
||||
<label className="block text-xs text-muted mb-1">Horas</label>
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-center">
|
||||
{horasPreview > 0 ? horasPreview.toFixed(2) : "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-4">
|
||||
<label className="block text-xs text-muted mb-1">Descripcion</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.descripcion}
|
||||
onChange={(e) => setForm({ ...form, descripcion: e.target.value })}
|
||||
placeholder="Qué se hizo en este tramo"
|
||||
className={`${INPUT} w-full`}
|
||||
maxLength={500}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-1">
|
||||
<label className="block text-xs text-muted mb-1">Tarifa/hr</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={form.tarifaHora}
|
||||
onChange={(e) => setForm({ ...form, tarifaHora: parseFloat(e.target.value) || 0 })}
|
||||
className={`${INPUT} w-full text-right`}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 bg-primary text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{editId ? <Pencil className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
|
||||
{editId ? "Guardar" : "Agregar"}
|
||||
</button>
|
||||
{editId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
className="px-2 py-1.5 border border-border rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="md:col-span-12 text-sm text-red-600">{error}</p>}
|
||||
</form>
|
||||
|
||||
{/* Filtros y agrupacion */}
|
||||
<div className="flex items-end gap-3 flex-wrap mb-3">
|
||||
<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} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-muted mb-1">Hasta</label>
|
||||
<input type="date" value={to} onChange={(e) => setTo(e.target.value)} className={INPUT} />
|
||||
</div>
|
||||
{(from || to) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setFrom("");
|
||||
setTo("");
|
||||
}}
|
||||
className="text-xs text-muted underline py-2"
|
||||
>
|
||||
Limpiar
|
||||
</button>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<label className="block text-xs text-muted mb-1">Desglose</label>
|
||||
<select
|
||||
value={modo}
|
||||
onChange={(e) => setModo(e.target.value as ModoAgrupacion)}
|
||||
className={INPUT}
|
||||
>
|
||||
{MODOS_AGRUPACION.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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" : ""}.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-muted border-b border-border">
|
||||
{modo === "detalle" ? (
|
||||
<>
|
||||
<th className="py-2 pr-3 font-medium">Fecha</th>
|
||||
<th className="py-2 pr-3 font-medium">Horario</th>
|
||||
<th className="py-2 pr-3 font-medium text-right">Horas</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">Descripcion</th>
|
||||
<th className="py-2 font-medium"></th>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<th className="py-2 pr-3 font-medium">Periodo</th>
|
||||
<th className="py-2 pr-3 font-medium text-right">Registros</th>
|
||||
<th className="py-2 pr-3 font-medium text-right">Horas</th>
|
||||
<th className="py-2 pr-3 font-medium text-right">Importe</th>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modo === "detalle"
|
||||
? filtrados.map((r) => (
|
||||
<tr key={r.id} className="border-b border-border/60">
|
||||
<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}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right">{r.horas.toFixed(2)}</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">{r.descripcion}</td>
|
||||
<td className="py-2 text-right whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => handleEdit(r)}
|
||||
className="p-1 text-muted hover:text-primary"
|
||||
title="Editar"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(r.id)}
|
||||
className="p-1 text-muted hover:text-red-600"
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
: grupos.map((g) => (
|
||||
<tr key={g.clave} className="border-b border-border/60">
|
||||
<td className="py-2 pr-3">{g.etiqueta}</td>
|
||||
<td className="py-2 pr-3 text-right">{g.count}</td>
|
||||
<td className="py-2 pr-3 text-right">{g.horas.toFixed(2)}</td>
|
||||
<td className="py-2 pr-3 text-right">{formatCurrency(g.importe)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Totales */}
|
||||
{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="font-medium">{totalHoras.toFixed(2)} h</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted">Subtotal</span>
|
||||
<span>{formatCurrency(subtotal)}</span>
|
||||
</div>
|
||||
{incluirIva && (
|
||||
<div className="flex justify-between text-muted">
|
||||
<span>IVA (16%)</span>
|
||||
<span>{formatCurrency(iva)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between font-bold pt-2 border-t border-border">
|
||||
<span>Total a pagar</span>
|
||||
<span className="text-primary">{formatCurrency(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Previsualizador de la nota de pago */}
|
||||
{previewHtml !== null && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col bg-black/60"
|
||||
onClick={() => setPreviewHtml(null)}
|
||||
>
|
||||
<div
|
||||
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>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={imprimirDesdePreview}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
<Printer className="w-4 h-4 text-primary" />
|
||||
Imprimir / Guardar PDF
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPreviewHtml(null)}
|
||||
aria-label="Cerrar previsualización"
|
||||
className="p-2 border border-border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4" onClick={(e) => e.stopPropagation()}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={previewHtml}
|
||||
title="Previsualización de la nota de pago"
|
||||
className="w-full h-full rounded-lg bg-white border border-border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { formatDate, formatCurrency, calcularTotalesOpcion, IVA_RATE, type MetaOpcion } from "@/lib/calculators";
|
||||
import { getConfigBranding } from "@/lib/config-helpers";
|
||||
import {
|
||||
formatDate,
|
||||
formatCurrency,
|
||||
calcularTotalesOpcion,
|
||||
esCotizacionPorTiempo,
|
||||
tarifaHoraSugerida,
|
||||
IVA_RATE,
|
||||
type MetaOpcion,
|
||||
} from "@/lib/calculators";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Pencil } from "lucide-react";
|
||||
import { notFound } from "next/navigation";
|
||||
@@ -7,6 +16,7 @@ import { ExportExcelButtonSaved, ExportPDFButtonSaved, PreviewPDFButtonSaved } f
|
||||
import { DeleteCotizacionButton } from "./DeleteButton";
|
||||
import { CambiarEstadoButtons } from "./CambiarEstadoButtons";
|
||||
import { PreciosEditables } from "./PreciosEditables";
|
||||
import { RegistroHorasPanel } from "./RegistroHorasPanel";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -23,17 +33,25 @@ export default async function CotizacionDetailPage({
|
||||
asesor: true,
|
||||
servicios: { include: { servicioCatalogo: true } },
|
||||
planBucefalo: true,
|
||||
registrosHoras: { orderBy: [{ fecha: "asc" }, { horaInicio: "asc" }] },
|
||||
},
|
||||
});
|
||||
|
||||
if (!cot) notFound();
|
||||
|
||||
// El registro de horas (notas de pago) solo aplica a cotizaciones aprobadas que se
|
||||
// cobran por tiempo (horas/retainer/demanda).
|
||||
const mostrarHoras = cot.estado === "aprobada" && esCotizacionPorTiempo(cot.servicios);
|
||||
const branding = mostrarHoras ? await getConfigBranding() : null;
|
||||
|
||||
const serviciosUnicos = cot.servicios
|
||||
.filter((s) => s.tipoPago === "unico" && s.seleccionado)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
nombre: s.servicioCatalogo?.nombre || s.nombre || "Servicio",
|
||||
precio: s.precio,
|
||||
modeloCobro: s.modeloCobro,
|
||||
tarifaHora: s.tarifaHora,
|
||||
}));
|
||||
|
||||
const serviciosMensuales = cot.servicios
|
||||
@@ -42,6 +60,8 @@ export default async function CotizacionDetailPage({
|
||||
id: s.id,
|
||||
nombre: s.servicioCatalogo?.nombre || s.nombre || "Servicio",
|
||||
precio: s.precio,
|
||||
modeloCobro: s.modeloCobro,
|
||||
tarifaHora: s.tarifaHora,
|
||||
}));
|
||||
|
||||
const opcionesMeta = (cot.opcionesMetadata as { "1"?: MetaOpcion; "2"?: MetaOpcion } | null) ?? {};
|
||||
@@ -85,7 +105,7 @@ export default async function CotizacionDetailPage({
|
||||
</div>
|
||||
|
||||
<div className="bg-card-bg rounded-xl border border-border p-5 mb-6">
|
||||
<h2 className="font-semibold text-lg mb-4">Informacion de la Cotizacion</h2>
|
||||
<h2 className="font-semibold text-lg mb-4">Información de la Cotización</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted">Cliente</span>
|
||||
@@ -180,6 +200,28 @@ export default async function CotizacionDetailPage({
|
||||
serviciosMensuales={serviciosMensuales}
|
||||
/>
|
||||
|
||||
{mostrarHoras && (
|
||||
<RegistroHorasPanel
|
||||
cotizacionId={cot.id}
|
||||
numero={cot.numero}
|
||||
clienteNombre={cot.cliente.nombre}
|
||||
clienteEmpresa={cot.cliente.empresa}
|
||||
proyecto={cot.proyecto}
|
||||
tarifaSugerida={tarifaHoraSugerida(cot.servicios)}
|
||||
incluirIva={cot.incluirIva}
|
||||
branding={branding ?? {}}
|
||||
registrosIniciales={cot.registrosHoras.map((r) => ({
|
||||
id: r.id,
|
||||
fecha: r.fecha.toISOString(),
|
||||
horaInicio: r.horaInicio,
|
||||
horaFin: r.horaFin,
|
||||
horas: r.horas,
|
||||
tarifaHora: r.tarifaHora,
|
||||
descripcion: r.descripcion,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{cot.observaciones && (
|
||||
<div className="bg-card-bg rounded-xl border border-border p-5">
|
||||
<h3 className="font-semibold mb-2">Observaciones</h3>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { registroHorasSchema } from "@/lib/schemas";
|
||||
import { calcularHorasRango, fechaRegistroDesdeISO } from "@/lib/calculators";
|
||||
|
||||
// PUT /api/cotizaciones/:id/horas/:registroId
|
||||
// Edita un registro de horas. Recalcula `horas` del rango.
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; registroId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id, registroId } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
const parsed = registroHorasSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues.map((i) => i.message).join(", ") },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { fecha, horaInicio, horaFin, descripcion, tarifaHora } = parsed.data;
|
||||
|
||||
const horas = calcularHorasRango(horaInicio, horaFin);
|
||||
if (horas <= 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "El rango de horas es invalido (la hora fin debe ser mayor a la de inicio)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
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: {
|
||||
fecha: fechaRegistroDesdeISO(fecha),
|
||||
horaInicio,
|
||||
horaFin,
|
||||
horas,
|
||||
descripcion: descripcion.trim(),
|
||||
...(tarifaHora !== undefined && { tarifaHora }),
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
{ params }: { params: Promise<{ id: string; registroId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id, registroId } = await params;
|
||||
|
||||
const existing = await prisma.registroHoras.findUnique({ where: { id: registroId } });
|
||||
if (!existing || existing.cotizacionId !== id) {
|
||||
return NextResponse.json({ error: "Registro no encontrado" }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.registroHoras.delete({ where: { id: registroId } });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : "Error interno";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { registroHorasSchema } from "@/lib/schemas";
|
||||
import {
|
||||
calcularHorasRango,
|
||||
esCotizacionPorTiempo,
|
||||
fechaRegistroDesdeISO,
|
||||
tarifaHoraSugerida,
|
||||
} from "@/lib/calculators";
|
||||
|
||||
// GET /api/cotizaciones/:id/horas?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
// Lista los registros de horas de la cotizacion (orden cronologico). Filtros from/to
|
||||
// opcionales para acotar el periodo de una nota de pago.
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const sp = request.nextUrl.searchParams;
|
||||
const from = sp.get("from");
|
||||
const to = sp.get("to");
|
||||
|
||||
const fechaFilter: { gte?: Date; lte?: Date } = {};
|
||||
if (from && /^\d{4}-\d{2}-\d{2}$/.test(from)) fechaFilter.gte = fechaRegistroDesdeISO(from);
|
||||
if (to && /^\d{4}-\d{2}-\d{2}$/.test(to)) fechaFilter.lte = fechaRegistroDesdeISO(to);
|
||||
|
||||
const registros = await prisma.registroHoras.findMany({
|
||||
where: {
|
||||
cotizacionId: id,
|
||||
...(Object.keys(fechaFilter).length > 0 && { fecha: fechaFilter }),
|
||||
},
|
||||
orderBy: [{ fecha: "asc" }, { horaInicio: "asc" }],
|
||||
});
|
||||
|
||||
return NextResponse.json(registros);
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : "Error interno";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/cotizaciones/:id/horas
|
||||
// Crea un registro de horas. Solo para cotizaciones con cobro por tiempo. `horas` se
|
||||
// calcula del rango en el servidor; si la tarifa no viene, se usa la sugerida.
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
const parsed = registroHorasSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues.map((i) => i.message).join(", ") },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { fecha, horaInicio, horaFin, descripcion, tarifaHora } = parsed.data;
|
||||
|
||||
const horas = calcularHorasRango(horaInicio, horaFin);
|
||||
if (horas <= 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "El rango de horas es invalido (la hora fin debe ser mayor a la de inicio)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const cot = await prisma.cotizacion.findUnique({
|
||||
where: { id },
|
||||
include: { servicios: true },
|
||||
});
|
||||
if (!cot) {
|
||||
return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
|
||||
}
|
||||
if (!esCotizacionPorTiempo(cot.servicios)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Esta cotizacion no se cobra por tiempo; no admite registro de horas" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tarifa = tarifaHora ?? tarifaHoraSugerida(cot.servicios);
|
||||
|
||||
const registro = await prisma.registroHoras.create({
|
||||
data: {
|
||||
cotizacionId: id,
|
||||
fecha: fechaRegistroDesdeISO(fecha),
|
||||
horaInicio,
|
||||
horaFin,
|
||||
horas,
|
||||
tarifaHora: tarifa,
|
||||
descripcion: descripcion.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(registro, { status: 201 });
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : "Error interno";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,28 @@ export async function PUT(
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
// Camino rápido: cambio de estado puntual (CambiarEstadoButtons envía solo
|
||||
// { estado }). El schema completo exige todos los campos de la cotización,
|
||||
// así que un PUT con solo el estado fallaría la validación; lo tratamos
|
||||
// como un patch parcial.
|
||||
const bodyKeys = Object.keys(body);
|
||||
if (bodyKeys.length === 1 && bodyKeys[0] === "estado") {
|
||||
const nuevoEstado = body.estado;
|
||||
if (!ESTADOS_COTIZACION.includes(nuevoEstado as typeof ESTADOS_COTIZACION[number])) {
|
||||
return NextResponse.json({ error: "Estado invalido" }, { status: 400 });
|
||||
}
|
||||
const existing = await prisma.cotizacion.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "No encontrada" }, { status: 404 });
|
||||
}
|
||||
const updated = await prisma.cotizacion.update({
|
||||
where: { id },
|
||||
data: { estado: nuevoEstado },
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
const parsed = cotizacionPutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
@@ -74,6 +96,7 @@ export async function PUT(
|
||||
esquemaPago,
|
||||
incluirBonos,
|
||||
incluirFinanciamiento,
|
||||
incluirIva,
|
||||
esDoble,
|
||||
opciones,
|
||||
observaciones,
|
||||
@@ -100,6 +123,7 @@ export async function PUT(
|
||||
data: {
|
||||
email: cliente.email || existingCliente.email,
|
||||
telefono: cliente.telefono || existingCliente.telefono,
|
||||
rfc: cliente.rfc || existingCliente.rfc,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -109,6 +133,7 @@ export async function PUT(
|
||||
empresa: cliente.empresa || null,
|
||||
email: cliente.email || null,
|
||||
telefono: cliente.telefono || null,
|
||||
rfc: cliente.rfc || null,
|
||||
},
|
||||
});
|
||||
clienteIdFinal = newCliente.id;
|
||||
@@ -129,6 +154,7 @@ export async function PUT(
|
||||
...(esquemaPago && { esquemaPago }),
|
||||
...(incluirBonos !== undefined && { incluirBonos }),
|
||||
...(incluirFinanciamiento !== undefined && { incluirFinanciamiento }),
|
||||
...(incluirIva !== undefined && { incluirIva }),
|
||||
...(esDoble !== undefined && { esDoble }),
|
||||
...(esDoble !== undefined && { opcionesMetadata: esDoble ? opciones ?? {} : undefined }),
|
||||
...(observaciones !== undefined && { observaciones }),
|
||||
@@ -174,9 +200,11 @@ export async function PUT(
|
||||
opcion: esDobleFinal ? serv.opcion ?? "ambas" : null,
|
||||
fase: serv.fase,
|
||||
tipoPago: serv.tipoPago,
|
||||
precio: serv.precio,
|
||||
// "demanda": tarifa por hora sin compromiso; nunca suma al total.
|
||||
precio: serv.modeloCobro === "demanda" ? 0 : serv.precio,
|
||||
tiempoEntrega: serv.tiempoEntrega,
|
||||
entregables: serv.entregables,
|
||||
beneficios: serv.beneficios ?? [],
|
||||
seleccionado: true,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -22,6 +22,7 @@ export async function POST(request: NextRequest) {
|
||||
esquemaPago,
|
||||
incluirBonos,
|
||||
incluirFinanciamiento,
|
||||
incluirIva,
|
||||
esDoble,
|
||||
opciones,
|
||||
observaciones,
|
||||
@@ -45,6 +46,7 @@ export async function POST(request: NextRequest) {
|
||||
empresa: cliente.empresa || null,
|
||||
email: cliente.email || null,
|
||||
telefono: cliente.telefono || null,
|
||||
rfc: cliente.rfc || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -75,6 +77,7 @@ export async function POST(request: NextRequest) {
|
||||
esquemaPago,
|
||||
incluirBonos,
|
||||
incluirFinanciamiento,
|
||||
incluirIva: incluirIva ?? true,
|
||||
esDoble: esDoble ?? false,
|
||||
opcionesMetadata: esDoble ? opciones ?? {} : undefined,
|
||||
observaciones: observaciones || null,
|
||||
@@ -107,9 +110,11 @@ export async function POST(request: NextRequest) {
|
||||
opcion: esDoble ? servicio.opcion ?? "ambas" : null,
|
||||
fase: servicio.fase,
|
||||
tipoPago: servicio.tipoPago,
|
||||
precio: servicio.precio,
|
||||
// "demanda": tarifa por hora sin compromiso; nunca suma al total.
|
||||
precio: servicio.modeloCobro === "demanda" ? 0 : servicio.precio,
|
||||
tiempoEntrega: servicio.tiempoEntrega,
|
||||
entregables: servicio.entregables,
|
||||
beneficios: servicio.beneficios ?? [],
|
||||
seleccionado: true,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -9,12 +9,64 @@ export function calcularPrecioHoras(horas: number, tarifaHora: number): number {
|
||||
|
||||
// Modelos de cobro de una partida. "retainer" = importe minimo mensual fijo +
|
||||
// tarifa de horas adicionales (las horas extra se facturan aparte, no suman al total).
|
||||
// "demanda" = tarifa por hora sin horas comprometidas; precio=0, no suma al total
|
||||
// (se factura a fin de mes segun consumo). Solo muestra la tarifa y sus beneficios.
|
||||
export const MODELOS_COBRO: Record<string, string> = {
|
||||
fijo: "Precio fijo",
|
||||
horas: "Por horas",
|
||||
retainer: "Retainer (minimo + adicionales)",
|
||||
demanda: "Bajo demanda / por hora",
|
||||
};
|
||||
|
||||
// Sub-linea de desglose por modelo de cobro (horas / retainer / demanda) para PDF/Excel.
|
||||
// Reutilizado por ambos backends para mantener paridad.
|
||||
export function detalleModelo(serv: {
|
||||
modeloCobro?: string | null;
|
||||
esPersonalizado?: boolean | null;
|
||||
horas?: number | null;
|
||||
tarifaHora?: number | null;
|
||||
montoMinimo?: number | null;
|
||||
horasIncluidas?: number | null;
|
||||
}): string {
|
||||
if (serv.modeloCobro === "retainer") {
|
||||
return describirRetainer(serv.montoMinimo ?? 0, serv.horasIncluidas ?? 0, serv.tarifaHora ?? 0);
|
||||
}
|
||||
if (serv.modeloCobro === "demanda") {
|
||||
return `${formatCurrency(serv.tarifaHora ?? 0)}/hr · segun consumo`;
|
||||
}
|
||||
if ((serv.modeloCobro === "horas" || serv.esPersonalizado) && serv.horas && serv.tarifaHora) {
|
||||
return `${serv.horas} h x ${formatCurrency(serv.tarifaHora)}/hr`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Texto a mostrar en la columna "Precio" de una partida. Para "demanda" muestra la
|
||||
// tarifa por hora en vez de $0 (el precio real depende del consumo). Reutilizado por
|
||||
// UI, PDF y Excel para mantener consistencia.
|
||||
export function precioDisplay(serv: {
|
||||
modeloCobro?: string | null;
|
||||
tarifaHora?: number | null;
|
||||
precio: number;
|
||||
}): string {
|
||||
if (serv.modeloCobro === "demanda") {
|
||||
return `${formatCurrency(serv.tarifaHora || 0)}/hr`;
|
||||
}
|
||||
return formatCurrency(serv.precio);
|
||||
}
|
||||
|
||||
// Nota al pie para las partidas "bajo demanda": no suman al total comprometido y se
|
||||
// facturan segun consumo. Devuelve "" si no hay partidas demanda. Reutilizado por PDF y Excel.
|
||||
export function notaDemanda(
|
||||
servicios: Array<{ modeloCobro?: string | null; nombre?: string | null; tarifaHora?: number | null }>
|
||||
): string {
|
||||
const dem = servicios.filter((s) => s.modeloCobro === "demanda");
|
||||
if (dem.length === 0) return "";
|
||||
const detalle = dem
|
||||
.map((s) => `${s.nombre || "Servicio"} a ${formatCurrency(s.tarifaHora || 0)}/hr`)
|
||||
.join(", ");
|
||||
return `+ Horas facturadas a fin de mes segun consumo (no incluidas en el total): ${detalle}.`;
|
||||
}
|
||||
|
||||
// Texto descriptivo de un retainer, reutilizado por UI, PDF y Excel.
|
||||
export function describirRetainer(
|
||||
montoMinimo: number,
|
||||
@@ -84,6 +136,112 @@ export const PLANES_BUCEFALO = [
|
||||
export const ESTADOS_COTIZACION = ["borrador", "enviada", "aprobada", "rechazada"] as const;
|
||||
export type EstadoCotizacion = (typeof ESTADOS_COTIZACION)[number];
|
||||
|
||||
// ----- Registro de horas (notas de pago) -----
|
||||
// Modelos de cobro que implican trabajo por tiempo: para estas cotizaciones se
|
||||
// habilita el registro de horas trabajadas y la emision de notas de pago.
|
||||
export const MODELOS_COBRO_TIEMPO = ["horas", "retainer", "demanda"] as const;
|
||||
|
||||
// Una cotizacion es "por tiempo" si al menos una de sus partidas se cobra por horas,
|
||||
// retainer o demanda. Solo en ese caso tiene sentido registrar horas trabajadas.
|
||||
export function esCotizacionPorTiempo(
|
||||
servicios: Array<{ modeloCobro?: string | null }>
|
||||
): boolean {
|
||||
return servicios.some((s) =>
|
||||
(MODELOS_COBRO_TIEMPO as readonly string[]).includes(s.modeloCobro ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
// Tarifa por hora a sugerir al registrar horas: la de la primera partida por tiempo
|
||||
// con tarifa > 0; si no hay, TARIFA_HORA_DEFAULT.
|
||||
export function tarifaHoraSugerida(
|
||||
servicios: Array<{ modeloCobro?: string | null; tarifaHora?: number | null }>
|
||||
): number {
|
||||
const serv = servicios.find(
|
||||
(s) =>
|
||||
(MODELOS_COBRO_TIEMPO as readonly string[]).includes(s.modeloCobro ?? "") &&
|
||||
(s.tarifaHora ?? 0) > 0
|
||||
);
|
||||
return serv?.tarifaHora ?? TARIFA_HORA_DEFAULT;
|
||||
}
|
||||
|
||||
// "09:00" + "13:30" => 4.5. Devuelve 0 si el formato es invalido o fin <= inicio.
|
||||
// No cruza medianoche (un registro = un tramo dentro de un dia).
|
||||
export function calcularHorasRango(horaInicio: string, horaFin: string): number {
|
||||
const re = /^(\d{1,2}):(\d{2})$/;
|
||||
const a = re.exec(horaInicio ?? "");
|
||||
const b = re.exec(horaFin ?? "");
|
||||
if (!a || !b) return 0;
|
||||
const ini = parseInt(a[1], 10) * 60 + parseInt(a[2], 10);
|
||||
const fin = parseInt(b[1], 10) * 60 + parseInt(b[2], 10);
|
||||
if (fin <= ini) return 0;
|
||||
return Math.round(((fin - ini) / 60) * 100) / 100;
|
||||
}
|
||||
|
||||
export type ModoAgrupacion = "detalle" | "dia" | "semana" | "mes";
|
||||
|
||||
export const MODOS_AGRUPACION: { value: ModoAgrupacion; label: string }[] = [
|
||||
{ value: "detalle", label: "Detalle" },
|
||||
{ value: "dia", label: "Por dia" },
|
||||
{ value: "semana", label: "Por semana" },
|
||||
{ value: "mes", label: "Por mes" },
|
||||
];
|
||||
|
||||
const MESES_LARGO = [
|
||||
"enero", "febrero", "marzo", "abril", "mayo", "junio",
|
||||
"julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre",
|
||||
];
|
||||
|
||||
// La fecha de un registro es una fecha-calendario sin huso horario. Para que el dia
|
||||
// no se corra entre servidor (UTC) y cliente (MX), se persiste a las 12:00 UTC y se
|
||||
// lee SIEMPRE con getters UTC. Helper para construir esa fecha desde "YYYY-MM-DD".
|
||||
export function fechaRegistroDesdeISO(fechaISO: string): Date {
|
||||
return new Date(`${fechaISO}T12:00:00.000Z`);
|
||||
}
|
||||
|
||||
// Formato corto de fecha de registro (DD/MM/YYYY) leyendo en UTC.
|
||||
export function formatFechaRegistro(date: Date): string {
|
||||
return new Intl.DateTimeFormat("es-MX", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function lunesDeSemanaUTC(d: Date): Date {
|
||||
const r = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||
const dia = r.getUTCDay(); // 0 = domingo
|
||||
const offset = dia === 0 ? -6 : 1 - dia;
|
||||
r.setUTCDate(r.getUTCDate() + offset);
|
||||
return r;
|
||||
}
|
||||
|
||||
// Clave + etiqueta del periodo al que pertenece una fecha, segun el modo de agrupacion.
|
||||
// La clave agrupa; la etiqueta se muestra. Todo en UTC para coherencia de calendario.
|
||||
export function periodoAgrupacion(
|
||||
fecha: Date,
|
||||
modo: ModoAgrupacion
|
||||
): { clave: string; etiqueta: string } {
|
||||
const y = fecha.getUTCFullYear();
|
||||
const m = fecha.getUTCMonth();
|
||||
const d = fecha.getUTCDate();
|
||||
const p2 = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
if (modo === "mes") {
|
||||
return { clave: `${y}-${p2(m + 1)}`, etiqueta: `${MESES_LARGO[m]} ${y}` };
|
||||
}
|
||||
if (modo === "semana") {
|
||||
const lunes = lunesDeSemanaUTC(fecha);
|
||||
const domingo = new Date(lunes);
|
||||
domingo.setUTCDate(lunes.getUTCDate() + 6);
|
||||
const fmt = (x: Date) => `${p2(x.getUTCDate())}/${p2(x.getUTCMonth() + 1)}`;
|
||||
const clave = `${lunes.getUTCFullYear()}-${p2(lunes.getUTCMonth() + 1)}-${p2(lunes.getUTCDate())}`;
|
||||
return { clave, etiqueta: `Semana ${fmt(lunes)} - ${fmt(domingo)}` };
|
||||
}
|
||||
// "dia" / "detalle"
|
||||
return { clave: `${y}-${p2(m + 1)}-${p2(d)}`, etiqueta: formatFechaRegistro(fecha) };
|
||||
}
|
||||
|
||||
export function bucefaloPrecio(nivel: string): number {
|
||||
return PLANES_BUCEFALO.find((p) => p.nivel === nivel)?.precio ?? 0;
|
||||
}
|
||||
|
||||
+21
-3
@@ -8,10 +8,11 @@ const servicioSchema = z.object({
|
||||
precio: z.number().min(0),
|
||||
tiempoEntrega: z.string().min(1),
|
||||
entregables: z.array(z.string()),
|
||||
beneficios: z.array(z.string()).optional(),
|
||||
esPersonalizado: z.boolean().optional(),
|
||||
horas: z.number().min(0).optional(),
|
||||
tarifaHora: z.number().min(0).optional(),
|
||||
modeloCobro: z.enum(["fijo", "horas", "retainer"]).optional(),
|
||||
modeloCobro: z.enum(["fijo", "horas", "retainer", "demanda"]).optional(),
|
||||
montoMinimo: z.number().min(0).optional(),
|
||||
horasIncluidas: z.number().min(0).optional(),
|
||||
opcion: z.enum(["1", "2", "ambas"]).optional(),
|
||||
@@ -35,9 +36,10 @@ export const cotizacionPostSchema = z.object({
|
||||
moneda: z.enum(["MXN", "USD"]),
|
||||
tipoCambio: z.string(),
|
||||
proyecto: z.string(),
|
||||
esquemaPago: z.enum(["Pago Unico", "Mensual", "Pago Unico/Mensual"]),
|
||||
esquemaPago: z.enum(["Pago Unico", "Mensual", "Pago Unico/Mensual", "Por hora (postpago)"]),
|
||||
incluirBonos: z.boolean(),
|
||||
incluirFinanciamiento: z.boolean(),
|
||||
incluirIva: z.boolean().optional(),
|
||||
esDoble: z.boolean().optional(),
|
||||
opciones: opcionesSchema,
|
||||
observaciones: z.string(),
|
||||
@@ -47,6 +49,7 @@ export const cotizacionPostSchema = z.object({
|
||||
empresa: z.string(),
|
||||
email: z.string().email().or(z.literal("")),
|
||||
telefono: z.string(),
|
||||
rfc: z.string().optional(),
|
||||
}),
|
||||
servicios: z.array(servicioSchema),
|
||||
planBucefalo: z
|
||||
@@ -63,9 +66,10 @@ export const cotizacionPutSchema = z.object({
|
||||
moneda: z.enum(["MXN", "USD"]),
|
||||
tipoCambio: z.string(),
|
||||
proyecto: z.string(),
|
||||
esquemaPago: z.enum(["Pago Unico", "Mensual", "Pago Unico/Mensual"]),
|
||||
esquemaPago: z.enum(["Pago Unico", "Mensual", "Pago Unico/Mensual", "Por hora (postpago)"]),
|
||||
incluirBonos: z.boolean(),
|
||||
incluirFinanciamiento: z.boolean(),
|
||||
incluirIva: z.boolean().optional(),
|
||||
esDoble: z.boolean().optional(),
|
||||
opciones: opcionesSchema,
|
||||
observaciones: z.string(),
|
||||
@@ -74,6 +78,7 @@ export const cotizacionPutSchema = z.object({
|
||||
empresa: z.string(),
|
||||
email: z.string().email().or(z.literal("")),
|
||||
telefono: z.string(),
|
||||
rfc: z.string().optional(),
|
||||
}),
|
||||
servicios: z.array(servicioSchema),
|
||||
planBucefalo: z
|
||||
@@ -84,6 +89,19 @@ export const cotizacionPutSchema = z.object({
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
const HORA_RE = /^([01]?\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
// Registro de horas trabajadas para una nota de pago. `fecha` es una fecha-calendario
|
||||
// (YYYY-MM-DD); el servidor la materializa a las 12:00 UTC. `horas` no se acepta del
|
||||
// cliente: se calcula del rango en el servidor (fuente de verdad).
|
||||
export const registroHorasSchema = z.object({
|
||||
fecha: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Fecha invalida (YYYY-MM-DD)"),
|
||||
horaInicio: z.string().regex(HORA_RE, "Hora inicio invalida (HH:MM)"),
|
||||
horaFin: z.string().regex(HORA_RE, "Hora fin invalida (HH:MM)"),
|
||||
descripcion: z.string().trim().min(1, "La descripcion es requerida").max(500),
|
||||
tarifaHora: z.number().min(0).optional(),
|
||||
});
|
||||
|
||||
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