Compare commits

..
10 Commits
Author SHA1 Message Date
urieljarethandClaude Opus 4.8 26e6d3a9bf UI: reemplazar diálogos nativos del navegador por modales/avisos propios de la plataforma
Los confirm()/alert()/prompt() nativos se veían fuera de la marca ("<dominio> dice…").
Se agrega un DialogProvider (montado en el layout autenticado) con su estilo visual y se
consume via hooks useConfirm / usePrompt / useToast:
- confirmaciones -> modal propio (con variante "peligro" para eliminaciones)
- prompts (crear paquete / fase) -> modal con input
- alerts de error/éxito -> toasts

Reemplazados todos los diálogos nativos: RegistroHorasPanel, CatalogoClient,
ConfiguracionClient, CotizacionForm, ExportButtons, ListDeleteButton, DeleteButton,
CambiarEstadoButtons. Validado E2E (Playwright): al revertir una partida pagada aparece
el modal propio y NO se dispara el confirm nativo.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-11 14:51:22 -06:00
urieljarethandClaude Opus 4.8 1aab45f29c Registro de horas: hacer descubrible como regresar una partida pagada a "Por pagar"
El boton de revertir solo aparecia en la vista "Pagadas", pero el filtro por defecto
es "Por pagar", que oculta las pagadas -> el usuario no encontraba como corregirlas.
Ahora:
- Las tarjetas de resumen "Por pagar" / "Pagado" son clicables y filtran la tabla; la
  de "Pagado" muestra el hint "Ver / editar" y lleva a las partidas pagadas.
- Cuando no hay pendientes pero si pagadas, el estado vacio ofrece un boton
  "Ver N pagada(s) para editar o regresar a Por pagar".
- Los botones de accion por fila ahora llevan texto ("Pagada" / "Por pagar"), no solo
  icono, para que revertir sea evidente.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-11 14:27:25 -06:00
urieljarethandClaude Opus 4.8 63b14fc8b8 Registro de horas: accion explicita para regresar una partida pagada a "Por pagar"
El badge de estado deja de ser un toggle (facil de disparar por error) y pasa a ser
un indicador. En su lugar, la columna de acciones muestra un boton claro segun el estado:
"Marcar como pagada" (pendientes) o "Regresar a Por pagar" (pagadas, con confirmacion).
Asi se corrigen los casos donde una partida se marca como pagada por error. El backend
ya limpiaba la fechaPago al regresar a por_pagar; solo cambia la UX del panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-11 11:31:18 -06:00
urieljarethandClaude Opus 4.8 3824dd00be Nota de horas: datos de pago (bancarios) + periodo en el nombre del archivo
- Las notas "por pagar" ahora incluyen los datos bancarios de cobro configurados
  (Transferencia Nacional/Internacional: cuenta, CLABE, beneficiario, RFC, banco, SWIFT),
  igual que el PDF de cotizacion. Solo se muestran en las notas por pagar.
- El nombre del PDF incluye el periodo cobrado (fecha minima a maxima de los registros),
  formato DD-MM-YYYY_DD-MM-YYYY. Ej: "Nota de horas por pagar UJ2606AG777 - 24-06-2026_24-06-2026".

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 12:46:35 -06:00
urieljarethandClaude Opus 4.8 feb88de203 Nota de horas: generar PDF en el servidor (sin URL del navegador)
Imprimir el HTML de la nota desde el navegador siempre inserta encabezado/pie con la
URL de la cotizacion, fecha y no. de pagina (el @page{margin:0} no lo evita al imprimir
un iframe). Se reemplaza por un PDF generado en el servidor con pdfkit, que no lleva
ningun encabezado del navegador.

- src/lib/nota-horas-pdf.ts: generateNotaHorasPDF (logo, titulo segun estado, tabla
  detalle/agrupada, columna Estado en "todas", totales con IVA opcional).
- API POST /api/cotizaciones/[id]/nota-horas: filtra por estado/periodo, agrupa y
  calcula totales del lado servidor y devuelve el PDF.
- Panel: el boton del modal pasa de "Imprimir" a "Descargar PDF" (baja el PDF del
  servidor); se conserva la vista previa HTML en pantalla.
- pdf-generator: toPngBuffer ahora convierte webp/otros a PNG con sharp, así el logo
  (webp) tambien aparece en el PDF de la cotizacion.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 10:59:04 -06:00
urieljarethandClaude Opus 4.8 89d7028044 Nota de horas: al imprimir, omitir el encabezado con la URL de la cotizacion
Se agrega @page{margin:0} al HTML de la nota para que el navegador no inserte sus
encabezados/pies automaticos (URL de la cotizacion, fecha, no. de pagina) al imprimir
o guardar como PDF. El margen visual del contenido se pasa a padding del body.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 10:22:58 -06:00
urieljarethandClaude Opus 4.8 34419d1752 Registro de horas: la nota por defecto excluye lo ya pagado
El filtro de estado por defecto pasa de "Todas" a "Por pagar", de modo que el
documento que se emite/manda al cliente ("Nota de horas por pagar") solo lista las
horas pendientes y nunca las ya pagadas. "Pagadas" (recibo) y "Todas" (estado de
cuenta) quedan como vistas internas seleccionables.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 09:15:34 -06:00
urieljarethandClaude Opus 4.8 d83a80c85a 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]>
2026-07-08 08:33:14 -06:00
urieljarethandClaude Opus 4.8 8b1e420d4a 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]>
2026-07-08 02:11:57 -06:00
urieljarethandClaude Opus 4.8 4d550c5963 Fix docker-compose.yaml: quitar BOM UTF-8 y mojibake que rompían Coolify
El archivo raíz docker-compose.yaml (usado por Coolify como "Docker Compose
Location") tenía un BOM UTF-8 al inicio y comentarios con doble codificación.
El parser YAML de Symfony en Coolify fallaba al parsearlo
(Yaml::parse(): argument must be string, null given) dejando docker_compose
en null y abortando el despliegue. Ahora es una copia limpia (UTF-8 sin BOM)
de docker-compose.coolify.yml.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 00:00:24 -06:00
26 changed files with 2334 additions and 84 deletions
+7
View File
@@ -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
+3 -3
View File
@@ -1,13 +1,13 @@
# Cotizador E3 — stack de producción para Coolify
# Cotizador E3 stack de producción para Coolify
#
# En Coolify: crear recurso "Docker Compose" apuntando a este repo y configurar
# "Docker Compose Location" = /docker-compose.coolify.yml
#
# Las variables SERVICE_FQDN_* las resuelve Coolify automáticamente (asignan
# Las variables SERVICE_FQDN_* las resuelve Coolify automáticamente (asignan
# dominio a cada servicio). Define JWT_SECRET, API_KEY, DB_PASSWORD y las
# SEED_* en el panel de variables de entorno de Coolify.
#
# Para desarrollo local sigue usándose docker-compose.yml + start.bat.
# Para desarrollo local sigue usándose docker-compose.yml + start.bat.
services:
postgres:
@@ -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 $$;
@@ -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");
+32
View File
@@ -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,34 @@ 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
// 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
cotizacion Cotizacion @relation(fields: [cotizacionId], references: [id], onDelete: Cascade)
@@index([cotizacionId])
@@index([fecha])
@@index([estadoPago])
}
model Configuracion {
id String @id @default(cuid())
clave String @unique
+39 -11
View File
@@ -17,6 +17,7 @@ import {
Upload,
} from "lucide-react";
import { formatCurrency, FASES as FASES_LABELS } from "@/lib/calculators";
import { useConfirm, usePrompt, useToast } from "@/components/ui/DialogProvider";
import clsx from "clsx";
interface Servicio {
@@ -71,6 +72,9 @@ type Tab = "categorias" | "paquetes" | "servicios";
const FASES = FASES_LABELS;
export function CatalogoClient({ initialServicios, initialCategorias, initialPaquetes }: Props) {
const showConfirm = useConfirm();
const showPrompt = usePrompt();
const showToast = useToast();
const [tab, setTab] = useState<Tab>("servicios");
const [servicios, setServicios] = useState<Servicio[]>(initialServicios);
const [categorias, setCategorias] = useState<Categoria[]>(initialCategorias);
@@ -106,14 +110,14 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
if (res.ok) {
let msg = `${data.creados} servicio(s) importado(s).`;
if (data.omitidos > 0) msg += ` ${data.omitidos} omitido(s).`;
if (data.errores?.length) msg += `\n\nErrores:\n${data.errores.join("\n")}`;
alert(msg);
showToast(msg, "success");
if (data.errores?.length) showToast(`Errores:\n${data.errores.join("\n")}`, "error");
await refresh();
} else {
alert(data.error || "Error al importar");
showToast(data.error || "Error al importar", "error");
}
} catch {
alert("Error al importar CSV");
showToast("Error al importar CSV", "error");
} finally {
setImporting(false);
e.target.value = "";
@@ -123,7 +127,7 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
const deleteCategoria = async (id: string) => {
const res = await fetch(`/api/categorias/${id}`, { method: "DELETE" });
if (res.ok) setCategorias((p) => p.filter((c) => c.id !== id));
else { const e = await res.json(); alert(e.error); }
else { const e = await res.json(); showToast(e.error, "error"); }
};
const toggleCategoriaActivo = async (cat: Categoria) => {
@@ -139,13 +143,24 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
};
const deletePaquete = async (id: string) => {
if (!confirm("Eliminar este paquete y todas sus fases?")) return;
const ok = await showConfirm({
title: "Eliminar paquete",
message: "¿Eliminar este paquete y todas sus fases?",
confirmText: "Eliminar",
danger: true,
});
if (!ok) return;
const res = await fetch(`/api/paquetes/${id}`, { method: "DELETE" });
if (res.ok) setPaquetes((p) => p.filter((pk) => pk.id !== id));
};
const handleCreatePaquete = async () => {
const nombre = prompt("Nombre del paquete:");
const nombre = await showPrompt({
title: "Nuevo paquete",
label: "Nombre del paquete",
placeholder: "Ej. Paquete Emprendedor",
confirmText: "Crear",
});
if (!nombre) return;
const res = await fetch("/api/paquetes", {
method: "POST",
@@ -159,7 +174,12 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
};
const addFaseToPaquete = async (paqueteId: string) => {
const nombre = prompt("Nombre de la fase:");
const nombre = await showPrompt({
title: "Nueva fase",
label: "Nombre de la fase",
placeholder: "Ej. Fase 1 - Setup",
confirmText: "Agregar",
});
if (!nombre) return;
const pk = paquetes.find((p) => p.id === paqueteId);
const orden = pk ? pk.fases.length : 0;
@@ -179,7 +199,13 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
};
const deleteFase = async (paqueteId: string, faseId: string) => {
if (!confirm("Eliminar esta fase?")) return;
const ok = await showConfirm({
title: "Eliminar fase",
message: "¿Eliminar esta fase?",
confirmText: "Eliminar",
danger: true,
});
if (!ok) return;
const res = await fetch(`/api/paquetes/${paqueteId}/manage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -343,7 +369,8 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={async () => {
if (!confirm(`Eliminar "${s.nombre}"?`)) return;
const ok = await showConfirm({ title: "Eliminar servicio", message: `¿Eliminar "${s.nombre}"?`, confirmText: "Eliminar", danger: true });
if (!ok) return;
const res = await fetch(`/api/catalogo/${s.id}`, { method: "DELETE" });
if (res.ok) { const d = await res.json(); if (d.archived) { setServicios((p) => p.map((x) => x.id === s.id ? { ...x, activo: false } : x)); } else { setServicios((p) => p.filter((x) => x.id !== s.id)); } }
}} className="p-1.5 text-muted hover:text-red-500 rounded" title="Eliminar">
@@ -529,6 +556,7 @@ function CreateCategoriaInline({ onCreated }: { onCreated: () => void }) {
const [nombre, setNombre] = useState("");
const [color, setColor] = useState("#6b7280");
const [saving, setSaving] = useState(false);
const showToast = useToast();
const handleSave = async () => {
if (!nombre.trim()) return;
@@ -540,7 +568,7 @@ function CreateCategoriaInline({ onCreated }: { onCreated: () => void }) {
body: JSON.stringify({ nombre: nombre.trim(), color }),
});
if (res.ok) { setNombre(""); onCreated(); }
else { const e = await res.json(); alert(e.error); }
else { const e = await res.json(); showToast(e.error, "error"); }
} finally { setSaving(false); }
};
@@ -2,6 +2,7 @@
import { useState, useRef } from "react";
import { Save, Upload, Trash2, Loader2, Image as ImageIcon } from "lucide-react";
import { useToast } from "@/components/ui/DialogProvider";
const SECTIONS = [
{
@@ -63,6 +64,7 @@ export function ConfiguracionClient({ initial }: { initial: Record<string, strin
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const showToast = useToast();
const handleChange = (key: string, value: string) => {
setConfig((prev) => ({ ...prev, [key]: value }));
@@ -73,7 +75,7 @@ export function ConfiguracionClient({ initial }: { initial: Record<string, strin
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 500 * 1024) {
alert("El logo debe ser menor a 500KB");
showToast("El logo debe ser menor a 500KB", "error");
return;
}
const reader = new FileReader();
@@ -102,10 +104,10 @@ export function ConfiguracionClient({ initial }: { initial: Record<string, strin
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} else {
alert("Error al guardar");
showToast("Error al guardar", "error");
}
} catch {
alert("Error al guardar");
showToast("Error al guardar", "error");
} finally {
setSaving(false);
}
@@ -2,20 +2,29 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useConfirm, useToast } from "@/components/ui/DialogProvider";
export function ListDeleteButton({ id }: { id: string }) {
const [deleting, setDeleting] = useState(false);
const router = useRouter();
const showConfirm = useConfirm();
const showToast = useToast();
const handleDelete = async () => {
if (!confirm("Eliminar esta cotizacion?")) return;
const ok = await showConfirm({
title: "Eliminar cotización",
message: "¿Eliminar esta cotización?",
confirmText: "Eliminar",
danger: true,
});
if (!ok) return;
setDeleting(true);
try {
const res = await fetch(`/api/cotizaciones/${id}`, { method: "DELETE" });
if (res.ok) router.refresh();
else alert("Error al eliminar");
else showToast("Error al eliminar", "error");
} catch {
alert("Error al eliminar");
showToast("Error al eliminar", "error");
} finally {
setDeleting(false);
}
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useToast } from "@/components/ui/DialogProvider";
const OPCIONES = [
{ value: "borrador", label: "Borrador" },
@@ -13,6 +14,7 @@ const OPCIONES = [
export function CambiarEstadoButtons({ cotizacionId, estado }: { cotizacionId: string; estado: string }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const showToast = useToast();
const handleChange = async (nuevoEstado: string) => {
if (nuevoEstado === estado) return;
@@ -24,7 +26,7 @@ export function CambiarEstadoButtons({ cotizacionId, estado }: { cotizacionId: s
body: JSON.stringify({ estado: nuevoEstado }),
});
if (res.ok) router.refresh();
else alert("Error al cambiar estado");
else showToast("Error al cambiar estado", "error");
} finally {
setLoading(false);
}
@@ -3,6 +3,7 @@
import { Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useToast } from "@/components/ui/DialogProvider";
export function DeleteCotizacionButton({
cotizacionId,
@@ -14,6 +15,7 @@ export function DeleteCotizacionButton({
const router = useRouter();
const [confirming, setConfirming] = useState(false);
const [deleting, setDeleting] = useState(false);
const showToast = useToast();
const handleDelete = async () => {
setDeleting(true);
@@ -24,11 +26,11 @@ export function DeleteCotizacionButton({
if (res.ok) {
router.push("/cotizaciones");
} else {
alert("Error al eliminar");
showToast("Error al eliminar", "error");
setConfirming(false);
}
} catch {
alert("Error al eliminar");
showToast("Error al eliminar", "error");
setConfirming(false);
} finally {
setDeleting(false);
@@ -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,854 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { Clock, Plus, Trash2, Pencil, Download, Eye, X, CheckCircle2, CircleDashed, RotateCcw } from "lucide-react";
import { useConfirm, useToast } from "@/components/ui/DialogProvider";
import {
formatCurrency,
formatFechaRegistro,
periodoAgrupacion,
calcularHorasRango,
resumenPagoHoras,
esPagada,
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;
estadoPago: string; // "por_pagar" | "pagada"
fechaPago?: string | null;
}
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[];
}
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");
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 showConfirm = useConfirm();
const showToast = useToast();
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");
// Por defecto "por_pagar": el documento que se emite/manda al cliente nunca debe
// incluir horas ya pagadas. "pagada"/"todas" son vistas internas (recibo/estado de cuenta).
const [estadoFiltro, setEstadoFiltro] = useState<FiltroEstado>("por_pagar");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [saving, setSaving] = useState(false);
const [descargando, setDescargando] = 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);
const iframeRef = useRef<HTMLIFrameElement>(null);
// Horas en vivo del rango actual del formulario (feedback al asesor).
const horasPreview = calcularHorasRango(form.horaInicio, form.horaFin);
// 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);
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]);
// 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 }>();
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 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;
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) => {
const ok = await showConfirm({
title: "Eliminar registro",
message: "¿Eliminar este registro de horas?",
confirmText: "Eliminar",
danger: true,
});
if (!ok) 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);
}
};
// Cambia el estado de cobro de una partida a un valor explícito. Optimista.
// Al regresar a "por_pagar" el backend limpia la fechaPago (deja de contar como cobrada).
const cambiarEstado = async (r: Registro, nuevo: "por_pagar" | "pagada") => {
if (esPagada(r.estadoPago) === (nuevo === "pagada")) return; // ya está en ese estado
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);
}
};
// Regresa una partida pagada por error a "Por pagar". Pide confirmación porque se
// pierde la marca/fecha de pago y la partida vuelve a entrar en la nota por cobrar.
const regresarPorPagar = async (r: Registro) => {
const ok = await showConfirm({
title: "Regresar a Por pagar",
message: 'Se quitará la marca de pagada y esta partida volverá a contar como pendiente de cobro.',
confirmText: "Regresar a Por pagar",
});
if (!ok) return;
cambiarEstado(r, "por_pagar");
};
// Congela el HTML de la nota (con branding) y abre el modal de previsualización.
const abrirPreview = () => setPreviewHtml(construirHTMLNota());
// Descarga la nota como PDF generado en el SERVIDOR (pdfkit). A diferencia de imprimir
// el HTML desde el navegador, este PDF no lleva encabezados/pies del navegador (URL,
// fecha, no. de pagina). Respeta el filtro de estado, periodo y desglose actuales.
const descargarNotaPDF = async () => {
setDescargando(true);
try {
const res = await fetch(`/api/cotizaciones/${cotizacionId}/nota-horas`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ estado: estadoFiltro, modo, from, to }),
});
if (!res.ok) {
showToast("No se pudo generar el PDF", "error");
return;
}
const blob = await res.blob();
// Periodo cobrado para el nombre: fecha minima_maxima de los registros de la nota.
const fechas = filtrados.map((r) => r.fecha.slice(0, 10)).sort();
const dd = (iso: string) => {
const [y, mo, d] = iso.split("-");
return `${d}-${mo}-${y}`;
};
const rango = fechas.length ? `${dd(fechas[0])}_${dd(fechas[fechas.length - 1])}` : "";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${TITULO_NOTA[estadoFiltro]} ${numero}${rango ? ` - ${rango}` : ""}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
} catch {
showToast("Error de conexión al generar el PDF", "error");
} finally {
setDescargando(false);
}
};
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";
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 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>${
mostrarColEstado ? "<th>Estado</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>${
mostrarColEstado
? `<td>${esPagada(r.estadoPago) ? "Pagada" : "Por pagar"}</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 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>${etiquetaTotal}</td><td class="r">${formatCurrency(total)}</td></tr>
</table>`;
return `<!doctype html><html lang="es"><head><meta charset="utf-8">
<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}
.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}
/* margin:0 en @page hace que el navegador omita sus encabezados/pies
automaticos (URL de la cotizacion, fecha, no. de pagina) al imprimir.
El margen visual del contenido se pasa a padding del body. */
@page{margin:0}
@media print{body{margin:0;padding:14mm}}
</style></head><body>
<div class="head">
<div class="brand">
${logoSrc ? `<img src="${logoSrc}" alt="Logo" class="logo">` : ""}
<div>
<h1>${escapeHtml(titulo)}</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" />
{estadoFiltro === "pagada"
? "Previsualizar recibo"
: estadoFiltro === "todas"
? "Previsualizar estado de cuenta"
: "Previsualizar nota por pagar"}
</button>
</div>
{/* Resumen separado: pendiente vs pagado (nunca se acumulan). Cada tarjeta
funciona ademas como filtro: al hacer clic muestra esas partidas abajo.
La de "Pagado" es el camino para editar/regresar a por pagar una partida. */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
<button
type="button"
onClick={() => setEstadoFiltro("por_pagar")}
className={`text-left rounded-lg border p-3 transition ${
estadoFiltro === "por_pagar"
? "border-amber-400 bg-amber-50 ring-1 ring-amber-300"
: "border-amber-200 bg-amber-50/60 hover:bg-amber-50"
}`}
>
<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>
</button>
<button
type="button"
onClick={() => setEstadoFiltro("pagada")}
className={`text-left rounded-lg border p-3 transition ${
estadoFiltro === "pagada"
? "border-green-400 bg-green-50 ring-1 ring-green-300"
: "border-green-200 bg-green-50/60 hover:bg-green-50"
}`}
>
<div className="flex items-center justify-between gap-2 text-green-800 text-sm font-medium">
<span className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" /> Pagado
</span>
{resumen.pagada.count > 0 && (
<span className="text-xs font-normal text-green-700/90 underline">Ver / editar</span>
)}
</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>
</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: 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} />
</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 ? (
<div className="py-6 text-center">
<p className="text-muted text-sm">
{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>
{estadoFiltro === "por_pagar" && resumen.pagada.count > 0 && (
<button
type="button"
onClick={() => setEstadoFiltro("pagada")}
className="mt-2 inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
>
<CheckCircle2 className="w-4 h-4" />
Ver {resumen.pagada.count} pagada(s) para editar o regresar a Por pagar
</button>
)}
</div>
) : (
<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 pr-3 font-medium">Estado</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 ${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}
</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 pr-3 whitespace-nowrap">{badgeEstado(r.estadoPago)}</td>
<td className="py-2 text-right whitespace-nowrap">
{esPagada(r.estadoPago) ? (
<button
onClick={() => regresarPorPagar(r)}
disabled={togglingId === r.id}
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-amber-300 text-amber-700 hover:bg-amber-50 text-xs align-middle disabled:opacity-50"
title="Regresar a Por pagar"
>
<RotateCcw className="w-3.5 h-3.5" />
Por pagar
</button>
) : (
<button
onClick={() => cambiarEstado(r, "pagada")}
disabled={togglingId === r.id}
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-green-300 text-green-700 hover:bg-green-50 text-xs align-middle disabled:opacity-50"
title="Marcar como pagada"
>
<CheckCircle2 className="w-3.5 h-3.5" />
Pagada
</button>
)}
<button
onClick={() => handleEdit(r)}
className="p-1 text-muted hover:text-primary align-middle"
title="Editar"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(r.id)}
className="p-1 text-muted hover:text-red-600 align-middle"
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 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{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">
<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>
{estadoFiltro === "pagada"
? "Total pagado"
: estadoFiltro === "todas"
? "Total general"
: "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">{TITULO_NOTA[estadoFiltro]} {numero}</h3>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={descargarNotaPDF}
disabled={descargando}
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50 disabled:opacity-50"
>
<Download className="w-4 h-4 text-primary" />
{descargando ? "Generando…" : "Descargar 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
+46 -2
View File
@@ -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,30 @@ 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,
estadoPago: r.estadoPago,
fechaPago: r.fechaPago ? r.fechaPago.toISOString() : null,
}))}
/>
)}
{cot.observaciones && (
<div className="bg-card-bg rounded-xl border border-border p-5">
<h3 className="font-semibold mb-2">Observaciones</h3>
+3 -2
View File
@@ -1,4 +1,5 @@
import Sidebar from "@/components/layout/Sidebar";
import { DialogProvider } from "@/components/ui/DialogProvider";
export default function AppLayout({
children,
@@ -6,11 +7,11 @@ export default function AppLayout({
children: React.ReactNode;
}) {
return (
<>
<DialogProvider>
<Sidebar />
<main className="flex-1 lg:ml-64">
<div className="p-4 lg:p-8 pt-16 lg:pt-8">{children}</div>
</main>
</>
</DialogProvider>
);
}
@@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { registroHorasSchema, registroHorasEstadoSchema } 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 });
}
}
// 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,
{ 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 });
}
}
@@ -0,0 +1,168 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getConfigBranding, getConfigBancaria } from "@/lib/config-helpers";
import { generateNotaHorasPDF, type NotaHorasGrupo } from "@/lib/nota-horas-pdf";
import {
IVA_RATE,
MODOS_AGRUPACION,
periodoAgrupacion,
formatFechaRegistro,
esPagada,
sanitizeFilename,
type ModoAgrupacion,
} from "@/lib/calculators";
const TITULO: Record<string, string> = {
por_pagar: "Nota de horas por pagar",
pagada: "Recibo de horas pagadas",
todas: "Estado de cuenta de horas",
};
const ETIQUETA_TOTAL: Record<string, string> = {
por_pagar: "Total a pagar",
pagada: "Total pagado",
todas: "Total",
};
const MODOS = new Set(["detalle", "dia", "semana", "mes"]);
const ESTADOS = new Set(["por_pagar", "pagada", "todas"]);
const ISO_RE = /^\d{4}-\d{2}-\d{2}$/;
// POST /api/cotizaciones/:id/nota-horas
// Genera un PDF de servidor de la nota de horas (sin encabezados de navegador).
// body: { estado, modo, from?, to? } — replica el filtro/agrupacion del panel.
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json().catch(() => ({}));
const estado: string = ESTADOS.has(body?.estado) ? body.estado : "por_pagar";
const modo: ModoAgrupacion = MODOS.has(body?.modo) ? body.modo : "detalle";
const from: string = ISO_RE.test(body?.from) ? body.from : "";
const to: string = ISO_RE.test(body?.to) ? body.to : "";
const [cot, branding, bancaria] = await Promise.all([
prisma.cotizacion.findUnique({
where: { id },
include: { cliente: true, registrosHoras: true },
}),
getConfigBranding(),
getConfigBancaria(),
]);
if (!cot) return NextResponse.json({ error: "No encontrada" }, { status: 404 });
// Filtro por fecha + estado (misma logica que el panel)
let regs = cot.registrosHoras.filter((r) => {
const d = r.fecha.toISOString().slice(0, 10);
if (from && d < from) return false;
if (to && d > to) return false;
if (estado === "pagada") return esPagada(r.estadoPago);
if (estado === "por_pagar") return !esPagada(r.estadoPago);
return true;
});
regs = regs.sort((a, b) =>
a.fecha.getTime() === b.fecha.getTime()
? a.horaInicio.localeCompare(b.horaInicio)
: a.fecha.getTime() - b.fecha.getTime()
);
// Totales
const totalHoras = Math.round(regs.reduce((a, r) => a + r.horas, 0) * 100) / 100;
const subtotal = Math.round(regs.reduce((a, r) => a + r.horas * r.tarifaHora, 0) * 100) / 100;
const iva = cot.incluirIva ? Math.round(subtotal * IVA_RATE * 100) / 100 : 0;
const total = Math.round((subtotal + iva) * 100) / 100;
// Agrupacion (si aplica)
let grupos: NotaHorasGrupo[] = [];
if (modo !== "detalle") {
const map = new Map<string, NotaHorasGrupo>();
for (const r of regs) {
const { clave, etiqueta } = periodoAgrupacion(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);
}
grupos = [...map.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([, g]) => ({ ...g, horas: Math.round(g.horas * 100) / 100, importe: Math.round(g.importe * 100) / 100 }));
}
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((mm) => mm.value === modo)?.label ?? "Detalle";
// Datos bancarios para el cobro (misma extraccion que el PDF de cotizacion).
// Solo se muestran en las notas "por pagar" (las que se envian para cobro).
const cfg = bancaria || {};
const datosPago =
estado === "por_pagar"
? {
razonSocial: cfg.razon_social || "URIEL JARETH ALVARADO ORTIZ",
rfc: cfg.rfc || "AAOU970201SU7",
cuentaNacional: cfg.cuenta_nacional || "",
clabeNacional: cfg.clabe_interbancaria || cfg.cuenta_nacional || "",
bancoNacional: cfg.cuenta_nacional ? "BBVA" : "",
clabeInternacional: cfg.cuenta_internacional_swift?.match(/CLABE[^:]*:\s*(\S+)/i)?.[1] || "",
swift: cfg.cuenta_internacional_swift?.match(/SWIFT[^:]*:\s*(\S+)/i)?.[1] || "BCMRMXMMPYM",
}
: undefined;
// Periodo cobrado para el nombre del archivo: fecha minima_maxima (DD-MM-YYYY).
const fechasISO = regs.map((r) => r.fecha.toISOString().slice(0, 10)).sort();
const ddmmyyyy = (iso: string) => {
const [yy, mo, dd] = iso.split("-");
return `${dd}-${mo}-${yy}`;
};
const rango = fechasISO.length
? `${ddmmyyyy(fechasISO[0])}_${ddmmyyyy(fechasISO[fechasISO.length - 1])}`
: "";
const buffer = await generateNotaHorasPDF({
numero: cot.numero,
proyecto: cot.proyecto,
clienteNombre: cot.cliente.nombre,
clienteEmpresa: cot.cliente.empresa,
titulo: TITULO[estado],
emitida: new Date(),
periodoTexto,
modoLabel,
modo,
mostrarColEstado: estado === "todas" && modo === "detalle",
detalle: regs.map((r) => ({
fecha: r.fecha,
horaInicio: r.horaInicio,
horaFin: r.horaFin,
horas: r.horas,
tarifaHora: r.tarifaHora,
descripcion: r.descripcion,
estadoPago: r.estadoPago,
})),
grupos,
incluirIva: cot.incluirIva,
totalHoras,
subtotal,
iva,
total,
etiquetaTotal: ETIQUETA_TOTAL[estado],
datosPago,
...branding,
});
const nombre = sanitizeFilename(`${TITULO[estado]} ${cot.numero}${rango ? ` - ${rango}` : ""}`);
return new NextResponse(new Uint8Array(buffer), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${nombre}.pdf"`,
},
});
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+29 -1
View File
@@ -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,
};
}),
+6 -1
View File
@@ -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,
};
}),
+4 -2
View File
@@ -37,6 +37,7 @@ import {
useCotizacionStore,
ServicioSeleccionado,
} from "@/lib/store";
import { useToast } from "@/components/ui/DialogProvider";
import {
ExportPDFButtonDraft,
ExportExcelButtonDraft,
@@ -136,6 +137,7 @@ export function CotizacionForm({
}: CotizacionFormProps) {
const router = useRouter();
const store = useCotizacionStore();
const showToast = useToast();
const [expandedFases, setExpandedFases] = useState<Set<number>>(
new Set([0, 1, 2, 3])
@@ -369,10 +371,10 @@ export function CotizacionForm({
}
} else {
const err = await res.json();
alert("Error: " + (err.error || "No se pudo guardar"));
showToast("Error: " + (err.error || "No se pudo guardar"), "error");
}
} catch {
alert("Error al guardar la cotizacion");
showToast("Error al guardar la cotización", "error");
} finally {
setSaving(false);
}
+9 -6
View File
@@ -2,6 +2,7 @@
import { Eye, FileDown, FileSpreadsheet, Loader2, X } from "lucide-react";
import { useState } from "react";
import { useToast } from "@/components/ui/DialogProvider";
function getFilenameFromHeaders(headers: Headers, fallback: string): string {
const cd = headers.get("Content-Disposition");
@@ -14,6 +15,7 @@ function getFilenameFromHeaders(headers: Headers, fallback: string): string {
function useFileExport() {
const [loading, setLoading] = useState(false);
const showToast = useToast();
const download = async (url: string, fallback: string) => {
setLoading(true);
@@ -29,10 +31,10 @@ function useFileExport() {
a.click();
window.URL.revokeObjectURL(blobUrl);
} else {
alert("Error al exportar");
showToast("Error al exportar", "error");
}
} catch {
alert("Error al exportar");
showToast("Error al exportar", "error");
} finally {
setLoading(false);
}
@@ -56,10 +58,10 @@ function useFileExport() {
a.click();
window.URL.revokeObjectURL(blobUrl);
} else {
alert("Error al exportar");
showToast("Error al exportar", "error");
}
} catch {
alert("Error al exportar");
showToast("Error al exportar", "error");
} finally {
setLoading(false);
}
@@ -175,6 +177,7 @@ function PDFPreviewModal({
function usePDFPreview() {
const [loading, setLoading] = useState(false);
const [preview, setPreview] = useState<{ url: string; filename: string } | null>(null);
const showToast = useToast();
const open = async (fetcher: () => Promise<Response>, fallback: string) => {
setLoading(true);
@@ -186,10 +189,10 @@ function usePDFPreview() {
const url = window.URL.createObjectURL(blob);
setPreview({ url, filename });
} else {
alert("Error al generar la previsualización");
showToast("Error al generar la previsualización", "error");
}
} catch {
alert("Error al generar la previsualización");
showToast("Error al generar la previsualización", "error");
} finally {
setLoading(false);
}
+296
View File
@@ -0,0 +1,296 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { AlertTriangle, CheckCircle2, Info, XCircle, X } from "lucide-react";
// Sistema de ventanas emergentes PROPIO de la plataforma (con su estilo visual),
// para reemplazar los diálogos nativos del navegador (confirm/alert/prompt), que
// se ven fuera de la marca ("<dominio> dice…"). Se monta una sola vez en el layout
// y se consume con los hooks useConfirm / usePrompt / useToast.
type ConfirmOptions = {
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
danger?: boolean;
};
type PromptOptions = {
title?: string;
message?: string;
label?: string;
defaultValue?: string;
placeholder?: string;
confirmText?: string;
cancelText?: string;
};
type ToastKind = "success" | "error" | "info";
type ToastItem = { id: number; message: string; kind: ToastKind };
type DialogContextValue = {
confirm: (opts: ConfirmOptions | string) => Promise<boolean>;
prompt: (opts: PromptOptions | string) => Promise<string | null>;
toast: (message: string, kind?: ToastKind) => void;
};
const DialogContext = createContext<DialogContextValue | null>(null);
type ActiveConfirm = { kind: "confirm"; opts: ConfirmOptions; resolve: (v: boolean) => void };
type ActivePrompt = { kind: "prompt"; opts: PromptOptions; resolve: (v: string | null) => void };
type Active = ActiveConfirm | ActivePrompt | null;
export function DialogProvider({ children }: { children: React.ReactNode }) {
const [active, setActive] = useState<Active>(null);
const [promptValue, setPromptValue] = useState("");
const [toasts, setToasts] = useState<ToastItem[]>([]);
const toastSeq = useRef(0);
const inputRef = useRef<HTMLInputElement>(null);
const confirmBtnRef = useRef<HTMLButtonElement>(null);
const confirm = useCallback((o: ConfirmOptions | string) => {
const opts = typeof o === "string" ? { message: o } : o;
return new Promise<boolean>((resolve) => setActive({ kind: "confirm", opts, resolve }));
}, []);
const prompt = useCallback((o: PromptOptions | string) => {
const opts = typeof o === "string" ? { message: o } : o;
setPromptValue(opts.defaultValue ?? "");
return new Promise<string | null>((resolve) => setActive({ kind: "prompt", opts, resolve }));
}, []);
const toast = useCallback((message: string, kind: ToastKind = "info") => {
const id = ++toastSeq.current;
setToasts((t) => [...t, { id, message, kind }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 4500);
}, []);
// Resuelven la promesa del diálogo activo y lo cierran. Se ejecutan en manejadores
// de eventos (post-render), capturando el `active` vigente: nada de efectos dentro
// del updater de setState (evita dobles resoluciones en StrictMode).
const finishConfirm = (v: boolean) => {
if (active?.kind === "confirm") active.resolve(v);
setActive(null);
};
const finishPrompt = (v: string | null) => {
if (active?.kind === "prompt") active.resolve(v);
setActive(null);
};
const dismiss = () => {
if (active?.kind === "confirm") active.resolve(false);
else if (active?.kind === "prompt") active.resolve(null);
setActive(null);
};
// Foco automático al abrir + cerrar con Escape.
useEffect(() => {
if (!active) return;
const t = setTimeout(() => {
if (active.kind === "prompt") inputRef.current?.focus();
else confirmBtnRef.current?.focus();
}, 30);
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (active.kind === "confirm") active.resolve(false);
else active.resolve(null);
setActive(null);
};
window.addEventListener("keydown", onKey);
return () => {
clearTimeout(t);
window.removeEventListener("keydown", onKey);
};
}, [active]);
const value = useMemo(() => ({ confirm, prompt, toast }), [confirm, prompt, toast]);
return (
<DialogContext.Provider value={value}>
{children}
{active && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4"
onClick={dismiss}
role="presentation"
>
<div
className="w-full max-w-md rounded-xl bg-card-bg border border-border shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
{active.kind === "confirm" ? (
<ConfirmBody
opts={active.opts}
confirmBtnRef={confirmBtnRef}
onCancel={() => finishConfirm(false)}
onConfirm={() => finishConfirm(true)}
/>
) : (
<PromptBody
opts={active.opts}
value={promptValue}
setValue={setPromptValue}
inputRef={inputRef}
onCancel={() => finishPrompt(null)}
onSubmit={() => finishPrompt(promptValue)}
/>
)}
</div>
</div>
)}
{/* Avisos (toasts) */}
<div className="fixed bottom-4 right-4 z-[110] flex flex-col gap-2 max-w-[calc(100vw-2rem)]">
{toasts.map((t) => (
<ToastCard key={t.id} item={t} onClose={() => setToasts((ts) => ts.filter((x) => x.id !== t.id))} />
))}
</div>
</DialogContext.Provider>
);
}
function ConfirmBody({
opts,
onCancel,
onConfirm,
confirmBtnRef,
}: {
opts: ConfirmOptions;
onCancel: () => void;
onConfirm: () => void;
confirmBtnRef: React.RefObject<HTMLButtonElement | null>;
}) {
return (
<div className="p-5">
<div className="flex items-start gap-3">
<div
className={`mt-0.5 shrink-0 rounded-full p-2 ${
opts.danger ? "bg-red-100 text-red-600" : "bg-primary-light text-primary"
}`}
>
{opts.danger ? <AlertTriangle className="w-5 h-5" /> : <Info className="w-5 h-5" />}
</div>
<div className="min-w-0">
{opts.title && <h3 className="font-semibold text-base mb-1">{opts.title}</h3>}
<p className="text-sm text-foreground/80 whitespace-pre-line">{opts.message}</p>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 rounded-lg border border-border text-sm hover:bg-gray-50"
>
{opts.cancelText || "Cancelar"}
</button>
<button
ref={confirmBtnRef}
type="button"
onClick={onConfirm}
className={`px-4 py-2 rounded-lg text-sm text-white hover:opacity-90 ${
opts.danger ? "bg-red-600" : "bg-primary"
}`}
>
{opts.confirmText || "Aceptar"}
</button>
</div>
</div>
);
}
function PromptBody({
opts,
value,
setValue,
inputRef,
onCancel,
onSubmit,
}: {
opts: PromptOptions;
value: string;
setValue: (v: string) => void;
inputRef: React.RefObject<HTMLInputElement | null>;
onCancel: () => void;
onSubmit: () => void;
}) {
return (
<form
className="p-5"
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
{opts.title && <h3 className="font-semibold text-base mb-1">{opts.title}</h3>}
{opts.message && <p className="text-sm text-foreground/80 mb-3">{opts.message}</p>}
{opts.label && <label className="block text-xs text-muted mb-1">{opts.label}</label>}
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={opts.placeholder}
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary"
/>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 rounded-lg border border-border text-sm hover:bg-gray-50"
>
{opts.cancelText || "Cancelar"}
</button>
<button
type="submit"
disabled={!value.trim()}
className="px-4 py-2 rounded-lg text-sm text-white bg-primary hover:opacity-90 disabled:opacity-50"
>
{opts.confirmText || "Aceptar"}
</button>
</div>
</form>
);
}
const TOAST_STYLE: Record<ToastKind, { cls: string; icon: React.ReactNode }> = {
success: { cls: "border-green-200 bg-green-50 text-green-800", icon: <CheckCircle2 className="w-4 h-4" /> },
error: { cls: "border-red-200 bg-red-50 text-red-800", icon: <XCircle className="w-4 h-4" /> },
info: { cls: "border-blue-200 bg-blue-50 text-blue-800", icon: <Info className="w-4 h-4" /> },
};
function ToastCard({ item, onClose }: { item: ToastItem; onClose: () => void }) {
const s = TOAST_STYLE[item.kind];
return (
<div
className={`flex items-start gap-2 rounded-lg border px-3 py-2 shadow-lg text-sm ${s.cls}`}
role="status"
>
<span className="mt-0.5 shrink-0">{s.icon}</span>
<span className="min-w-0 break-words">{item.message}</span>
<button onClick={onClose} className="ml-1 shrink-0 opacity-60 hover:opacity-100" aria-label="Cerrar aviso">
<X className="w-4 h-4" />
</button>
</div>
);
}
export function useDialogs(): DialogContextValue {
const ctx = useContext(DialogContext);
if (!ctx) throw new Error("useDialogs debe usarse dentro de <DialogProvider>");
return ctx;
}
export const useConfirm = () => useDialogs().confirm;
export const usePrompt = () => useDialogs().prompt;
export const useToast = () => useDialogs().toast;
+202
View File
@@ -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,156 @@ 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) };
}
// 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;
}
+272
View File
@@ -0,0 +1,272 @@
import PDFDocument from "pdfkit";
import { formatCurrency, formatFechaRegistro } from "./calculators";
export interface NotaHorasRegistro {
fecha: Date;
horaInicio: string;
horaFin: string;
horas: number;
tarifaHora: number;
descripcion: string;
estadoPago: string;
}
export interface NotaHorasGrupo {
etiqueta: string;
count: number;
horas: number;
importe: number;
}
export interface NotaHorasPDFData {
numero: string;
proyecto: string;
clienteNombre: string;
clienteEmpresa: string | null;
titulo: string; // "Nota de horas por pagar" | "Recibo de horas pagadas" | "Estado de cuenta de horas"
emitida: Date;
periodoTexto: string;
modoLabel: string;
modo: "detalle" | "dia" | "semana" | "mes";
mostrarColEstado: boolean;
detalle: NotaHorasRegistro[];
grupos: NotaHorasGrupo[];
incluirIva: boolean;
totalHoras: number;
subtotal: number;
iva: number;
total: number;
etiquetaTotal: string; // "Total a pagar" | "Total pagado" | "Total"
// Datos bancarios para el cobro (se muestran solo en las notas "por pagar").
datosPago?: {
razonSocial: string;
rfc: string;
cuentaNacional: string;
clabeNacional: string;
bancoNacional: string;
clabeInternacional: string;
swift: string;
};
colorPrimario?: string;
colorSecundario?: string;
logoBase64?: string;
logoMime?: string;
}
// pdfkit solo soporta PNG/JPEG. Cualquier otro formato (webp, svg, ...) se convierte
// a PNG con sharp para que el logo si aparezca en el PDF.
async function toPngBuffer(base64: string, mime?: string): Promise<Buffer | undefined> {
try {
const buf = Buffer.from(base64, "base64");
if (mime === "image/png" || mime === "image/jpeg" || mime === "image/jpg") return buf;
const sharp = (await import("sharp")).default;
return await sharp(buf).png().toBuffer();
} catch {
return undefined;
}
}
export async function generateNotaHorasPDF(data: NotaHorasPDFData): Promise<Buffer> {
const logoBuffer = data.logoBase64 ? await toPngBuffer(data.logoBase64, data.logoMime) : undefined;
return new Promise((resolve, reject) => {
const PRIMARY = data.colorPrimario || "#2563eb";
const DARK = data.colorSecundario || "#111827";
const MUTED = "#6b7280";
const BORDER = "#d1d5db";
const HEAD_BG = "#f3f4f6";
const doc = new PDFDocument({ size: "LETTER", margins: { top: 45, bottom: 55, left: 50, right: 50 } });
const chunks: Buffer[] = [];
doc.on("data", (c: Buffer) => chunks.push(c));
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
const pw = doc.page.width;
const ph = doc.page.height;
const m = doc.page.margins;
const W = pw - m.left - m.right;
const L = m.left;
const maxY = ph - m.bottom;
function need(h: number, y: number): number {
if (y + h > maxY) {
doc.addPage();
return m.top;
}
return y;
}
// ── ENCABEZADO ────────────────────────────────
let y = m.top;
if (logoBuffer) {
try { doc.image(logoBuffer, L, y, { fit: [46, 46] }); } catch {}
}
const txtX = logoBuffer ? L + 58 : L;
doc.font("Helvetica-Bold").fontSize(20).fillColor(DARK).text(data.titulo, txtX, y + 2, { width: W - 170 });
doc.font("Helvetica").fontSize(9).fillColor(MUTED)
.text(`Cotizacion ${data.numero} · ${data.proyecto}`, txtX, doc.y + 1, { width: W - 170 });
doc.font("Helvetica").fontSize(9).fillColor(MUTED)
.text(`Emitida: ${formatFechaRegistro(data.emitida)}`, L, y + 2, { width: W, align: "right" });
y = Math.max(y + 50, doc.y + 6);
doc.moveTo(L, y).lineTo(L + W, y).strokeColor(PRIMARY).lineWidth(2.5).stroke();
y += 14;
// ── META ──────────────────────────────────────
const cliente = data.clienteEmpresa || data.clienteNombre;
doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text("Cliente: ", L, y, { continued: true });
doc.font("Helvetica").fillColor(DARK).text(cliente);
y = doc.y + 2;
if (data.clienteEmpresa) {
doc.font("Helvetica-Bold").fontSize(9.5).fillColor(DARK).text("Contacto: ", L, y, { continued: true });
doc.font("Helvetica").fillColor(DARK).text(data.clienteNombre);
y = doc.y + 2;
}
doc.font("Helvetica").fontSize(8.5).fillColor(MUTED)
.text(`${data.periodoTexto} · Desglose ${data.modoLabel.toLowerCase()}`, L, y);
y = doc.y + 12;
// ── TABLA ─────────────────────────────────────
const money = (n: number) => formatCurrency(n);
if (data.modo === "detalle") {
const wEstado = data.mostrarColEstado ? 58 : 0;
const cFecha = L;
const wFecha = 62, wHorario = 72, wHoras = 42, wTarifa = 52, wImporte = 58;
const cHorario = cFecha + wFecha;
const cHoras = cHorario + wHorario;
const cTarifa = cHoras + wHoras;
const cImporte = cTarifa + wTarifa;
const cDesc = cImporte + wImporte + 6;
const wDesc = L + W - cDesc - wEstado - (wEstado ? 6 : 0);
const cEstado = L + W - wEstado;
const header = (yy: number) => {
doc.save();
doc.rect(L, yy, W, 16).fill(HEAD_BG);
doc.restore();
doc.font("Helvetica-Bold").fontSize(7).fillColor(DARK);
doc.text("FECHA", cFecha + 4, yy + 5);
doc.text("HORARIO", cHorario + 4, yy + 5);
doc.text("HORAS", cHoras, yy + 5, { width: wHoras - 2, align: "right" });
doc.text("TARIFA", cTarifa, yy + 5, { width: wTarifa - 2, align: "right" });
doc.text("IMPORTE", cImporte, yy + 5, { width: wImporte - 2, align: "right" });
doc.text("DESCRIPCION", cDesc, yy + 5, { width: wDesc });
if (wEstado) doc.text("ESTADO", cEstado, yy + 5, { width: wEstado, align: "left" });
return yy + 16;
};
y = header(y);
doc.font("Helvetica").fontSize(8).fillColor(DARK);
for (const r of data.detalle) {
const descH = doc.font("Helvetica").fontSize(8).heightOfString(r.descripcion, { width: wDesc });
const rowH = Math.max(16, descH + 8);
if (y + rowH > maxY) { y = header(need(rowH + 16, y)); }
doc.font("Helvetica").fontSize(8).fillColor(DARK);
doc.text(formatFechaRegistro(r.fecha), cFecha + 4, y + 4, { width: wFecha - 4, lineBreak: false });
doc.text(`${r.horaInicio}-${r.horaFin}`, cHorario + 4, y + 4, { width: wHorario - 4, lineBreak: false });
doc.text(r.horas.toFixed(2), cHoras, y + 4, { width: wHoras - 2, align: "right" });
doc.text(money(r.tarifaHora), cTarifa, y + 4, { width: wTarifa - 2, align: "right" });
doc.text(money(r.horas * r.tarifaHora), cImporte, y + 4, { width: wImporte - 2, align: "right" });
doc.text(r.descripcion, cDesc, y + 4, { width: wDesc });
if (wEstado) doc.text(r.estadoPago === "pagada" ? "Pagada" : "Por pagar", cEstado, y + 4, { width: wEstado });
doc.moveTo(L, y + rowH).lineTo(L + W, y + rowH).strokeColor(BORDER).lineWidth(0.3).stroke();
y += rowH;
}
} else {
const cPer = L, wPer = W - 90 - 90 - 90;
const cReg = cPer + wPer, wReg = 90;
const cHor = cReg + wReg, wHor = 90;
const cImp = cHor + wHor, wImp = 90;
const header = (yy: number) => {
doc.save(); doc.rect(L, yy, W, 16).fill(HEAD_BG); doc.restore();
doc.font("Helvetica-Bold").fontSize(7).fillColor(DARK);
doc.text(data.modoLabel.toUpperCase(), cPer + 4, yy + 5);
doc.text("REGISTROS", cReg, yy + 5, { width: wReg - 2, align: "right" });
doc.text("HORAS", cHor, yy + 5, { width: wHor - 2, align: "right" });
doc.text("IMPORTE", cImp, yy + 5, { width: wImp - 2, align: "right" });
return yy + 16;
};
y = header(y);
doc.font("Helvetica").fontSize(8).fillColor(DARK);
for (const g of data.grupos) {
const rowH = 16;
if (y + rowH > maxY) { y = header(need(rowH + 16, y)); }
doc.font("Helvetica").fontSize(8).fillColor(DARK);
doc.text(g.etiqueta, cPer + 4, y + 4, { width: wPer - 6 });
doc.text(String(g.count), cReg, y + 4, { width: wReg - 2, align: "right" });
doc.text(g.horas.toFixed(2), cHor, y + 4, { width: wHor - 2, align: "right" });
doc.text(money(g.importe), cImp, y + 4, { width: wImp - 2, align: "right" });
doc.moveTo(L, y + rowH).lineTo(L + W, y + rowH).strokeColor(BORDER).lineWidth(0.3).stroke();
y += rowH;
}
}
// ── TOTALES ───────────────────────────────────
y += 12;
const totW = 240;
const totX = L + W - totW;
const line = (label: string, val: string, bold = false, top = false) => {
y = need(18, y);
if (top) { doc.moveTo(totX, y).lineTo(totX + totW, y).strokeColor(PRIMARY).lineWidth(1.2).stroke(); y += 5; }
doc.font(bold ? "Helvetica-Bold" : "Helvetica").fontSize(bold ? 12 : 9).fillColor(bold ? DARK : MUTED);
doc.text(label, totX, y, { width: totW - 90 });
doc.font(bold ? "Helvetica-Bold" : "Helvetica").fontSize(bold ? 12 : 9).fillColor(bold ? DARK : DARK);
doc.text(val, totX + totW - 90, y, { width: 90, align: "right" });
y += bold ? 18 : 15;
};
line("Total de horas", `${data.totalHoras.toFixed(2)} h`);
line("Subtotal", money(data.subtotal));
if (data.incluirIva) line("IVA (16%)", money(data.iva));
line(data.etiquetaTotal, money(data.total), true, true);
// ── DATOS PARA EL PAGO (solo notas por pagar) ──
const dp = data.datosPago;
if (dp) {
const boxH = 52;
y = need(boxH + 30, y) + 16;
doc.rect(L, y, 3, 10).fill(PRIMARY);
doc.font("Helvetica-Bold").fontSize(10).fillColor(DARK).text("Datos para el pago", L + 10, y);
y += 16;
// Transferencia Nacional
doc.save();
doc.rect(L, y, W, boxH).fill(HEAD_BG).stroke(BORDER);
doc.font("Helvetica-Bold").fontSize(8).fillColor(DARK).text("Transferencia Nacional", L + 8, y + 5);
doc.font("Helvetica").fontSize(8.5).fillColor(DARK);
if (dp.cuentaNacional) doc.text(`Cuenta: ${dp.cuentaNacional}`, L + 8, y + 18, { lineBreak: false });
if (dp.clabeNacional) doc.text(`CLABE: ${dp.clabeNacional}`, L + W * 0.5, y + 18, { lineBreak: false });
doc.text(`Beneficiario: ${dp.razonSocial}`, L + 8, y + 30, { lineBreak: false });
doc.text(`RFC: ${dp.rfc}`, L + W * 0.5, y + 30, { lineBreak: false });
if (dp.bancoNacional) doc.text(`Banco: ${dp.bancoNacional}`, L + 8, y + 42, { lineBreak: false });
doc.restore();
y += boxH + 8;
// Transferencia Internacional (si hay datos)
if (dp.clabeInternacional || dp.swift) {
y = need(boxH, y);
doc.save();
doc.rect(L, y, W, boxH).fill(HEAD_BG).stroke(BORDER);
doc.font("Helvetica-Bold").fontSize(8).fillColor(DARK).text("Transferencia Internacional", L + 8, y + 5);
doc.font("Helvetica").fontSize(8.5).fillColor(DARK);
doc.text(`Beneficiario: ${dp.razonSocial}`, L + 8, y + 18, { lineBreak: false });
if (dp.clabeInternacional) doc.text(`CLABE: ${dp.clabeInternacional}`, L + W * 0.5, y + 18, { lineBreak: false });
doc.text(`Banco: BBVA Mexico`, L + 8, y + 30, { lineBreak: false });
if (dp.swift) doc.text(`SWIFT: ${dp.swift}`, L + W * 0.5, y + 30, { lineBreak: false });
doc.restore();
y += boxH + 8;
}
}
// ── PIE ───────────────────────────────────────
y = need(30, y) + 14;
doc.font("Helvetica").fontSize(8).fillColor(MUTED).text(
"Documento generado desde el Cotizador. Conteo de horas trabajadas para cobro del proyecto.",
L, y, { width: W, align: "center" }
);
doc.end();
});
}
+3 -3
View File
@@ -71,12 +71,12 @@ function fmtDate(d: Date): string {
async function toPngBuffer(base64: string, mime: string): Promise<Buffer> {
const buf = Buffer.from(base64, "base64");
if (mime === "image/svg+xml") {
// pdfkit solo soporta PNG/JPEG; cualquier otro formato (svg, webp, ...) se convierte
// a PNG con sharp para que el logo si se renderice.
if (mime === "image/png" || mime === "image/jpeg" || mime === "image/jpg") return buf;
const sharp = (await import("sharp")).default;
return sharp(buf).png().toBuffer();
}
return buf;
}
export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Buffer> {
const logoBuffer = data.logoBase64 && data.logoMime
+27 -3
View File
@@ -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,25 @@ 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(),
});
// 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"),