diff --git a/server.js b/server.js index 57566c2..ac96718 100644 --- a/server.js +++ b/server.js @@ -498,7 +498,212 @@ async function ensureInstance(instanceName) { return { baseUrl, headers }; } -app.get("/public/portal/:token +// ========================================== +// 馃殌 RUTAS P脷BLICAS (M脫VIL OPERARIO) +// ========================================== + +// 1. Cargar datos del cliente, logo y empresa +app.get("/public/portal/:token", async (req, res) => { + try { + const { token } = req.params; + const clientQ = await pool.query(` + SELECT c.id, c.full_name, c.phone, c.addresses, c.owner_id, + u.company_slug, u.full_name as company_name, u.company_logo + FROM clients c + JOIN users u ON c.owner_id = u.id + WHERE c.portal_token = $1 + `, [token]); + + if (clientQ.rowCount === 0) return res.status(404).json({ ok: false, error: "Enlace no v谩lido o caducado" }); + const clientData = clientQ.rows[0]; + + const phoneRaw = clientData.phone.replace('+34', ''); + const scrapedQ = await pool.query(` + SELECT id, service_ref as title, raw_data->>'Descripci贸n' as description, + raw_data->>'scheduled_date' as scheduled_date, + raw_data->>'scheduled_time' as scheduled_time, + raw_data->>'appointment_status' as appointment_status, + created_at, + is_urgent, + (SELECT full_name FROM users WHERE id = scraped_services.assigned_to) as assigned_worker, + (SELECT name FROM service_statuses WHERE id::text = raw_data->>'status_operativo') as real_status_name + FROM scraped_services + WHERE owner_id = $1 + AND (raw_data->>'Tel茅fono' ILIKE $2 OR raw_data->>'TELEFONO' ILIKE $2 OR raw_data->>'TELEFONOS' ILIKE $2) + ORDER BY created_at DESC + `, [clientData.owner_id, `%${phoneRaw}%`]); + + const services = scrapedQ.rows.map(s => { + // Evaluamos el nombre real de la base de datos + let stNameDb = (s.real_status_name || 'Pendiente de Asignar').toLowerCase(); + let finalStatusName = s.real_status_name || "Pendiente de Asignar"; + + if (stNameDb.includes('asignado') || stNameDb.includes('esperando')) { finalStatusName = "Asignado a T茅cnico"; } + if (stNameDb.includes('citado')) { finalStatusName = "Visita Agendada"; } + if (stNameDb.includes('camino')) { finalStatusName = "T茅cnico de Camino"; } + if (stNameDb.includes('trabajando')) { finalStatusName = "En Reparaci贸n"; } + if (stNameDb.includes('incidencia')) { finalStatusName = "Pausado / Incidencia"; } + if (stNameDb.includes('terminado') || stNameDb.includes('finalizado') || stNameDb.includes('anulado') || stNameDb.includes('desasignado')) { finalStatusName = "Terminado"; } + + return { + id: s.id, + title: (s.is_urgent ? "馃毃 URGENTE: " : "") + "Expediente #" + s.title, + description: s.description || "Aver铆a reportada.", + scheduled_date: s.scheduled_date, + scheduled_time: s.scheduled_time, + appointment_status: s.appointment_status, + created_at: s.created_at, + status_name: finalStatusName, + assigned_worker: s.assigned_worker || "Pendiente" + }; + }); + + res.json({ + ok: true, + client: { name: clientData.full_name, phone: clientData.phone, addresses: clientData.addresses }, + company: { name: clientData.company_name, slug: clientData.company_slug, logo: clientData.company_logo }, + services: services + }); + } catch (e) { res.status(500).json({ ok: false, error: "Error de servidor" }); } +}); + +// 2. Obtener huecos disponibles inteligentes (CON HORARIOS DIN脕MICOS Y TRAMOS DE 1 HORA) +app.get("/public/portal/:token/slots", async (req, res) => { + try { + const { token } = req.params; + const { serviceId } = req.query; + + const clientQ = await pool.query("SELECT id, owner_id FROM clients WHERE portal_token = $1", [token]); + if (clientQ.rowCount === 0) return res.status(404).json({ ok: false, error: "Token inv谩lido" }); + const ownerId = clientQ.rows[0].owner_id; + + // EXTRAEMOS LA CONFIGURACI脫N DE HORARIOS DEL PORTAL + const userQ = await pool.query("SELECT portal_settings FROM users WHERE id = $1", [ownerId]); + const pSet = userQ.rows[0]?.portal_settings || { m_start:"09:00", m_end:"14:00", a_start:"16:00", a_end:"19:00" }; + + // Funci贸n para generar huecos cada 60 minutos (Ventanas de 1 hora) + function genSlots(start, end) { + if(!start || !end) return []; + let s = []; + let [sh, sm] = start.split(':').map(Number); + let [eh, em] = end.split(':').map(Number); + let cur = sh * 60 + sm; + let limit = eh * 60 + em; + + // Queremos ventanas de 1 hora completas. + // Si el l铆mite es 15:00, el 煤ltimo hueco que puede empezar es a las 14:00. + while(cur + 60 <= limit) { + s.push(`${String(Math.floor(cur/60)).padStart(2,'0')}:${String(cur%60).padStart(2,'0')}`); + cur += 60; // Avanzamos de hora en hora + } + return s; + } + + // Creamos la plantilla de horas libres del d铆a + const morningBase = genSlots(pSet.m_start, pSet.m_end); + const afternoonBase = genSlots(pSet.a_start, pSet.a_end); + + const serviceQ = await pool.query("SELECT * FROM scraped_services WHERE id=$1", [serviceId]); + if (serviceQ.rowCount === 0) return res.status(404).json({ ok: false, error: "Servicio no encontrado" }); + + const service = serviceQ.rows[0]; + const assignedTo = service.assigned_to; + if (!assignedTo) return res.status(400).json({ ok: false, error: "No hay operario asignado" }); + + const raw = service.raw_data || {}; + const targetZone = (raw["Poblaci贸n"] || raw["POBLACION-PROVINCIA"] || raw["C贸digo Postal"] || "").toLowerCase().trim(); + const targetGuildId = raw["guild_id"]; + + const agendaQ = await pool.query(` + SELECT raw_data->>'scheduled_date' as date, + raw_data->>'scheduled_time' as time, + raw_data->>'duration_minutes' as duration, + raw_data->>'Poblaci贸n' as poblacion, + raw_data->>'C贸digo Postal' as cp, + provider, + raw_data->>'blocked_guild_id' as blocked_guild_id + FROM scraped_services + WHERE assigned_to = $1 + AND raw_data->>'scheduled_date' IS NOT NULL + AND raw_data->>'scheduled_date' >= CURRENT_DATE::text + `, [assignedTo]); + + const agendaMap = {}; + agendaQ.rows.forEach(row => { + if (row.provider === 'SYSTEM_BLOCK' && row.blocked_guild_id && String(row.blocked_guild_id) !== String(targetGuildId)) { + return; + } + + if (!agendaMap[row.date]) agendaMap[row.date] = { times: [], zone: (row.poblacion || row.cp || "").toLowerCase().trim() }; + + // Bloqueamos la agenda evaluando la duraci贸n estimada real del aviso/bloqueo + const dur = parseInt(row.duration || 60); + if (row.time) { + let [th, tm] = row.time.split(':').map(Number); + let startMin = th * 60 + tm; + let endMin = startMin + dur; + + // Bloquea cualquier franja de 1 HORA que se solape con este aviso + // Como ahora generamos horas en punto (o y media, seg煤n config), chequeamos si el tramo de 60 mins pisa al servicio + morningBase.forEach(slot => { + let [sh, sm] = slot.split(':').map(Number); + let slotStart = sh * 60 + sm; + let slotEnd = slotStart + 60; + if(slotStart < endMin && slotEnd > startMin) { + agendaMap[row.date].times.push(slot); + } + }); + + afternoonBase.forEach(slot => { + let [sh, sm] = slot.split(':').map(Number); + let slotStart = sh * 60 + sm; + let slotEnd = slotStart + 60; + if(slotStart < endMin && slotEnd > startMin) { + agendaMap[row.date].times.push(slot); + } + }); + } + }); + + const availableDays = []; + let d = new Date(); + d.setDate(d.getDate() + 1); + + let daysAdded = 0; + while(daysAdded < 10) { + if (d.getDay() !== 0) { // Omitir domingos + const dateStr = d.toISOString().split('T')[0]; + const dayData = agendaMap[dateStr]; + let isDayAllowed = true; + + if (dayData && dayData.zone && targetZone) { + if (!dayData.zone.includes(targetZone) && !targetZone.includes(dayData.zone)) { + isDayAllowed = false; + } + } + + if (isDayAllowed) { + const takenTimes = dayData ? dayData.times : []; + // Filtramos nuestra plantilla contra los huecos ocupados + const availMorning = morningBase.filter(t => !takenTimes.includes(t)); + const availAfternoon = afternoonBase.filter(t => !takenTimes.includes(t)); + + if (availMorning.length > 0 || availAfternoon.length > 0) { + availableDays.push({ + date: dateStr, + displayDate: d.toLocaleDateString('es-ES', { weekday: 'long', day: 'numeric', month: 'long' }), + morning: availMorning, + afternoon: availAfternoon + }); + daysAdded++; + } + } + } + d.setDate(d.getDate() + 1); + } + res.json({ ok: true, days: availableDays }); + } catch (e) { console.error("Error Slots:", e); res.status(500).json({ ok: false }); } +}); // --- RUTA PARA GUARDAR LA CITA SOLICITADA POR EL CLIENTE ---