feat: comentario al completar turno, Google Sheets corregido, scheduling 8:30-17 5 slots, mejoras responsive
Deploy / deploy (push) Failing after 12m35s
Deploy / deploy (push) Failing after 12m35s
- Agrega campo comentario opcional al marcar turno COMPLETADO (textarea en UI, guardado en DB, escrito en col N del sheet) - Corrige mapeo Google Sheets: email→I, telefono→J, vacíos en K-M, comentario→N - Actualiza scheduling: inicio 8:30, fin 17:00, máx 5 turnos simultáneos - entrypoint.sh: migrate deploy → db push para aplicar cambios de schema automáticamente - Historial cliente: tarjetas visuales con header, fecha y observaciones destacadas - Dashboard/turnos cliente: mejoras responsive mobile - Fix display duración: 0.5 hs → "30 min" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
echo "Corriendo migraciones..."
|
echo "Corriendo migraciones..."
|
||||||
node node_modules/prisma/build/index.js migrate deploy
|
node node_modules/prisma/build/index.js db push --accept-data-loss
|
||||||
echo "Migraciones OK. Iniciando app..."
|
echo "Migraciones OK. Iniciando app..."
|
||||||
exec node_modules/.bin/next start
|
exec node_modules/.bin/next start
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ model Turno {
|
|||||||
fechaFin DateTime
|
fechaFin DateTime
|
||||||
kilometros Int
|
kilometros Int
|
||||||
estado EstadoTurno @default(PENDIENTE)
|
estado EstadoTurno @default(PENDIENTE)
|
||||||
|
comentario String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
cliente Cliente @relation(fields: [clienteId], references: [id])
|
cliente Cliente @relation(fields: [clienteId], references: [id])
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export default function AdminDashboard() {
|
|||||||
const [fecha, setFecha] = useState(() => new Date().toISOString().slice(0, 10));
|
const [fecha, setFecha] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
const [turnos, setTurnos] = useState<Turno[]>([]);
|
const [turnos, setTurnos] = useState<Turno[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [completando, setCompletando] = useState<Set<string>>(new Set());
|
||||||
|
const [comentarios, setComentarios] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
useEffect(() => { cargar(); }, [fecha]);
|
useEffect(() => { cargar(); }, [fecha]);
|
||||||
|
|
||||||
@@ -29,15 +31,26 @@ export default function AdminDashboard() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cambiarEstado(id: string, estado: string) {
|
async function cambiarEstado(id: string, estado: string, comentario?: string) {
|
||||||
await fetch(`/api/admin/turnos/${id}`, {
|
await fetch(`/api/admin/turnos/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ estado }),
|
body: JSON.stringify({ estado, comentario }),
|
||||||
});
|
});
|
||||||
|
setCompletando((prev) => { const s = new Set(prev); s.delete(id); return s; });
|
||||||
|
setComentarios((prev) => { const c = { ...prev }; delete c[id]; return c; });
|
||||||
cargar();
|
cargar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function iniciarCompletado(id: string) {
|
||||||
|
setCompletando((prev) => new Set(prev).add(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelarCompletado(id: string) {
|
||||||
|
setCompletando((prev) => { const s = new Set(prev); s.delete(id); return s; });
|
||||||
|
setComentarios((prev) => { const c = { ...prev }; delete c[id]; return c; });
|
||||||
|
}
|
||||||
|
|
||||||
function formatHora(iso: string) {
|
function formatHora(iso: string) {
|
||||||
return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
|
return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
|
||||||
}
|
}
|
||||||
@@ -45,12 +58,12 @@ export default function AdminDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
<h1 className="text-2xl font-bold text-gray-800">Turnos del día</h1>
|
<h1 className="text-xl sm:text-2xl font-bold text-gray-800">Turnos del día</h1>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={fecha}
|
value={fecha}
|
||||||
onChange={(e) => setFecha(e.target.value)}
|
onChange={(e) => setFecha(e.target.value)}
|
||||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500"
|
className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 w-full sm:w-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -96,11 +109,40 @@ export default function AdminDashboard() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{t.estado === "CONFIRMADO" && (
|
|
||||||
<Button onClick={() => cambiarEstado(t.id, "COMPLETADO")} variant="secondary" className="text-sm">
|
{t.estado === "CONFIRMADO" && !completando.has(t.id) && (
|
||||||
|
<Button onClick={() => iniciarCompletado(t.id)} variant="secondary" className="text-sm">
|
||||||
✓ Marcar completado
|
✓ Marcar completado
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{t.estado === "CONFIRMADO" && completando.has(t.id) && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
value={comentarios[t.id] ?? ""}
|
||||||
|
onChange={(e) => setComentarios((prev) => ({ ...prev, [t.id]: e.target.value }))}
|
||||||
|
placeholder="Observaciones (opcional)..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => cambiarEstado(t.id, "COMPLETADO", comentarios[t.id])}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1 text-sm"
|
||||||
|
>
|
||||||
|
✓ Confirmar completado
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => cancelarCompletado(t.id)}
|
||||||
|
className="flex-1 text-sm"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
|
|||||||
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { estado } = await req.json();
|
const { estado, comentario } = await req.json();
|
||||||
|
|
||||||
const turno = await prisma.turno.update({
|
const turno = await prisma.turno.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { estado },
|
data: { estado, ...(comentario !== undefined && { comentario }) },
|
||||||
include: { auto: true, tipoServicio: true, cliente: true },
|
include: { auto: true, tipoServicio: true, cliente: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,13 +24,13 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
|
|||||||
marca: turno.auto.marca,
|
marca: turno.auto.marca,
|
||||||
modelo: turno.auto.modelo,
|
modelo: turno.auto.modelo,
|
||||||
anio: turno.auto.anio,
|
anio: turno.auto.anio,
|
||||||
nroChasis: turno.auto.nroChasis,
|
|
||||||
nombre: turno.cliente.nombre,
|
nombre: turno.cliente.nombre,
|
||||||
apellido: turno.cliente.apellido,
|
apellido: turno.cliente.apellido,
|
||||||
email: turno.cliente.email,
|
email: turno.cliente.email,
|
||||||
telefono: turno.cliente.telefono,
|
telefono: turno.cliente.telefono,
|
||||||
fecha: turno.fechaInicio,
|
fecha: turno.fechaInicio,
|
||||||
trabajo: turno.tipoServicio.nombre,
|
trabajo: turno.tipoServicio.nombre,
|
||||||
|
comentario: turno.comentario,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error escribiendo en Google Sheet:", err);
|
console.error("Error escribiendo en Google Sheet:", err);
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export default function DashboardPage() {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{autos.map((auto) => (
|
{autos.map((auto) => (
|
||||||
<Card key={auto.id} className="hover:shadow-md transition">
|
<Card key={auto.id} className="hover:shadow-md transition">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-800">
|
<p className="font-semibold text-gray-800">
|
||||||
{auto.marca} {auto.modelo} <span className="text-gray-500">({auto.anio})</span>
|
{auto.marca} {auto.modelo} <span className="text-gray-500">({auto.anio})</span>
|
||||||
@@ -87,13 +87,13 @@ export default function DashboardPage() {
|
|||||||
<p className="text-sm text-gray-500 font-mono mt-0.5">{auto.patente}</p>
|
<p className="text-sm text-gray-500 font-mono mt-0.5">{auto.patente}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link href={`/cliente/historial?autoId=${auto.id}`}>
|
<Link href={`/cliente/historial?autoId=${auto.id}`} className="flex-1 sm:flex-none">
|
||||||
<Button variant="secondary" className="text-sm">
|
<Button variant="secondary" className="text-sm w-full sm:w-auto">
|
||||||
📋 Historial
|
📋 Historial
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={`/cliente/turnos?autoId=${auto.id}`}>
|
<Link href={`/cliente/turnos?autoId=${auto.id}`} className="flex-1 sm:flex-none">
|
||||||
<Button className="text-sm">
|
<Button className="text-sm w-full sm:w-auto">
|
||||||
📅 Sacar turno
|
📅 Sacar turno
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { Suspense, useEffect, useState } from "react";
|
import { Suspense, useEffect, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Card from "@/components/ui/Card";
|
|
||||||
import Button from "@/components/ui/Button";
|
import Button from "@/components/ui/Button";
|
||||||
|
|
||||||
interface Registro {
|
interface Registro {
|
||||||
@@ -34,49 +33,60 @@ function HistorialContent() {
|
|||||||
}, [autoId, router]);
|
}, [autoId, router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/cliente/dashboard">
|
<Link href="/cliente/dashboard">
|
||||||
<Button variant="ghost" className="px-2">← Volver</Button>
|
<Button variant="ghost" className="px-2">← Volver</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-2xl font-bold text-gray-800">Historial del auto</h1>
|
<h1 className="text-xl sm:text-2xl font-bold text-gray-800">Historial del auto</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-10 text-gray-400">Cargando...</div>
|
<div className="text-center py-10 text-gray-400">Cargando...</div>
|
||||||
) : historial.length === 0 ? (
|
) : historial.length === 0 ? (
|
||||||
<Card>
|
<div className="bg-white border border-gray-200 rounded-2xl p-8 text-center">
|
||||||
<p className="text-center text-gray-400 py-4">Este auto no tiene historial registrado todavía.</p>
|
<p className="text-4xl mb-3">🔧</p>
|
||||||
</Card>
|
<p className="text-gray-500 font-medium">Sin servicios registrados</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">El historial aparecerá aquí cuando el taller registre servicios.</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-4">
|
||||||
{historial.map((reg) => (
|
{historial.map((reg) => {
|
||||||
<Card key={reg.id}>
|
const fecha = new Date(reg.fechaRealizado);
|
||||||
<div className="flex flex-col gap-2">
|
const dia = fecha.toLocaleDateString("es-AR", { day: "2-digit", month: "long", year: "numeric" });
|
||||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
const diaSemana = fecha.toLocaleDateString("es-AR", { weekday: "long" });
|
||||||
<div>
|
return (
|
||||||
<p className="font-semibold text-gray-800">{reg.descripcion}</p>
|
<div key={reg.id} className="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition">
|
||||||
{reg.tipoServicio && (
|
{/* Header de la tarjeta */}
|
||||||
<span className="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full">
|
<div className="bg-blue-600 px-4 py-3 flex items-center justify-between gap-2">
|
||||||
{reg.tipoServicio.nombre}
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white text-lg">🔧</span>
|
||||||
|
<span className="text-white font-semibold text-sm sm:text-base">
|
||||||
|
{reg.tipoServicio?.nombre ?? reg.descripcion}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="text-blue-100 text-xs capitalize">{diaSemana}</p>
|
||||||
|
<p className="text-white text-xs font-medium">{dia}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cuerpo */}
|
||||||
|
<div className="px-4 py-3 flex flex-col gap-2">
|
||||||
|
{reg.tipoServicio && reg.descripcion !== reg.tipoServicio.nombre && (
|
||||||
|
<p className="text-sm text-gray-700">{reg.descripcion}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-400 shrink-0">
|
|
||||||
{new Date(reg.fechaRealizado).toLocaleDateString("es-AR", {
|
|
||||||
day: "2-digit", month: "long", year: "numeric",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{reg.observaciones && (
|
{reg.observaciones && (
|
||||||
<p className="text-sm text-gray-500 bg-gray-50 rounded-lg px-3 py-2">
|
<div className="bg-amber-50 border border-amber-100 rounded-lg px-3 py-2">
|
||||||
{reg.observaciones}
|
<p className="text-xs text-amber-700 font-medium mb-0.5">Observaciones</p>
|
||||||
</p>
|
<p className="text-sm text-amber-900">{reg.observaciones}</p>
|
||||||
)}
|
|
||||||
<p className="text-xs text-gray-400">Realizado por: {reg.admin.nombre}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
))}
|
<p className="text-xs text-gray-400 text-right">Realizado por: {reg.admin.nombre}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ interface Turno {
|
|||||||
|
|
||||||
type Step = "servicio" | "slot" | "confirmado";
|
type Step = "servicio" | "slot" | "confirmado";
|
||||||
|
|
||||||
|
function formatDuracion(horas: number): string {
|
||||||
|
if (horas < 1) return `${Math.round(horas * 60)} min`;
|
||||||
|
if (horas === 1) return "1 hora";
|
||||||
|
if (Number.isInteger(horas)) return `${horas} horas`;
|
||||||
|
const h = Math.floor(horas);
|
||||||
|
const m = Math.round((horas % 1) * 60);
|
||||||
|
return `${h} h ${m} min`;
|
||||||
|
}
|
||||||
|
|
||||||
function TurnosContent() {
|
function TurnosContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
@@ -126,7 +135,7 @@ function TurnosContent() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-800">{s.nombre}</p>
|
<p className="font-medium text-gray-800">{s.nombre}</p>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
Duración: {s.duracionHoras === 1 ? "1 hora" : `${s.duracionHoras} horas`}
|
Duración: {formatDuracion(s.duracionHoras)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-gray-400 text-lg">→</span>
|
<span className="text-gray-400 text-lg">→</span>
|
||||||
|
|||||||
@@ -44,13 +44,13 @@ export interface FilaTaller {
|
|||||||
marca: string;
|
marca: string;
|
||||||
modelo: string;
|
modelo: string;
|
||||||
anio: number;
|
anio: number;
|
||||||
nroChasis?: string | null;
|
|
||||||
nombre: string;
|
nombre: string;
|
||||||
apellido: string;
|
apellido: string;
|
||||||
email: string;
|
email: string;
|
||||||
telefono: string;
|
telefono: string;
|
||||||
fecha: Date;
|
fecha: Date;
|
||||||
trabajo: string;
|
trabajo: string;
|
||||||
|
comentario?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function agregarFilaSheet(fila: FilaTaller) {
|
export async function agregarFilaSheet(fila: FilaTaller) {
|
||||||
@@ -62,22 +62,26 @@ export async function agregarFilaSheet(fila: FilaTaller) {
|
|||||||
year: "numeric",
|
year: "numeric",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// K=GASTOS, L=VARIOS, M=M DE OBRA son manuales — se dejan vacíos
|
||||||
const valores = [
|
const valores = [
|
||||||
fila.patente,
|
fila.patente,
|
||||||
fila.kilometros,
|
fila.kilometros,
|
||||||
fila.marca,
|
fila.marca,
|
||||||
fila.modelo,
|
fila.modelo,
|
||||||
fila.anio,
|
fila.anio,
|
||||||
fila.nroChasis ?? "",
|
|
||||||
fila.nombre,
|
fila.nombre,
|
||||||
fila.apellido,
|
fila.apellido,
|
||||||
|
fecha,
|
||||||
fila.email,
|
fila.email,
|
||||||
fila.telefono,
|
fila.telefono,
|
||||||
fecha,
|
"", // K: GASTOS
|
||||||
fila.trabajo,
|
"", // L: VARIOS
|
||||||
|
"", // M: M DE OBRA
|
||||||
|
fila.comentario ?? "", // N: Comentarios
|
||||||
];
|
];
|
||||||
|
|
||||||
const url = `https://sheets.googleapis.com/v4/spreadsheets/${SHEET_ID}/values/${SHEET_TAB}!A:L:append?valueInputOption=USER_ENTERED`;
|
const range = encodeURIComponent(`${SHEET_TAB}!A:N`);
|
||||||
|
const url = `https://sheets.googleapis.com/v4/spreadsheets/${SHEET_ID}/values/${range}:append?valueInputOption=USER_ENTERED`;
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
+27
-20
@@ -1,21 +1,30 @@
|
|||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
const SLOTS_PARALELOS = 4; // 2 jefes × 2 autos c/u
|
const SLOTS_PARALELOS = 5;
|
||||||
const HORA_INICIO = 8;
|
const INICIO_H = 8;
|
||||||
const HORA_FIN = 18;
|
const INICIO_M = 30;
|
||||||
|
const FIN_H = 17;
|
||||||
|
const FIN_M = 0;
|
||||||
|
const INICIO_MINUTOS = INICIO_H * 60 + INICIO_M; // 510
|
||||||
|
const FIN_MINUTOS = FIN_H * 60 + FIN_M; // 1020
|
||||||
|
|
||||||
function esDiaHabil(fecha: Date): boolean {
|
function esDiaHabil(fecha: Date): boolean {
|
||||||
const dia = fecha.getDay(); // 0=Dom, 6=Sab
|
const dia = fecha.getDay();
|
||||||
return dia >= 1 && dia <= 5;
|
return dia >= 1 && dia <= 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
function horasDentroDeJornada(inicio: Date, fin: Date): boolean {
|
function minutosDelDia(fecha: Date): number {
|
||||||
const h1 = inicio.getHours() + inicio.getMinutes() / 60;
|
return fecha.getHours() * 60 + fecha.getMinutes();
|
||||||
const h2 = fin.getHours() + fin.getMinutes() / 60;
|
}
|
||||||
return h1 >= HORA_INICIO && h2 <= HORA_FIN;
|
|
||||||
|
function horasDentroDeJornada(inicio: Date, fin: Date): boolean {
|
||||||
|
return (
|
||||||
|
minutosDelDia(inicio) >= INICIO_MINUTOS &&
|
||||||
|
minutosDelDia(fin) <= FIN_MINUTOS &&
|
||||||
|
inicio.toDateString() === fin.toDateString()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Devuelve cuántos turnos activos se superponen con el rango dado
|
|
||||||
async function turnosSolapados(inicio: Date, fin: Date): Promise<number> {
|
async function turnosSolapados(inicio: Date, fin: Date): Promise<number> {
|
||||||
const count = await prisma.turno.count({
|
const count = await prisma.turno.count({
|
||||||
where: {
|
where: {
|
||||||
@@ -34,7 +43,6 @@ export interface SlotDisponible {
|
|||||||
fechaFin: Date;
|
fechaFin: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Busca los próximos N slots disponibles para una duración dada (en horas)
|
|
||||||
export async function obtenerSlotsDisponibles(
|
export async function obtenerSlotsDisponibles(
|
||||||
duracionHoras: number,
|
duracionHoras: number,
|
||||||
diasAdelante = 14
|
diasAdelante = 14
|
||||||
@@ -47,29 +55,29 @@ export async function obtenerSlotsDisponibles(
|
|||||||
fechaLimite.setDate(fechaLimite.getDate() + diasAdelante);
|
fechaLimite.setDate(fechaLimite.getDate() + diasAdelante);
|
||||||
|
|
||||||
let cursor = new Date(ahora);
|
let cursor = new Date(ahora);
|
||||||
// Empezar desde el siguiente slot redondo (media hora)
|
// Avanzar al siguiente slot de :00 o :30
|
||||||
cursor.setMinutes(cursor.getMinutes() >= 30 ? 60 : 30, 0, 0);
|
cursor.setMinutes(cursor.getMinutes() >= 30 ? 60 : 30, 0, 0);
|
||||||
|
|
||||||
while (cursor < fechaLimite && slots.length < 20) {
|
while (cursor < fechaLimite && slots.length < 20) {
|
||||||
if (!esDiaHabil(cursor)) {
|
if (!esDiaHabil(cursor)) {
|
||||||
cursor.setDate(cursor.getDate() + 1);
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
cursor.setHours(HORA_INICIO, 0, 0, 0);
|
cursor.setHours(INICIO_H, INICIO_M, 0, 0);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hInicio = cursor.getHours() + cursor.getMinutes() / 60;
|
if (minutosDelDia(cursor) < INICIO_MINUTOS) {
|
||||||
if (hInicio < HORA_INICIO) {
|
cursor.setHours(INICIO_H, INICIO_M, 0, 0);
|
||||||
cursor.setHours(HORA_INICIO, 0, 0, 0);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fin = new Date(cursor.getTime() + duracionMs);
|
const fin = new Date(cursor.getTime() + duracionMs);
|
||||||
const hFin = fin.getHours() + fin.getMinutes() / 60;
|
|
||||||
|
|
||||||
if (hFin > HORA_FIN) {
|
if (
|
||||||
// Pasar al día siguiente
|
fin.toDateString() !== cursor.toDateString() ||
|
||||||
|
minutosDelDia(fin) > FIN_MINUTOS
|
||||||
|
) {
|
||||||
cursor.setDate(cursor.getDate() + 1);
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
cursor.setHours(HORA_INICIO, 0, 0, 0);
|
cursor.setHours(INICIO_H, INICIO_M, 0, 0);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +86,6 @@ export async function obtenerSlotsDisponibles(
|
|||||||
slots.push({ fechaInicio: new Date(cursor), fechaFin: new Date(fin) });
|
slots.push({ fechaInicio: new Date(cursor), fechaFin: new Date(fin) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avanzar 30 minutos
|
|
||||||
cursor = new Date(cursor.getTime() + 30 * 60 * 1000);
|
cursor = new Date(cursor.getTime() + 30 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user