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
set -e
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..."
exec node_modules/.bin/next start
+1
View File
@@ -53,6 +53,7 @@ model Turno {
fechaFin DateTime
kilometros Int
estado EstadoTurno @default(PENDIENTE)
comentario String?
createdAt DateTime @default(now())
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 [turnos, setTurnos] = useState<Turno[]>([]);
const [loading, setLoading] = useState(true);
const [completando, setCompletando] = useState<Set<string>>(new Set());
const [comentarios, setComentarios] = useState<Record<string, string>>({});
useEffect(() => { cargar(); }, [fecha]);
@@ -29,15 +31,26 @@ export default function AdminDashboard() {
setLoading(false);
}
async function cambiarEstado(id: string, estado: string) {
async function cambiarEstado(id: string, estado: string, comentario?: string) {
await fetch(`/api/admin/turnos/${id}`, {
method: "PATCH",
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();
}
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) {
return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
}
@@ -45,12 +58,12 @@ export default function AdminDashboard() {
return (
<div className="flex flex-col gap-6">
<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
type="date"
value={fecha}
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>
@@ -96,11 +109,40 @@ export default function AdminDashboard() {
</Button>
</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
</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>
</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 });
const { id } = await params;
const { estado } = await req.json();
const { estado, comentario } = await req.json();
const turno = await prisma.turno.update({
where: { id },
data: { estado },
data: { estado, ...(comentario !== undefined && { comentario }) },
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,
modelo: turno.auto.modelo,
anio: turno.auto.anio,
nroChasis: turno.auto.nroChasis,
nombre: turno.cliente.nombre,
apellido: turno.cliente.apellido,
email: turno.cliente.email,
telefono: turno.cliente.telefono,
fecha: turno.fechaInicio,
trabajo: turno.tipoServicio.nombre,
comentario: turno.comentario,
});
} catch (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">
{autos.map((auto) => (
<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>
<p className="font-semibold text-gray-800">
{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>
</div>
<div className="flex gap-2">
<Link href={`/cliente/historial?autoId=${auto.id}`}>
<Button variant="secondary" className="text-sm">
<Link href={`/cliente/historial?autoId=${auto.id}`} className="flex-1 sm:flex-none">
<Button variant="secondary" className="text-sm w-full sm:w-auto">
📋 Historial
</Button>
</Link>
<Link href={`/cliente/turnos?autoId=${auto.id}`}>
<Button className="text-sm">
<Link href={`/cliente/turnos?autoId=${auto.id}`} className="flex-1 sm:flex-none">
<Button className="text-sm w-full sm:w-auto">
📅 Sacar turno
</Button>
</Link>
+41 -31
View File
@@ -2,7 +2,6 @@
import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button";
interface Registro {
@@ -34,49 +33,60 @@ function HistorialContent() {
}, [autoId, router]);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-5">
<div className="flex items-center gap-3">
<Link href="/cliente/dashboard">
<Button variant="ghost" className="px-2">← Volver</Button>
</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>
{loading ? (
<div className="text-center py-10 text-gray-400">Cargando...</div>
) : historial.length === 0 ? (
<Card>
<p className="text-center text-gray-400 py-4">Este auto no tiene historial registrado todavía.</p>
</Card>
<div className="bg-white border border-gray-200 rounded-2xl p-8 text-center">
<p className="text-4xl mb-3">🔧</p>
<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">
{historial.map((reg) => (
<Card key={reg.id}>
<div className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-2 flex-wrap">
<div>
<p className="font-semibold text-gray-800">{reg.descripcion}</p>
{reg.tipoServicio && (
<span className="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full">
{reg.tipoServicio.nombre}
</span>
)}
<div className="flex flex-col gap-4">
{historial.map((reg) => {
const fecha = new Date(reg.fechaRealizado);
const dia = fecha.toLocaleDateString("es-AR", { day: "2-digit", month: "long", year: "numeric" });
const diaSemana = fecha.toLocaleDateString("es-AR", { weekday: "long" });
return (
<div key={reg.id} className="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition">
{/* Header de la tarjeta */}
<div className="bg-blue-600 px-4 py-3 flex items-center justify-between gap-2">
<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>
</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>
<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 && (
<p className="text-sm text-gray-500 bg-gray-50 rounded-lg px-3 py-2">
{reg.observaciones}
</p>
)}
<p className="text-xs text-gray-400">Realizado por: {reg.admin.nombre}</p>
{/* 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>
)}
{reg.observaciones && (
<div className="bg-amber-50 border border-amber-100 rounded-lg px-3 py-2">
<p className="text-xs text-amber-700 font-medium mb-0.5">Observaciones</p>
<p className="text-sm text-amber-900">{reg.observaciones}</p>
</div>
)}
<p className="text-xs text-gray-400 text-right">Realizado por: {reg.admin.nombre}</p>
</div>
</div>
</Card>
))}
);
})}
</div>
)}
</div>
+10 -1
View File
@@ -19,6 +19,15 @@ interface Turno {
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() {
const router = useRouter();
const params = useSearchParams();
@@ -126,7 +135,7 @@ function TurnosContent() {
<div>
<p className="font-medium text-gray-800">{s.nombre}</p>
<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>
</div>
<span className="text-gray-400 text-lg">→</span>
+10 -6
View File
@@ -1,7 +1,7 @@
import crypto from "crypto";
const SHEET_ID = process.env.GOOGLE_SHEET_ID!;
const SHEET_TAB = "Hoja1";
const SHEET_TAB = "Hoja 1";
async function getAccessToken(): Promise<string> {
const clientEmail = process.env.GOOGLE_CLIENT_EMAIL!;
@@ -44,13 +44,13 @@ export interface FilaTaller {
marca: string;
modelo: string;
anio: number;
nroChasis?: string | null;
nombre: string;
apellido: string;
email: string;
telefono: string;
fecha: Date;
trabajo: string;
comentario?: string | null;
}
export async function agregarFilaSheet(fila: FilaTaller) {
@@ -62,22 +62,26 @@ export async function agregarFilaSheet(fila: FilaTaller) {
year: "numeric",
});
// K=GASTOS, L=VARIOS, M=M DE OBRA son manuales — se dejan vacíos
const valores = [
fila.patente,
fila.kilometros,
fila.marca,
fila.modelo,
fila.anio,
fila.nroChasis ?? "",
fila.nombre,
fila.apellido,
fecha,
fila.email,
fila.telefono,
fecha,
fila.trabajo,
"", // K: GASTOS
"", // 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, {
method: "POST",
+27 -20
View File
@@ -1,21 +1,30 @@
import { prisma } from "./prisma";
const SLOTS_PARALELOS = 4; // 2 jefes × 2 autos c/u
const HORA_INICIO = 8;
const HORA_FIN = 18;
const SLOTS_PARALELOS = 5;
const INICIO_H = 8;
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 {
const dia = fecha.getDay(); // 0=Dom, 6=Sab
const dia = fecha.getDay();
return dia >= 1 && dia <= 5;
}
function horasDentroDeJornada(inicio: Date, fin: Date): boolean {
const h1 = inicio.getHours() + inicio.getMinutes() / 60;
const h2 = fin.getHours() + fin.getMinutes() / 60;
return h1 >= HORA_INICIO && h2 <= HORA_FIN;
function minutosDelDia(fecha: Date): number {
return fecha.getHours() * 60 + fecha.getMinutes();
}
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> {
const count = await prisma.turno.count({
where: {
@@ -34,7 +43,6 @@ export interface SlotDisponible {
fechaFin: Date;
}
// Busca los próximos N slots disponibles para una duración dada (en horas)
export async function obtenerSlotsDisponibles(
duracionHoras: number,
diasAdelante = 14
@@ -47,29 +55,29 @@ export async function obtenerSlotsDisponibles(
fechaLimite.setDate(fechaLimite.getDate() + diasAdelante);
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);
while (cursor < fechaLimite && slots.length < 20) {
if (!esDiaHabil(cursor)) {
cursor.setDate(cursor.getDate() + 1);
cursor.setHours(HORA_INICIO, 0, 0, 0);
cursor.setHours(INICIO_H, INICIO_M, 0, 0);
continue;
}
const hInicio = cursor.getHours() + cursor.getMinutes() / 60;
if (hInicio < HORA_INICIO) {
cursor.setHours(HORA_INICIO, 0, 0, 0);
if (minutosDelDia(cursor) < INICIO_MINUTOS) {
cursor.setHours(INICIO_H, INICIO_M, 0, 0);
continue;
}
const fin = new Date(cursor.getTime() + duracionMs);
const hFin = fin.getHours() + fin.getMinutes() / 60;
if (hFin > HORA_FIN) {
// Pasar al día siguiente
if (
fin.toDateString() !== cursor.toDateString() ||
minutosDelDia(fin) > FIN_MINUTOS
) {
cursor.setDate(cursor.getDate() + 1);
cursor.setHours(HORA_INICIO, 0, 0, 0);
cursor.setHours(INICIO_H, INICIO_M, 0, 0);
continue;
}
@@ -78,7 +86,6 @@ export async function obtenerSlotsDisponibles(
slots.push({ fechaInicio: new Date(cursor), fechaFin: new Date(fin) });
}
// Avanzar 30 minutos
cursor = new Date(cursor.getTime() + 30 * 60 * 1000);
}