@@ -180,6 +200,28 @@ export default async function CotizacionDetailPage({
serviciosMensuales={serviciosMensuales}
/>
+ {mostrarHoras && (
+
({
+ id: r.id,
+ fecha: r.fecha.toISOString(),
+ horaInicio: r.horaInicio,
+ horaFin: r.horaFin,
+ horas: r.horas,
+ tarifaHora: r.tarifaHora,
+ descripcion: r.descripcion,
+ }))}
+ />
+ )}
+
{cot.observaciones && (
Observaciones
diff --git a/src/app/api/cotizaciones/[id]/horas/[registroId]/route.ts b/src/app/api/cotizaciones/[id]/horas/[registroId]/route.ts
new file mode 100644
index 0000000..97eca74
--- /dev/null
+++ b/src/app/api/cotizaciones/[id]/horas/[registroId]/route.ts
@@ -0,0 +1,76 @@
+import { NextRequest, NextResponse } from "next/server";
+import { prisma } from "@/lib/db";
+import { registroHorasSchema } from "@/lib/schemas";
+import { calcularHorasRango, fechaRegistroDesdeISO } from "@/lib/calculators";
+
+// PUT /api/cotizaciones/:id/horas/:registroId
+// Edita un registro de horas. Recalcula `horas` del rango.
+export async function PUT(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string; registroId: string }> }
+) {
+ try {
+ const { id, registroId } = await params;
+ const body = await request.json();
+
+ const parsed = registroHorasSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.issues.map((i) => i.message).join(", ") },
+ { status: 400 }
+ );
+ }
+ const { fecha, horaInicio, horaFin, descripcion, tarifaHora } = parsed.data;
+
+ const horas = calcularHorasRango(horaInicio, horaFin);
+ if (horas <= 0) {
+ return NextResponse.json(
+ { error: "El rango de horas es invalido (la hora fin debe ser mayor a la de inicio)" },
+ { status: 400 }
+ );
+ }
+
+ const existing = await prisma.registroHoras.findUnique({ where: { id: registroId } });
+ if (!existing || existing.cotizacionId !== id) {
+ return NextResponse.json({ error: "Registro no encontrado" }, { status: 404 });
+ }
+
+ const registro = await prisma.registroHoras.update({
+ where: { id: registroId },
+ data: {
+ fecha: fechaRegistroDesdeISO(fecha),
+ horaInicio,
+ horaFin,
+ horas,
+ descripcion: descripcion.trim(),
+ ...(tarifaHora !== undefined && { tarifaHora }),
+ },
+ });
+
+ return NextResponse.json(registro);
+ } catch (error: unknown) {
+ const msg = error instanceof Error ? error.message : "Error interno";
+ return NextResponse.json({ error: msg }, { status: 500 });
+ }
+}
+
+// DELETE /api/cotizaciones/:id/horas/:registroId
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string; registroId: string }> }
+) {
+ try {
+ const { id, registroId } = await params;
+
+ const existing = await prisma.registroHoras.findUnique({ where: { id: registroId } });
+ if (!existing || existing.cotizacionId !== id) {
+ return NextResponse.json({ error: "Registro no encontrado" }, { status: 404 });
+ }
+
+ await prisma.registroHoras.delete({ where: { id: registroId } });
+ return NextResponse.json({ ok: true });
+ } catch (error: unknown) {
+ const msg = error instanceof Error ? error.message : "Error interno";
+ return NextResponse.json({ error: msg }, { status: 500 });
+ }
+}
diff --git a/src/app/api/cotizaciones/[id]/horas/route.ts b/src/app/api/cotizaciones/[id]/horas/route.ts
new file mode 100644
index 0000000..405d40a
--- /dev/null
+++ b/src/app/api/cotizaciones/[id]/horas/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/api/cotizaciones/[id]/route.ts b/src/app/api/cotizaciones/[id]/route.ts
index 8ae70a7..4ba8657 100644
--- a/src/app/api/cotizaciones/[id]/route.ts
+++ b/src/app/api/cotizaciones/[id]/route.ts
@@ -58,6 +58,28 @@ export async function PUT(
try {
const { id } = await params;
const body = await request.json();
+
+ // Camino rápido: cambio de estado puntual (CambiarEstadoButtons envía solo
+ // { estado }). El schema completo exige todos los campos de la cotización,
+ // así que un PUT con solo el estado fallaría la validación; lo tratamos
+ // como un patch parcial.
+ const bodyKeys = Object.keys(body);
+ if (bodyKeys.length === 1 && bodyKeys[0] === "estado") {
+ const nuevoEstado = body.estado;
+ if (!ESTADOS_COTIZACION.includes(nuevoEstado as typeof ESTADOS_COTIZACION[number])) {
+ return NextResponse.json({ error: "Estado invalido" }, { status: 400 });
+ }
+ const existing = await prisma.cotizacion.findUnique({ where: { id } });
+ if (!existing) {
+ return NextResponse.json({ error: "No encontrada" }, { status: 404 });
+ }
+ const updated = await prisma.cotizacion.update({
+ where: { id },
+ data: { estado: nuevoEstado },
+ });
+ return NextResponse.json(updated);
+ }
+
const parsed = cotizacionPutSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
@@ -74,6 +96,7 @@ export async function PUT(
esquemaPago,
incluirBonos,
incluirFinanciamiento,
+ incluirIva,
esDoble,
opciones,
observaciones,
@@ -100,6 +123,7 @@ export async function PUT(
data: {
email: cliente.email || existingCliente.email,
telefono: cliente.telefono || existingCliente.telefono,
+ rfc: cliente.rfc || existingCliente.rfc,
},
});
} else {
@@ -109,6 +133,7 @@ export async function PUT(
empresa: cliente.empresa || null,
email: cliente.email || null,
telefono: cliente.telefono || null,
+ rfc: cliente.rfc || null,
},
});
clienteIdFinal = newCliente.id;
@@ -129,6 +154,7 @@ export async function PUT(
...(esquemaPago && { esquemaPago }),
...(incluirBonos !== undefined && { incluirBonos }),
...(incluirFinanciamiento !== undefined && { incluirFinanciamiento }),
+ ...(incluirIva !== undefined && { incluirIva }),
...(esDoble !== undefined && { esDoble }),
...(esDoble !== undefined && { opcionesMetadata: esDoble ? opciones ?? {} : undefined }),
...(observaciones !== undefined && { observaciones }),
@@ -174,9 +200,11 @@ export async function PUT(
opcion: esDobleFinal ? serv.opcion ?? "ambas" : null,
fase: serv.fase,
tipoPago: serv.tipoPago,
- precio: serv.precio,
+ // "demanda": tarifa por hora sin compromiso; nunca suma al total.
+ precio: serv.modeloCobro === "demanda" ? 0 : serv.precio,
tiempoEntrega: serv.tiempoEntrega,
entregables: serv.entregables,
+ beneficios: serv.beneficios ?? [],
seleccionado: true,
};
}),
diff --git a/src/app/api/cotizaciones/route.ts b/src/app/api/cotizaciones/route.ts
index 4f808f9..d1bfa33 100644
--- a/src/app/api/cotizaciones/route.ts
+++ b/src/app/api/cotizaciones/route.ts
@@ -22,6 +22,7 @@ export async function POST(request: NextRequest) {
esquemaPago,
incluirBonos,
incluirFinanciamiento,
+ incluirIva,
esDoble,
opciones,
observaciones,
@@ -45,6 +46,7 @@ export async function POST(request: NextRequest) {
empresa: cliente.empresa || null,
email: cliente.email || null,
telefono: cliente.telefono || null,
+ rfc: cliente.rfc || null,
},
});
}
@@ -75,6 +77,7 @@ export async function POST(request: NextRequest) {
esquemaPago,
incluirBonos,
incluirFinanciamiento,
+ incluirIva: incluirIva ?? true,
esDoble: esDoble ?? false,
opcionesMetadata: esDoble ? opciones ?? {} : undefined,
observaciones: observaciones || null,
@@ -107,9 +110,11 @@ export async function POST(request: NextRequest) {
opcion: esDoble ? servicio.opcion ?? "ambas" : null,
fase: servicio.fase,
tipoPago: servicio.tipoPago,
- precio: servicio.precio,
+ // "demanda": tarifa por hora sin compromiso; nunca suma al total.
+ precio: servicio.modeloCobro === "demanda" ? 0 : servicio.precio,
tiempoEntrega: servicio.tiempoEntrega,
entregables: servicio.entregables,
+ beneficios: servicio.beneficios ?? [],
seleccionado: true,
};
}),
diff --git a/src/lib/calculators.ts b/src/lib/calculators.ts
index ff9e815..869a0a2 100644
--- a/src/lib/calculators.ts
+++ b/src/lib/calculators.ts
@@ -9,12 +9,64 @@ export function calcularPrecioHoras(horas: number, tarifaHora: number): number {
// Modelos de cobro de una partida. "retainer" = importe minimo mensual fijo +
// tarifa de horas adicionales (las horas extra se facturan aparte, no suman al total).
+// "demanda" = tarifa por hora sin horas comprometidas; precio=0, no suma al total
+// (se factura a fin de mes segun consumo). Solo muestra la tarifa y sus beneficios.
export const MODELOS_COBRO: Record = {
fijo: "Precio fijo",
horas: "Por horas",
retainer: "Retainer (minimo + adicionales)",
+ demanda: "Bajo demanda / por hora",
};
+// Sub-linea de desglose por modelo de cobro (horas / retainer / demanda) para PDF/Excel.
+// Reutilizado por ambos backends para mantener paridad.
+export function detalleModelo(serv: {
+ modeloCobro?: string | null;
+ esPersonalizado?: boolean | null;
+ horas?: number | null;
+ tarifaHora?: number | null;
+ montoMinimo?: number | null;
+ horasIncluidas?: number | null;
+}): string {
+ if (serv.modeloCobro === "retainer") {
+ return describirRetainer(serv.montoMinimo ?? 0, serv.horasIncluidas ?? 0, serv.tarifaHora ?? 0);
+ }
+ if (serv.modeloCobro === "demanda") {
+ return `${formatCurrency(serv.tarifaHora ?? 0)}/hr · segun consumo`;
+ }
+ if ((serv.modeloCobro === "horas" || serv.esPersonalizado) && serv.horas && serv.tarifaHora) {
+ return `${serv.horas} h x ${formatCurrency(serv.tarifaHora)}/hr`;
+ }
+ return "";
+}
+
+// Texto a mostrar en la columna "Precio" de una partida. Para "demanda" muestra la
+// tarifa por hora en vez de $0 (el precio real depende del consumo). Reutilizado por
+// UI, PDF y Excel para mantener consistencia.
+export function precioDisplay(serv: {
+ modeloCobro?: string | null;
+ tarifaHora?: number | null;
+ precio: number;
+}): string {
+ if (serv.modeloCobro === "demanda") {
+ return `${formatCurrency(serv.tarifaHora || 0)}/hr`;
+ }
+ return formatCurrency(serv.precio);
+}
+
+// Nota al pie para las partidas "bajo demanda": no suman al total comprometido y se
+// facturan segun consumo. Devuelve "" si no hay partidas demanda. Reutilizado por PDF y Excel.
+export function notaDemanda(
+ servicios: Array<{ modeloCobro?: string | null; nombre?: string | null; tarifaHora?: number | null }>
+): string {
+ const dem = servicios.filter((s) => s.modeloCobro === "demanda");
+ if (dem.length === 0) return "";
+ const detalle = dem
+ .map((s) => `${s.nombre || "Servicio"} a ${formatCurrency(s.tarifaHora || 0)}/hr`)
+ .join(", ");
+ return `+ Horas facturadas a fin de mes segun consumo (no incluidas en el total): ${detalle}.`;
+}
+
// Texto descriptivo de un retainer, reutilizado por UI, PDF y Excel.
export function describirRetainer(
montoMinimo: number,
@@ -84,6 +136,112 @@ export const PLANES_BUCEFALO = [
export const ESTADOS_COTIZACION = ["borrador", "enviada", "aprobada", "rechazada"] as const;
export type EstadoCotizacion = (typeof ESTADOS_COTIZACION)[number];
+// ----- Registro de horas (notas de pago) -----
+// Modelos de cobro que implican trabajo por tiempo: para estas cotizaciones se
+// habilita el registro de horas trabajadas y la emision de notas de pago.
+export const MODELOS_COBRO_TIEMPO = ["horas", "retainer", "demanda"] as const;
+
+// Una cotizacion es "por tiempo" si al menos una de sus partidas se cobra por horas,
+// retainer o demanda. Solo en ese caso tiene sentido registrar horas trabajadas.
+export function esCotizacionPorTiempo(
+ servicios: Array<{ modeloCobro?: string | null }>
+): boolean {
+ return servicios.some((s) =>
+ (MODELOS_COBRO_TIEMPO as readonly string[]).includes(s.modeloCobro ?? "")
+ );
+}
+
+// Tarifa por hora a sugerir al registrar horas: la de la primera partida por tiempo
+// con tarifa > 0; si no hay, TARIFA_HORA_DEFAULT.
+export function tarifaHoraSugerida(
+ servicios: Array<{ modeloCobro?: string | null; tarifaHora?: number | null }>
+): number {
+ const serv = servicios.find(
+ (s) =>
+ (MODELOS_COBRO_TIEMPO as readonly string[]).includes(s.modeloCobro ?? "") &&
+ (s.tarifaHora ?? 0) > 0
+ );
+ return serv?.tarifaHora ?? TARIFA_HORA_DEFAULT;
+}
+
+// "09:00" + "13:30" => 4.5. Devuelve 0 si el formato es invalido o fin <= inicio.
+// No cruza medianoche (un registro = un tramo dentro de un dia).
+export function calcularHorasRango(horaInicio: string, horaFin: string): number {
+ const re = /^(\d{1,2}):(\d{2})$/;
+ const a = re.exec(horaInicio ?? "");
+ const b = re.exec(horaFin ?? "");
+ if (!a || !b) return 0;
+ const ini = parseInt(a[1], 10) * 60 + parseInt(a[2], 10);
+ const fin = parseInt(b[1], 10) * 60 + parseInt(b[2], 10);
+ if (fin <= ini) return 0;
+ return Math.round(((fin - ini) / 60) * 100) / 100;
+}
+
+export type ModoAgrupacion = "detalle" | "dia" | "semana" | "mes";
+
+export const MODOS_AGRUPACION: { value: ModoAgrupacion; label: string }[] = [
+ { value: "detalle", label: "Detalle" },
+ { value: "dia", label: "Por dia" },
+ { value: "semana", label: "Por semana" },
+ { value: "mes", label: "Por mes" },
+];
+
+const MESES_LARGO = [
+ "enero", "febrero", "marzo", "abril", "mayo", "junio",
+ "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre",
+];
+
+// La fecha de un registro es una fecha-calendario sin huso horario. Para que el dia
+// no se corra entre servidor (UTC) y cliente (MX), se persiste a las 12:00 UTC y se
+// lee SIEMPRE con getters UTC. Helper para construir esa fecha desde "YYYY-MM-DD".
+export function fechaRegistroDesdeISO(fechaISO: string): Date {
+ return new Date(`${fechaISO}T12:00:00.000Z`);
+}
+
+// Formato corto de fecha de registro (DD/MM/YYYY) leyendo en UTC.
+export function formatFechaRegistro(date: Date): string {
+ return new Intl.DateTimeFormat("es-MX", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ timeZone: "UTC",
+ }).format(date);
+}
+
+function lunesDeSemanaUTC(d: Date): Date {
+ const r = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
+ const dia = r.getUTCDay(); // 0 = domingo
+ const offset = dia === 0 ? -6 : 1 - dia;
+ r.setUTCDate(r.getUTCDate() + offset);
+ return r;
+}
+
+// Clave + etiqueta del periodo al que pertenece una fecha, segun el modo de agrupacion.
+// La clave agrupa; la etiqueta se muestra. Todo en UTC para coherencia de calendario.
+export function periodoAgrupacion(
+ fecha: Date,
+ modo: ModoAgrupacion
+): { clave: string; etiqueta: string } {
+ const y = fecha.getUTCFullYear();
+ const m = fecha.getUTCMonth();
+ const d = fecha.getUTCDate();
+ const p2 = (n: number) => String(n).padStart(2, "0");
+
+ if (modo === "mes") {
+ return { clave: `${y}-${p2(m + 1)}`, etiqueta: `${MESES_LARGO[m]} ${y}` };
+ }
+ if (modo === "semana") {
+ const lunes = lunesDeSemanaUTC(fecha);
+ const domingo = new Date(lunes);
+ domingo.setUTCDate(lunes.getUTCDate() + 6);
+ const fmt = (x: Date) => `${p2(x.getUTCDate())}/${p2(x.getUTCMonth() + 1)}`;
+ const clave = `${lunes.getUTCFullYear()}-${p2(lunes.getUTCMonth() + 1)}-${p2(lunes.getUTCDate())}`;
+ return { clave, etiqueta: `Semana ${fmt(lunes)} - ${fmt(domingo)}` };
+ }
+ // "dia" / "detalle"
+ return { clave: `${y}-${p2(m + 1)}-${p2(d)}`, etiqueta: formatFechaRegistro(fecha) };
+}
+
export function bucefaloPrecio(nivel: string): number {
return PLANES_BUCEFALO.find((p) => p.nivel === nivel)?.precio ?? 0;
}
diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts
index b64102b..e35a642 100644
--- a/src/lib/schemas.ts
+++ b/src/lib/schemas.ts
@@ -8,10 +8,11 @@ const servicioSchema = z.object({
precio: z.number().min(0),
tiempoEntrega: z.string().min(1),
entregables: z.array(z.string()),
+ beneficios: z.array(z.string()).optional(),
esPersonalizado: z.boolean().optional(),
horas: z.number().min(0).optional(),
tarifaHora: z.number().min(0).optional(),
- modeloCobro: z.enum(["fijo", "horas", "retainer"]).optional(),
+ modeloCobro: z.enum(["fijo", "horas", "retainer", "demanda"]).optional(),
montoMinimo: z.number().min(0).optional(),
horasIncluidas: z.number().min(0).optional(),
opcion: z.enum(["1", "2", "ambas"]).optional(),
@@ -35,9 +36,10 @@ export const cotizacionPostSchema = z.object({
moneda: z.enum(["MXN", "USD"]),
tipoCambio: z.string(),
proyecto: z.string(),
- esquemaPago: z.enum(["Pago Unico", "Mensual", "Pago Unico/Mensual"]),
+ esquemaPago: z.enum(["Pago Unico", "Mensual", "Pago Unico/Mensual", "Por hora (postpago)"]),
incluirBonos: z.boolean(),
incluirFinanciamiento: z.boolean(),
+ incluirIva: z.boolean().optional(),
esDoble: z.boolean().optional(),
opciones: opcionesSchema,
observaciones: z.string(),
@@ -47,6 +49,7 @@ export const cotizacionPostSchema = z.object({
empresa: z.string(),
email: z.string().email().or(z.literal("")),
telefono: z.string(),
+ rfc: z.string().optional(),
}),
servicios: z.array(servicioSchema),
planBucefalo: z
@@ -63,9 +66,10 @@ export const cotizacionPutSchema = z.object({
moneda: z.enum(["MXN", "USD"]),
tipoCambio: z.string(),
proyecto: z.string(),
- esquemaPago: z.enum(["Pago Unico", "Mensual", "Pago Unico/Mensual"]),
+ esquemaPago: z.enum(["Pago Unico", "Mensual", "Pago Unico/Mensual", "Por hora (postpago)"]),
incluirBonos: z.boolean(),
incluirFinanciamiento: z.boolean(),
+ incluirIva: z.boolean().optional(),
esDoble: z.boolean().optional(),
opciones: opcionesSchema,
observaciones: z.string(),
@@ -74,6 +78,7 @@ export const cotizacionPutSchema = z.object({
empresa: z.string(),
email: z.string().email().or(z.literal("")),
telefono: z.string(),
+ rfc: z.string().optional(),
}),
servicios: z.array(servicioSchema),
planBucefalo: z
@@ -84,6 +89,19 @@ export const cotizacionPutSchema = z.object({
.nullable(),
});
+const HORA_RE = /^([01]?\d|2[0-3]):[0-5]\d$/;
+
+// Registro de horas trabajadas para una nota de pago. `fecha` es una fecha-calendario
+// (YYYY-MM-DD); el servidor la materializa a las 12:00 UTC. `horas` no se acepta del
+// cliente: se calcula del rango en el servidor (fuente de verdad).
+export const registroHorasSchema = z.object({
+ fecha: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Fecha invalida (YYYY-MM-DD)"),
+ horaInicio: z.string().regex(HORA_RE, "Hora inicio invalida (HH:MM)"),
+ horaFin: z.string().regex(HORA_RE, "Hora fin invalida (HH:MM)"),
+ descripcion: z.string().trim().min(1, "La descripcion es requerida").max(500),
+ tarifaHora: z.number().min(0).optional(),
+});
+
export const loginSchema = z.object({
email: z.string().email("Email invalido"),
password: z.string().min(1, "Password es requerido"),