Compare commits
10
Commits
e1f3263c9f
...
26e6d3a9bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26e6d3a9bf | ||
|
|
1aab45f29c | ||
|
|
63b14fc8b8 | ||
|
|
3824dd00be | ||
|
|
feb88de203 | ||
|
|
89d7028044 | ||
|
|
34419d1752 | ||
|
|
d83a80c85a | ||
|
|
8b1e420d4a | ||
|
|
4d550c5963 |
@@ -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
@@ -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
|
# En Coolify: crear recurso "Docker Compose" apuntando a este repo y configurar
|
||||||
# "Docker Compose Location" = /docker-compose.coolify.yml
|
# "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
|
# dominio a cada servicio). Define JWT_SECRET, API_KEY, DB_PASSWORD y las
|
||||||
# SEED_* en el panel de variables de entorno de Coolify.
|
# 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:
|
services:
|
||||||
postgres:
|
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");
|
||||||
@@ -25,6 +25,7 @@ model Cliente {
|
|||||||
empresa String?
|
empresa String?
|
||||||
email String?
|
email String?
|
||||||
telefono String?
|
telefono String?
|
||||||
|
rfc String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ model Cotizacion {
|
|||||||
estado String @default("borrador")
|
estado String @default("borrador")
|
||||||
incluirBonos Boolean @default(false)
|
incluirBonos Boolean @default(false)
|
||||||
incluirFinanciamiento Boolean @default(false)
|
incluirFinanciamiento Boolean @default(false)
|
||||||
|
incluirIva Boolean @default(true)
|
||||||
esDoble Boolean @default(false)
|
esDoble Boolean @default(false)
|
||||||
opcionesMetadata Json?
|
opcionesMetadata Json?
|
||||||
observaciones String?
|
observaciones String?
|
||||||
@@ -57,6 +59,7 @@ model Cotizacion {
|
|||||||
asesor User @relation("AsesorCotizaciones", fields: [asesorId], references: [id])
|
asesor User @relation("AsesorCotizaciones", fields: [asesorId], references: [id])
|
||||||
servicios ServicioCotizado[]
|
servicios ServicioCotizado[]
|
||||||
planBucefalo PlanBucefaloCotizacion?
|
planBucefalo PlanBucefaloCotizacion?
|
||||||
|
registrosHoras RegistroHoras[]
|
||||||
|
|
||||||
@@index([clienteId])
|
@@index([clienteId])
|
||||||
@@index([asesorId])
|
@@index([asesorId])
|
||||||
@@ -158,6 +161,7 @@ model ServicioCotizado {
|
|||||||
precio Float
|
precio Float
|
||||||
tiempoEntrega String
|
tiempoEntrega String
|
||||||
entregables Json @default("[]")
|
entregables Json @default("[]")
|
||||||
|
beneficios Json @default("[]")
|
||||||
notas String?
|
notas String?
|
||||||
seleccionado Boolean @default(true)
|
seleccionado Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -182,6 +186,34 @@ model PlanBucefaloCotizacion {
|
|||||||
cotizacion Cotizacion @relation(fields: [cotizacionId], references: [id], onDelete: Cascade)
|
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 {
|
model Configuracion {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
clave String @unique
|
clave String @unique
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { formatCurrency, FASES as FASES_LABELS } from "@/lib/calculators";
|
import { formatCurrency, FASES as FASES_LABELS } from "@/lib/calculators";
|
||||||
|
import { useConfirm, usePrompt, useToast } from "@/components/ui/DialogProvider";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
interface Servicio {
|
interface Servicio {
|
||||||
@@ -71,6 +72,9 @@ type Tab = "categorias" | "paquetes" | "servicios";
|
|||||||
const FASES = FASES_LABELS;
|
const FASES = FASES_LABELS;
|
||||||
|
|
||||||
export function CatalogoClient({ initialServicios, initialCategorias, initialPaquetes }: Props) {
|
export function CatalogoClient({ initialServicios, initialCategorias, initialPaquetes }: Props) {
|
||||||
|
const showConfirm = useConfirm();
|
||||||
|
const showPrompt = usePrompt();
|
||||||
|
const showToast = useToast();
|
||||||
const [tab, setTab] = useState<Tab>("servicios");
|
const [tab, setTab] = useState<Tab>("servicios");
|
||||||
const [servicios, setServicios] = useState<Servicio[]>(initialServicios);
|
const [servicios, setServicios] = useState<Servicio[]>(initialServicios);
|
||||||
const [categorias, setCategorias] = useState<Categoria[]>(initialCategorias);
|
const [categorias, setCategorias] = useState<Categoria[]>(initialCategorias);
|
||||||
@@ -106,14 +110,14 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
let msg = `${data.creados} servicio(s) importado(s).`;
|
let msg = `${data.creados} servicio(s) importado(s).`;
|
||||||
if (data.omitidos > 0) msg += ` ${data.omitidos} omitido(s).`;
|
if (data.omitidos > 0) msg += ` ${data.omitidos} omitido(s).`;
|
||||||
if (data.errores?.length) msg += `\n\nErrores:\n${data.errores.join("\n")}`;
|
showToast(msg, "success");
|
||||||
alert(msg);
|
if (data.errores?.length) showToast(`Errores:\n${data.errores.join("\n")}`, "error");
|
||||||
await refresh();
|
await refresh();
|
||||||
} else {
|
} else {
|
||||||
alert(data.error || "Error al importar");
|
showToast(data.error || "Error al importar", "error");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert("Error al importar CSV");
|
showToast("Error al importar CSV", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
@@ -123,7 +127,7 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
|
|||||||
const deleteCategoria = async (id: string) => {
|
const deleteCategoria = async (id: string) => {
|
||||||
const res = await fetch(`/api/categorias/${id}`, { method: "DELETE" });
|
const res = await fetch(`/api/categorias/${id}`, { method: "DELETE" });
|
||||||
if (res.ok) setCategorias((p) => p.filter((c) => c.id !== id));
|
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) => {
|
const toggleCategoriaActivo = async (cat: Categoria) => {
|
||||||
@@ -139,13 +143,24 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deletePaquete = async (id: string) => {
|
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" });
|
const res = await fetch(`/api/paquetes/${id}`, { method: "DELETE" });
|
||||||
if (res.ok) setPaquetes((p) => p.filter((pk) => pk.id !== id));
|
if (res.ok) setPaquetes((p) => p.filter((pk) => pk.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreatePaquete = async () => {
|
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;
|
if (!nombre) return;
|
||||||
const res = await fetch("/api/paquetes", {
|
const res = await fetch("/api/paquetes", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -159,7 +174,12 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addFaseToPaquete = async (paqueteId: string) => {
|
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;
|
if (!nombre) return;
|
||||||
const pk = paquetes.find((p) => p.id === paqueteId);
|
const pk = paquetes.find((p) => p.id === paqueteId);
|
||||||
const orden = pk ? pk.fases.length : 0;
|
const orden = pk ? pk.fases.length : 0;
|
||||||
@@ -179,7 +199,13 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteFase = async (paqueteId: string, faseId: string) => {
|
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`, {
|
const res = await fetch(`/api/paquetes/${paqueteId}/manage`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -343,7 +369,8 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
|
|||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={async () => {
|
<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" });
|
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)); } }
|
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">
|
}} 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 [nombre, setNombre] = useState("");
|
||||||
const [color, setColor] = useState("#6b7280");
|
const [color, setColor] = useState("#6b7280");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const showToast = useToast();
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!nombre.trim()) return;
|
if (!nombre.trim()) return;
|
||||||
@@ -540,7 +568,7 @@ function CreateCategoriaInline({ onCreated }: { onCreated: () => void }) {
|
|||||||
body: JSON.stringify({ nombre: nombre.trim(), color }),
|
body: JSON.stringify({ nombre: nombre.trim(), color }),
|
||||||
});
|
});
|
||||||
if (res.ok) { setNombre(""); onCreated(); }
|
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); }
|
} finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { Save, Upload, Trash2, Loader2, Image as ImageIcon } from "lucide-react";
|
import { Save, Upload, Trash2, Loader2, Image as ImageIcon } from "lucide-react";
|
||||||
|
import { useToast } from "@/components/ui/DialogProvider";
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
{
|
{
|
||||||
@@ -63,6 +64,7 @@ export function ConfiguracionClient({ initial }: { initial: Record<string, strin
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const showToast = useToast();
|
||||||
|
|
||||||
const handleChange = (key: string, value: string) => {
|
const handleChange = (key: string, value: string) => {
|
||||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -73,7 +75,7 @@ export function ConfiguracionClient({ initial }: { initial: Record<string, strin
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (file.size > 500 * 1024) {
|
if (file.size > 500 * 1024) {
|
||||||
alert("El logo debe ser menor a 500KB");
|
showToast("El logo debe ser menor a 500KB", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -102,10 +104,10 @@ export function ConfiguracionClient({ initial }: { initial: Record<string, strin
|
|||||||
setSaved(true);
|
setSaved(true);
|
||||||
setTimeout(() => setSaved(false), 2000);
|
setTimeout(() => setSaved(false), 2000);
|
||||||
} else {
|
} else {
|
||||||
alert("Error al guardar");
|
showToast("Error al guardar", "error");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert("Error al guardar");
|
showToast("Error al guardar", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,29 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useConfirm, useToast } from "@/components/ui/DialogProvider";
|
||||||
|
|
||||||
export function ListDeleteButton({ id }: { id: string }) {
|
export function ListDeleteButton({ id }: { id: string }) {
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const showConfirm = useConfirm();
|
||||||
|
const showToast = useToast();
|
||||||
|
|
||||||
const handleDelete = async () => {
|
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);
|
setDeleting(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/cotizaciones/${id}`, { method: "DELETE" });
|
const res = await fetch(`/api/cotizaciones/${id}`, { method: "DELETE" });
|
||||||
if (res.ok) router.refresh();
|
if (res.ok) router.refresh();
|
||||||
else alert("Error al eliminar");
|
else showToast("Error al eliminar", "error");
|
||||||
} catch {
|
} catch {
|
||||||
alert("Error al eliminar");
|
showToast("Error al eliminar", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useToast } from "@/components/ui/DialogProvider";
|
||||||
|
|
||||||
const OPCIONES = [
|
const OPCIONES = [
|
||||||
{ value: "borrador", label: "Borrador" },
|
{ value: "borrador", label: "Borrador" },
|
||||||
@@ -13,6 +14,7 @@ const OPCIONES = [
|
|||||||
export function CambiarEstadoButtons({ cotizacionId, estado }: { cotizacionId: string; estado: string }) {
|
export function CambiarEstadoButtons({ cotizacionId, estado }: { cotizacionId: string; estado: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const showToast = useToast();
|
||||||
|
|
||||||
const handleChange = async (nuevoEstado: string) => {
|
const handleChange = async (nuevoEstado: string) => {
|
||||||
if (nuevoEstado === estado) return;
|
if (nuevoEstado === estado) return;
|
||||||
@@ -24,7 +26,7 @@ export function CambiarEstadoButtons({ cotizacionId, estado }: { cotizacionId: s
|
|||||||
body: JSON.stringify({ estado: nuevoEstado }),
|
body: JSON.stringify({ estado: nuevoEstado }),
|
||||||
});
|
});
|
||||||
if (res.ok) router.refresh();
|
if (res.ok) router.refresh();
|
||||||
else alert("Error al cambiar estado");
|
else showToast("Error al cambiar estado", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useToast } from "@/components/ui/DialogProvider";
|
||||||
|
|
||||||
export function DeleteCotizacionButton({
|
export function DeleteCotizacionButton({
|
||||||
cotizacionId,
|
cotizacionId,
|
||||||
@@ -14,6 +15,7 @@ export function DeleteCotizacionButton({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const showToast = useToast();
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
@@ -24,11 +26,11 @@ export function DeleteCotizacionButton({
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
router.push("/cotizaciones");
|
router.push("/cotizaciones");
|
||||||
} else {
|
} else {
|
||||||
alert("Error al eliminar");
|
showToast("Error al eliminar", "error");
|
||||||
setConfirming(false);
|
setConfirming(false);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert("Error al eliminar");
|
showToast("Error al eliminar", "error");
|
||||||
setConfirming(false);
|
setConfirming(false);
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { formatCurrency, IVA_RATE } from "@/lib/calculators";
|
import { formatCurrency, precioDisplay, IVA_RATE } from "@/lib/calculators";
|
||||||
|
|
||||||
interface ServicioItem {
|
interface ServicioItem {
|
||||||
id: string;
|
id: string;
|
||||||
nombre: string;
|
nombre: string;
|
||||||
precio: number;
|
precio: number;
|
||||||
|
modeloCobro?: string | null;
|
||||||
|
tarifaHora?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PreciosEditablesProps {
|
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 (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||||
<div className="bg-card-bg rounded-xl border border-border">
|
<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>
|
<p className="text-muted text-sm">Sin servicios</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{unicos.map((s) => (
|
{unicos.map((s) => renderFila(s, "unico"))}
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="border-t border-border mt-3 pt-3 space-y-1">
|
<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>
|
<p className="text-muted text-sm">Sin servicios</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{mensuales.map((s) => (
|
{mensuales.map((s) => renderFila(s, "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">
|
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="border-t border-border mt-3 pt-3 space-y-1">
|
<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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import { prisma } from "@/lib/db";
|
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 Link from "next/link";
|
||||||
import { ArrowLeft, Pencil } from "lucide-react";
|
import { ArrowLeft, Pencil } from "lucide-react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
@@ -7,6 +16,7 @@ import { ExportExcelButtonSaved, ExportPDFButtonSaved, PreviewPDFButtonSaved } f
|
|||||||
import { DeleteCotizacionButton } from "./DeleteButton";
|
import { DeleteCotizacionButton } from "./DeleteButton";
|
||||||
import { CambiarEstadoButtons } from "./CambiarEstadoButtons";
|
import { CambiarEstadoButtons } from "./CambiarEstadoButtons";
|
||||||
import { PreciosEditables } from "./PreciosEditables";
|
import { PreciosEditables } from "./PreciosEditables";
|
||||||
|
import { RegistroHorasPanel } from "./RegistroHorasPanel";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -23,17 +33,25 @@ export default async function CotizacionDetailPage({
|
|||||||
asesor: true,
|
asesor: true,
|
||||||
servicios: { include: { servicioCatalogo: true } },
|
servicios: { include: { servicioCatalogo: true } },
|
||||||
planBucefalo: true,
|
planBucefalo: true,
|
||||||
|
registrosHoras: { orderBy: [{ fecha: "asc" }, { horaInicio: "asc" }] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!cot) notFound();
|
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
|
const serviciosUnicos = cot.servicios
|
||||||
.filter((s) => s.tipoPago === "unico" && s.seleccionado)
|
.filter((s) => s.tipoPago === "unico" && s.seleccionado)
|
||||||
.map((s) => ({
|
.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
nombre: s.servicioCatalogo?.nombre || s.nombre || "Servicio",
|
nombre: s.servicioCatalogo?.nombre || s.nombre || "Servicio",
|
||||||
precio: s.precio,
|
precio: s.precio,
|
||||||
|
modeloCobro: s.modeloCobro,
|
||||||
|
tarifaHora: s.tarifaHora,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const serviciosMensuales = cot.servicios
|
const serviciosMensuales = cot.servicios
|
||||||
@@ -42,6 +60,8 @@ export default async function CotizacionDetailPage({
|
|||||||
id: s.id,
|
id: s.id,
|
||||||
nombre: s.servicioCatalogo?.nombre || s.nombre || "Servicio",
|
nombre: s.servicioCatalogo?.nombre || s.nombre || "Servicio",
|
||||||
precio: s.precio,
|
precio: s.precio,
|
||||||
|
modeloCobro: s.modeloCobro,
|
||||||
|
tarifaHora: s.tarifaHora,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const opcionesMeta = (cot.opcionesMetadata as { "1"?: MetaOpcion; "2"?: MetaOpcion } | null) ?? {};
|
const opcionesMeta = (cot.opcionesMetadata as { "1"?: MetaOpcion; "2"?: MetaOpcion } | null) ?? {};
|
||||||
@@ -85,7 +105,7 @@ export default async function CotizacionDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-card-bg rounded-xl border border-border p-5 mb-6">
|
<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 className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted">Cliente</span>
|
<span className="text-muted">Cliente</span>
|
||||||
@@ -180,6 +200,30 @@ export default async function CotizacionDetailPage({
|
|||||||
serviciosMensuales={serviciosMensuales}
|
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 && (
|
{cot.observaciones && (
|
||||||
<div className="bg-card-bg rounded-xl border border-border p-5">
|
<div className="bg-card-bg rounded-xl border border-border p-5">
|
||||||
<h3 className="font-semibold mb-2">Observaciones</h3>
|
<h3 className="font-semibold mb-2">Observaciones</h3>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Sidebar from "@/components/layout/Sidebar";
|
import Sidebar from "@/components/layout/Sidebar";
|
||||||
|
import { DialogProvider } from "@/components/ui/DialogProvider";
|
||||||
|
|
||||||
export default function AppLayout({
|
export default function AppLayout({
|
||||||
children,
|
children,
|
||||||
@@ -6,11 +7,11 @@ export default function AppLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<DialogProvider>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 lg:ml-64">
|
<main className="flex-1 lg:ml-64">
|
||||||
<div className="p-4 lg:p-8 pt-16 lg:pt-8">{children}</div>
|
<div className="p-4 lg:p-8 pt-16 lg:pt-8">{children}</div>
|
||||||
</main>
|
</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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,28 @@ export async function PUT(
|
|||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const body = await request.json();
|
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);
|
const parsed = cotizacionPutSchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -74,6 +96,7 @@ export async function PUT(
|
|||||||
esquemaPago,
|
esquemaPago,
|
||||||
incluirBonos,
|
incluirBonos,
|
||||||
incluirFinanciamiento,
|
incluirFinanciamiento,
|
||||||
|
incluirIva,
|
||||||
esDoble,
|
esDoble,
|
||||||
opciones,
|
opciones,
|
||||||
observaciones,
|
observaciones,
|
||||||
@@ -100,6 +123,7 @@ export async function PUT(
|
|||||||
data: {
|
data: {
|
||||||
email: cliente.email || existingCliente.email,
|
email: cliente.email || existingCliente.email,
|
||||||
telefono: cliente.telefono || existingCliente.telefono,
|
telefono: cliente.telefono || existingCliente.telefono,
|
||||||
|
rfc: cliente.rfc || existingCliente.rfc,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -109,6 +133,7 @@ export async function PUT(
|
|||||||
empresa: cliente.empresa || null,
|
empresa: cliente.empresa || null,
|
||||||
email: cliente.email || null,
|
email: cliente.email || null,
|
||||||
telefono: cliente.telefono || null,
|
telefono: cliente.telefono || null,
|
||||||
|
rfc: cliente.rfc || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
clienteIdFinal = newCliente.id;
|
clienteIdFinal = newCliente.id;
|
||||||
@@ -129,6 +154,7 @@ export async function PUT(
|
|||||||
...(esquemaPago && { esquemaPago }),
|
...(esquemaPago && { esquemaPago }),
|
||||||
...(incluirBonos !== undefined && { incluirBonos }),
|
...(incluirBonos !== undefined && { incluirBonos }),
|
||||||
...(incluirFinanciamiento !== undefined && { incluirFinanciamiento }),
|
...(incluirFinanciamiento !== undefined && { incluirFinanciamiento }),
|
||||||
|
...(incluirIva !== undefined && { incluirIva }),
|
||||||
...(esDoble !== undefined && { esDoble }),
|
...(esDoble !== undefined && { esDoble }),
|
||||||
...(esDoble !== undefined && { opcionesMetadata: esDoble ? opciones ?? {} : undefined }),
|
...(esDoble !== undefined && { opcionesMetadata: esDoble ? opciones ?? {} : undefined }),
|
||||||
...(observaciones !== undefined && { observaciones }),
|
...(observaciones !== undefined && { observaciones }),
|
||||||
@@ -174,9 +200,11 @@ export async function PUT(
|
|||||||
opcion: esDobleFinal ? serv.opcion ?? "ambas" : null,
|
opcion: esDobleFinal ? serv.opcion ?? "ambas" : null,
|
||||||
fase: serv.fase,
|
fase: serv.fase,
|
||||||
tipoPago: serv.tipoPago,
|
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,
|
tiempoEntrega: serv.tiempoEntrega,
|
||||||
entregables: serv.entregables,
|
entregables: serv.entregables,
|
||||||
|
beneficios: serv.beneficios ?? [],
|
||||||
seleccionado: true,
|
seleccionado: true,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export async function POST(request: NextRequest) {
|
|||||||
esquemaPago,
|
esquemaPago,
|
||||||
incluirBonos,
|
incluirBonos,
|
||||||
incluirFinanciamiento,
|
incluirFinanciamiento,
|
||||||
|
incluirIva,
|
||||||
esDoble,
|
esDoble,
|
||||||
opciones,
|
opciones,
|
||||||
observaciones,
|
observaciones,
|
||||||
@@ -45,6 +46,7 @@ export async function POST(request: NextRequest) {
|
|||||||
empresa: cliente.empresa || null,
|
empresa: cliente.empresa || null,
|
||||||
email: cliente.email || null,
|
email: cliente.email || null,
|
||||||
telefono: cliente.telefono || null,
|
telefono: cliente.telefono || null,
|
||||||
|
rfc: cliente.rfc || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -75,6 +77,7 @@ export async function POST(request: NextRequest) {
|
|||||||
esquemaPago,
|
esquemaPago,
|
||||||
incluirBonos,
|
incluirBonos,
|
||||||
incluirFinanciamiento,
|
incluirFinanciamiento,
|
||||||
|
incluirIva: incluirIva ?? true,
|
||||||
esDoble: esDoble ?? false,
|
esDoble: esDoble ?? false,
|
||||||
opcionesMetadata: esDoble ? opciones ?? {} : undefined,
|
opcionesMetadata: esDoble ? opciones ?? {} : undefined,
|
||||||
observaciones: observaciones || null,
|
observaciones: observaciones || null,
|
||||||
@@ -107,9 +110,11 @@ export async function POST(request: NextRequest) {
|
|||||||
opcion: esDoble ? servicio.opcion ?? "ambas" : null,
|
opcion: esDoble ? servicio.opcion ?? "ambas" : null,
|
||||||
fase: servicio.fase,
|
fase: servicio.fase,
|
||||||
tipoPago: servicio.tipoPago,
|
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,
|
tiempoEntrega: servicio.tiempoEntrega,
|
||||||
entregables: servicio.entregables,
|
entregables: servicio.entregables,
|
||||||
|
beneficios: servicio.beneficios ?? [],
|
||||||
seleccionado: true,
|
seleccionado: true,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
useCotizacionStore,
|
useCotizacionStore,
|
||||||
ServicioSeleccionado,
|
ServicioSeleccionado,
|
||||||
} from "@/lib/store";
|
} from "@/lib/store";
|
||||||
|
import { useToast } from "@/components/ui/DialogProvider";
|
||||||
import {
|
import {
|
||||||
ExportPDFButtonDraft,
|
ExportPDFButtonDraft,
|
||||||
ExportExcelButtonDraft,
|
ExportExcelButtonDraft,
|
||||||
@@ -136,6 +137,7 @@ export function CotizacionForm({
|
|||||||
}: CotizacionFormProps) {
|
}: CotizacionFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const store = useCotizacionStore();
|
const store = useCotizacionStore();
|
||||||
|
const showToast = useToast();
|
||||||
|
|
||||||
const [expandedFases, setExpandedFases] = useState<Set<number>>(
|
const [expandedFases, setExpandedFases] = useState<Set<number>>(
|
||||||
new Set([0, 1, 2, 3])
|
new Set([0, 1, 2, 3])
|
||||||
@@ -369,10 +371,10 @@ export function CotizacionForm({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
alert("Error: " + (err.error || "No se pudo guardar"));
|
showToast("Error: " + (err.error || "No se pudo guardar"), "error");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert("Error al guardar la cotizacion");
|
showToast("Error al guardar la cotización", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Eye, FileDown, FileSpreadsheet, Loader2, X } from "lucide-react";
|
import { Eye, FileDown, FileSpreadsheet, Loader2, X } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useToast } from "@/components/ui/DialogProvider";
|
||||||
|
|
||||||
function getFilenameFromHeaders(headers: Headers, fallback: string): string {
|
function getFilenameFromHeaders(headers: Headers, fallback: string): string {
|
||||||
const cd = headers.get("Content-Disposition");
|
const cd = headers.get("Content-Disposition");
|
||||||
@@ -14,6 +15,7 @@ function getFilenameFromHeaders(headers: Headers, fallback: string): string {
|
|||||||
|
|
||||||
function useFileExport() {
|
function useFileExport() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const showToast = useToast();
|
||||||
|
|
||||||
const download = async (url: string, fallback: string) => {
|
const download = async (url: string, fallback: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -29,10 +31,10 @@ function useFileExport() {
|
|||||||
a.click();
|
a.click();
|
||||||
window.URL.revokeObjectURL(blobUrl);
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
} else {
|
} else {
|
||||||
alert("Error al exportar");
|
showToast("Error al exportar", "error");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert("Error al exportar");
|
showToast("Error al exportar", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -56,10 +58,10 @@ function useFileExport() {
|
|||||||
a.click();
|
a.click();
|
||||||
window.URL.revokeObjectURL(blobUrl);
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
} else {
|
} else {
|
||||||
alert("Error al exportar");
|
showToast("Error al exportar", "error");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert("Error al exportar");
|
showToast("Error al exportar", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -175,6 +177,7 @@ function PDFPreviewModal({
|
|||||||
function usePDFPreview() {
|
function usePDFPreview() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [preview, setPreview] = useState<{ url: string; filename: string } | null>(null);
|
const [preview, setPreview] = useState<{ url: string; filename: string } | null>(null);
|
||||||
|
const showToast = useToast();
|
||||||
|
|
||||||
const open = async (fetcher: () => Promise<Response>, fallback: string) => {
|
const open = async (fetcher: () => Promise<Response>, fallback: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -186,10 +189,10 @@ function usePDFPreview() {
|
|||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
setPreview({ url, filename });
|
setPreview({ url, filename });
|
||||||
} else {
|
} else {
|
||||||
alert("Error al generar la previsualización");
|
showToast("Error al generar la previsualización", "error");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert("Error al generar la previsualización");
|
showToast("Error al generar la previsualización", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -9,12 +9,64 @@ export function calcularPrecioHoras(horas: number, tarifaHora: number): number {
|
|||||||
|
|
||||||
// Modelos de cobro de una partida. "retainer" = importe minimo mensual fijo +
|
// 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).
|
// 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> = {
|
export const MODELOS_COBRO: Record<string, string> = {
|
||||||
fijo: "Precio fijo",
|
fijo: "Precio fijo",
|
||||||
horas: "Por horas",
|
horas: "Por horas",
|
||||||
retainer: "Retainer (minimo + adicionales)",
|
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.
|
// Texto descriptivo de un retainer, reutilizado por UI, PDF y Excel.
|
||||||
export function describirRetainer(
|
export function describirRetainer(
|
||||||
montoMinimo: number,
|
montoMinimo: number,
|
||||||
@@ -84,6 +136,156 @@ export const PLANES_BUCEFALO = [
|
|||||||
export const ESTADOS_COTIZACION = ["borrador", "enviada", "aprobada", "rechazada"] as const;
|
export const ESTADOS_COTIZACION = ["borrador", "enviada", "aprobada", "rechazada"] as const;
|
||||||
export type EstadoCotizacion = (typeof ESTADOS_COTIZACION)[number];
|
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 {
|
export function bucefaloPrecio(nivel: string): number {
|
||||||
return PLANES_BUCEFALO.find((p) => p.nivel === nivel)?.precio ?? 0;
|
return PLANES_BUCEFALO.find((p) => p.nivel === nivel)?.precio ?? 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -71,12 +71,12 @@ function fmtDate(d: Date): string {
|
|||||||
|
|
||||||
async function toPngBuffer(base64: string, mime: string): Promise<Buffer> {
|
async function toPngBuffer(base64: string, mime: string): Promise<Buffer> {
|
||||||
const buf = Buffer.from(base64, "base64");
|
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;
|
const sharp = (await import("sharp")).default;
|
||||||
return sharp(buf).png().toBuffer();
|
return sharp(buf).png().toBuffer();
|
||||||
}
|
}
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Buffer> {
|
export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Buffer> {
|
||||||
const logoBuffer = data.logoBase64 && data.logoMime
|
const logoBuffer = data.logoBase64 && data.logoMime
|
||||||
|
|||||||
+27
-3
@@ -8,10 +8,11 @@ const servicioSchema = z.object({
|
|||||||
precio: z.number().min(0),
|
precio: z.number().min(0),
|
||||||
tiempoEntrega: z.string().min(1),
|
tiempoEntrega: z.string().min(1),
|
||||||
entregables: z.array(z.string()),
|
entregables: z.array(z.string()),
|
||||||
|
beneficios: z.array(z.string()).optional(),
|
||||||
esPersonalizado: z.boolean().optional(),
|
esPersonalizado: z.boolean().optional(),
|
||||||
horas: z.number().min(0).optional(),
|
horas: z.number().min(0).optional(),
|
||||||
tarifaHora: 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(),
|
montoMinimo: z.number().min(0).optional(),
|
||||||
horasIncluidas: z.number().min(0).optional(),
|
horasIncluidas: z.number().min(0).optional(),
|
||||||
opcion: z.enum(["1", "2", "ambas"]).optional(),
|
opcion: z.enum(["1", "2", "ambas"]).optional(),
|
||||||
@@ -35,9 +36,10 @@ export const cotizacionPostSchema = z.object({
|
|||||||
moneda: z.enum(["MXN", "USD"]),
|
moneda: z.enum(["MXN", "USD"]),
|
||||||
tipoCambio: z.string(),
|
tipoCambio: z.string(),
|
||||||
proyecto: 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(),
|
incluirBonos: z.boolean(),
|
||||||
incluirFinanciamiento: z.boolean(),
|
incluirFinanciamiento: z.boolean(),
|
||||||
|
incluirIva: z.boolean().optional(),
|
||||||
esDoble: z.boolean().optional(),
|
esDoble: z.boolean().optional(),
|
||||||
opciones: opcionesSchema,
|
opciones: opcionesSchema,
|
||||||
observaciones: z.string(),
|
observaciones: z.string(),
|
||||||
@@ -47,6 +49,7 @@ export const cotizacionPostSchema = z.object({
|
|||||||
empresa: z.string(),
|
empresa: z.string(),
|
||||||
email: z.string().email().or(z.literal("")),
|
email: z.string().email().or(z.literal("")),
|
||||||
telefono: z.string(),
|
telefono: z.string(),
|
||||||
|
rfc: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
servicios: z.array(servicioSchema),
|
servicios: z.array(servicioSchema),
|
||||||
planBucefalo: z
|
planBucefalo: z
|
||||||
@@ -63,9 +66,10 @@ export const cotizacionPutSchema = z.object({
|
|||||||
moneda: z.enum(["MXN", "USD"]),
|
moneda: z.enum(["MXN", "USD"]),
|
||||||
tipoCambio: z.string(),
|
tipoCambio: z.string(),
|
||||||
proyecto: 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(),
|
incluirBonos: z.boolean(),
|
||||||
incluirFinanciamiento: z.boolean(),
|
incluirFinanciamiento: z.boolean(),
|
||||||
|
incluirIva: z.boolean().optional(),
|
||||||
esDoble: z.boolean().optional(),
|
esDoble: z.boolean().optional(),
|
||||||
opciones: opcionesSchema,
|
opciones: opcionesSchema,
|
||||||
observaciones: z.string(),
|
observaciones: z.string(),
|
||||||
@@ -74,6 +78,7 @@ export const cotizacionPutSchema = z.object({
|
|||||||
empresa: z.string(),
|
empresa: z.string(),
|
||||||
email: z.string().email().or(z.literal("")),
|
email: z.string().email().or(z.literal("")),
|
||||||
telefono: z.string(),
|
telefono: z.string(),
|
||||||
|
rfc: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
servicios: z.array(servicioSchema),
|
servicios: z.array(servicioSchema),
|
||||||
planBucefalo: z
|
planBucefalo: z
|
||||||
@@ -84,6 +89,25 @@ export const cotizacionPutSchema = z.object({
|
|||||||
.nullable(),
|
.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({
|
export const loginSchema = z.object({
|
||||||
email: z.string().email("Email invalido"),
|
email: z.string().email("Email invalido"),
|
||||||
password: z.string().min(1, "Password es requerido"),
|
password: z.string().min(1, "Password es requerido"),
|
||||||
|
|||||||
Reference in New Issue
Block a user