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
|
||||
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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
<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>
|
||||
</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 && (
|
||||
<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>
|
||||
<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>
|
||||
</Card>
|
||||
))}
|
||||
)}
|
||||
<p className="text-xs text-gray-400 text-right">Realizado por: {reg.admin.nombre}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user