feat: comentario al completar turno, Google Sheets corregido, scheduling 8:30-17 5 slots, mejoras responsive
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:
Beldor
2026-06-22 21:57:27 -03:00
parent e556e2f16b
commit c2b0e39b06
9 changed files with 146 additions and 73 deletions
+1 -1
View File
@@ -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
+1
View File
@@ -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])
+48 -6
View File
@@ -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>
))} ))}
+3 -3
View File
@@ -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);
+5 -5
View File
@@ -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>
+40 -30
View File
@@ -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>
+10 -1
View File
@@ -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>
+9 -5
View File
@@ -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
View File
@@ -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);
} }